037798b367
Tasks 14, 15 and 16, landed as ONE commit. PLAN DEFECT: these three tasks cannot compile separately. SiteReplicationActor takes a ReplicationService and calls ReplaceAllAsync (Task 14 deletes both); DeploymentManagerActor Tells message types declared in ReplicationMessages.cs (Task 15 deletes it); AkkaHostedService constructs the actor (Task 16). Any ordering leaves a broken intermediate. Combining them also strengthens the invariant Task 14 already stated for itself — the two mechanisms never both run, and never neither. Registered 8 tables in SiteLocalDbSetup.OnReady: sf_messages plus the 7 site config tables. notification_lists and smtp_configurations are deliberately NOT registered — permanently empty by design, so registering them would open a standing replication channel whose only historical payload was plaintext SMTP passwords. Migrate stays the LAST call in OnReady, after all registrations, so migrated rows enter the oplog through live capture triggers. Deleted: SiteReplicationActor, ReplicationMessages.cs, ReplicationService, StoreAndForwardStorage.ReplaceAllAsync, and 6 test files. ReplaceAllAsync is not merely unused but unsafe to keep: a mass DELETE on a now-replicated table would be captured and shipped to the peer. Kept ActiveNodeEvaluator (delivery gate + heartbeat still need it) with its doc corrected, and activeNodeCheck in AkkaHostedService (SiteCommunicationActor). The positional-argument hazard the plan flagged was real: removing DeploymentManagerActor's optional IActorRef? replicationActor shifted 6 trailing optionals, and 4 test call sites bound the wrong arguments with no compile error at some positions. Converted them to named arguments where possible — Props.Create builds an expression tree, which rejects out-of-position named args, so the rest are padded positionally with a comment saying why. The Task 7 'not yet registered' test was INVERTED rather than deleted, and is exact in both directions: too few means a table silently stops replicating, too many means the SMTP tables leak. Added a separate security-named test for those two, and a composite-PK test (LWW keys on the full PK, so a truncated key set would collapse distinct rows). The convergence suites now get their registrations from the real OnReady — their temporary harness registration is deleted, so they prove the cutover rather than agreeing with themselves. Verified: build 0 warnings; SiteRuntime 512, StoreAndForward 130, Host 330, AuditLog 355, ExternalSystemGateway 142, HealthMonitoring 97, LocalDb integration 16 — all pass, 0 failures. Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts
800 lines
34 KiB
C#
800 lines
34 KiB
C#
using Microsoft.Data.Sqlite;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
|
using ZB.MOM.WW.ScadaBridge.TestSupport;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests;
|
|
|
|
/// <summary>
|
|
/// WP-9: Tests for SQLite persistence layer.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Backed by a real temp-file LocalDb rather than the shared-cache in-memory database
|
|
/// this class used before: <see cref="StoreAndForwardStorage"/> now takes
|
|
/// <c>ILocalDb</c>, and LocalDb has no in-memory mode (<c>LocalDb:Path</c> is a
|
|
/// filesystem path). Isolation still comes from a fresh database per test — xUnit
|
|
/// constructs one instance per test — it is just a file now.
|
|
/// </remarks>
|
|
public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable
|
|
{
|
|
private readonly TestLocalDb _localDb;
|
|
private readonly StoreAndForwardStorage _storage;
|
|
|
|
public StoreAndForwardStorageTests()
|
|
{
|
|
_localDb = TestLocalDb.CreateTemp("StorageTests");
|
|
_storage = new StoreAndForwardStorage(_localDb.Db, NullLogger<StoreAndForwardStorage>.Instance);
|
|
}
|
|
|
|
public async Task InitializeAsync() => await _storage.InitializeAsync();
|
|
|
|
public Task DisposeAsync() => Task.CompletedTask;
|
|
|
|
public void Dispose()
|
|
{
|
|
// Dispose first — the master connection anchors the WAL, so the sidecars
|
|
// cannot be removed while it is open.
|
|
var path = _localDb.Path;
|
|
_localDb.Dispose();
|
|
TestLocalDb.DeleteFiles(path);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EnqueueAsync_StoresMessage()
|
|
{
|
|
var message = CreateMessage("msg1", StoreAndForwardCategory.ExternalSystem);
|
|
await _storage.EnqueueAsync(message);
|
|
|
|
var retrieved = await _storage.GetMessageByIdAsync("msg1");
|
|
Assert.NotNull(retrieved);
|
|
Assert.Equal("msg1", retrieved!.Id);
|
|
Assert.Equal(StoreAndForwardCategory.ExternalSystem, retrieved.Category);
|
|
Assert.Equal("target1", retrieved.Target);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EnqueueAsync_AllCategories()
|
|
{
|
|
await _storage.EnqueueAsync(CreateMessage("es1", StoreAndForwardCategory.ExternalSystem));
|
|
await _storage.EnqueueAsync(CreateMessage("n1", StoreAndForwardCategory.Notification));
|
|
await _storage.EnqueueAsync(CreateMessage("db1", StoreAndForwardCategory.CachedDbWrite));
|
|
|
|
var es = await _storage.GetMessageByIdAsync("es1");
|
|
var n = await _storage.GetMessageByIdAsync("n1");
|
|
var db = await _storage.GetMessageByIdAsync("db1");
|
|
|
|
Assert.Equal(StoreAndForwardCategory.ExternalSystem, es!.Category);
|
|
Assert.Equal(StoreAndForwardCategory.Notification, n!.Category);
|
|
Assert.Equal(StoreAndForwardCategory.CachedDbWrite, db!.Category);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RemoveMessageAsync_RemovesSuccessfully()
|
|
{
|
|
await _storage.EnqueueAsync(CreateMessage("rm1", StoreAndForwardCategory.ExternalSystem));
|
|
await _storage.RemoveMessageAsync("rm1");
|
|
|
|
var retrieved = await _storage.GetMessageByIdAsync("rm1");
|
|
Assert.Null(retrieved);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateMessageAsync_UpdatesFields()
|
|
{
|
|
var message = CreateMessage("upd1", StoreAndForwardCategory.ExternalSystem);
|
|
await _storage.EnqueueAsync(message);
|
|
|
|
message.RetryCount = 5;
|
|
message.LastAttemptAt = DateTimeOffset.UtcNow;
|
|
message.Status = StoreAndForwardMessageStatus.Parked;
|
|
message.LastError = "Connection refused";
|
|
await _storage.UpdateMessageAsync(message);
|
|
|
|
var retrieved = await _storage.GetMessageByIdAsync("upd1");
|
|
Assert.Equal(5, retrieved!.RetryCount);
|
|
Assert.Equal(StoreAndForwardMessageStatus.Parked, retrieved.Status);
|
|
Assert.Equal("Connection refused", retrieved.LastError);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetMessagesForRetryAsync_ReturnsOnlyPendingMessages()
|
|
{
|
|
var pending = CreateMessage("pend1", StoreAndForwardCategory.ExternalSystem);
|
|
pending.Status = StoreAndForwardMessageStatus.Pending;
|
|
await _storage.EnqueueAsync(pending);
|
|
|
|
var parked = CreateMessage("park1", StoreAndForwardCategory.ExternalSystem);
|
|
parked.Status = StoreAndForwardMessageStatus.Parked;
|
|
await _storage.EnqueueAsync(parked);
|
|
await _storage.UpdateMessageAsync(parked);
|
|
|
|
var forRetry = await _storage.GetMessagesForRetryAsync();
|
|
Assert.All(forRetry, m => Assert.Equal(StoreAndForwardMessageStatus.Pending, m.Status));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetMessagesForRetryAsync_NonZeroInterval_ExcludesNotYetDueIncludesDue()
|
|
{
|
|
// StoreAndForward-013: exercise the julianday elapsed-time comparison with a
|
|
// non-zero retry interval. A message attempted just now must NOT be due; one
|
|
// attempted long ago must be due.
|
|
var notDue = CreateMessage("notdue", StoreAndForwardCategory.ExternalSystem);
|
|
notDue.RetryIntervalMs = (long)TimeSpan.FromHours(1).TotalMilliseconds;
|
|
notDue.LastAttemptAt = DateTimeOffset.UtcNow;
|
|
await _storage.EnqueueAsync(notDue);
|
|
|
|
var due = CreateMessage("due", StoreAndForwardCategory.ExternalSystem);
|
|
due.RetryIntervalMs = (long)TimeSpan.FromMinutes(5).TotalMilliseconds;
|
|
due.LastAttemptAt = DateTimeOffset.UtcNow.AddHours(-2);
|
|
await _storage.EnqueueAsync(due);
|
|
|
|
var neverAttempted = CreateMessage("never", StoreAndForwardCategory.ExternalSystem);
|
|
neverAttempted.RetryIntervalMs = (long)TimeSpan.FromHours(1).TotalMilliseconds;
|
|
neverAttempted.LastAttemptAt = null;
|
|
await _storage.EnqueueAsync(neverAttempted);
|
|
|
|
var forRetry = await _storage.GetMessagesForRetryAsync();
|
|
var ids = forRetry.Select(m => m.Id).ToHashSet();
|
|
|
|
Assert.DoesNotContain("notdue", ids);
|
|
Assert.Contains("due", ids);
|
|
Assert.Contains("never", ids);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetParkedMessagesAsync_ReturnsParkedOnly()
|
|
{
|
|
var msg = CreateMessage("prk1", StoreAndForwardCategory.Notification);
|
|
msg.Status = StoreAndForwardMessageStatus.Parked;
|
|
await _storage.EnqueueAsync(msg);
|
|
await _storage.UpdateMessageAsync(msg);
|
|
|
|
var (messages, total) = await _storage.GetParkedMessagesAsync();
|
|
Assert.True(total > 0);
|
|
Assert.All(messages, m => Assert.Equal(StoreAndForwardMessageStatus.Parked, m.Status));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RetryParkedMessageAsync_MovesToPending()
|
|
{
|
|
var msg = CreateMessage("retry1", StoreAndForwardCategory.ExternalSystem);
|
|
msg.Status = StoreAndForwardMessageStatus.Parked;
|
|
msg.RetryCount = 10;
|
|
await _storage.EnqueueAsync(msg);
|
|
await _storage.UpdateMessageAsync(msg);
|
|
|
|
var success = await _storage.RetryParkedMessageAsync("retry1");
|
|
Assert.True(success);
|
|
|
|
var retrieved = await _storage.GetMessageByIdAsync("retry1");
|
|
Assert.Equal(StoreAndForwardMessageStatus.Pending, retrieved!.Status);
|
|
Assert.Equal(0, retrieved.RetryCount);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RetryParkedMessageAsync_ClearsLastAttemptAt_SoMessageIsImmediatelyDue()
|
|
{
|
|
// StoreAndForward-010: a re-queued parked message must be unambiguously due
|
|
// for the next sweep regardless of its (stale) last_attempt_at. Use a large
|
|
// retry interval so a leftover timestamp would otherwise exclude the message.
|
|
var msg = CreateMessage("requeue1", StoreAndForwardCategory.ExternalSystem);
|
|
msg.RetryIntervalMs = (long)TimeSpan.FromHours(1).TotalMilliseconds;
|
|
msg.LastAttemptAt = DateTimeOffset.UtcNow; // recent attempt
|
|
msg.Status = StoreAndForwardMessageStatus.Parked;
|
|
await _storage.EnqueueAsync(msg);
|
|
await _storage.UpdateMessageAsync(msg);
|
|
|
|
var requeued = await _storage.RetryParkedMessageAsync("requeue1");
|
|
Assert.True(requeued);
|
|
|
|
var retrieved = await _storage.GetMessageByIdAsync("requeue1");
|
|
Assert.Null(retrieved!.LastAttemptAt);
|
|
|
|
// It must appear in the retry-due set even though the configured interval
|
|
// (1 hour) has not elapsed since the original attempt.
|
|
var due = await _storage.GetMessagesForRetryAsync();
|
|
Assert.Contains(due, m => m.Id == "requeue1");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DiscardParkedMessageAsync_RemovesMessage()
|
|
{
|
|
var msg = CreateMessage("disc1", StoreAndForwardCategory.ExternalSystem);
|
|
msg.Status = StoreAndForwardMessageStatus.Parked;
|
|
await _storage.EnqueueAsync(msg);
|
|
await _storage.UpdateMessageAsync(msg);
|
|
|
|
var success = await _storage.DiscardParkedMessageAsync("disc1");
|
|
Assert.True(success);
|
|
|
|
var retrieved = await _storage.GetMessageByIdAsync("disc1");
|
|
Assert.Null(retrieved);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetBufferDepthByCategoryAsync_ReturnsCorrectCounts()
|
|
{
|
|
await _storage.EnqueueAsync(CreateMessage("bd1", StoreAndForwardCategory.ExternalSystem));
|
|
await _storage.EnqueueAsync(CreateMessage("bd2", StoreAndForwardCategory.ExternalSystem));
|
|
await _storage.EnqueueAsync(CreateMessage("bd3", StoreAndForwardCategory.Notification));
|
|
|
|
var depth = await _storage.GetBufferDepthByCategoryAsync();
|
|
Assert.True(depth.GetValueOrDefault(StoreAndForwardCategory.ExternalSystem) >= 2);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetMessageCountByOriginInstanceAsync_ReturnsCount()
|
|
{
|
|
var msg1 = CreateMessage("oi1", StoreAndForwardCategory.ExternalSystem);
|
|
msg1.OriginInstanceName = "Pump1";
|
|
await _storage.EnqueueAsync(msg1);
|
|
|
|
var msg2 = CreateMessage("oi2", StoreAndForwardCategory.Notification);
|
|
msg2.OriginInstanceName = "Pump1";
|
|
await _storage.EnqueueAsync(msg2);
|
|
|
|
var count = await _storage.GetMessageCountByOriginInstanceAsync("Pump1");
|
|
Assert.Equal(2, count);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetParkedMessagesAsync_Pagination()
|
|
{
|
|
for (int i = 0; i < 5; i++)
|
|
{
|
|
var msg = CreateMessage($"page{i}", StoreAndForwardCategory.ExternalSystem);
|
|
msg.Status = StoreAndForwardMessageStatus.Parked;
|
|
await _storage.EnqueueAsync(msg);
|
|
await _storage.UpdateMessageAsync(msg);
|
|
}
|
|
|
|
var (page1, total) = await _storage.GetParkedMessagesAsync(pageNumber: 1, pageSize: 2);
|
|
Assert.Equal(2, page1.Count);
|
|
Assert.True(total >= 5);
|
|
|
|
var (page2, _) = await _storage.GetParkedMessagesAsync(pageNumber: 2, pageSize: 2);
|
|
Assert.Equal(2, page2.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetParkedMessagesAsync_TransactionedReads_CountMatchesFullResultSet()
|
|
{
|
|
// StoreAndForward-006: the COUNT(*) and paged SELECT now run inside one
|
|
// transaction so they share a consistent snapshot. This functional check
|
|
// guards the fix — it verifies the transaction wiring did not break paging:
|
|
// the reported TotalCount and the rows assembled across all pages agree, and
|
|
// a page wide enough to hold every parked row contains exactly TotalCount rows.
|
|
for (int i = 0; i < 25; i++)
|
|
{
|
|
var m = CreateMessage($"txn-{i}", StoreAndForwardCategory.ExternalSystem);
|
|
m.Status = StoreAndForwardMessageStatus.Parked;
|
|
await _storage.EnqueueAsync(m);
|
|
await _storage.UpdateMessageAsync(m);
|
|
}
|
|
|
|
var (wholePage, wholeTotal) = await _storage.GetParkedMessagesAsync(pageNumber: 1, pageSize: 1000);
|
|
Assert.Equal(25, wholeTotal);
|
|
Assert.Equal(wholeTotal, wholePage.Count);
|
|
|
|
var collected = new List<string>();
|
|
int reportedTotal = -1;
|
|
for (int page = 1; ; page++)
|
|
{
|
|
var (rows, total) = await _storage.GetParkedMessagesAsync(pageNumber: page, pageSize: 7);
|
|
reportedTotal = total;
|
|
collected.AddRange(rows.Select(r => r.Id));
|
|
if (rows.Count < 7) break;
|
|
}
|
|
Assert.Equal(reportedTotal, collected.Count);
|
|
Assert.Equal(25, collected.Distinct().Count());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetMessageCountByStatusAsync_ReturnsAccurateCount()
|
|
{
|
|
var msg = CreateMessage("cnt1", StoreAndForwardCategory.ExternalSystem);
|
|
await _storage.EnqueueAsync(msg);
|
|
|
|
var count = await _storage.GetMessageCountByStatusAsync(StoreAndForwardMessageStatus.Pending);
|
|
Assert.True(count >= 1);
|
|
}
|
|
|
|
// ── Audit Log #23 (ExecutionId Task 4): execution_id / source_script ──
|
|
|
|
[Fact]
|
|
public async Task EnqueueAsync_RoundTripsExecutionIdAndSourceScript()
|
|
{
|
|
// A cached call buffered on a transient failure carries the originating
|
|
// script execution's ExecutionId + SourceScript; both must survive a
|
|
// persist + read-back so the retry loop can stamp them on audit rows.
|
|
var executionId = Guid.NewGuid();
|
|
var message = CreateMessage("exec1", StoreAndForwardCategory.ExternalSystem);
|
|
message.ExecutionId = executionId;
|
|
message.SourceScript = "Plant.Pump42/OnTick";
|
|
|
|
await _storage.EnqueueAsync(message);
|
|
|
|
var retrieved = await _storage.GetMessageByIdAsync("exec1");
|
|
Assert.NotNull(retrieved);
|
|
Assert.Equal(executionId, retrieved!.ExecutionId);
|
|
Assert.Equal("Plant.Pump42/OnTick", retrieved.SourceScript);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EnqueueAsync_NullExecutionIdAndSourceScript_RoundTripAsNull()
|
|
{
|
|
// Non-cached-call enqueues (notifications) supply neither field — they
|
|
// must round-trip as null rather than throwing or coercing.
|
|
var message = CreateMessage("noexec1", StoreAndForwardCategory.Notification);
|
|
Assert.Null(message.ExecutionId);
|
|
Assert.Null(message.SourceScript);
|
|
|
|
await _storage.EnqueueAsync(message);
|
|
|
|
var retrieved = await _storage.GetMessageByIdAsync("noexec1");
|
|
Assert.NotNull(retrieved);
|
|
Assert.Null(retrieved!.ExecutionId);
|
|
Assert.Null(retrieved.SourceScript);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ExecutionIdAndSourceScript_SurviveRetrySweepRead()
|
|
{
|
|
// The retry sweep reads due rows via GetMessagesForRetryAsync; the new
|
|
// fields must be present on that read path too (it is the path that
|
|
// feeds the CachedCallAttemptContext).
|
|
var executionId = Guid.NewGuid();
|
|
var message = CreateMessage("sweep1", StoreAndForwardCategory.CachedDbWrite);
|
|
message.ExecutionId = executionId;
|
|
message.SourceScript = "Plant.Tank/OnAlarm";
|
|
message.LastAttemptAt = null; // due immediately
|
|
await _storage.EnqueueAsync(message);
|
|
|
|
var due = await _storage.GetMessagesForRetryAsync();
|
|
|
|
var row = Assert.Single(due, m => m.Id == "sweep1");
|
|
Assert.Equal(executionId, row.ExecutionId);
|
|
Assert.Equal("Plant.Tank/OnAlarm", row.SourceScript);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LegacyRowWithoutNewColumns_ReadsBackAsNull()
|
|
{
|
|
// Back-compat: a row persisted by a build that pre-dates the
|
|
// execution_id / source_script columns must still deserialize, with
|
|
// ExecutionId / SourceScript reading back as null. Simulate the legacy
|
|
// schema by dropping the table and recreating it without the columns,
|
|
// inserting directly, then running InitializeAsync (which ALTER-adds
|
|
// the columns) and reading the row back.
|
|
await using (var setup = _localDb.Db.CreateConnection())
|
|
{
|
|
await using var drop = setup.CreateCommand();
|
|
drop.CommandText = @"
|
|
DROP TABLE IF EXISTS sf_messages;
|
|
CREATE TABLE sf_messages (
|
|
id TEXT PRIMARY KEY,
|
|
category INTEGER NOT NULL,
|
|
target TEXT NOT NULL,
|
|
payload_json TEXT NOT NULL,
|
|
retry_count INTEGER NOT NULL DEFAULT 0,
|
|
max_retries INTEGER NOT NULL DEFAULT 50,
|
|
retry_interval_ms INTEGER NOT NULL DEFAULT 30000,
|
|
created_at TEXT NOT NULL,
|
|
last_attempt_at TEXT,
|
|
status INTEGER NOT NULL DEFAULT 0,
|
|
last_error TEXT,
|
|
origin_instance TEXT
|
|
);
|
|
INSERT INTO sf_messages (id, category, target, payload_json, created_at, status)
|
|
VALUES ('legacy1', 0, 'ERP', '{}', '2026-01-01T00:00:00.0000000+00:00', 0);";
|
|
await drop.ExecuteNonQueryAsync();
|
|
}
|
|
|
|
// InitializeAsync must additively ALTER-in the new columns without
|
|
// disturbing the pre-existing legacy row.
|
|
await _storage.InitializeAsync();
|
|
|
|
var retrieved = await _storage.GetMessageByIdAsync("legacy1");
|
|
Assert.NotNull(retrieved);
|
|
Assert.Equal("legacy1", retrieved!.Id);
|
|
Assert.Null(retrieved.ExecutionId);
|
|
Assert.Null(retrieved.SourceScript);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MalformedExecutionId_ReadsBackAsNull_DoesNotAbortRetrySweep()
|
|
{
|
|
// Defensive read path: a corrupt (non-null, non-GUID) execution_id must
|
|
// be treated as "no execution id" rather than throwing FormatException
|
|
// — a single bad row must not abort the whole GetMessagesForRetryAsync
|
|
// sweep, which reads many rows. Persist two due rows, then corrupt the
|
|
// execution_id of one directly in the DB.
|
|
var goodId = Guid.NewGuid();
|
|
var good = CreateMessage("good1", StoreAndForwardCategory.ExternalSystem);
|
|
good.ExecutionId = goodId;
|
|
good.LastAttemptAt = null; // due immediately
|
|
await _storage.EnqueueAsync(good);
|
|
|
|
var bad = CreateMessage("bad1", StoreAndForwardCategory.ExternalSystem);
|
|
bad.ExecutionId = Guid.NewGuid();
|
|
bad.LastAttemptAt = null; // due immediately
|
|
await _storage.EnqueueAsync(bad);
|
|
|
|
await using (var conn = _localDb.Db.CreateConnection())
|
|
{
|
|
await using var corrupt = conn.CreateCommand();
|
|
corrupt.CommandText =
|
|
"UPDATE sf_messages SET execution_id = 'not-a-guid' WHERE id = 'bad1';";
|
|
await corrupt.ExecuteNonQueryAsync();
|
|
}
|
|
|
|
// The sweep must not throw; the corrupt row reads back with a null
|
|
// ExecutionId, the well-formed row keeps its value.
|
|
var due = await _storage.GetMessagesForRetryAsync();
|
|
Assert.Null(Assert.Single(due, m => m.Id == "bad1").ExecutionId);
|
|
Assert.Equal(goodId, Assert.Single(due, m => m.Id == "good1").ExecutionId);
|
|
|
|
// The single-row read path is equally defensive.
|
|
var retrieved = await _storage.GetMessageByIdAsync("bad1");
|
|
Assert.NotNull(retrieved);
|
|
Assert.Null(retrieved!.ExecutionId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task InitializeAsync_IsIdempotent_WhenColumnsAlreadyExist()
|
|
{
|
|
// The additive ALTER must not fail on a second InitializeAsync call
|
|
// (SQLite has no ADD COLUMN IF NOT EXISTS — the probe must skip it).
|
|
await _storage.InitializeAsync();
|
|
await _storage.InitializeAsync();
|
|
|
|
var message = CreateMessage("idem1", StoreAndForwardCategory.ExternalSystem);
|
|
message.ExecutionId = Guid.NewGuid();
|
|
await _storage.EnqueueAsync(message);
|
|
var retrieved = await _storage.GetMessageByIdAsync("idem1");
|
|
Assert.NotNull(retrieved);
|
|
Assert.Equal(message.ExecutionId, retrieved!.ExecutionId);
|
|
}
|
|
|
|
// ── Audit Log #23 (ParentExecutionId Task 6): parent_execution_id ──
|
|
|
|
[Fact]
|
|
public async Task EnqueueAsync_RoundTripsParentExecutionId()
|
|
{
|
|
// A cached call buffered from an inbound-API-routed script run carries
|
|
// the spawning execution's ParentExecutionId; it must survive a persist
|
|
// + read-back so the retry loop can stamp it on audit rows.
|
|
var parentExecutionId = Guid.NewGuid();
|
|
var message = CreateMessage("parent1", StoreAndForwardCategory.ExternalSystem);
|
|
message.ParentExecutionId = parentExecutionId;
|
|
|
|
await _storage.EnqueueAsync(message);
|
|
|
|
var retrieved = await _storage.GetMessageByIdAsync("parent1");
|
|
Assert.NotNull(retrieved);
|
|
Assert.Equal(parentExecutionId, retrieved!.ParentExecutionId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EnqueueAsync_NullParentExecutionId_RoundTripsAsNull()
|
|
{
|
|
// A non-routed run supplies no ParentExecutionId — it must round-trip
|
|
// as null rather than throwing or coercing.
|
|
var message = CreateMessage("noparent1", StoreAndForwardCategory.ExternalSystem);
|
|
Assert.Null(message.ParentExecutionId);
|
|
|
|
await _storage.EnqueueAsync(message);
|
|
|
|
var retrieved = await _storage.GetMessageByIdAsync("noparent1");
|
|
Assert.NotNull(retrieved);
|
|
Assert.Null(retrieved!.ParentExecutionId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ParentExecutionId_SurvivesRetrySweepRead()
|
|
{
|
|
// The retry sweep reads due rows via GetMessagesForRetryAsync; the new
|
|
// parent_execution_id field must be present on that read path too — it
|
|
// is the path that feeds the CachedCallAttemptContext.
|
|
var parentExecutionId = Guid.NewGuid();
|
|
var message = CreateMessage("psweep1", StoreAndForwardCategory.CachedDbWrite);
|
|
message.ParentExecutionId = parentExecutionId;
|
|
message.LastAttemptAt = null; // due immediately
|
|
await _storage.EnqueueAsync(message);
|
|
|
|
var due = await _storage.GetMessagesForRetryAsync();
|
|
|
|
var row = Assert.Single(due, m => m.Id == "psweep1");
|
|
Assert.Equal(parentExecutionId, row.ParentExecutionId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LegacyRowWithoutParentExecutionIdColumn_ReadsBackAsNull()
|
|
{
|
|
// Back-compat: a row persisted by a build that pre-dates the
|
|
// parent_execution_id column must still deserialize, with
|
|
// ParentExecutionId reading back as null. Simulate the pre-Task-6
|
|
// schema (which already has execution_id / source_script from the
|
|
// ExecutionId rollout) by recreating the table without
|
|
// parent_execution_id, inserting directly, then running InitializeAsync
|
|
// which ALTER-adds the column.
|
|
await using (var setup = _localDb.Db.CreateConnection())
|
|
{
|
|
await using var drop = setup.CreateCommand();
|
|
drop.CommandText = @"
|
|
DROP TABLE IF EXISTS sf_messages;
|
|
CREATE TABLE sf_messages (
|
|
id TEXT PRIMARY KEY,
|
|
category INTEGER NOT NULL,
|
|
target TEXT NOT NULL,
|
|
payload_json TEXT NOT NULL,
|
|
retry_count INTEGER NOT NULL DEFAULT 0,
|
|
max_retries INTEGER NOT NULL DEFAULT 50,
|
|
retry_interval_ms INTEGER NOT NULL DEFAULT 30000,
|
|
created_at TEXT NOT NULL,
|
|
last_attempt_at TEXT,
|
|
status INTEGER NOT NULL DEFAULT 0,
|
|
last_error TEXT,
|
|
origin_instance TEXT,
|
|
execution_id TEXT,
|
|
source_script TEXT
|
|
);
|
|
INSERT INTO sf_messages (id, category, target, payload_json, created_at, status)
|
|
VALUES ('plegacy1', 0, 'ERP', '{}', '2026-01-01T00:00:00.0000000+00:00', 0);";
|
|
await drop.ExecuteNonQueryAsync();
|
|
}
|
|
|
|
// InitializeAsync must additively ALTER-in parent_execution_id without
|
|
// disturbing the pre-existing legacy row.
|
|
await _storage.InitializeAsync();
|
|
|
|
var retrieved = await _storage.GetMessageByIdAsync("plegacy1");
|
|
Assert.NotNull(retrieved);
|
|
Assert.Equal("plegacy1", retrieved!.Id);
|
|
Assert.Null(retrieved.ParentExecutionId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MalformedParentExecutionId_ReadsBackAsNull_DoesNotAbortRetrySweep()
|
|
{
|
|
// Defensive read path: a corrupt (non-null, non-GUID) parent_execution_id
|
|
// must be treated as "no parent execution id" rather than throwing
|
|
// FormatException — a single bad row must not abort the whole
|
|
// GetMessagesForRetryAsync sweep.
|
|
var goodParent = Guid.NewGuid();
|
|
var good = CreateMessage("pgood1", StoreAndForwardCategory.ExternalSystem);
|
|
good.ParentExecutionId = goodParent;
|
|
good.LastAttemptAt = null; // due immediately
|
|
await _storage.EnqueueAsync(good);
|
|
|
|
var bad = CreateMessage("pbad1", StoreAndForwardCategory.ExternalSystem);
|
|
bad.ParentExecutionId = Guid.NewGuid();
|
|
bad.LastAttemptAt = null; // due immediately
|
|
await _storage.EnqueueAsync(bad);
|
|
|
|
await using (var conn = _localDb.Db.CreateConnection())
|
|
{
|
|
await using var corrupt = conn.CreateCommand();
|
|
corrupt.CommandText =
|
|
"UPDATE sf_messages SET parent_execution_id = 'not-a-guid' WHERE id = 'pbad1';";
|
|
await corrupt.ExecuteNonQueryAsync();
|
|
}
|
|
|
|
var due = await _storage.GetMessagesForRetryAsync();
|
|
Assert.Null(Assert.Single(due, m => m.Id == "pbad1").ParentExecutionId);
|
|
Assert.Equal(goodParent, Assert.Single(due, m => m.Id == "pgood1").ParentExecutionId);
|
|
|
|
var retrieved = await _storage.GetMessageByIdAsync("pbad1");
|
|
Assert.NotNull(retrieved);
|
|
Assert.Null(retrieved!.ParentExecutionId);
|
|
}
|
|
|
|
// ── Task 7 (arch review 02, Performance): bounded due-rows query ──
|
|
|
|
[Fact]
|
|
public async Task GetMessagesForRetry_HonorsLimit_OldestFirst()
|
|
{
|
|
var baseTime = DateTimeOffset.UtcNow.AddMinutes(-10);
|
|
for (var i = 0; i < 5; i++)
|
|
{
|
|
await _storage.EnqueueAsync(new StoreAndForwardMessage
|
|
{
|
|
Id = $"m{i}",
|
|
Category = StoreAndForwardCategory.ExternalSystem,
|
|
Target = "t",
|
|
PayloadJson = "{}",
|
|
RetryCount = 0,
|
|
MaxRetries = 50,
|
|
RetryIntervalMs = 0, // always due
|
|
CreatedAt = baseTime.AddSeconds(i),
|
|
Status = StoreAndForwardMessageStatus.Pending
|
|
});
|
|
}
|
|
|
|
var page = await _storage.GetMessagesForRetryAsync(limit: 3);
|
|
Assert.Equal(3, page.Count);
|
|
Assert.Equal(new[] { "m0", "m1", "m2" }, page.Select(m => m.Id));
|
|
}
|
|
|
|
private static StoreAndForwardMessage CreateMessage(string id, StoreAndForwardCategory category)
|
|
{
|
|
return new StoreAndForwardMessage
|
|
{
|
|
Id = id,
|
|
Category = category,
|
|
Target = "target1",
|
|
PayloadJson = """{"method":"Test","args":{}}""",
|
|
RetryCount = 0,
|
|
MaxRetries = 50,
|
|
RetryIntervalMs = 30000,
|
|
CreatedAt = DateTimeOffset.UtcNow,
|
|
Status = StoreAndForwardMessageStatus.Pending
|
|
};
|
|
}
|
|
|
|
// ── Invariants that moved owner ──
|
|
//
|
|
// Directory creation and WAL journal mode used to be this storage class's own job
|
|
// (EnsureDatabaseDirectoryExists / an explicit PRAGMA in InitializeAsync). Both moved
|
|
// out when the store stopped owning its file. They landed in DIFFERENT places, so the
|
|
// coverage split rather than moving wholesale:
|
|
//
|
|
// * WAL is genuinely LocalDb's — it sets the journal mode on the database it owns.
|
|
// The test below still asserts it, now against the LocalDb-backed store.
|
|
//
|
|
// * Directory creation is NOT LocalDb's. The library does not create the parent
|
|
// directory and opens the file eagerly, so a missing directory is a hard boot
|
|
// failure. The guarantee had to be re-established explicitly in the Host
|
|
// (SiteServiceRegistration.EnsureLocalDbDirectoryExists), and it is pinned there
|
|
// by SiteLocalDbDirectoryTests — the layer that now owns it. Asserting it here
|
|
// would be asserting a guarantee this class no longer provides.
|
|
|
|
[Fact]
|
|
public async Task Initialize_LeavesTheDatabaseInWalJournalMode()
|
|
{
|
|
// WAL lets the retry-sweep lanes, script enqueues, and standby replication
|
|
// applies read/write concurrently without "database is locked".
|
|
using var localDb = TestLocalDb.CreateTemp("sf-wal-test");
|
|
var path = localDb.Path;
|
|
try
|
|
{
|
|
var storage = new StoreAndForwardStorage(
|
|
localDb.Db, NullLogger<StoreAndForwardStorage>.Instance);
|
|
await storage.InitializeAsync();
|
|
|
|
// journal_mode is persistent + file-scoped, so any connection observes it.
|
|
await using var conn = localDb.Db.CreateConnection();
|
|
await using var cmd = conn.CreateCommand();
|
|
cmd.CommandText = "PRAGMA journal_mode";
|
|
Assert.Equal("wal", (string)(await cmd.ExecuteScalarAsync())!);
|
|
}
|
|
finally
|
|
{
|
|
localDb.Dispose();
|
|
TestLocalDb.DeleteFiles(path);
|
|
}
|
|
}
|
|
|
|
// ── Task 18: epoch-ms due-check ──
|
|
|
|
[Fact]
|
|
public async Task GetMessagesForRetry_UsesEpochMs_RespectsInterval()
|
|
{
|
|
var msg = new StoreAndForwardMessage
|
|
{
|
|
Id = "due-check",
|
|
Category = StoreAndForwardCategory.ExternalSystem,
|
|
Target = "t",
|
|
PayloadJson = "{}",
|
|
RetryCount = 0,
|
|
MaxRetries = 50,
|
|
RetryIntervalMs = 60_000,
|
|
CreatedAt = DateTimeOffset.UtcNow,
|
|
Status = StoreAndForwardMessageStatus.Pending,
|
|
LastAttemptAt = DateTimeOffset.UtcNow.AddSeconds(-30), // not yet due
|
|
};
|
|
await _storage.EnqueueAsync(msg);
|
|
Assert.Empty(await _storage.GetMessagesForRetryAsync());
|
|
|
|
msg.LastAttemptAt = DateTimeOffset.UtcNow.AddSeconds(-90); // due
|
|
await _storage.UpdateMessageAsync(msg);
|
|
Assert.Single(await _storage.GetMessagesForRetryAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Initialize_BackfillsEpochMs_ForLegacyRows()
|
|
{
|
|
// Simulate a legacy row: text timestamp present, ms column NULL.
|
|
await ExecRawAsync(
|
|
"INSERT INTO sf_messages (id, category, target, payload_json, created_at, last_attempt_at, status) " +
|
|
"VALUES ('legacy', 0, 't', '{}', @c, @l, 0)",
|
|
("@c", DateTimeOffset.UtcNow.ToString("O")),
|
|
("@l", DateTimeOffset.UtcNow.AddHours(-1).ToString("O")));
|
|
|
|
await _storage.InitializeAsync(); // second init runs the one-time backfill
|
|
|
|
var ms = await ScalarRawAsync("SELECT last_attempt_at_ms FROM sf_messages WHERE id='legacy'");
|
|
Assert.NotNull(ms);
|
|
Assert.InRange(Convert.ToInt64(ms),
|
|
DateTimeOffset.UtcNow.AddHours(-1).AddMinutes(-1).ToUnixTimeMilliseconds(),
|
|
DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
|
|
}
|
|
|
|
private async Task ExecRawAsync(string sql, params (string, object)[] parameters)
|
|
{
|
|
await using var conn = _localDb.Db.CreateConnection();
|
|
await using var cmd = conn.CreateCommand();
|
|
cmd.CommandText = sql;
|
|
foreach (var (name, value) in parameters) cmd.Parameters.AddWithValue(name, value);
|
|
await cmd.ExecuteNonQueryAsync();
|
|
}
|
|
|
|
private async Task<object?> ScalarRawAsync(string sql)
|
|
{
|
|
await using var conn = _localDb.Db.CreateConnection();
|
|
await using var cmd = conn.CreateCommand();
|
|
cmd.CommandText = sql;
|
|
var result = await cmd.ExecuteScalarAsync();
|
|
return result is DBNull ? null : result;
|
|
}
|
|
|
|
// ── Task 20: buffer-resync storage primitives ──
|
|
|
|
private static StoreAndForwardMessage NewMsg(
|
|
string id,
|
|
StoreAndForwardMessageStatus status = StoreAndForwardMessageStatus.Pending,
|
|
DateTimeOffset? createdAt = null) => new()
|
|
{
|
|
Id = id,
|
|
Category = StoreAndForwardCategory.ExternalSystem,
|
|
Target = "t",
|
|
PayloadJson = "{}",
|
|
RetryCount = 0,
|
|
MaxRetries = 50,
|
|
RetryIntervalMs = 30000,
|
|
CreatedAt = createdAt ?? DateTimeOffset.UtcNow,
|
|
Status = status,
|
|
};
|
|
|
|
[Fact]
|
|
public async Task GetAllMessages_ReturnsEveryStatus_OldestFirst_UpToLimit()
|
|
{
|
|
var @base = DateTimeOffset.UtcNow;
|
|
await _storage.EnqueueAsync(NewMsg("p1", StoreAndForwardMessageStatus.Pending, @base));
|
|
await _storage.EnqueueAsync(NewMsg("k1", StoreAndForwardMessageStatus.Parked, @base.AddSeconds(1)));
|
|
await _storage.EnqueueAsync(NewMsg("p2", StoreAndForwardMessageStatus.Pending, @base.AddSeconds(2)));
|
|
|
|
var (all, truncated) = await _storage.GetAllMessagesAsync(limit: 2);
|
|
Assert.Equal(new[] { "p1", "k1" }, all.Select(m => m.Id)); // every status, oldest-first
|
|
Assert.True(truncated); // a third row exists beyond the limit
|
|
}
|
|
|
|
// ReplaceAll_SwapsTheEntireBuffer_Atomically was DELETED with ReplaceAllAsync in
|
|
// LocalDb Phase 2, and deliberately not replaced. It asserted a destructive
|
|
// delete-all-then-insert-all, which was the standby's anti-entropy apply: a resync
|
|
// overwrote the standby's copy with the active node's snapshot. sf_messages is now a
|
|
// replicated table, so a mass DELETE would be CAPTURED and shipped to the peer — the
|
|
// method is not merely unused, it is unsafe to keep. LocalDb's own snapshot resync
|
|
// merges per row under last-writer-wins and never deletes, which is what
|
|
// LocalDbConfigConvergenceTests.ANodeWithNewerLocalRows_KeepsThem_WhenAPeerSnapshotArrives
|
|
// now covers.
|
|
|
|
// ── Task 23: oldest-parked-age health signal ──
|
|
|
|
[Fact]
|
|
public async Task GetOldestParkedCreatedAt_ReturnsOldestParkedRow_OrNull()
|
|
{
|
|
Assert.Null(await _storage.GetOldestParkedCreatedAtAsync());
|
|
|
|
var old = NewMsg("old-parked", StoreAndForwardMessageStatus.Parked, DateTimeOffset.UtcNow.AddDays(-3));
|
|
var recent = NewMsg("new-parked", StoreAndForwardMessageStatus.Parked, DateTimeOffset.UtcNow.AddHours(-1));
|
|
var pending = NewMsg("pending", StoreAndForwardMessageStatus.Pending, DateTimeOffset.UtcNow.AddDays(-9)); // must not count
|
|
await _storage.EnqueueAsync(old);
|
|
await _storage.EnqueueAsync(recent);
|
|
await _storage.EnqueueAsync(pending);
|
|
|
|
var oldest = await _storage.GetOldestParkedCreatedAtAsync();
|
|
Assert.Equal(old.CreatedAt, oldest!.Value, TimeSpan.FromSeconds(1));
|
|
}
|
|
}
|