test(coverage): close Theme 8 — 13 test-coverage findings, +35 tests
13 well-bounded test-coverage gaps closed across 11 test projects.
Net +35 regression tests; no production code changes except the
SiteEventLogger src reference unchanged (W3 redacted only test code).
Test additions:
- CLI-022: CommandTreeTests pinned-count assertion bumped 14→16 and
3 InlineData rows added for the audit + bundle command groups.
- Commons-020: new TransportRecordsTests covers BundleManifest /
ExportSelection / ImportPreview / ImportResolution / ImportResult —
ctor + System.Text.Json round-trip + record-equality (14 tests).
- CD-024: SPLIT-RANGE failure-continuation now under
EnsureLookahead_SecondSplitThrows_LoopAborts_FirstBoundaryStillCommitted
(Skippable MS-SQL fixture); production-shape rowversion delete
asserted by DeleteDeploymentRecord_CurrentRowVersion_StubAttachPath_DeleteSucceeds.
- CentralUI-033: new QueryStringDrillInTests with 4 bUnit cases for
Transport + SiteCalls drill-in / query-string handling.
- DM-024: probe actors (ReconcileProbeActor, SerializationProbeActor,
ArtifactProbeActor) refactored from static fields to per-test instances
(Interlocked on counter) — all 31 callers updated; no production
changes required.
- HM-022: real-time PeriodicTimer test flake fixed by replacing
fixed-budget Task.Delay with a RunLoopUntil poll-until-condition
helper (5s/25ms). Production loop untouched.
- InboundAPI-023: new EndpointExtensionsTests covers the
POST /api/{methodName} composition wiring via TestServer (7 cases:
happy path, missing key 401, unknown method 403, invalid JSON 400,
missing param 400, script-throws 500 sanitised, AuditActorItemKey
stash invariant).
- MgmtSvc-021: 6 new ManagementActorTests cover the Transport bundle
handlers (role gate for Export/Preview/Import, unknown-name
ManagementCommandException, blocker-rejection, dedupe last-write-wins).
- SCA-006: SiteCallQueryRequest_StuckOnly_CursorAtNonStuckBoundary_SkipsToNextStuckRow
pins the missing boundary case.
- SEL-023: stress-test `bool stop` promoted to `volatile bool` for
cross-thread visibility under release/JIT.
Verify-only resolutions:
- NS-024: closed by NS-019 (commit ac96b83 deletion of
NotificationDeliveryService + its test file). No edits needed.
- NotifOutbox-008: FallbackMaxRetries/FallbackRetryDelay are private
forward-compat constants returned only when no SMTP-config row exists
(in which case EmailNotificationDeliveryAdapter returns Permanent,
bypassing the values entirely). Marked Resolved with note.
- Transport-010: Overwrite child-collection sync covered by the T-001/
T-002 tests added in commit e3ca9af; per-IP throttle by
BundleUnlockRateLimiterTests; failed-session retention by
BundleSessionStoreTests; T-009 closed structurally via AsyncLocal.
Marked Resolved by reference.
Build clean; all 11 affected test suites green. README regenerated:
33 open (was 46).
This commit is contained in:
+134
@@ -1,4 +1,6 @@
|
||||
using System.Data.Common;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ScadaLink.ConfigurationDatabase.Maintenance;
|
||||
using ScadaLink.ConfigurationDatabase.Tests.Migrations;
|
||||
@@ -150,6 +152,138 @@ public class AuditLogPartitionMaintenanceTests : IClassFixture<MsSqlMigrationFix
|
||||
Assert.Equal(maxBefore.Value.AddMonths(3), maxAfter);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ConfigurationDatabase-024: CD-019 removed the try/catch around the per-month
|
||||
/// SPLIT call so a genuine SQL failure (deadlock, permission, log full, transient
|
||||
/// connection drop) now aborts the loop instead of leaving partition holes. This
|
||||
/// test pins that abort behaviour: with an interceptor that throws on the SECOND
|
||||
/// SPLIT, the call must propagate the exception AND the first SPLIT's boundary
|
||||
/// must already be persisted in <c>pf_AuditLog_Month</c> (visible to a fresh
|
||||
/// <see cref="AuditLogPartitionMaintenance"/> instance) — proof that the loop did
|
||||
/// commit boundary N before throwing, and that the next tick can resume from
|
||||
/// boundary N+1 at-least-once with no holes.
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
public async Task EnsureLookahead_SecondSplitThrows_LoopAborts_FirstBoundaryStillCommitted()
|
||||
{
|
||||
// STM: CD-024-SecondSplitThrowsAbortsLoop marker.
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
// Baseline max-boundary observed via a clean context.
|
||||
await using var baselineCtx = CreateContext();
|
||||
var maxBefore = await NewMaintenance(baselineCtx).GetMaxBoundaryAsync();
|
||||
Assert.NotNull(maxBefore);
|
||||
|
||||
var lookahead = LookaheadForExtraBoundaries(maxBefore!.Value, extraBoundaries: 3);
|
||||
var expectedFirst = maxBefore.Value.AddMonths(1);
|
||||
|
||||
// Build a fresh context with an interceptor that throws on the 2nd ALTER
|
||||
// PARTITION FUNCTION SPLIT RANGE. EF Core surfaces the throw through
|
||||
// ExecuteSqlRawAsync exactly as a SqlException would — the loop has no
|
||||
// try/catch (CD-019), so the exception propagates after the first SPLIT
|
||||
// has already committed.
|
||||
var interceptor = new SecondSplitThrowsInterceptor();
|
||||
var options = new DbContextOptionsBuilder<ScadaLinkDbContext>()
|
||||
.UseSqlServer(_fixture.ConnectionString)
|
||||
.AddInterceptors(interceptor)
|
||||
.Options;
|
||||
await using var ctx = new ScadaLinkDbContext(options);
|
||||
var maintenance = NewMaintenance(ctx);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => maintenance.EnsureLookaheadAsync(lookahead));
|
||||
|
||||
// Verify exactly one ALTER PARTITION FUNCTION SPLIT RANGE actually ran
|
||||
// before the interceptor's throw: split #1 committed, split #2 threw,
|
||||
// split #3 was never attempted.
|
||||
Assert.Equal(1, interceptor.SuccessfulSplits);
|
||||
|
||||
// And verify the first boundary IS now persisted — the loop aborted but
|
||||
// boundary N is durable so the next tick resumes from N+1 (no holes).
|
||||
await using var verifyCtx = CreateContext();
|
||||
var maxAfter = await NewMaintenance(verifyCtx).GetMaxBoundaryAsync();
|
||||
Assert.Equal(expectedFirst, maxAfter);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EF Core command interceptor: lets the first <c>ALTER PARTITION FUNCTION
|
||||
/// pf_AuditLog_Month() SPLIT RANGE</c> through and throws <see cref="InvalidOperationException"/>
|
||||
/// on the second one. Threads through synchronous + async + scalar + reader
|
||||
/// entry-points because <c>ExecuteSqlRawAsync</c> routes through the
|
||||
/// non-query async path but other code paths still go through the same
|
||||
/// interceptor pipeline. <see cref="SuccessfulSplits"/> counts the splits
|
||||
/// that were allowed to run so the test can pin the abort-after-one
|
||||
/// behaviour.
|
||||
/// </summary>
|
||||
private sealed class SecondSplitThrowsInterceptor : DbCommandInterceptor
|
||||
{
|
||||
public int SuccessfulSplits { get; private set; }
|
||||
|
||||
private bool IsTargetSplit(DbCommand command) =>
|
||||
command.CommandText.Contains("SPLIT RANGE", StringComparison.OrdinalIgnoreCase)
|
||||
&& command.CommandText.Contains("pf_AuditLog_Month", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public override InterceptionResult<int> NonQueryExecuting(
|
||||
DbCommand command,
|
||||
CommandEventData eventData,
|
||||
InterceptionResult<int> result)
|
||||
{
|
||||
ThrowIfSecondSplit(command);
|
||||
return base.NonQueryExecuting(command, eventData, result);
|
||||
}
|
||||
|
||||
public override ValueTask<InterceptionResult<int>> NonQueryExecutingAsync(
|
||||
DbCommand command,
|
||||
CommandEventData eventData,
|
||||
InterceptionResult<int> result,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ThrowIfSecondSplit(command);
|
||||
return base.NonQueryExecutingAsync(command, eventData, result, cancellationToken);
|
||||
}
|
||||
|
||||
public override int NonQueryExecuted(
|
||||
DbCommand command,
|
||||
CommandExecutedEventData eventData,
|
||||
int result)
|
||||
{
|
||||
if (IsTargetSplit(command))
|
||||
{
|
||||
SuccessfulSplits++;
|
||||
}
|
||||
return base.NonQueryExecuted(command, eventData, result);
|
||||
}
|
||||
|
||||
public override ValueTask<int> NonQueryExecutedAsync(
|
||||
DbCommand command,
|
||||
CommandExecutedEventData eventData,
|
||||
int result,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (IsTargetSplit(command))
|
||||
{
|
||||
SuccessfulSplits++;
|
||||
}
|
||||
return base.NonQueryExecutedAsync(command, eventData, result, cancellationToken);
|
||||
}
|
||||
|
||||
private void ThrowIfSecondSplit(DbCommand command)
|
||||
{
|
||||
if (!IsTargetSplit(command))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Allow the first SPLIT through; throw on the second so the loop's
|
||||
// post-CD-019 "let it propagate" behaviour can be asserted.
|
||||
if (SuccessfulSplits >= 1)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Simulated SqlException on the second SPLIT RANGE — exercising CD-019's no-try/catch abort path.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task EnsureLookahead_BoundaryAlreadyExists_NoError_Idempotent()
|
||||
{
|
||||
|
||||
@@ -847,6 +847,44 @@ public class DeploymentManagerRepositoryTests : IDisposable
|
||||
Assert.Null(await _repository.GetDeploymentRecordByIdAsync(id));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ConfigurationDatabase-024: CD-017 added optimistic-concurrency to
|
||||
/// <c>DeleteDeploymentRecordAsync(int id, byte[] expectedRowVersion)</c> — the stub-attach
|
||||
/// path now seeds <c>OriginalValues["RowVersion"]</c> from the caller's last-observed
|
||||
/// value so the generated SQL becomes <c>DELETE … WHERE Id = @id AND RowVersion = @prior</c>.
|
||||
/// This test pins the production-shape happy path: caller holds the entity's CURRENT
|
||||
/// RowVersion, clears the change-tracker (i.e. no tracked instance — exactly the M&V
|
||||
/// admin / handler shape), calls Delete with that token, and the delete completes
|
||||
/// without throwing <see cref="Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException"/>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task DeleteDeploymentRecord_CurrentRowVersion_StubAttachPath_DeleteSucceeds()
|
||||
{
|
||||
// STM: CD-024-RowVersionDeleteHappyPath marker.
|
||||
var instance = await SeedInstanceAsync();
|
||||
var record = new DeploymentRecord("d-rv-001", "admin")
|
||||
{
|
||||
InstanceId = instance.Id,
|
||||
DeployedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
await _repository.AddDeploymentRecordAsync(record);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
// Capture the entity's CURRENT RowVersion (the one the caller would have
|
||||
// read from a prior GetDeploymentRecordByIdAsync), then detach so the
|
||||
// delete travels through the stub-attach branch (no tracked entity).
|
||||
var id = record.Id;
|
||||
var currentRowVersion = record.RowVersion ?? Array.Empty<byte>();
|
||||
_context.ChangeTracker.Clear();
|
||||
|
||||
// No NotSupported/Concurrency exception should fire on this code path.
|
||||
await _repository.DeleteDeploymentRecordAsync(id, currentRowVersion);
|
||||
var affected = await _repository.SaveChangesAsync();
|
||||
|
||||
Assert.Equal(1, affected);
|
||||
Assert.Null(await _repository.GetDeploymentRecordByIdAsync(id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteInstance_RemovesRestrictFkDeploymentRecordsFirst()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user