fix(deployment): pending-deployment supersede/purge/stage deletes never read ConfigurationJson; drop stale deferred-TODO comment (review 2026-10-21 08/P6, 08/C2)

This commit is contained in:
Joseph Doherty
2026-10-22 06:18:32 -04:00
parent 0d4f7d33b9
commit da5a049077
3 changed files with 192 additions and 25 deletions
@@ -18,11 +18,12 @@ 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();
_context = SqliteTestHelper.CreateInMemoryContext(_recorder);
_repository = new DeploymentManagerRepository(_context);
}
@@ -344,4 +345,75 @@ public class PendingDeploymentRepositoryTests : IDisposable
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);
}
}
@@ -0,0 +1,46 @@
using System.Data.Common;
using Microsoft.EntityFrameworkCore.Diagnostics;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests;
/// <summary>
/// Records the text of every reader command a context executes, so a query-shape test can
/// assert what a repository method actually reads (e.g. that a delete never SELECTs a fat
/// JSON column).
/// </summary>
public sealed class SqlReaderCommandRecorder : DbCommandInterceptor
{
private readonly List<string> _commands = new();
/// <summary>The text of every reader command recorded since the last <see cref="Reset"/>.</summary>
public IReadOnlyList<string> Commands => _commands;
/// <summary>Clears all recorded commands.</summary>
public void Reset() => _commands.Clear();
/// <summary>
/// True when any recorded reader command is a SELECT that projects <paramref name="column"/>.
/// </summary>
/// <param name="column">Column name to look for.</param>
/// <returns>Whether a recorded SELECT read the column.</returns>
public bool AnySelectReads(string column) =>
_commands.Any(c => c.TrimStart().StartsWith("SELECT", StringComparison.OrdinalIgnoreCase)
&& c.Contains(column, StringComparison.Ordinal));
/// <inheritdoc />
public override InterceptionResult<DbDataReader> ReaderExecuting(
DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result)
{
_commands.Add(command.CommandText);
return base.ReaderExecuting(command, eventData, result);
}
/// <inheritdoc />
public override ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result,
CancellationToken cancellationToken = default)
{
_commands.Add(command.CommandText);
return base.ReaderExecutingAsync(command, eventData, result, cancellationToken);
}
}