Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/PendingDeploymentRepositoryTests.cs
T

420 lines
20 KiB
C#

using Microsoft.EntityFrameworkCore;
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.ConfigurationDatabase.Repositories;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests;
/// <summary>
/// Coverage for the notify-and-fetch <see cref="PendingDeployment"/> staging store on
/// <see cref="DeploymentManagerRepository"/>: supersession (a newer deploy for the same
/// instance replaces the prior pending row), multi-instance coexistence, TTL purge, and
/// delete-by-id. Uses the shared SQLite in-memory fixture, which enforces the
/// PendingDeployment → Instance FK, so each test seeds real Instance rows.
/// </summary>
public class PendingDeploymentRepositoryTests : IDisposable
{
private readonly ScadaBridgeDbContext _context;
private readonly DeploymentManagerRepository _repository;
private readonly SqlReaderCommandRecorder _recorder = new();
/// <summary>Initializes the test class with an in-memory context and repository.</summary>
public PendingDeploymentRepositoryTests()
{
_context = SqliteTestHelper.CreateInMemoryContext(_recorder);
_repository = new DeploymentManagerRepository(_context);
}
/// <summary>Releases the resources held by this test class.</summary>
public void Dispose()
{
_context.Database.CloseConnection();
_context.Dispose();
}
/// <summary>
/// Seeds an Instance (with its required Site + Template) and returns its generated id.
/// SQLite EnsureCreated enforces the FK to Instances, so a real row is required.
/// </summary>
private async Task<int> SeedInstanceAsync(string uniqueName)
{
var site = new Site($"Site-{uniqueName}", $"S-{uniqueName}");
var template = new Template($"T-{uniqueName}");
_context.Sites.Add(site);
_context.Templates.Add(template);
await _context.SaveChangesAsync();
var instance = new Instance(uniqueName) { SiteId = site.Id, TemplateId = template.Id };
_context.Instances.Add(instance);
await _context.SaveChangesAsync();
return instance.Id;
}
private static PendingDeployment NewPending(string deploymentId, int instanceId, string config)
{
var now = DateTimeOffset.UtcNow;
return new PendingDeployment(
deploymentId, instanceId, revisionHash: "rev-" + deploymentId,
configurationJson: config, token: "tok-" + deploymentId,
createdAtUtc: now, expiresAtUtc: now.AddMinutes(10));
}
/// <summary>Verifies that a second pending deployment for the same instance supersedes the prior row.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AddPendingDeployment_SecondDeployForSameInstance_SupersedesPriorRow()
{
var instanceId = await SeedInstanceAsync("Inst7");
await _repository.AddPendingDeploymentAsync(NewPending("dep1", instanceId, "{\"v\":1}"));
await _repository.SaveChangesAsync();
await _repository.AddPendingDeploymentAsync(NewPending("dep2", instanceId, "{\"v\":2}"));
await _repository.SaveChangesAsync();
// The prior pending row must be gone; only the newest deploy survives.
Assert.Null(await _repository.GetPendingDeploymentByIdAsync("dep1"));
var current = await _repository.GetPendingDeploymentByIdAsync("dep2");
Assert.NotNull(current);
Assert.Equal("{\"v\":2}", current!.ConfigurationJson);
Assert.Equal(instanceId, current.InstanceId);
}
/// <summary>Verifies that pending deployments for different instances coexist.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AddPendingDeployment_DifferentInstances_Coexist()
{
var instance1 = await SeedInstanceAsync("Inst1");
var instance2 = await SeedInstanceAsync("Inst2");
await _repository.AddPendingDeploymentAsync(NewPending("depA", instance1, "{\"a\":1}"));
await _repository.AddPendingDeploymentAsync(NewPending("depB", instance2, "{\"b\":1}"));
await _repository.SaveChangesAsync();
var a = await _repository.GetPendingDeploymentByIdAsync("depA");
var b = await _repository.GetPendingDeploymentByIdAsync("depB");
Assert.NotNull(a);
Assert.NotNull(b);
Assert.Equal(instance1, a!.InstanceId);
Assert.Equal(instance2, b!.InstanceId);
}
/// <summary>Verifies that purging expired pending deployments removes only the expired rows and returns the count.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PurgeExpiredPendingDeployments_RemovesOnlyExpired_ReturnsCount()
{
var instance1 = await SeedInstanceAsync("Inst1");
var instance2 = await SeedInstanceAsync("Inst2");
var instance3 = await SeedInstanceAsync("Inst3");
var now = DateTimeOffset.UtcNow;
// Two expired rows (ExpiresAtUtc <= now) and one live row (in the future).
await _repository.AddPendingDeploymentAsync(new PendingDeployment(
"expired-1", instance1, "rev", "{}", "tok", now.AddMinutes(-20), now.AddMinutes(-10)));
await _repository.SaveChangesAsync();
await _repository.AddPendingDeploymentAsync(new PendingDeployment(
"expired-2", instance2, "rev", "{}", "tok", now.AddMinutes(-20), now));
await _repository.SaveChangesAsync();
await _repository.AddPendingDeploymentAsync(new PendingDeployment(
"live-1", instance3, "rev", "{}", "tok", now, now.AddMinutes(10)));
await _repository.SaveChangesAsync();
var purged = await _repository.PurgeExpiredPendingDeploymentsAsync(now);
Assert.Equal(2, purged);
Assert.Null(await _repository.GetPendingDeploymentByIdAsync("expired-1"));
Assert.Null(await _repository.GetPendingDeploymentByIdAsync("expired-2"));
Assert.NotNull(await _repository.GetPendingDeploymentByIdAsync("live-1"));
}
/// <summary>Verifies that purging expired pending deployments returns zero when none are expired.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PurgeExpiredPendingDeployments_NoneExpired_ReturnsZero()
{
var instanceId = await SeedInstanceAsync("Inst1");
var now = DateTimeOffset.UtcNow;
await _repository.AddPendingDeploymentAsync(new PendingDeployment(
"live-1", instanceId, "rev", "{}", "tok", now, now.AddMinutes(10)));
await _repository.SaveChangesAsync();
var purged = await _repository.PurgeExpiredPendingDeploymentsAsync(now);
Assert.Equal(0, purged);
Assert.NotNull(await _repository.GetPendingDeploymentByIdAsync("live-1"));
}
/// <summary>Verifies that deleting a pending deployment by id removes the row.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeletePendingDeploymentById_RemovesRow()
{
var instanceId = await SeedInstanceAsync("Inst1");
await _repository.AddPendingDeploymentAsync(NewPending("dep1", instanceId, "{}"));
await _repository.SaveChangesAsync();
await _repository.DeletePendingDeploymentByIdAsync("dep1");
await _repository.SaveChangesAsync();
Assert.Null(await _repository.GetPendingDeploymentByIdAsync("dep1"));
}
/// <summary>Verifies that deleting a pending deployment with an unknown id does not throw.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeletePendingDeploymentById_UnknownId_NoThrow()
{
await _repository.DeletePendingDeploymentByIdAsync("does-not-exist");
await _repository.SaveChangesAsync();
Assert.Null(await _repository.GetPendingDeploymentByIdAsync("does-not-exist"));
}
/// <summary>Verifies that getting a pending deployment by instance id returns the row when present.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetPendingDeploymentByInstanceId_ReturnsRow_WhenPresent()
{
// Startup-reconcile read path: when StagePendingIfAbsent reports a row already exists
// (concurrent reconcile from the other node, or an in-flight deploy), the handler reads
// the existing row by instance id to hand the second node the same fetch token.
var instanceId = await SeedInstanceAsync("Inst9");
await _repository.AddPendingDeploymentAsync(NewPending("dep9", instanceId, "{\"v\":9}"));
await _repository.SaveChangesAsync();
var row = await _repository.GetPendingDeploymentByInstanceIdAsync(instanceId);
Assert.NotNull(row);
Assert.Equal("dep9", row!.DeploymentId);
Assert.Equal(instanceId, row.InstanceId);
Assert.Equal("tok-dep9", row.Token);
Assert.Equal("{\"v\":9}", row.ConfigurationJson);
}
/// <summary>Verifies that getting a pending deployment by instance id returns null when absent.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetPendingDeploymentByInstanceId_ReturnsNull_WhenAbsent()
{
// No pending row staged for this instance → null (the reconcile fallback/omit path).
var instanceId = await SeedInstanceAsync("Inst10");
var row = await _repository.GetPendingDeploymentByInstanceIdAsync(instanceId);
Assert.Null(row);
}
/// <summary>Verifies that staging is not blocked by an expired row with a different deployment id, which is removed and replaced by the live row.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StagePendingIfAbsent_ExpiredRowDifferentDeploymentId_DoesNotBlock_RemovesExpiredAndStagesLiveRow()
{
// Live-found bug: pending rows are only TTL-purged (the periodic purge is a deferred TODO),
// so an EXPIRED-but-unpurged row for the instance must NOT make staging report "a deploy is
// in flight" — it would hand the node an expired token (HTTP 404) and leave it unhealed.
// The expired row is removed and the fresh, live row is staged.
var instanceId = await SeedInstanceAsync("InstExp1");
var now = DateTimeOffset.UtcNow;
// Seed an EXPIRED pending row (different deploymentId), bypassing the repo.
_context.Set<PendingDeployment>().Add(new PendingDeployment(
"dep-expired", instanceId, "rev-old", "{\"old\":true}", "tok-old",
now.AddMinutes(-20), now.AddMinutes(-10)));
await _context.SaveChangesAsync();
var staged = await _repository.StagePendingIfAbsentAsync(
instanceId, "dep-fresh", "rev-fresh", "{\"fresh\":true}", "tok-fresh",
now, now.AddMinutes(10));
Assert.True(staged);
Assert.Null(await _repository.GetPendingDeploymentByIdAsync("dep-expired"));
var row = await _repository.GetPendingDeploymentByIdAsync("dep-fresh");
Assert.NotNull(row);
Assert.Equal(instanceId, row!.InstanceId);
Assert.Equal("tok-fresh", row.Token);
Assert.True(row.ExpiresAtUtc > now); // the new row is live
}
/// <summary>Verifies that staging over an expired row with the same deployment id causes no unique-key collision and stages the new row.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StagePendingIfAbsent_ExpiredRowSameDeploymentId_NoUniqueCollision_StagesNew()
{
// A fresh reconcile re-stages the snapshot's own DeploymentId. If an EXPIRED row already
// carries that same DeploymentId, staging must remove it FIRST to avoid colliding on the
// PendingDeployment.DeploymentId UNIQUE index — re-staging "D1" over an expired "D1" works.
var instanceId = await SeedInstanceAsync("InstExp2");
var now = DateTimeOffset.UtcNow;
_context.Set<PendingDeployment>().Add(new PendingDeployment(
"D1", instanceId, "rev-old", "{\"old\":true}", "tok-old",
now.AddMinutes(-20), now.AddMinutes(-10)));
await _context.SaveChangesAsync();
var staged = await _repository.StagePendingIfAbsentAsync(
instanceId, "D1", "rev-new", "{\"new\":true}", "tok-new",
now, now.AddMinutes(10));
Assert.True(staged);
var row = await _repository.GetPendingDeploymentByIdAsync("D1");
Assert.NotNull(row);
Assert.Equal("tok-new", row!.Token);
Assert.Equal("{\"new\":true}", row.ConfigurationJson);
Assert.True(row.ExpiresAtUtc > now);
}
/// <summary>Verifies that a live row blocks staging and is left untouched.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StagePendingIfAbsent_LiveRow_Blocks_LeavesUntouched()
{
// A still-LIVE pending row (future expiry) signals a genuine in-flight deploy or a concurrent
// reconcile — staging must return false and leave it untouched (no supersession).
var instanceId = await SeedInstanceAsync("InstLive");
var now = DateTimeOffset.UtcNow;
_context.Set<PendingDeployment>().Add(new PendingDeployment(
"dep-live", instanceId, "rev-live", "{\"live\":true}", "tok-live",
now.AddMinutes(-1), now.AddMinutes(9)));
await _context.SaveChangesAsync();
var staged = await _repository.StagePendingIfAbsentAsync(
instanceId, "dep-new", "rev-new", "{\"new\":true}", "tok-new",
now, now.AddMinutes(10));
Assert.False(staged);
var existing = await _repository.GetPendingDeploymentByIdAsync("dep-live");
Assert.NotNull(existing);
Assert.Equal("tok-live", existing!.Token);
Assert.Null(await _repository.GetPendingDeploymentByIdAsync("dep-new"));
}
/// <summary>Verifies that getting a pending deployment by instance id with an explicit current time returns the live, unexpired row.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetPendingDeploymentByInstanceId_WithNowUtc_ReturnsLiveRow_NotExpired()
{
// The by-instance getter must never hand back an EXPIRED row (whose token the config-fetch
// endpoint would 404). With nowUtc supplied, an expired row is filtered out; a live row is returned.
var expiredInst = await SeedInstanceAsync("InstGetExpired");
var liveInst = await SeedInstanceAsync("InstGetLive");
var now = DateTimeOffset.UtcNow;
_context.Set<PendingDeployment>().Add(new PendingDeployment(
"dep-getexp", expiredInst, "rev", "{}", "tok-exp",
now.AddMinutes(-20), now.AddMinutes(-10)));
_context.Set<PendingDeployment>().Add(new PendingDeployment(
"dep-getlive", liveInst, "rev", "{}", "tok-live",
now.AddMinutes(-1), now.AddMinutes(9)));
await _context.SaveChangesAsync();
// Expired row filtered out when nowUtc is supplied.
Assert.Null(await _repository.GetPendingDeploymentByInstanceIdAsync(expiredInst, now));
// Live row still returned.
var live = await _repository.GetPendingDeploymentByInstanceIdAsync(liveInst, now);
Assert.NotNull(live);
Assert.Equal("dep-getlive", live!.DeploymentId);
}
/// <summary>Verifies that adding a pending deployment supersedes all prior rows for the same instance.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AddPendingDeployment_MultiplePriorRowsSameInstance_AllSuperseded()
{
// Guards the defensive ToListAsync + RemoveRange supersession path: if a prior
// bug or corruption left >1 pending row for an instance, a new deploy must clear
// them all (a refactor to FirstOrDefault + Remove would silently regress this).
var instanceId = await SeedInstanceAsync("Inst8");
var now = DateTimeOffset.UtcNow;
// Force two pending rows for the same instance, bypassing the repo, to simulate corruption.
_context.Set<PendingDeployment>().AddRange(
new PendingDeployment("stale-a", instanceId, "rev", "{}", "tok", now.AddMinutes(-5), now.AddMinutes(5)),
new PendingDeployment("stale-b", instanceId, "rev", "{}", "tok", now.AddMinutes(-3), now.AddMinutes(7)));
await _context.SaveChangesAsync();
await _repository.AddPendingDeploymentAsync(NewPending("dep3", instanceId, "{\"v\":3}"));
await _repository.SaveChangesAsync();
Assert.Null(await _repository.GetPendingDeploymentByIdAsync("stale-a"));
Assert.Null(await _repository.GetPendingDeploymentByIdAsync("stale-b"));
Assert.NotNull(await _repository.GetPendingDeploymentByIdAsync("dep3"));
}
// ── review 2026-10-21 08/P6: pending deletes never materialise ConfigurationJson ──
/// <summary>True when the change tracker holds a pending row whose JSON is <paramref name="json"/>.</summary>
private bool TracksPendingJson(string json) =>
_context.ChangeTracker.Entries<PendingDeployment>().Any(e => e.Entity.ConfigurationJson == json);
/// <summary>
/// Supersession must delete the prior row by key, without loading its (tens-of-KB)
/// flattened config into the change tracker.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AddPendingDeployment_Supersede_DoesNotMaterialisePriorConfigJson()
{
var instanceId = await SeedInstanceAsync("InstP6a");
await _repository.AddPendingDeploymentAsync(NewPending("old", instanceId, "{\"prior\":true}"));
await _repository.SaveChangesAsync();
_context.ChangeTracker.Clear();
_recorder.Reset();
await _repository.AddPendingDeploymentAsync(NewPending("new", instanceId, "{\"v\":2}"));
Assert.False(TracksPendingJson("{\"prior\":true}"));
Assert.False(_recorder.AnySelectReads("ConfigurationJson"));
await _repository.SaveChangesAsync();
Assert.Null(await _repository.GetPendingDeploymentByIdAsync("old"));
Assert.NotNull(await _repository.GetPendingDeploymentByIdAsync("new"));
}
/// <summary>The TTL purge is a set-based delete: nothing is loaded into the tracker.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PurgeExpiredPendingDeployments_DoesNotMaterialiseRows()
{
var instanceId = await SeedInstanceAsync("InstP6b");
var now = DateTimeOffset.UtcNow;
await _repository.AddPendingDeploymentAsync(new PendingDeployment(
"exp", instanceId, "rev", "{\"expired\":true}", "tok", now.AddMinutes(-20), now.AddMinutes(-10)));
await _repository.SaveChangesAsync();
_context.ChangeTracker.Clear();
_recorder.Reset();
var purged = await _repository.PurgeExpiredPendingDeploymentsAsync(now);
Assert.Equal(1, purged);
Assert.False(_recorder.AnySelectReads("ConfigurationJson"));
Assert.Null(await _repository.GetPendingDeploymentByIdAsync("exp"));
}
/// <summary>StagePendingIfAbsent drops expired rows without reading their config.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StagePendingIfAbsent_ExpiredCleanup_DoesNotMaterialiseExpiredConfigJson()
{
var instanceId = await SeedInstanceAsync("InstP6c");
var now = DateTimeOffset.UtcNow;
await _repository.AddPendingDeploymentAsync(new PendingDeployment(
"dep-x", instanceId, "rev", "{\"expired\":true}", "tok", now.AddMinutes(-20), now.AddMinutes(-10)));
await _repository.SaveChangesAsync();
_context.ChangeTracker.Clear();
_recorder.Reset();
var staged = await _repository.StagePendingIfAbsentAsync(
instanceId, "dep-x", "rev2", "{\"fresh\":true}", "tok2", now, now.AddMinutes(5));
Assert.True(staged);
Assert.False(_recorder.AnySelectReads("ConfigurationJson"));
var row = await _repository.GetPendingDeploymentByIdAsync("dep-x");
Assert.Equal("{\"fresh\":true}", row!.ConfigurationJson);
}
}