diff --git a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/DeployNotifyFetchTests.cs b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/DeployNotifyFetchTests.cs
new file mode 100644
index 00000000..ae85ac4a
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/DeployNotifyFetchTests.cs
@@ -0,0 +1,338 @@
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.DataProtection;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.TestHost;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Diagnostics;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging.Abstractions;
+using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment;
+using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
+using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
+using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
+using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
+using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
+using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
+using ZB.MOM.WW.ScadaBridge.ManagementService;
+using ZB.MOM.WW.ScadaBridge.SiteRuntime.Deployment;
+
+namespace ZB.MOM.WW.ScadaBridge.IntegrationTests;
+
+///
+/// Integration coverage for the "notify-and-fetch" deploy seam: central stages a flattened
+/// config in a row and serves it from
+/// GET /api/internal/deployments/{deploymentId}/config (token in the
+/// X-Deployment-Token header); the SITE fetches it over HTTP via
+/// . This decouples config size from Akka's 128 KB
+/// remote-frame limit.
+///
+///
+/// Harness. A minimal in-memory ASP.NET Core (NOT the full
+/// WebApplicationFactory<Program>) maps ONLY the real
+/// endpoint over a real
+/// backed by a real (SQLite in-memory)
+/// . The light TestServer is preferred over the full Host
+/// because the central Host startup (migrations, Akka remoting, cluster bootstrap) cannot run
+/// in this sandbox — and the real seam under test is the production serve-endpoint plus the
+/// production fetcher over an in-memory HTTP transport, both of which run hermetically.
+///
+///
+///
+/// Sandbox note. A full two-cluster end-to-end over Akka.Remote dot-netty sockets is
+/// NOT exercised here (sockets cannot bind in this sandbox); the actor-level apply/replication
+/// is covered by unit tests and the real cluster by a separate live-smoke task. These tests
+/// prove the HTTP seam — the part the size-limit fix hinges on.
+///
+///
+public sealed class DeployNotifyFetchTests
+{
+ ///
+ /// SQLite-adapted : maps columns
+ /// to sortable ISO 8601 strings (SQLite has no native DateTimeOffset), drops the SQL Server
+ /// rowversion concurrency token on , and is constructed with an
+ /// ephemeral Data Protection provider so secret-bearing columns are write-capable.
+ ///
+ private sealed class SqliteFetchDbContext : ScadaBridgeDbContext
+ {
+ public SqliteFetchDbContext(DbContextOptions options)
+ : base(options, new EphemeralDataProtectionProvider())
+ {
+ }
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ base.OnModelCreating(modelBuilder);
+
+ // SQLite cannot auto-generate SQL Server rowversion values.
+ modelBuilder.Entity(builder =>
+ {
+ builder.Property(d => d.RowVersion)
+ .IsRequired(false)
+ .IsConcurrencyToken(false)
+ .ValueGeneratedNever();
+ });
+
+ var converter = new ValueConverter(
+ v => v.UtcDateTime.ToString("o"),
+ v => DateTimeOffset.Parse(v));
+ var nullableConverter = new ValueConverter(
+ v => v.HasValue ? v.Value.UtcDateTime.ToString("o") : null,
+ v => v != null ? DateTimeOffset.Parse(v) : null);
+
+ foreach (var entityType in modelBuilder.Model.GetEntityTypes())
+ {
+ foreach (var property in entityType.GetProperties())
+ {
+ if (property.ClrType == typeof(DateTimeOffset))
+ {
+ property.SetValueConverter(converter);
+ property.SetColumnType("TEXT");
+ }
+ else if (property.ClrType == typeof(DateTimeOffset?))
+ {
+ property.SetValueConverter(nullableConverter);
+ property.SetColumnType("TEXT");
+ }
+ }
+ }
+ }
+ }
+
+ ///
+ /// One self-contained test fixture: a kept-open SQLite in-memory connection (an in-memory
+ /// database is destroyed when its last connection closes), an in-memory
+ /// hosting ONLY the real config-fetch endpoint over a real repository, and the in-memory
+ /// the production fetcher drives.
+ ///
+ private sealed class FetchHarness : IDisposable
+ {
+ private readonly SqliteConnection _connection;
+ private readonly IHost _host;
+ private readonly DbContextOptions _dbOptions;
+
+ public HttpClient Client { get; }
+
+ /// Base URL the site presents to the fetcher (the test server's address).
+ public string BaseUrl { get; }
+
+ private FetchHarness(
+ SqliteConnection connection,
+ IHost host,
+ DbContextOptions dbOptions,
+ HttpClient client)
+ {
+ _connection = connection;
+ _host = host;
+ _dbOptions = dbOptions;
+ Client = client;
+ BaseUrl = client.BaseAddress!.ToString();
+ }
+
+ public static async Task CreateAsync()
+ {
+ var connection = new SqliteConnection("DataSource=:memory:");
+ connection.Open();
+
+ var dbOptions = new DbContextOptionsBuilder()
+ .UseSqlite(connection)
+ .ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning))
+ .Options;
+
+ // Create the schema once on a throwaway context over the shared connection.
+ using (var schemaContext = new SqliteFetchDbContext(dbOptions))
+ {
+ await schemaContext.Database.EnsureCreatedAsync();
+ }
+
+ // Minimal host: routing + the REAL serve-endpoint over the REAL repository.
+ var host = await new HostBuilder()
+ .ConfigureWebHost(webBuilder =>
+ {
+ webBuilder
+ .UseTestServer()
+ .ConfigureServices(services =>
+ {
+ services.AddRouting();
+ services.AddScoped(_ => new SqliteFetchDbContext(dbOptions));
+ services.AddScoped(sp =>
+ new DeploymentManagerRepository(sp.GetRequiredService()));
+ })
+ .Configure(app =>
+ {
+ app.UseRouting();
+ app.UseEndpoints(endpoints => endpoints.MapDeploymentConfigAPI());
+ });
+ })
+ .StartAsync();
+
+ var client = host.GetTestClient();
+ return new FetchHarness(connection, host, dbOptions, client);
+ }
+
+ ///
+ /// Seeds an (with its required + )
+ /// and returns its generated id. The PendingDeployment → Instance FK is enforced by SQLite, so a
+ /// real row is required.
+ ///
+ public async Task SeedInstanceAsync(string uniqueName)
+ {
+ await using var ctx = new SqliteFetchDbContext(_dbOptions);
+ var site = new Site($"Site-{uniqueName}", $"S-{uniqueName}");
+ var template = new Template($"T-{uniqueName}");
+ ctx.Sites.Add(site);
+ ctx.Templates.Add(template);
+ await ctx.SaveChangesAsync();
+
+ var instance = new Instance(uniqueName) { SiteId = site.Id, TemplateId = template.Id };
+ ctx.Instances.Add(instance);
+ await ctx.SaveChangesAsync();
+ return instance.Id;
+ }
+
+ ///
+ /// Stages a pending deployment through the REAL repository path
+ /// ( + SaveChanges), so
+ /// supersession of any prior row for the same instance is exercised exactly as in production.
+ ///
+ public async Task StageAsync(PendingDeployment pending)
+ {
+ await using var ctx = new SqliteFetchDbContext(_dbOptions);
+ var repo = new DeploymentManagerRepository(ctx);
+ await repo.AddPendingDeploymentAsync(pending);
+ await repo.SaveChangesAsync();
+ }
+
+ public void Dispose()
+ {
+ Client.Dispose();
+ _host.Dispose();
+ _connection.Dispose();
+ }
+ }
+
+ private static HttpDeploymentConfigFetcher NewFetcher(FetchHarness harness)
+ => new(harness.Client, NullLogger.Instance);
+
+ private static PendingDeployment NewPending(
+ string deploymentId, int instanceId, string config, string token, DateTimeOffset expiresAtUtc)
+ => new(
+ deploymentId, instanceId, revisionHash: "rev-" + deploymentId,
+ configurationJson: config, token: token,
+ createdAtUtc: DateTimeOffset.UtcNow.AddMinutes(-1), expiresAtUtc: expiresAtUtc);
+
+ ///
+ /// Deterministic JSON string comfortably larger than Akka's 128 KB remote-frame limit
+ /// (~204 KB here). It need not be a real FlattenedConfiguration — the endpoint serves the
+ /// stored string verbatim.
+ ///
+ private static string BuildLargeConfigJson()
+ {
+ // 200 KiB of payload + JSON wrapper. ASCII only, so byte-for-byte == char-for-char.
+ var payload = new string('A', 200 * 1024);
+ return "{\"payload\":\"" + payload + "\"}";
+ }
+
+ // ----------------------------------------------------------------------------------------
+ // 1. Large config round-trips: the core proof that the 128 KB size limit is gone.
+ // ----------------------------------------------------------------------------------------
+
+ [Fact]
+ public async Task FetchAsync_LargeConfigOver128Kb_RoundTripsExactBytes()
+ {
+ using var harness = await FetchHarness.CreateAsync();
+ var fetcher = NewFetcher(harness);
+ var instanceId = await harness.SeedInstanceAsync("LargeInst");
+
+ var largeJson = BuildLargeConfigJson();
+ Assert.True(
+ largeJson.Length > 128 * 1024,
+ $"staged config must exceed the old 128 KB Akka frame limit; was {largeJson.Length} bytes");
+
+ await harness.StageAsync(NewPending(
+ "dep-large", instanceId, largeJson, token: "tok-large",
+ expiresAtUtc: DateTimeOffset.UtcNow.AddMinutes(10)));
+
+ var fetched = await fetcher.FetchAsync(harness.BaseUrl, "dep-large", "tok-large", CancellationToken.None);
+
+ // Byte-for-byte (ordinal) round-trip of a >128 KB body over the HTTP seam.
+ Assert.Equal(largeJson, fetched, StringComparer.Ordinal);
+ Assert.True(
+ fetched.Length > 128 * 1024,
+ $"fetched config must exceed the old 128 KB Akka frame limit; was {fetched.Length} bytes");
+ }
+
+ // ----------------------------------------------------------------------------------------
+ // 2. Supersession: staging B for an instance deletes its prior pending row A.
+ // Fetching A -> 404 (IsSuperseded); fetching B -> B's config.
+ // ----------------------------------------------------------------------------------------
+
+ [Fact]
+ public async Task FetchAsync_SupersededDeployment_Throws404SupersededAndServesNewest()
+ {
+ using var harness = await FetchHarness.CreateAsync();
+ var fetcher = NewFetcher(harness);
+ var instanceId = await harness.SeedInstanceAsync("SupersedeInst");
+ var future = DateTimeOffset.UtcNow.AddMinutes(10);
+
+ await harness.StageAsync(NewPending("dep-A", instanceId, "{\"v\":1}", "tok-A", future));
+ // Staging B for the SAME instance supersedes (deletes) A via the real repository path.
+ await harness.StageAsync(NewPending("dep-B", instanceId, "{\"v\":2}", "tok-B", future));
+
+ // A is gone -> 404 -> IsSuperseded.
+ var ex = await Assert.ThrowsAsync(
+ () => fetcher.FetchAsync(harness.BaseUrl, "dep-A", "tok-A", CancellationToken.None));
+ Assert.True(ex.IsSuperseded);
+
+ // B is current and still served.
+ var fetchedB = await fetcher.FetchAsync(harness.BaseUrl, "dep-B", "tok-B", CancellationToken.None);
+ Assert.Equal("{\"v\":2}", fetchedB, StringComparer.Ordinal);
+ }
+
+ // ----------------------------------------------------------------------------------------
+ // 3. Wrong token: a live, non-expired row with a bad token -> 401 (NOT 404).
+ // ----------------------------------------------------------------------------------------
+
+ [Fact]
+ public async Task FetchAsync_WrongToken_Throws401NotSuperseded()
+ {
+ using var harness = await FetchHarness.CreateAsync();
+ var fetcher = NewFetcher(harness);
+ var instanceId = await harness.SeedInstanceAsync("TokenInst");
+
+ await harness.StageAsync(NewPending(
+ "dep-tok", instanceId, "{\"v\":1}", token: "right-token",
+ expiresAtUtc: DateTimeOffset.UtcNow.AddMinutes(10)));
+
+ var ex = await Assert.ThrowsAsync(
+ () => fetcher.FetchAsync(harness.BaseUrl, "dep-tok", "wrong-token", CancellationToken.None));
+
+ // 401 (not 404): a valid, live id with a bad token is unauthorized, not "gone".
+ Assert.False(ex.IsSuperseded);
+ }
+
+ // ----------------------------------------------------------------------------------------
+ // 4. Expired: a row past its TTL is indistinguishable from gone -> 404 (IsSuperseded),
+ // even with the correct token.
+ // ----------------------------------------------------------------------------------------
+
+ [Fact]
+ public async Task FetchAsync_ExpiredRow_Throws404SupersededEvenWithCorrectToken()
+ {
+ using var harness = await FetchHarness.CreateAsync();
+ var fetcher = NewFetcher(harness);
+ var instanceId = await harness.SeedInstanceAsync("ExpiredInst");
+
+ await harness.StageAsync(NewPending(
+ "dep-exp", instanceId, "{\"v\":1}", token: "tok-exp",
+ expiresAtUtc: DateTimeOffset.UtcNow.AddMinutes(-1))); // TTL already elapsed
+
+ var ex = await Assert.ThrowsAsync(
+ () => fetcher.FetchAsync(harness.BaseUrl, "dep-exp", "tok-exp", CancellationToken.None));
+
+ // Expired is hidden as 404 (existence-hiding), so IsSuperseded is set.
+ Assert.True(ex.IsSuperseded);
+ }
+}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/ZB.MOM.WW.ScadaBridge.IntegrationTests.csproj b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/ZB.MOM.WW.ScadaBridge.IntegrationTests.csproj
index 6b80cb32..e25ecc1e 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/ZB.MOM.WW.ScadaBridge.IntegrationTests.csproj
+++ b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/ZB.MOM.WW.ScadaBridge.IntegrationTests.csproj
@@ -16,6 +16,7 @@
+
@@ -36,6 +37,8 @@
+
+