639e331db1
Tests: anchor SeedOccurredAt() to a fixed thresholdAnchor (2026-01-20) and compute RetentionDays dynamically (UtcNow - anchor + 1d) so the threshold always sits near Jan 20 2026, between the Jan-15 "old" seed (purged) and Apr-15/Jun-15 "kept" seeds. Seed dates stay within the explicit pf_AuditLog_Month boundary range (Jan 2026 – Dec 2027) — relative-from-now offsets landed before 2026-01-01 (the catch-all partition, invisible to GetPartitionBoundariesOlderThanAsync). Both tests confirmed passing; all 284 AuditLog tests green. Docs: - Component-AuditLog.md: per-channel retention overrides (T3, PerChannelRetentionDays + bounded DELETE + AuditLogPurge:ChannelPurgeBatchSize); ParentExecutionId tag-cascade now spans alarm-triggered + nested CallScript/CallShared + inbound→routed (T4, "no further spawn points deferred"); per-node stuck KPIs for Notification Outbox + Site Call Audit (T6); T7 structured response-capture increments (request headers in Extra.requestHeaders, AuditInboundCeilingHits counter, per-method SkipBodyCapture); T8 CLI audit tree; T1 hash-chain + T2 Parquet explicitly marked deferred to v1.x. - Component-CLI.md + README.md: document audit tree --execution-id <guid> and audit backfill-source-node --sentinel/--before/--batch with exact options verified against AuditCommands.cs; update Interactions to list new endpoints. - CLAUDE.md: update audit-log design-decision bullets for T3 per-channel retention, T4 tag-cascade complete, T6 per-node KPIs, T7 inbound capture increments, T8 tree command; clarify T1/T2 remain deferred to v1.x.
478 lines
23 KiB
C#
478 lines
23 KiB
C#
using Akka.Actor;
|
|
using Akka.TestKit.Xunit2;
|
|
using Microsoft.Data.SqlClient;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Options;
|
|
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
|
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
|
|
using ZB.MOM.WW.Audit;
|
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Integration;
|
|
|
|
/// <summary>
|
|
/// Bundle F (#23 M6-T11) end-to-end test for the daily partition-switch
|
|
/// purge: seeds three monthly partitions (Jan / Feb / Mar 2026) with direct
|
|
/// INSERTs that bypass the standard repository ingest path (so the seed
|
|
/// timestamps are explicit), drives <see cref="AuditLogPurgeActor"/> against
|
|
/// the real <see cref="AuditLogRepository"/> + per-test
|
|
/// <see cref="MsSqlMigrationFixture"/> database, and asserts:
|
|
/// <list type="number">
|
|
/// <item>The oldest partition (Jan) is removed.</item>
|
|
/// <item>Newer partitions (Feb + Mar) are untouched.</item>
|
|
/// <item>The <c>UX_AuditLog_EventId</c> unique index survives the
|
|
/// drop-and-rebuild dance.</item>
|
|
/// <item><see cref="IAuditLogRepository.InsertIfNotExistsAsync"/> remains
|
|
/// idempotent against the rebuilt index after the purge.</item>
|
|
/// </list>
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The brief calls out that direct INSERTs bypass the writer role's INSERT-only
|
|
/// grant; the fixture connects as <c>sa</c> (see
|
|
/// <see cref="MsSqlMigrationFixture"/>'s default admin connection string), so
|
|
/// the seed step does not need the writer role at all. The drop-and-rebuild
|
|
/// dance itself runs under the same admin connection because the test owns
|
|
/// the database — the role granularity is exercised in the repository tests,
|
|
/// not here.
|
|
/// </remarks>
|
|
public class PartitionPurgeTests : TestKit, IClassFixture<MsSqlMigrationFixture>
|
|
{
|
|
private readonly MsSqlMigrationFixture _fixture;
|
|
|
|
public PartitionPurgeTests(MsSqlMigrationFixture fixture)
|
|
{
|
|
_fixture = fixture;
|
|
}
|
|
|
|
private ScadaBridgeDbContext CreateContext() =>
|
|
new(new DbContextOptionsBuilder<ScadaBridgeDbContext>()
|
|
.UseSqlServer(_fixture.ConnectionString).Options);
|
|
|
|
/// <summary>
|
|
/// Direct INSERT into <c>dbo.AuditLog</c> bypassing
|
|
/// <see cref="IAuditLogRepository.InsertIfNotExistsAsync"/>. Used by the
|
|
/// seed step so the test can place rows in arbitrary partitions without
|
|
/// the repository's idempotency wrapper or ingest-stamping behaviour
|
|
/// affecting the seed payload.
|
|
/// </summary>
|
|
private async Task DirectInsertAsync(
|
|
SqlConnection conn,
|
|
Guid eventId,
|
|
DateTime occurredAtUtc,
|
|
string siteId,
|
|
string channel = "ApiOutbound",
|
|
string kind = "ApiCall")
|
|
{
|
|
await using var cmd = conn.CreateCommand();
|
|
// C5 (Task 2.5): dbo.AuditLog is now the 10 canonical columns + DetailsJson;
|
|
// the ScadaBridge domain fields (channel/kind/status/sourceSiteId) ride in
|
|
// DetailsJson and the SourceSiteId/Kind/Status computed columns auto-derive.
|
|
// Action = "{channel}.{kind}", Category = channel name, Outcome = Success.
|
|
// The channel/kind are parameterized so the M5.5 per-channel purge test can
|
|
// seed multiple channels into the same partition.
|
|
cmd.CommandText = @"
|
|
INSERT INTO dbo.AuditLog
|
|
(EventId, OccurredAtUtc, Actor, Action, Outcome, Category, Target, SourceNode, CorrelationId, DetailsJson)
|
|
VALUES
|
|
(@EventId, @OccurredAtUtc, NULL, @Action, 'Success', @Category, NULL, NULL, NULL,
|
|
@DetailsJson);";
|
|
cmd.Parameters.Add("@Action", System.Data.SqlDbType.VarChar, 64).Value = $"{channel}.{kind}";
|
|
cmd.Parameters.Add("@Category", System.Data.SqlDbType.VarChar, 32).Value = channel;
|
|
cmd.Parameters.Add("@EventId", System.Data.SqlDbType.UniqueIdentifier).Value = eventId;
|
|
// SqlDbType.DateTime2 with explicit Scale 7 matches the
|
|
// OccurredAtUtc column shape (datetime2(7)) and avoids the implicit
|
|
// narrowing that SqlClient's default DateTime → datetime applies via
|
|
// AddWithValue. Critical for partition assignment: the partition
|
|
// function key column is datetime2(7); a narrowed value would still
|
|
// land in the correct partition for first-of-month seeds, but
|
|
// explicit typing here documents the intent and matches how the
|
|
// production repository INSERT shapes its parameters.
|
|
var occurredParam = cmd.Parameters.Add("@OccurredAtUtc", System.Data.SqlDbType.DateTime2);
|
|
occurredParam.Scale = 7;
|
|
occurredParam.Value = occurredAtUtc;
|
|
// DetailsJson carries the camelCase domain fields (matching AuditDetailsCodec):
|
|
// channel/kind/status drive the computed Kind/Status columns; sourceSiteId drives
|
|
// the computed SourceSiteId column the verify queries scope on. payloadTruncated
|
|
// is always present (the codec always writes the bool).
|
|
var detailsJson =
|
|
"{\"channel\":\"" + channel + "\",\"kind\":\"" + kind + "\",\"status\":\"Delivered\"," +
|
|
"\"sourceSiteId\":\"" + siteId + "\",\"payloadTruncated\":false}";
|
|
cmd.Parameters.Add("@DetailsJson", System.Data.SqlDbType.NVarChar, -1).Value = detailsJson;
|
|
await cmd.ExecuteNonQueryAsync();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Asserts that <c>UX_AuditLog_EventId</c> exists in
|
|
/// <c>sys.indexes</c>. The drop-and-rebuild dance briefly removes the
|
|
/// index inside its transaction; this check is meant to fire AFTER the
|
|
/// actor's purge tick has committed so the rebuilt index is observable.
|
|
/// </summary>
|
|
private static async Task AssertUxIndexExistsAsync(SqlConnection conn)
|
|
{
|
|
await using var cmd = conn.CreateCommand();
|
|
cmd.CommandText = @"
|
|
SELECT COUNT(*)
|
|
FROM sys.indexes
|
|
WHERE name = 'UX_AuditLog_EventId'
|
|
AND object_id = OBJECT_ID('dbo.AuditLog');";
|
|
var raw = await cmd.ExecuteScalarAsync();
|
|
var count = Convert.ToInt32(raw);
|
|
Assert.True(count == 1, $"UX_AuditLog_EventId should be present post-purge; sys.indexes count was {count}.");
|
|
}
|
|
|
|
private IActorRef CreateActor(
|
|
IServiceProvider sp,
|
|
AuditLogPurgeOptions purgeOptions,
|
|
AuditLogOptions auditOptions)
|
|
{
|
|
return Sys.ActorOf(Props.Create(() => new AuditLogPurgeActor(
|
|
sp,
|
|
Options.Create(purgeOptions),
|
|
Options.Create(auditOptions),
|
|
NullLogger<AuditLogPurgeActor>.Instance)));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns three seed timestamps and a computed <c>RetentionDays</c> value that
|
|
/// keep the purge-intent date-independent regardless of when the test runs.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The partition function <c>pf_AuditLog_Month</c> has explicit boundaries only
|
|
/// for 2026-01-01 through 2027-12-01. Rows outside that range land in the
|
|
/// catch-all partitions which have no <c>partition_range_values</c> entry and are
|
|
/// therefore never returned by
|
|
/// <see cref="IAuditLogRepository.GetPartitionBoundariesOlderThanAsync"/>.
|
|
/// All three seeds must therefore fall inside the defined boundary range.
|
|
/// </para>
|
|
/// <para>
|
|
/// To remain date-independent the test computes <c>RetentionDays</c> dynamically
|
|
/// so the purge threshold always lands near <b>2026-01-20</b>:
|
|
/// <code>
|
|
/// RetentionDays = (int)(DateTime.UtcNow - new DateTime(2026, 1, 20, UTC)).TotalDays + 1
|
|
/// </code>
|
|
/// This gives:
|
|
/// <list type="bullet">
|
|
/// <item>Jan 15 2026 row → Jan 15 < Jan 20 threshold → <b>PURGED</b>.</item>
|
|
/// <item>Apr 15 / Jun 15 2026 rows → both after Jan 20 → <b>KEPT</b>.</item>
|
|
/// </list>
|
|
/// The threshold anchors to a fixed calendar point (~Jan 20 2026), so the
|
|
/// relationship holds for any future run date as long as the explicit partition
|
|
/// boundaries remain.
|
|
/// </para>
|
|
/// </remarks>
|
|
private static (DateTime Old, DateTime Mid, DateTime Recent, int RetentionDays) SeedOccurredAt()
|
|
{
|
|
// Anchor the threshold midway through January 2026 — strictly after the
|
|
// "old" seed (Jan 15) and strictly before the "mid" seed (Apr 15).
|
|
var thresholdAnchor = new DateTime(2026, 1, 20, 0, 0, 0, DateTimeKind.Utc);
|
|
var retentionDays = (int)(DateTime.UtcNow - thresholdAnchor).TotalDays + 1;
|
|
|
|
return (
|
|
Old: new DateTime(2026, 1, 15, 0, 0, 0, DateTimeKind.Utc), // in Jan-2026 partition → PURGED
|
|
Mid: new DateTime(2026, 4, 15, 0, 0, 0, DateTimeKind.Utc), // in Apr-2026 partition → KEPT
|
|
Recent: new DateTime(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc), // in Jun-2026 partition → KEPT
|
|
RetentionDays: retentionDays
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// 1. EndToEnd_OldestPartition_PurgedViaActor_NewerKept
|
|
// ---------------------------------------------------------------------
|
|
|
|
[SkippableFact]
|
|
public async Task EndToEnd_OldestPartition_PurgedViaActor_NewerKept()
|
|
{
|
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
|
|
|
// Seeds three rows in distinct calendar months. RetentionDays is computed
|
|
// dynamically so the purge threshold always lands near 2026-01-20 (see
|
|
// SeedOccurredAt() for the full rationale):
|
|
// Old = Jan 15 2026 → Jan 15 < threshold ~Jan 20 → PURGED
|
|
// Mid = Apr 15 2026 → Apr 15 > threshold ~Jan 20 → KEPT
|
|
// Recent = Jun 15 2026 → Jun 15 > threshold ~Jan 20 → KEPT
|
|
var siteId = "purge-e2e-" + Guid.NewGuid().ToString("N").Substring(0, 8);
|
|
var oldEventId = Guid.NewGuid();
|
|
var midEventId = Guid.NewGuid();
|
|
var recentEventId = Guid.NewGuid();
|
|
var (oldOccurred, midOccurred, recentOccurred, retentionDays) = SeedOccurredAt();
|
|
|
|
await using (var seedConn = _fixture.OpenConnection())
|
|
{
|
|
await DirectInsertAsync(seedConn, oldEventId, oldOccurred, siteId);
|
|
await DirectInsertAsync(seedConn, midEventId, midOccurred, siteId);
|
|
await DirectInsertAsync(seedConn, recentEventId, recentOccurred, siteId);
|
|
}
|
|
|
|
// Wire the actor with a real EF context against the fixture DB.
|
|
var services = new ServiceCollection();
|
|
services.AddDbContext<ScadaBridgeDbContext>(
|
|
opts => opts.UseSqlServer(_fixture.ConnectionString),
|
|
ServiceLifetime.Scoped);
|
|
services.AddScoped<IAuditLogRepository, AuditLogRepository>();
|
|
var sp = services.BuildServiceProvider();
|
|
|
|
var probe = CreateTestProbe();
|
|
Sys.EventStream.Subscribe(probe.Ref, typeof(AuditLogPurgedEvent));
|
|
|
|
var purgeOptions = new AuditLogPurgeOptions
|
|
{
|
|
IntervalHours = 24,
|
|
IntervalOverride = TimeSpan.FromMilliseconds(100),
|
|
};
|
|
var auditOptions = new AuditLogOptions { RetentionDays = retentionDays };
|
|
|
|
CreateActor(sp, purgeOptions, auditOptions);
|
|
|
|
// The Jan-2026 partition boundary is the only eligible one in this fixture DB.
|
|
var janBoundary = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
|
var matched = probe.FishForMessage<AuditLogPurgedEvent>(
|
|
isMessage: m => m.MonthBoundary == janBoundary,
|
|
max: TimeSpan.FromSeconds(30));
|
|
Assert.True(matched.RowsDeleted >= 1,
|
|
$"Expected RowsDeleted >= 1 for Jan-2026 boundary; got {matched.RowsDeleted}.");
|
|
|
|
// Allow a brief settle in case the actor re-enumerates quickly.
|
|
await Task.Delay(TimeSpan.FromMilliseconds(500));
|
|
|
|
await using var verify = CreateContext();
|
|
var rows = await verify.Set<AuditLogRow>()
|
|
.Where(e => e.SourceSiteId == siteId)
|
|
.ToListAsync();
|
|
|
|
// Old (Jan) removed; Mid (Apr) + Recent (Jun) untouched.
|
|
Assert.DoesNotContain(rows, r => r.EventId == oldEventId);
|
|
Assert.Contains(rows, r => r.EventId == midEventId);
|
|
Assert.Contains(rows, r => r.EventId == recentEventId);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// 2. EndToEnd_UxIndexRebuilt_AfterPurge
|
|
// ---------------------------------------------------------------------
|
|
|
|
[SkippableFact]
|
|
public async Task EndToEnd_UxIndexRebuilt_AfterPurge()
|
|
{
|
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
|
|
|
// Same shape as test 1 — purge the Jan-2026 partition and then assert the
|
|
// UX_AuditLog_EventId index is still present. RetentionDays is computed
|
|
// dynamically so the threshold always lands near 2026-01-20 (see SeedOccurredAt()).
|
|
// The drop-and-rebuild dance briefly removes the index inside its transaction
|
|
// (the SWITCH PARTITION step requires the non-aligned unique index to be absent),
|
|
// but step 5 rebuilds it before committing.
|
|
var siteId = "purge-uxidx-" + Guid.NewGuid().ToString("N").Substring(0, 8);
|
|
var oldEventId = Guid.NewGuid();
|
|
var (oldOccurred, _, _, retentionDays) = SeedOccurredAt();
|
|
|
|
await using (var seedConn = _fixture.OpenConnection())
|
|
{
|
|
await DirectInsertAsync(seedConn, oldEventId, oldOccurred, siteId);
|
|
}
|
|
|
|
var services = new ServiceCollection();
|
|
services.AddDbContext<ScadaBridgeDbContext>(
|
|
opts => opts.UseSqlServer(_fixture.ConnectionString),
|
|
ServiceLifetime.Scoped);
|
|
services.AddScoped<IAuditLogRepository, AuditLogRepository>();
|
|
var sp = services.BuildServiceProvider();
|
|
|
|
var probe = CreateTestProbe();
|
|
Sys.EventStream.Subscribe(probe.Ref, typeof(AuditLogPurgedEvent));
|
|
|
|
CreateActor(
|
|
sp,
|
|
new AuditLogPurgeOptions
|
|
{
|
|
IntervalHours = 24,
|
|
IntervalOverride = TimeSpan.FromMilliseconds(100),
|
|
},
|
|
new AuditLogOptions { RetentionDays = retentionDays });
|
|
|
|
var janBoundary = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
|
probe.FishForMessage<AuditLogPurgedEvent>(
|
|
isMessage: m => m.MonthBoundary == janBoundary,
|
|
max: TimeSpan.FromSeconds(30));
|
|
|
|
// Open a fresh connection (the actor's pool is owned by EF) and
|
|
// assert the index is present post-purge.
|
|
await using var check = _fixture.OpenConnection();
|
|
await AssertUxIndexExistsAsync(check);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// 3. EndToEnd_InsertIfNotExistsAsync_StillIdempotent_AfterPurge
|
|
// ---------------------------------------------------------------------
|
|
|
|
[SkippableFact]
|
|
public async Task EndToEnd_InsertIfNotExistsAsync_StillIdempotent_AfterPurge()
|
|
{
|
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
|
|
|
// Seed + purge the Jan-2026 row, THEN exercise InsertIfNotExistsAsync twice for
|
|
// a fresh recent EventId. The second call must be a no-op (duplicate-key collision
|
|
// swallowed by the repository, per M2 Bundle A's race-fix) — which means the
|
|
// rebuilt UX_AuditLog_EventId unique index is functioning as intended.
|
|
// RetentionDays is computed dynamically so the threshold always lands near
|
|
// 2026-01-20 (see SeedOccurredAt()).
|
|
var siteId = "purge-idem-" + Guid.NewGuid().ToString("N").Substring(0, 8);
|
|
var oldEventId = Guid.NewGuid();
|
|
var (oldOccurred, _, _, retentionDays) = SeedOccurredAt();
|
|
|
|
await using (var seedConn = _fixture.OpenConnection())
|
|
{
|
|
await DirectInsertAsync(seedConn, oldEventId, oldOccurred, siteId);
|
|
}
|
|
|
|
var services = new ServiceCollection();
|
|
services.AddDbContext<ScadaBridgeDbContext>(
|
|
opts => opts.UseSqlServer(_fixture.ConnectionString),
|
|
ServiceLifetime.Scoped);
|
|
services.AddScoped<IAuditLogRepository, AuditLogRepository>();
|
|
var sp = services.BuildServiceProvider();
|
|
|
|
var probe = CreateTestProbe();
|
|
Sys.EventStream.Subscribe(probe.Ref, typeof(AuditLogPurgedEvent));
|
|
|
|
CreateActor(
|
|
sp,
|
|
new AuditLogPurgeOptions
|
|
{
|
|
IntervalHours = 24,
|
|
IntervalOverride = TimeSpan.FromMilliseconds(100),
|
|
},
|
|
new AuditLogOptions { RetentionDays = retentionDays });
|
|
|
|
var janBoundary = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
|
probe.FishForMessage<AuditLogPurgedEvent>(
|
|
isMessage: m => m.MonthBoundary == janBoundary,
|
|
max: TimeSpan.FromSeconds(30));
|
|
|
|
// Settle then exercise InsertIfNotExistsAsync twice for the same
|
|
// EventId. The repository's idempotency relies on
|
|
// UX_AuditLog_EventId being present so the IF NOT EXISTS … INSERT
|
|
// race window resolves to a duplicate-key violation the repo
|
|
// swallows. If the index were missing here, two rows would land
|
|
// and the second InsertIfNotExistsAsync would silently double-insert.
|
|
await Task.Delay(TimeSpan.FromMilliseconds(500));
|
|
|
|
var freshEventId = Guid.NewGuid();
|
|
var freshOccurred = new DateTime(2026, 5, 15, 12, 0, 0, DateTimeKind.Utc); // within partition range, well inside retention window
|
|
var freshSite = "purge-idem-fresh-" + Guid.NewGuid().ToString("N").Substring(0, 8);
|
|
var freshEvt = ScadaBridgeAuditEventFactory.Create(
|
|
eventId: freshEventId,
|
|
occurredAtUtc: freshOccurred,
|
|
channel: AuditChannel.ApiOutbound,
|
|
kind: AuditKind.ApiCall,
|
|
status: AuditStatus.Delivered,
|
|
sourceSiteId: freshSite,
|
|
target: "system-x/method");
|
|
|
|
await using (var ctx = CreateContext())
|
|
{
|
|
var repo = new AuditLogRepository(ctx);
|
|
await repo.InsertIfNotExistsAsync(freshEvt);
|
|
// Same row a second time — must be a silent no-op.
|
|
await repo.InsertIfNotExistsAsync(freshEvt);
|
|
}
|
|
|
|
await using var verify = CreateContext();
|
|
var rows = await verify.Set<AuditLogRow>()
|
|
.Where(e => e.SourceSiteId == freshSite)
|
|
.ToListAsync();
|
|
Assert.Single(rows);
|
|
Assert.Equal(freshEventId, rows[0].EventId);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// 4. PerChannelOverride_DeletesOnlyOverriddenChannelsOldRows (M5.5 T3)
|
|
// ---------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// M5.5 (T3): exercises <see cref="IAuditLogRepository.PurgeChannelOlderThanAsync"/>
|
|
/// directly against the real repository + fixture DB. Seeds, in the SAME partition,
|
|
/// old + recent rows for an OVERRIDDEN channel (<c>ApiOutbound</c>) and old + recent
|
|
/// rows for an UN-overridden channel (<c>DbOutbound</c>), then runs the per-channel
|
|
/// purge for <c>ApiOutbound</c> only. Asserts:
|
|
/// <list type="number">
|
|
/// <item>The overridden channel's OLD rows are deleted.</item>
|
|
/// <item>The overridden channel's RECENT rows (newer than the channel threshold) survive.</item>
|
|
/// <item>The un-overridden channel's rows (old AND recent) are completely untouched
|
|
/// — they follow the global window, which the channel purge never applies to them.</item>
|
|
/// </list>
|
|
/// This is the maintenance-path row DELETE; the fixture connects as <c>sa</c>, which
|
|
/// the append-only writer-role DENYs do not bind (the role granularity is exercised
|
|
/// in the repository/migration tests).
|
|
/// </summary>
|
|
[SkippableFact]
|
|
public async Task PerChannelOverride_DeletesOnlyOverriddenChannelsOldRows()
|
|
{
|
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
|
|
|
var siteId = "perchannel-" + Guid.NewGuid().ToString("N").Substring(0, 8);
|
|
|
|
// Two timestamps: one OLD (older than the channel threshold we will purge with)
|
|
// and one RECENT (newer than it). Both sit comfortably inside the retention
|
|
// window so the global partition purge would NOT touch either — isolating the
|
|
// per-channel DELETE as the only force acting here.
|
|
var oldOccurred = new DateTime(2026, 1, 15, 0, 0, 0, DateTimeKind.Utc);
|
|
var recentOccurred = new DateTime(2026, 5, 15, 0, 0, 0, DateTimeKind.Utc);
|
|
|
|
var apiOldId = Guid.NewGuid(); // ApiOutbound, old → SHOULD be deleted
|
|
var apiRecentId = Guid.NewGuid(); // ApiOutbound, recent→ SHOULD survive
|
|
var dbOldId = Guid.NewGuid(); // DbOutbound, old → SHOULD survive (un-overridden)
|
|
var dbRecentId = Guid.NewGuid(); // DbOutbound, recent → SHOULD survive
|
|
|
|
await using (var seedConn = _fixture.OpenConnection())
|
|
{
|
|
await DirectInsertAsync(seedConn, apiOldId, oldOccurred, siteId, channel: "ApiOutbound", kind: "ApiCall");
|
|
await DirectInsertAsync(seedConn, apiRecentId, recentOccurred, siteId, channel: "ApiOutbound", kind: "ApiCall");
|
|
await DirectInsertAsync(seedConn, dbOldId, oldOccurred, siteId, channel: "DbOutbound", kind: "DbWrite");
|
|
await DirectInsertAsync(seedConn, dbRecentId, recentOccurred, siteId, channel: "DbOutbound", kind: "DbWrite");
|
|
}
|
|
|
|
// Purge ApiOutbound rows older than a threshold that sits strictly between the
|
|
// old (Jan 15) and recent (May 15) seeds — e.g. Mar 1. Only apiOldId qualifies.
|
|
var channelThreshold = new DateTime(2026, 3, 1, 0, 0, 0, DateTimeKind.Utc);
|
|
|
|
await using (var ctx = CreateContext())
|
|
{
|
|
var repo = new AuditLogRepository(ctx);
|
|
var deleted = await repo.PurgeChannelOlderThanAsync(
|
|
channel: "ApiOutbound",
|
|
threshold: channelThreshold,
|
|
batchSize: 2);
|
|
|
|
Assert.Equal(1L, deleted);
|
|
|
|
// Idempotent: a second run deletes nothing (the eligible row is gone).
|
|
var deletedAgain = await repo.PurgeChannelOlderThanAsync(
|
|
channel: "ApiOutbound",
|
|
threshold: channelThreshold,
|
|
batchSize: 2);
|
|
Assert.Equal(0L, deletedAgain);
|
|
}
|
|
|
|
await using var verify = CreateContext();
|
|
var rows = await verify.Set<AuditLogRow>()
|
|
.Where(e => e.SourceSiteId == siteId)
|
|
.ToListAsync();
|
|
|
|
// Overridden channel: old gone, recent kept.
|
|
Assert.DoesNotContain(rows, r => r.EventId == apiOldId);
|
|
Assert.Contains(rows, r => r.EventId == apiRecentId);
|
|
|
|
// Un-overridden channel: BOTH rows untouched (follow the global window).
|
|
Assert.Contains(rows, r => r.EventId == dbOldId);
|
|
Assert.Contains(rows, r => r.EventId == dbRecentId);
|
|
}
|
|
}
|