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

360 lines
17 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.Commons.Types.Deployment;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests;
/// <summary>
/// Coverage for the startup-reconciliation repo methods on <see cref="DeploymentManagerRepository"/>:
/// <see cref="IDeploymentManagerRepository.GetExpectedDeploymentsForSiteAsync"/> (central's deployed
/// set for a site, excluding instances with no snapshot) and
/// <see cref="IDeploymentManagerRepository.StagePendingIfAbsentAsync"/> (insert-if-absent, used by
/// the reconcile handler to mint fresh fetch tokens for the gap without clobbering an in-flight deploy).
/// Uses the shared SQLite in-memory fixture with real FK enforcement.
/// </summary>
public class ReconcileRepositoryTests : IDisposable
{
private readonly ScadaBridgeDbContext _context;
private readonly DeploymentManagerRepository _repository;
/// <summary>Initializes the test with an in-memory database context and repository.</summary>
public ReconcileRepositoryTests()
{
_context = SqliteTestHelper.CreateInMemoryContext();
_repository = new DeploymentManagerRepository(_context);
}
/// <summary>Releases the resources held by this test class.</summary>
public void Dispose()
{
_context.Database.CloseConnection();
_context.Dispose();
}
// --- Seed helpers ---
/// <summary>Seeds a Site row and returns its generated id.</summary>
private async Task<int> SeedSiteAsync(string name)
{
var site = new Site(name, $"SI-{name}");
_context.Sites.Add(site);
await _context.SaveChangesAsync();
return site.Id;
}
/// <summary>Seeds a Template + Instance on the given site and returns the instance id.</summary>
private async Task<int> SeedInstanceAsync(string uniqueName, int siteId, InstanceState state = InstanceState.Enabled)
{
var template = new Template($"T-{uniqueName}");
_context.Templates.Add(template);
await _context.SaveChangesAsync();
var instance = new Instance(uniqueName) { SiteId = siteId, TemplateId = template.Id, State = state };
_context.Instances.Add(instance);
await _context.SaveChangesAsync();
return instance.Id;
}
/// <summary>Seeds a <see cref="DeployedConfigSnapshot"/> for the given instance and returns it.</summary>
private async Task<DeployedConfigSnapshot> SeedSnapshotAsync(int instanceId, string deploymentId, string revisionHash)
{
var snapshot = new DeployedConfigSnapshot(deploymentId, revisionHash, "{}") { InstanceId = instanceId };
_context.Set<DeployedConfigSnapshot>().Add(snapshot);
await _context.SaveChangesAsync();
return snapshot;
}
// --- GetExpectedDeploymentsForSiteAsync ---
/// <summary>Verifies that only deployed instances belonging to the site are returned as expected deployments.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetExpectedDeployments_ReturnsOnlyDeployedInstancesForSite()
{
// Arrange: site A has two deployed instances (one Enabled, one Disabled)
// site B has one deployed instance
// site A also has one instance WITHOUT a snapshot (must be excluded)
var siteAId = await SeedSiteAsync("SiteA");
var siteBId = await SeedSiteAsync("SiteB");
var instA1Id = await SeedInstanceAsync("A-Inst1", siteAId, InstanceState.Enabled);
var instA2Id = await SeedInstanceAsync("A-Inst2", siteAId, InstanceState.Disabled);
var instA3Id = await SeedInstanceAsync("A-Inst3-NoSnap", siteAId, InstanceState.Enabled);
var instB1Id = await SeedInstanceAsync("B-Inst1", siteBId, InstanceState.Enabled);
await SeedSnapshotAsync(instA1Id, "dep-a1", "hash-a1");
await SeedSnapshotAsync(instA2Id, "dep-a2", "hash-a2");
// instA3 intentionally has no snapshot
await SeedSnapshotAsync(instB1Id, "dep-b1", "hash-b1");
// Act
var result = await _repository.GetExpectedDeploymentsForSiteAsync(siteAId);
// Assert: exactly the two site-A instances that have snapshots
Assert.Equal(2, result.Count);
var byName = result.ToDictionary(r => r.InstanceUniqueName);
Assert.True(byName.ContainsKey("A-Inst1"), "A-Inst1 should be present");
Assert.True(byName.ContainsKey("A-Inst2"), "A-Inst2 should be present");
Assert.False(byName.ContainsKey("A-Inst3-NoSnap"), "instance without snapshot must be excluded");
Assert.False(byName.ContainsKey("B-Inst1"), "site B instance must be excluded");
// Check field correctness for A-Inst1 (Enabled)
var a1 = byName["A-Inst1"];
Assert.Equal(instA1Id, a1.InstanceId);
Assert.Equal("dep-a1", a1.DeploymentId);
Assert.Equal("hash-a1", a1.RevisionHash);
Assert.True(a1.IsEnabled);
// Check field correctness for A-Inst2 (Disabled)
var a2 = byName["A-Inst2"];
Assert.Equal(instA2Id, a2.InstanceId);
Assert.Equal("dep-a2", a2.DeploymentId);
Assert.Equal("hash-a2", a2.RevisionHash);
Assert.False(a2.IsEnabled);
}
/// <summary>Verifies that no expected deployments are returned when the site has no deployed instances.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetExpectedDeployments_NoDeployedInstances_ReturnsEmpty()
{
var siteId = await SeedSiteAsync("EmptySite");
// Seed an instance but no snapshot
await SeedInstanceAsync("Inst-NoSnap", siteId);
var result = await _repository.GetExpectedDeploymentsForSiteAsync(siteId);
Assert.Empty(result);
}
// --- StagePendingIfAbsentAsync ---
/// <summary>Verifies that staging a pending deployment with no prior row stages it and returns true.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StagePendingIfAbsent_NoPriorRow_StagesAndReturnsTrue()
{
// Arrange
var siteId = await SeedSiteAsync("SiteX");
var instanceId = await SeedInstanceAsync("InstX", siteId);
var now = DateTimeOffset.UtcNow;
// Act
var staged = await _repository.StagePendingIfAbsentAsync(
instanceId,
deploymentId: "dep-new",
revisionHash: "rev-new",
configurationJson: "{\"x\":1}",
token: "tok-new",
createdAtUtc: now,
expiresAtUtc: now.AddMinutes(10));
// Assert: staged, and the row is retrievable with the correct token + config
Assert.True(staged);
var row = await _repository.GetPendingDeploymentByIdAsync("dep-new");
Assert.NotNull(row);
Assert.Equal(instanceId, row!.InstanceId);
Assert.Equal("tok-new", row.Token);
Assert.Equal("{\"x\":1}", row.ConfigurationJson);
Assert.Equal("rev-new", row.RevisionHash);
}
/// <summary>Verifies that staging a pending deployment when a row already exists returns false and does not overwrite it.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StagePendingIfAbsent_PriorRowExists_ReturnsFalseAndDoesNotOverwrite()
{
// Arrange: an existing pending row for the instance (simulates an in-flight deploy)
var siteId = await SeedSiteAsync("SiteY");
var instanceId = await SeedInstanceAsync("InstY", siteId);
var now = DateTimeOffset.UtcNow;
var existing = new PendingDeployment(
"dep-existing", instanceId, revisionHash: "rev-existing",
configurationJson: "{\"existing\":true}", token: "tok-existing",
createdAtUtc: now.AddMinutes(-5), expiresAtUtc: now.AddMinutes(5));
_context.Set<PendingDeployment>().Add(existing);
await _context.SaveChangesAsync();
// Act: try to stage a newer row for the same instance
var staged = await _repository.StagePendingIfAbsentAsync(
instanceId,
deploymentId: "dep-reconcile",
revisionHash: "rev-reconcile",
configurationJson: "{\"reconcile\":true}",
token: "tok-reconcile",
createdAtUtc: now,
expiresAtUtc: now.AddMinutes(10));
// Assert: skipped (returns false), existing row is unchanged
Assert.False(staged);
var existingRow = await _repository.GetPendingDeploymentByIdAsync("dep-existing");
Assert.NotNull(existingRow);
Assert.Equal("tok-existing", existingRow!.Token);
Assert.Equal("{\"existing\":true}", existingRow.ConfigurationJson);
// The reconcile row must not exist
Assert.Null(await _repository.GetPendingDeploymentByIdAsync("dep-reconcile"));
}
// --- review 2026-10-21 03/P-1: batched reconcile reads/stages ---
private static PendingDeployment Candidate(int instanceId, string deploymentId, DateTimeOffset now) =>
new(deploymentId, instanceId, "rev-" + deploymentId, $"{{\"cfg\":\"{deploymentId}\"}}",
"tok-" + deploymentId, now, now.AddMinutes(5));
/// <summary>The gap snapshot read returns only the requested instances, untracked.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetDeployedSnapshotsByInstanceIds_ReturnsRequestedOnly_Untracked()
{
var siteId = await SeedSiteAsync("SiteBatch");
var a = await SeedInstanceAsync("BatchA", siteId);
var b = await SeedInstanceAsync("BatchB", siteId);
var c = await SeedInstanceAsync("BatchC", siteId);
await SeedSnapshotAsync(a, "dep-a", "rev-a");
await SeedSnapshotAsync(b, "dep-b", "rev-b");
_context.ChangeTracker.Clear();
var snapshots = await _repository.GetDeployedSnapshotsByInstanceIdsAsync(new[] { a, c });
var only = Assert.Single(snapshots);
Assert.Equal(a, only.Key);
Assert.Equal("dep-a", only.Value.DeploymentId);
Assert.Empty(_context.ChangeTracker.Entries<DeployedConfigSnapshot>());
}
/// <summary>
/// The batch stage inserts every candidate without a live row in one call, hands back an
/// existing live row (without its JSON) instead of superseding it, and drops expired rows.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StagePendingBatchIfAbsent_StagesAbsent_KeepsLive_ReplacesExpired()
{
var siteId = await SeedSiteAsync("SiteStage");
var fresh = await SeedInstanceAsync("StageFresh", siteId);
var inFlight = await SeedInstanceAsync("StageLive", siteId);
var expired = await SeedInstanceAsync("StageExpired", siteId);
var now = DateTimeOffset.UtcNow;
_context.Set<PendingDeployment>().AddRange(
new PendingDeployment("dep-live", inFlight, "rev-live", "{\"live\":true}", "tok-live",
now.AddMinutes(-1), now.AddMinutes(4)),
// Same DeploymentId the reconcile re-stages — the expired row must go first.
new PendingDeployment("dep-exp", expired, "rev-old", "{\"old\":true}", "tok-old",
now.AddMinutes(-20), now.AddMinutes(-10)));
await _context.SaveChangesAsync();
_context.ChangeTracker.Clear();
var freshCandidate = Candidate(fresh, "dep-fresh", now);
var liveCandidate = Candidate(inFlight, "dep-other", now);
var expiredCandidate = Candidate(expired, "dep-exp", now);
var result = await _repository.StagePendingBatchIfAbsentAsync(
new[] { freshCandidate, liveCandidate, expiredCandidate }, now);
Assert.Equal(3, result.Count);
Assert.Same(freshCandidate, result[fresh]);
Assert.Same(expiredCandidate, result[expired]);
var keptLive = result[inFlight];
Assert.NotSame(liveCandidate, keptLive);
Assert.Equal("dep-live", keptLive.DeploymentId);
Assert.Equal("tok-live", keptLive.Token);
Assert.Equal(string.Empty, keptLive.ConfigurationJson);
Assert.Equal("tok-dep-exp", (await _repository.GetPendingDeploymentByIdAsync("dep-exp"))!.Token);
Assert.NotNull(await _repository.GetPendingDeploymentByIdAsync("dep-fresh"));
Assert.Null(await _repository.GetPendingDeploymentByIdAsync("dep-other"));
}
/// <summary>
/// A batch whose single save fails (here: one candidate's instance was deleted, FK) falls
/// back to per-row staging, so the good rows are still staged and only the bad one is
/// omitted.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StagePendingBatchIfAbsent_OneBadRow_OthersStillStaged()
{
var siteId = await SeedSiteAsync("SiteFk");
var good = await SeedInstanceAsync("FkGood", siteId);
var now = DateTimeOffset.UtcNow;
const int deletedInstanceId = 987_654;
var goodCandidate = Candidate(good, "dep-good", now);
var result = await _repository.StagePendingBatchIfAbsentAsync(
new[] { goodCandidate, Candidate(deletedInstanceId, "dep-gone", now) }, now);
var only = Assert.Single(result);
Assert.Equal(good, only.Key);
Assert.NotNull(await _repository.GetPendingDeploymentByIdAsync("dep-good"));
Assert.Null(await _repository.GetPendingDeploymentByIdAsync("dep-gone"));
}
/// <summary>
/// review 2026-10-21 04/P2: the pending-updates instance list is a slim, untracked projection
/// of the deployed (non-NotDeployed) instances.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetDeployedInstanceSummaries_ExcludesNotDeployed_Untracked()
{
var siteId = await SeedSiteAsync("SiteSlim");
var enabled = await SeedInstanceAsync("SlimEnabled", siteId, InstanceState.Enabled);
var disabled = await SeedInstanceAsync("SlimDisabled", siteId, InstanceState.Disabled);
await SeedInstanceAsync("SlimNotDeployed", siteId, InstanceState.NotDeployed);
_context.ChangeTracker.Clear();
var summaries = await _repository.GetDeployedInstanceSummariesAsync();
Assert.Equal(new[] { enabled, disabled }.Order(), summaries.Select(i => i.Id).Order());
var e = summaries.Single(i => i.Id == enabled);
Assert.Equal(siteId, e.SiteId);
Assert.Equal(InstanceState.Enabled, e.State);
Assert.Equal("SlimEnabled", e.UniqueName);
Assert.Empty(_context.ChangeTracker.Entries<Instance>());
}
/// <summary>
/// review 2026-10-21 04/P1: the batch current-status read returns, per instance, the same
/// record GetCurrentDeploymentStatusAsync would (latest DeployedAt, then highest Id), in
/// one untracked query.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetCurrentDeploymentStatuses_MatchesPerInstanceCurrentRecord()
{
var siteId = await SeedSiteAsync("SiteStatus");
var a = await SeedInstanceAsync("StatusA", siteId);
var b = await SeedInstanceAsync("StatusB", siteId);
var c = await SeedInstanceAsync("StatusC", siteId);
var t0 = DateTimeOffset.UtcNow.AddHours(-1);
_context.DeploymentRecords.AddRange(
new DeploymentRecord("a-old", "admin") { InstanceId = a, Status = DeploymentStatus.Success, DeployedAt = t0 },
new DeploymentRecord("a-new", "admin") { InstanceId = a, Status = DeploymentStatus.InProgress, DeployedAt = t0.AddMinutes(5) },
// Tie on DeployedAt: the higher Id (inserted later) wins.
new DeploymentRecord("b-1", "admin") { InstanceId = b, Status = DeploymentStatus.Success, DeployedAt = t0 },
new DeploymentRecord("b-2", "admin") { InstanceId = b, Status = DeploymentStatus.Failed, DeployedAt = t0 });
await _context.SaveChangesAsync();
_context.ChangeTracker.Clear();
var current = await _repository.GetCurrentDeploymentStatusesAsync(new[] { a, b, c });
Assert.Equal(2, current.Count);
Assert.Equal("a-new", current[a].DeploymentId);
Assert.Equal("b-2", current[b].DeploymentId);
Assert.False(current.ContainsKey(c));
Assert.Equal((await _repository.GetCurrentDeploymentStatusAsync(b))!.DeploymentId, current[b].DeploymentId);
}
}