feat(commons): TrackedOperationId strong type (#23 M3)
This commit is contained in:
53
src/ScadaLink.Commons/Types/TrackedOperationId.cs
Normal file
53
src/ScadaLink.Commons/Types/TrackedOperationId.cs
Normal 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");
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user