diff --git a/tests/ScadaLink.AuditLog.Tests/Integration/PartitionPurgeTests.cs b/tests/ScadaLink.AuditLog.Tests/Integration/PartitionPurgeTests.cs
new file mode 100644
index 0000000..69db1b1
--- /dev/null
+++ b/tests/ScadaLink.AuditLog.Tests/Integration/PartitionPurgeTests.cs
@@ -0,0 +1,354 @@
+using Akka.Actor;
+using Akka.TestKit.Xunit2;
+using Microsoft.Data.SqlClient;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Extensions.Options;
+using ScadaLink.AuditLog.Central;
+using ScadaLink.AuditLog.Configuration;
+using ScadaLink.Commons.Entities.Audit;
+using ScadaLink.Commons.Interfaces.Repositories;
+using ScadaLink.Commons.Types.Enums;
+using ScadaLink.ConfigurationDatabase;
+using ScadaLink.ConfigurationDatabase.Repositories;
+using ScadaLink.ConfigurationDatabase.Tests.Migrations;
+
+namespace ScadaLink.AuditLog.Tests.Integration;
+
+///
+/// Bundle F (#23 M6-T11) end-to-end test for the daily partition-switch
+/// purge: seeds three monthly partitions (Jan / Feb / Mar 2026) with direct
+/// INSERTs that bypass the standard repository ingest path (so the seed
+/// timestamps are explicit), drives against
+/// the real + per-test
+/// database, and asserts:
+///
+/// - The oldest partition (Jan) is removed.
+/// - Newer partitions (Feb + Mar) are untouched.
+/// - The UX_AuditLog_EventId unique index survives the
+/// drop-and-rebuild dance.
+/// - remains
+/// idempotent against the rebuilt index after the purge.
+///
+///
+///
+/// The brief calls out that direct INSERTs bypass the writer role's INSERT-only
+/// grant; the fixture connects as sa (see
+/// 's default admin connection string), so
+/// the seed step does not need the writer role at all. The drop-and-rebuild
+/// dance itself runs under the same admin connection because the test owns
+/// the database — the role granularity is exercised in the repository tests,
+/// not here.
+///
+public class PartitionPurgeTests : TestKit, IClassFixture
+{
+ private readonly MsSqlMigrationFixture _fixture;
+
+ public PartitionPurgeTests(MsSqlMigrationFixture fixture)
+ {
+ _fixture = fixture;
+ }
+
+ private ScadaLinkDbContext CreateContext() =>
+ new(new DbContextOptionsBuilder()
+ .UseSqlServer(_fixture.ConnectionString).Options);
+
+ ///
+ /// Direct INSERT into dbo.AuditLog bypassing
+ /// . Used by the
+ /// seed step so the test can place rows in arbitrary partitions without
+ /// the repository's idempotency wrapper or ingest-stamping behaviour
+ /// affecting the seed payload.
+ ///
+ private async Task DirectInsertAsync(
+ SqlConnection conn,
+ Guid eventId,
+ DateTime occurredAtUtc,
+ string siteId)
+ {
+ await using var cmd = conn.CreateCommand();
+ cmd.CommandText = @"
+INSERT INTO dbo.AuditLog
+ (EventId, OccurredAtUtc, IngestedAtUtc, Channel, Kind, CorrelationId,
+ SourceSiteId, SourceInstanceId, SourceScript, Actor, Target, Status,
+ HttpStatus, DurationMs, ErrorMessage, ErrorDetail, RequestSummary,
+ ResponseSummary, PayloadTruncated, Extra, ForwardState)
+VALUES
+ (@EventId, @OccurredAtUtc, @IngestedAtUtc, 'ApiOutbound', 'ApiCall', NULL,
+ @SourceSiteId, NULL, NULL, NULL, NULL, 'Delivered',
+ NULL, NULL, NULL, NULL, NULL,
+ NULL, 0, NULL, NULL);";
+ cmd.Parameters.Add("@EventId", System.Data.SqlDbType.UniqueIdentifier).Value = eventId;
+ // SqlDbType.DateTime2 with explicit Scale 7 matches the
+ // OccurredAtUtc column shape (datetime2(7)) and avoids the implicit
+ // narrowing that SqlClient's default DateTime → datetime applies via
+ // AddWithValue. Critical for partition assignment: the partition
+ // function key column is datetime2(7); a narrowed value would still
+ // land in the correct partition for first-of-month seeds, but
+ // explicit typing here documents the intent and matches how the
+ // production repository INSERT shapes its parameters.
+ var occurredParam = cmd.Parameters.Add("@OccurredAtUtc", System.Data.SqlDbType.DateTime2);
+ occurredParam.Scale = 7;
+ occurredParam.Value = occurredAtUtc;
+ var ingestedParam = cmd.Parameters.Add("@IngestedAtUtc", System.Data.SqlDbType.DateTime2);
+ ingestedParam.Scale = 7;
+ ingestedParam.Value = DateTime.UtcNow;
+ cmd.Parameters.Add("@SourceSiteId", System.Data.SqlDbType.VarChar, 64).Value = siteId;
+ await cmd.ExecuteNonQueryAsync();
+ }
+
+ ///
+ /// Asserts that UX_AuditLog_EventId exists in
+ /// sys.indexes. The drop-and-rebuild dance briefly removes the
+ /// index inside its transaction; this check is meant to fire AFTER the
+ /// actor's purge tick has committed so the rebuilt index is observable.
+ ///
+ private static async Task AssertUxIndexExistsAsync(SqlConnection conn)
+ {
+ await using var cmd = conn.CreateCommand();
+ cmd.CommandText = @"
+SELECT COUNT(*)
+FROM sys.indexes
+WHERE name = 'UX_AuditLog_EventId'
+ AND object_id = OBJECT_ID('dbo.AuditLog');";
+ var raw = await cmd.ExecuteScalarAsync();
+ var count = Convert.ToInt32(raw);
+ Assert.True(count == 1, $"UX_AuditLog_EventId should be present post-purge; sys.indexes count was {count}.");
+ }
+
+ private IActorRef CreateActor(
+ IServiceProvider sp,
+ AuditLogPurgeOptions purgeOptions,
+ AuditLogOptions auditOptions)
+ {
+ return Sys.ActorOf(Props.Create(() => new AuditLogPurgeActor(
+ sp,
+ Options.Create(purgeOptions),
+ Options.Create(auditOptions),
+ NullLogger.Instance)));
+ }
+
+ private static (DateTime Jan, DateTime Feb, DateTime Mar) SeedOccurredAt() => (
+ new DateTime(2026, 1, 15, 0, 0, 0, DateTimeKind.Utc),
+ new DateTime(2026, 2, 15, 0, 0, 0, DateTimeKind.Utc),
+ new DateTime(2026, 3, 15, 0, 0, 0, DateTimeKind.Utc));
+
+ // ---------------------------------------------------------------------
+ // 1. EndToEnd_OldestPartition_PurgedViaActor_NewerKept
+ // ---------------------------------------------------------------------
+
+ [SkippableFact]
+ public async Task EndToEnd_OldestPartition_PurgedViaActor_NewerKept()
+ {
+ Skip.IfNot(_fixture.Available, _fixture.SkipReason);
+
+ // Test date is ~2026-05-20 per environment. We want a threshold that
+ // sits strictly between Jan 15 (the Jan partition's MAX) and Feb 15
+ // (the Feb partition's MAX) so only the Jan-2026 partition is
+ // eligible for purge. RetentionDays = 100 gives a threshold of
+ // ~2026-02-09 — Jan 15 is older (purged), Feb 15 and Mar 15 are
+ // newer (kept). The window between Jan 15 and Feb 15 is wide enough
+ // (~30 days) to tolerate any plausible test-clock drift in CI.
+ var siteId = "purge-e2e-" + Guid.NewGuid().ToString("N").Substring(0, 8);
+ var janEventId = Guid.NewGuid();
+ var febEventId = Guid.NewGuid();
+ var marEventId = Guid.NewGuid();
+ var (janOccurred, febOccurred, marOccurred) = SeedOccurredAt();
+
+ await using (var seedConn = _fixture.OpenConnection())
+ {
+ await DirectInsertAsync(seedConn, janEventId, janOccurred, siteId);
+ await DirectInsertAsync(seedConn, febEventId, febOccurred, siteId);
+ await DirectInsertAsync(seedConn, marEventId, marOccurred, siteId);
+ }
+
+ // Wire the actor with a real EF context against the fixture DB.
+ var services = new ServiceCollection();
+ services.AddDbContext(
+ opts => opts.UseSqlServer(_fixture.ConnectionString),
+ ServiceLifetime.Scoped);
+ services.AddScoped();
+ var sp = services.BuildServiceProvider();
+
+ var probe = CreateTestProbe();
+ Sys.EventStream.Subscribe(probe.Ref, typeof(AuditLogPurgedEvent));
+
+ var purgeOptions = new AuditLogPurgeOptions
+ {
+ IntervalHours = 24,
+ IntervalOverride = TimeSpan.FromMilliseconds(100),
+ };
+ var auditOptions = new AuditLogOptions { RetentionDays = 100 };
+
+ CreateActor(sp, purgeOptions, auditOptions);
+
+ // Wait for the actor's tick to purge the Jan-2026 partition.
+ // Concurrent test runs against the same fixture might also create
+ // eligible partitions, but each test class owns its own fixture DB
+ // (MsSqlMigrationFixture seeds a guid-named DB per class), so the
+ // Jan-2026 boundary is the only one this test can have produced.
+ var janBoundary = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
+ var matched = probe.FishForMessage(
+ isMessage: m => m.MonthBoundary == janBoundary,
+ max: TimeSpan.FromSeconds(30));
+ Assert.True(matched.RowsDeleted >= 1,
+ $"Expected RowsDeleted >= 1 for Jan-2026 boundary; got {matched.RowsDeleted}.");
+
+ // Allow a brief settle in case the actor is mid-tick on Feb/Mar
+ // (it shouldn't be, since RetentionDays = 90 means only Jan is
+ // eligible, but the actor MAY re-enumerate quickly while we read).
+ await Task.Delay(TimeSpan.FromMilliseconds(500));
+
+ await using var verify = CreateContext();
+ var rows = await verify.Set()
+ .Where(e => e.SourceSiteId == siteId)
+ .ToListAsync();
+
+ // Jan removed; Feb + Mar untouched. Because the test owns the site
+ // id and the fixture DB, exact set membership is observable.
+ Assert.DoesNotContain(rows, r => r.EventId == janEventId);
+ Assert.Contains(rows, r => r.EventId == febEventId);
+ Assert.Contains(rows, r => r.EventId == marEventId);
+ }
+
+ // ---------------------------------------------------------------------
+ // 2. EndToEnd_UxIndexRebuilt_AfterPurge
+ // ---------------------------------------------------------------------
+
+ [SkippableFact]
+ public async Task EndToEnd_UxIndexRebuilt_AfterPurge()
+ {
+ Skip.IfNot(_fixture.Available, _fixture.SkipReason);
+
+ // Same shape as test 1 — purge the Jan-2026 partition and then
+ // assert the UX_AuditLog_EventId index is still present. The
+ // drop-and-rebuild dance briefly removes it inside its transaction
+ // (the SWITCH PARTITION step requires the non-aligned unique index
+ // to be absent), but step 5 rebuilds it before committing. Sanity-
+ // checking the post-COMMIT shape here documents the invariant in an
+ // assertable way.
+ var siteId = "purge-uxidx-" + Guid.NewGuid().ToString("N").Substring(0, 8);
+ var janEventId = Guid.NewGuid();
+ var (janOccurred, _, _) = SeedOccurredAt();
+
+ await using (var seedConn = _fixture.OpenConnection())
+ {
+ await DirectInsertAsync(seedConn, janEventId, janOccurred, siteId);
+ }
+
+ var services = new ServiceCollection();
+ services.AddDbContext(
+ opts => opts.UseSqlServer(_fixture.ConnectionString),
+ ServiceLifetime.Scoped);
+ services.AddScoped();
+ var sp = services.BuildServiceProvider();
+
+ var probe = CreateTestProbe();
+ Sys.EventStream.Subscribe(probe.Ref, typeof(AuditLogPurgedEvent));
+
+ CreateActor(
+ sp,
+ new AuditLogPurgeOptions
+ {
+ IntervalHours = 24,
+ IntervalOverride = TimeSpan.FromMilliseconds(100),
+ },
+ new AuditLogOptions { RetentionDays = 90 });
+
+ var janBoundary = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
+ probe.FishForMessage(
+ isMessage: m => m.MonthBoundary == janBoundary,
+ max: TimeSpan.FromSeconds(30));
+
+ // Open a fresh connection (the actor's pool is owned by EF) and
+ // assert the index is present post-purge.
+ await using var check = _fixture.OpenConnection();
+ await AssertUxIndexExistsAsync(check);
+ }
+
+ // ---------------------------------------------------------------------
+ // 3. EndToEnd_InsertIfNotExistsAsync_StillIdempotent_AfterPurge
+ // ---------------------------------------------------------------------
+
+ [SkippableFact]
+ public async Task EndToEnd_InsertIfNotExistsAsync_StillIdempotent_AfterPurge()
+ {
+ Skip.IfNot(_fixture.Available, _fixture.SkipReason);
+
+ // Seed + purge a Jan-2026 row, THEN exercise InsertIfNotExistsAsync
+ // twice for a fresh (May-2026) EventId. The second call must be a
+ // no-op (duplicate-key collision swallowed by the repository, per
+ // M2 Bundle A's race-fix) — which means the rebuilt
+ // UX_AuditLog_EventId unique index is functioning as intended.
+ var siteId = "purge-idem-" + Guid.NewGuid().ToString("N").Substring(0, 8);
+ var janEventId = Guid.NewGuid();
+ var (janOccurred, _, _) = SeedOccurredAt();
+
+ await using (var seedConn = _fixture.OpenConnection())
+ {
+ await DirectInsertAsync(seedConn, janEventId, janOccurred, siteId);
+ }
+
+ var services = new ServiceCollection();
+ services.AddDbContext(
+ opts => opts.UseSqlServer(_fixture.ConnectionString),
+ ServiceLifetime.Scoped);
+ services.AddScoped();
+ var sp = services.BuildServiceProvider();
+
+ var probe = CreateTestProbe();
+ Sys.EventStream.Subscribe(probe.Ref, typeof(AuditLogPurgedEvent));
+
+ CreateActor(
+ sp,
+ new AuditLogPurgeOptions
+ {
+ IntervalHours = 24,
+ IntervalOverride = TimeSpan.FromMilliseconds(100),
+ },
+ new AuditLogOptions { RetentionDays = 90 });
+
+ var janBoundary = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
+ probe.FishForMessage(
+ isMessage: m => m.MonthBoundary == janBoundary,
+ max: TimeSpan.FromSeconds(30));
+
+ // Settle then exercise InsertIfNotExistsAsync twice for the same
+ // EventId. The repository's idempotency relies on
+ // UX_AuditLog_EventId being present so the IF NOT EXISTS … INSERT
+ // race window resolves to a duplicate-key violation the repo
+ // swallows. If the index were missing here, two rows would land
+ // and the second InsertIfNotExistsAsync would silently double-insert.
+ await Task.Delay(TimeSpan.FromMilliseconds(500));
+
+ var freshEventId = Guid.NewGuid();
+ var freshOccurred = new DateTime(2026, 5, 15, 12, 0, 0, DateTimeKind.Utc);
+ var freshSite = "purge-idem-fresh-" + Guid.NewGuid().ToString("N").Substring(0, 8);
+ var freshEvt = new AuditEvent
+ {
+ EventId = freshEventId,
+ OccurredAtUtc = freshOccurred,
+ Channel = AuditChannel.ApiOutbound,
+ Kind = AuditKind.ApiCall,
+ Status = AuditStatus.Delivered,
+ SourceSiteId = freshSite,
+ Target = "system-x/method",
+ };
+
+ await using (var ctx = CreateContext())
+ {
+ var repo = new AuditLogRepository(ctx);
+ await repo.InsertIfNotExistsAsync(freshEvt);
+ // Same row a second time — must be a silent no-op.
+ await repo.InsertIfNotExistsAsync(freshEvt);
+ }
+
+ await using var verify = CreateContext();
+ var rows = await verify.Set()
+ .Where(e => e.SourceSiteId == freshSite)
+ .ToListAsync();
+ Assert.Single(rows);
+ Assert.Equal(freshEventId, rows[0].EventId);
+ }
+}