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