From 1c38dd540f2c38e9180dea1ae4e9a84a66cb46d8 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 20 May 2026 13:47:40 -0400 Subject: [PATCH] feat(commons): TrackedOperationId strong type (#23 M3) --- .../Types/TrackedOperationId.cs | 53 ++++++++++++ .../Types/TrackedOperationIdTests.cs | 80 +++++++++++++++++++ 2 files changed, 133 insertions(+) create mode 100644 src/ScadaLink.Commons/Types/TrackedOperationId.cs create mode 100644 tests/ScadaLink.Commons.Tests/Types/TrackedOperationIdTests.cs diff --git a/src/ScadaLink.Commons/Types/TrackedOperationId.cs b/src/ScadaLink.Commons/Types/TrackedOperationId.cs new file mode 100644 index 0000000..3a6bb22 --- /dev/null +++ b/src/ScadaLink.Commons/Types/TrackedOperationId.cs @@ -0,0 +1,53 @@ +namespace ScadaLink.Commons.Types; + +/// +/// Strongly-typed identifier for a cached outbound operation +/// (ExternalSystem.CachedCall / Database.CachedWrite) — the unified +/// tracking handle introduced by Audit Log #23 (M3). The same id is the +/// idempotency key end-to-end: it is stamped on every AuditLog row +/// produced for the operation's lifecycle (CachedSubmit → ApiCallCached / +/// DbWriteCached × N attempts → CachedResolve) and is the PK on the central +/// SiteCalls row that mirrors the operation's operational state. +/// +/// +/// +/// The struct wraps a so it serialises identically to a +/// 36-character "D"-format string anywhere the existing GUID conventions are +/// used (gRPC strings, JSON, SQL TEXT columns). returns +/// the lower-case 8-4-4-4-12 form unconditionally; never the brace- / parens- +/// wrapped variants — central ingest parses with , which +/// is format-tolerant but the wire shape is fixed for log readability. +/// +/// +public readonly record struct TrackedOperationId(Guid Value) +{ + /// Mint a fresh id at the call site (script-thread safe). + public static TrackedOperationId New() => new(Guid.NewGuid()); + + /// + /// Parse a serialised id back into the strong type. Throws when the input + /// is not a valid GUID — callers crossing untrusted boundaries should use + /// instead. + /// + public static TrackedOperationId Parse(string s) => new(Guid.Parse(s)); + + /// + /// Attempt to parse a serialised id. Returns false for null, empty + /// or non-GUID input; is default on + /// failure. + /// + public static bool TryParse(string? s, out TrackedOperationId result) + { + if (Guid.TryParse(s, out var g)) + { + result = new TrackedOperationId(g); + return true; + } + + result = default; + return false; + } + + /// + public override string ToString() => Value.ToString("D"); +} diff --git a/tests/ScadaLink.Commons.Tests/Types/TrackedOperationIdTests.cs b/tests/ScadaLink.Commons.Tests/Types/TrackedOperationIdTests.cs new file mode 100644 index 0000000..3e8822d --- /dev/null +++ b/tests/ScadaLink.Commons.Tests/Types/TrackedOperationIdTests.cs @@ -0,0 +1,80 @@ +using ScadaLink.Commons.Types; + +namespace ScadaLink.Commons.Tests.Types; + +/// +/// Audit Log #23 (M3): tests for the strongly-typed cached-operation identifier +/// produced by ExternalSystem.CachedCall / Database.CachedWrite and +/// surfaced to scripts via Tracking.Status(id). +/// +public class TrackedOperationIdTests +{ + [Fact] + public void New_ProducesUniqueIds() + { + var a = TrackedOperationId.New(); + var b = TrackedOperationId.New(); + + Assert.NotEqual(a, b); + Assert.NotEqual(Guid.Empty, a.Value); + Assert.NotEqual(Guid.Empty, b.Value); + } + + [Fact] + public void Parse_RoundTrip_PreservesValue() + { + var original = TrackedOperationId.New(); + var serialized = original.ToString(); + + var parsed = TrackedOperationId.Parse(serialized); + + Assert.Equal(original, parsed); + Assert.Equal(original.Value, parsed.Value); + } + + [Fact] + public void TryParse_InvalidInput_ReturnsFalse() + { + Assert.False(TrackedOperationId.TryParse("not-a-guid", out var result)); + Assert.Equal(default, result); + + Assert.False(TrackedOperationId.TryParse(null, out var nullResult)); + Assert.Equal(default, nullResult); + + Assert.False(TrackedOperationId.TryParse(string.Empty, out var emptyResult)); + Assert.Equal(default, emptyResult); + } + + [Fact] + public void TryParse_ValidInput_ReturnsTrueAndId() + { + var original = TrackedOperationId.New(); + var serialized = original.ToString(); + + Assert.True(TrackedOperationId.TryParse(serialized, out var parsed)); + Assert.Equal(original, parsed); + } + + [Fact] + public void Equality_BasedOnValue() + { + var guid = Guid.NewGuid(); + var a = new TrackedOperationId(guid); + var b = new TrackedOperationId(guid); + + Assert.Equal(a, b); + Assert.True(a == b); + Assert.False(a != b); + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + } + + [Fact] + public void ToString_StandardGuidFormat() + { + var guid = Guid.Parse("12345678-1234-1234-1234-1234567890ab"); + var id = new TrackedOperationId(guid); + + // "D" format: 32 hex digits separated by hyphens (8-4-4-4-12). + Assert.Equal("12345678-1234-1234-1234-1234567890ab", id.ToString()); + } +}