feat(commons): TrackedOperationId strong type (#23 M3)

This commit is contained in:
Joseph Doherty
2026-05-20 13:47:40 -04:00
parent 4ca0b3ce2a
commit 1c38dd540f
2 changed files with 133 additions and 0 deletions

View File

@@ -0,0 +1,53 @@
namespace ScadaLink.Commons.Types;
/// <summary>
/// Strongly-typed identifier for a cached outbound operation
/// (<c>ExternalSystem.CachedCall</c> / <c>Database.CachedWrite</c>) — 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 <c>AuditLog</c> row
/// produced for the operation's lifecycle (CachedSubmit → ApiCallCached /
/// DbWriteCached × N attempts → CachedResolve) and is the PK on the central
/// <c>SiteCalls</c> row that mirrors the operation's operational state.
/// </summary>
/// <remarks>
/// <para>
/// The struct wraps a <see cref="Guid"/> so it serialises identically to a
/// 36-character "D"-format string anywhere the existing GUID conventions are
/// used (gRPC strings, JSON, SQL TEXT columns). <see cref="ToString"/> returns
/// the lower-case 8-4-4-4-12 form unconditionally; never the brace- / parens-
/// wrapped variants — central ingest parses with <see cref="Guid.Parse"/>, which
/// is format-tolerant but the wire shape is fixed for log readability.
/// </para>
/// </remarks>
public readonly record struct TrackedOperationId(Guid Value)
{
/// <summary>Mint a fresh id at the call site (script-thread safe).</summary>
public static TrackedOperationId New() => new(Guid.NewGuid());
/// <summary>
/// Parse a serialised id back into the strong type. Throws when the input
/// is not a valid GUID — callers crossing untrusted boundaries should use
/// <see cref="TryParse"/> instead.
/// </summary>
public static TrackedOperationId Parse(string s) => new(Guid.Parse(s));
/// <summary>
/// Attempt to parse a serialised id. Returns <c>false</c> for null, empty
/// or non-GUID input; <paramref name="result"/> is <c>default</c> on
/// failure.
/// </summary>
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;
}
/// <inheritdoc/>
public override string ToString() => Value.ToString("D");
}

View File

@@ -0,0 +1,80 @@
using ScadaLink.Commons.Types;
namespace ScadaLink.Commons.Tests.Types;
/// <summary>
/// Audit Log #23 (M3): tests for the strongly-typed cached-operation identifier
/// produced by <c>ExternalSystem.CachedCall</c> / <c>Database.CachedWrite</c> and
/// surfaced to scripts via <c>Tracking.Status(id)</c>.
/// </summary>
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());
}
}