diff --git a/tests/ScadaLink.AuditLog.Tests/Integration/PartitionMaintenanceTests.cs b/tests/ScadaLink.AuditLog.Tests/Integration/PartitionMaintenanceTests.cs
new file mode 100644
index 0000000..bd1a81c
--- /dev/null
+++ b/tests/ScadaLink.AuditLog.Tests/Integration/PartitionMaintenanceTests.cs
@@ -0,0 +1,278 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Extensions.Options;
+using ScadaLink.AuditLog.Central;
+using ScadaLink.Commons.Interfaces;
+using ScadaLink.ConfigurationDatabase;
+using ScadaLink.ConfigurationDatabase.Maintenance;
+using ScadaLink.ConfigurationDatabase.Tests.Migrations;
+
+namespace ScadaLink.AuditLog.Tests.Integration;
+
+///
+/// Bundle F (#23 M6-T12) end-to-end tests for the
+/// hosted service running
+/// the real EF/MSSQL against the
+/// per-class . The migration seeds
+/// boundaries for every month Jan 2026 – Dec 2027, so the eager startup tick
+/// can be exercised both for the "future covered" no-op case and for the
+/// "lookahead larger than covered" SPLIT case.
+///
+///
+/// Tests within this class share one fixture DB — boundaries added by one
+/// test persist across the next. Each test reads the max boundary at the
+/// start and computes its lookahead relative to it, mirroring the pattern
+/// used by the per-component AuditLogPartitionMaintenanceTests in
+/// ScadaLink.ConfigurationDatabase.Tests.
+///
+public class PartitionMaintenanceTests : IClassFixture
+{
+ private readonly MsSqlMigrationFixture _fixture;
+
+ public PartitionMaintenanceTests(MsSqlMigrationFixture fixture)
+ {
+ _fixture = fixture;
+ }
+
+ private ScadaLinkDbContext CreateContext() =>
+ new(new DbContextOptionsBuilder()
+ .UseSqlServer(_fixture.ConnectionString).Options);
+
+ ///
+ /// Builds the central-side DI graph for the hosted service: scoped EF
+ /// context + scoped matching how
+ /// AddConfigurationDatabase wires the production composition root.
+ ///
+ private ServiceProvider BuildProvider()
+ {
+ var services = new ServiceCollection();
+ services.AddDbContext(
+ opts => opts.UseSqlServer(_fixture.ConnectionString),
+ ServiceLifetime.Scoped);
+ services.AddScoped();
+ return services.BuildServiceProvider();
+ }
+
+ private static async Task ReadMaxBoundaryAsync(IServiceProvider sp)
+ {
+ await using var scope = sp.CreateAsyncScope();
+ var maintenance = scope.ServiceProvider.GetRequiredService();
+ return await maintenance.GetMaxBoundaryAsync();
+ }
+
+ ///
+ /// Mirrors the helper in
+ /// AuditLogPartitionMaintenanceTests.LookaheadForExtraBoundaries:
+ /// the smallest lookahead value that lands the SPLIT horizon exactly
+ /// months past the current max.
+ ///
+ private static int LookaheadForExtraBoundaries(DateTime max, int extraBoundaries)
+ {
+ var nowFirstOfNextMonth = FirstOfNextMonth(DateTime.UtcNow);
+ var monthsToMax = ((max.Year - nowFirstOfNextMonth.Year) * 12)
+ + max.Month - nowFirstOfNextMonth.Month;
+ return monthsToMax + extraBoundaries;
+ }
+
+ private static int LookaheadInsideExistingRange(DateTime max)
+ {
+ var now = DateTime.UtcNow;
+ var months = ((max.Year - now.Year) * 12) + max.Month - now.Month - 1;
+ return Math.Max(1, months);
+ }
+
+ private static DateTime FirstOfNextMonth(DateTime instant)
+ {
+ var firstOfThisMonth = new DateTime(instant.Year, instant.Month, 1, 0, 0, 0, DateTimeKind.Utc);
+ return firstOfThisMonth.AddMonths(1);
+ }
+
+ ///
+ /// Awaits one full tick of the hosted service. The service runs an
+ /// eager startup tick inside 's
+ /// continuation, but the continuation is dispatched on a background
+ /// Task.Run — so we poll the side effect (the boundary count or
+ /// max-boundary value) until it changes.
+ ///
+ private async Task StartAndAwaitStartupTickAsync(
+ AuditLogPartitionMaintenanceService svc,
+ Func> awaitCondition,
+ TimeSpan timeout)
+ {
+ await svc.StartAsync(CancellationToken.None);
+ var deadline = DateTime.UtcNow + timeout;
+ while (DateTime.UtcNow < deadline)
+ {
+ if (await awaitCondition())
+ {
+ return;
+ }
+ await Task.Delay(50);
+ }
+ }
+
+ // ---------------------------------------------------------------------
+ // 1. EndToEnd_DefaultLookahead_NoSplit_WhenFutureCovered
+ // ---------------------------------------------------------------------
+
+ [SkippableFact]
+ public async Task EndToEnd_DefaultLookahead_NoSplit_WhenFutureCovered()
+ {
+ Skip.IfNot(_fixture.Available, _fixture.SkipReason);
+
+ await using var sp = BuildProvider();
+
+ // The migration seeds boundaries through Dec 2027. With default
+ // lookahead = 1 and today = ~2026-05-20, horizon =
+ // NormalizeToFirstOfMonth(now) + 1 = 2026-07-01, well within the
+ // seeded range, so the startup tick should issue zero SPLITs.
+ var maxBefore = await ReadMaxBoundaryAsync(sp);
+ Assert.NotNull(maxBefore);
+
+ // Skip if the fixture DB already has boundaries past Dec 2027 from
+ // a prior test in this class — the lookahead-already-covered path
+ // is what we want to exercise, regardless of how far past Dec 2027
+ // the boundary may be.
+ var opts = Options.Create(new AuditLogPartitionMaintenanceOptions
+ {
+ IntervalSeconds = 60, // long enough that only the startup tick fires inside the test window
+ LookaheadMonths = 1,
+ });
+
+ var svc = new AuditLogPartitionMaintenanceService(
+ sp.GetRequiredService(),
+ opts,
+ NullLogger.Instance);
+
+ // Drive the startup tick. There is no public completion handle;
+ // poll until either (a) the max boundary changes (which would be a
+ // failure for this test) or (b) the polling window expires (success).
+ await svc.StartAsync(CancellationToken.None);
+ await Task.Delay(TimeSpan.FromSeconds(2));
+ await svc.StopAsync(CancellationToken.None);
+ svc.Dispose();
+
+ // Assert the max boundary is unchanged: no SPLIT was issued.
+ var maxAfter = await ReadMaxBoundaryAsync(sp);
+ Assert.Equal(maxBefore, maxAfter);
+ }
+
+ // ---------------------------------------------------------------------
+ // 2. EndToEnd_LookaheadLargerThanCovered_Splits_NewBoundaries
+ // ---------------------------------------------------------------------
+
+ [SkippableFact]
+ public async Task EndToEnd_LookaheadLargerThanCovered_Splits_NewBoundaries()
+ {
+ Skip.IfNot(_fixture.Available, _fixture.SkipReason);
+
+ await using var sp = BuildProvider();
+
+ var maxBefore = await ReadMaxBoundaryAsync(sp);
+ Assert.NotNull(maxBefore);
+
+ // Pick a lookahead that adds exactly two new boundaries past the
+ // current max. The expected new boundaries are max+1mo and max+2mo.
+ var lookahead = LookaheadForExtraBoundaries(maxBefore.Value, extraBoundaries: 2);
+ var expectedFirstNew = maxBefore.Value.AddMonths(1);
+ var expectedSecondNew = maxBefore.Value.AddMonths(2);
+
+ var opts = Options.Create(new AuditLogPartitionMaintenanceOptions
+ {
+ IntervalSeconds = 60,
+ LookaheadMonths = lookahead,
+ });
+
+ var svc = new AuditLogPartitionMaintenanceService(
+ sp.GetRequiredService(),
+ opts,
+ NullLogger.Instance);
+
+ // Drive the startup tick. Wait until max boundary moves forward by
+ // the expected amount; SPLIT against MSSQL can take a second or two
+ // on a busy dev container.
+ await StartAndAwaitStartupTickAsync(
+ svc,
+ async () =>
+ {
+ var current = await ReadMaxBoundaryAsync(sp);
+ return current == expectedSecondNew;
+ },
+ timeout: TimeSpan.FromSeconds(15));
+
+ await svc.StopAsync(CancellationToken.None);
+ svc.Dispose();
+
+ var maxAfter = await ReadMaxBoundaryAsync(sp);
+ // Two new boundaries should be present after the startup tick. The
+ // hosted service does not surface the added-list directly (it logs
+ // only at Information), so we assert via the max-boundary delta.
+ Assert.Equal(expectedSecondNew, maxAfter);
+ // Sanity: the intermediate boundary was also added (the loop
+ // SPLITs every month from max+1 up to horizon, in order).
+ Assert.True(expectedFirstNew < expectedSecondNew);
+ }
+
+ // ---------------------------------------------------------------------
+ // 3. EndToEnd_PartitionMaintenance_Idempotent_OverTwoRuns
+ // ---------------------------------------------------------------------
+
+ [SkippableFact]
+ public async Task EndToEnd_PartitionMaintenance_Idempotent_OverTwoRuns()
+ {
+ Skip.IfNot(_fixture.Available, _fixture.SkipReason);
+
+ await using var sp = BuildProvider();
+
+ var maxBefore = await ReadMaxBoundaryAsync(sp);
+ Assert.NotNull(maxBefore);
+
+ // Add exactly one new boundary on the first run.
+ var lookahead = LookaheadForExtraBoundaries(maxBefore.Value, extraBoundaries: 1);
+ var expectedAdded = maxBefore.Value.AddMonths(1);
+
+ var opts = Options.Create(new AuditLogPartitionMaintenanceOptions
+ {
+ IntervalSeconds = 60,
+ LookaheadMonths = lookahead,
+ });
+
+ // First run.
+ var svc1 = new AuditLogPartitionMaintenanceService(
+ sp.GetRequiredService(),
+ opts,
+ NullLogger.Instance);
+ await StartAndAwaitStartupTickAsync(
+ svc1,
+ async () =>
+ {
+ var current = await ReadMaxBoundaryAsync(sp);
+ return current == expectedAdded;
+ },
+ timeout: TimeSpan.FromSeconds(15));
+ await svc1.StopAsync(CancellationToken.None);
+ svc1.Dispose();
+
+ var maxAfterFirst = await ReadMaxBoundaryAsync(sp);
+ Assert.Equal(expectedAdded, maxAfterFirst);
+
+ // Second run with the SAME lookahead value. Because the boundary
+ // is already covered, the EnsureLookaheadAsync call must be a
+ // no-op — max boundary is unchanged AND no exception is thrown.
+ var svc2 = new AuditLogPartitionMaintenanceService(
+ sp.GetRequiredService(),
+ opts,
+ NullLogger.Instance);
+ await svc2.StartAsync(CancellationToken.None);
+ // Wait long enough that the startup tick would have fired and
+ // logged any boundary addition; the boundary state must remain
+ // unchanged after the wait.
+ await Task.Delay(TimeSpan.FromSeconds(2));
+ await svc2.StopAsync(CancellationToken.None);
+ svc2.Dispose();
+
+ var maxAfterSecond = await ReadMaxBoundaryAsync(sp);
+ Assert.Equal(maxAfterFirst, maxAfterSecond);
+ }
+}