using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.Canonical.Json.Tests;
///
/// Tests for versioned canonicalization and hash computation.
/// Verifies version marker embedding, determinism, and backward compatibility.
///
public class CanonVersionTests
{
#region Version Constants
[Trait("Category", TestCategories.Unit)]
[Fact]
public void V1_HasExpectedValue()
{
Assert.Equal("stella:canon:v1", CanonVersion.V1);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void VersionFieldName_HasUnderscorePrefix()
{
Assert.Equal("_canonVersion", CanonVersion.VersionFieldName);
Assert.StartsWith("_", CanonVersion.VersionFieldName);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Current_EqualsV1()
{
Assert.Equal(CanonVersion.V1, CanonVersion.Current);
}
#endregion
#region IsVersioned Detection
[Trait("Category", TestCategories.Unit)]
[Fact]
public void IsVersioned_VersionedJson_ReturnsTrue()
{
var json = """{"_canonVersion":"stella:canon:v1","foo":"bar"}"""u8;
Assert.True(CanonVersion.IsVersioned(json));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void IsVersioned_LegacyJson_ReturnsFalse()
{
var json = """{"foo":"bar"}"""u8;
Assert.False(CanonVersion.IsVersioned(json));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void IsVersioned_EmptyJson_ReturnsFalse()
{
var json = "{}"u8;
Assert.False(CanonVersion.IsVersioned(json));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void IsVersioned_TooShort_ReturnsFalse()
{
var json = """{"_ca":"v"}"""u8;
Assert.False(CanonVersion.IsVersioned(json));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void IsVersioned_WrongFieldName_ReturnsFalse()
{
var json = """{"_version":"stella:canon:v1","foo":"bar"}"""u8;
Assert.False(CanonVersion.IsVersioned(json));
}
#endregion
#region ExtractVersion
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ExtractVersion_VersionedJson_ReturnsVersion()
{
var json = """{"_canonVersion":"stella:canon:v1","foo":"bar"}"""u8;
Assert.Equal("stella:canon:v1", CanonVersion.ExtractVersion(json));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ExtractVersion_CustomVersion_ReturnsVersion()
{
var json = """{"_canonVersion":"custom:v2","foo":"bar"}"""u8;
Assert.Equal("custom:v2", CanonVersion.ExtractVersion(json));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ExtractVersion_LegacyJson_ReturnsNull()
{
var json = """{"foo":"bar"}"""u8;
Assert.Null(CanonVersion.ExtractVersion(json));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ExtractVersion_EmptyVersion_ReturnsNull()
{
var json = """{"_canonVersion":"","foo":"bar"}"""u8;
Assert.Null(CanonVersion.ExtractVersion(json));
}
#endregion
#region CanonicalizeVersioned
[Trait("Category", TestCategories.Unit)]
[Fact]
public void CanonicalizeVersioned_IncludesVersionMarker()
{
var obj = new { foo = "bar" };
var canonical = CanonJson.CanonicalizeVersioned(obj);
var json = Encoding.UTF8.GetString(canonical);
Assert.StartsWith("{\"_canonVersion\":\"stella:canon:v1\"", json);
Assert.Contains("\"foo\":\"bar\"", json);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void CanonicalizeVersioned_VersionMarkerIsFirst()
{
var obj = new { aaa = 1, zzz = 2 };
var canonical = CanonJson.CanonicalizeVersioned(obj);
var json = Encoding.UTF8.GetString(canonical);
// Version field should be before 'aaa' even though 'aaa' sorts first alphabetically
var versionIndex = json.IndexOf("_canonVersion");
var aaaIndex = json.IndexOf("aaa");
Assert.True(versionIndex < aaaIndex);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void CanonicalizeVersioned_SortsOtherKeys()
{
var obj = new { z = 3, a = 1, m = 2 };
var canonical = CanonJson.CanonicalizeVersioned(obj);
var json = Encoding.UTF8.GetString(canonical);
// After version marker, keys should be sorted
Assert.Matches(@"\{""_canonVersion"":""[^""]+"",""a"":1,""m"":2,""z"":3\}", json);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void CanonicalizeVersioned_CustomVersion_UsesProvidedVersion()
{
var obj = new { foo = "bar" };
var canonical = CanonJson.CanonicalizeVersioned(obj, "custom:v99");
var json = Encoding.UTF8.GetString(canonical);
Assert.Contains("\"_canonVersion\":\"custom:v99\"", json);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void CanonicalizeVersioned_NullVersion_ThrowsArgumentException()
{
var obj = new { foo = "bar" };
Assert.ThrowsAny(() => CanonJson.CanonicalizeVersioned(obj, null!));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void CanonicalizeVersioned_EmptyVersion_ThrowsArgumentException()
{
var obj = new { foo = "bar" };
Assert.Throws(() => CanonJson.CanonicalizeVersioned(obj, ""));
}
#endregion
#region Hash Difference (Versioned vs Legacy)
[Trait("Category", TestCategories.Unit)]
[Fact]
public void HashVersioned_DiffersFromLegacyHash()
{
var obj = new { foo = "bar", count = 42 };
var legacyHash = CanonJson.Hash(obj);
var versionedHash = CanonJson.HashVersioned(obj);
Assert.NotEqual(legacyHash, versionedHash);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void HashVersionedPrefixed_DiffersFromLegacyHashPrefixed()
{
var obj = new { foo = "bar", count = 42 };
var legacyHash = CanonJson.HashPrefixed(obj);
var versionedHash = CanonJson.HashVersionedPrefixed(obj);
Assert.NotEqual(legacyHash, versionedHash);
Assert.StartsWith("sha256:", versionedHash);
Assert.StartsWith("sha256:", legacyHash);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void HashVersioned_SameInput_ProducesSameHash()
{
var obj = new { foo = "bar", count = 42 };
var hash1 = CanonJson.HashVersioned(obj);
var hash2 = CanonJson.HashVersioned(obj);
Assert.Equal(hash1, hash2);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void HashVersioned_DifferentVersions_ProduceDifferentHashes()
{
var obj = new { foo = "bar" };
var hashV1 = CanonJson.HashVersioned(obj, "stella:canon:v1");
var hashV2 = CanonJson.HashVersioned(obj, "stella:canon:v2");
Assert.NotEqual(hashV1, hashV2);
}
#endregion
#region Determinism
[Trait("Category", TestCategories.Unit)]
[Fact]
public void CanonicalizeVersioned_SameInput_ProducesSameBytes()
{
var obj = new { name = "test", value = 123, nested = new { x = 1, y = 2 } };
var bytes1 = CanonJson.CanonicalizeVersioned(obj);
var bytes2 = CanonJson.CanonicalizeVersioned(obj);
Assert.Equal(bytes1, bytes2);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void CanonicalizeVersioned_DifferentPropertyOrder_ProducesSameBytes()
{
// Create two objects with same properties but defined in different order
var json1 = """{"z":3,"a":1,"m":2}""";
var json2 = """{"a":1,"m":2,"z":3}""";
var obj1 = JsonSerializer.Deserialize(json1);
var obj2 = JsonSerializer.Deserialize(json2);
var bytes1 = CanonJson.CanonicalizeVersioned(obj1);
var bytes2 = CanonJson.CanonicalizeVersioned(obj2);
Assert.Equal(bytes1, bytes2);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void CanonicalizeVersioned_StableAcrossMultipleCalls()
{
var obj = new { id = Guid.Parse("12345678-1234-1234-1234-123456789012"), name = "stable" };
var hashes = Enumerable.Range(0, 100)
.Select(_ => CanonJson.HashVersioned(obj))
.Distinct()
.ToList();
Assert.Single(hashes);
}
#endregion
#region Golden File / Snapshot Tests
[Trait("Category", TestCategories.Unit)]
[Fact]
public void CanonicalizeVersioned_KnownInput_ProducesKnownOutput()
{
// Golden test: exact output for known input to detect algorithm changes
var obj = new { message = "hello", number = 42 };
var canonical = CanonJson.CanonicalizeVersioned(obj, "stella:canon:v1");
var json = Encoding.UTF8.GetString(canonical);
// Exact expected output with version marker first
Assert.Equal("""{"_canonVersion":"stella:canon:v1","message":"hello","number":42}""", json);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void HashVersioned_KnownInput_ProducesKnownHash()
{
// Golden test: exact hash for known input to detect algorithm changes
var obj = new { message = "hello", number = 42 };
var hash = CanonJson.HashVersioned(obj, "stella:canon:v1");
// If this test fails, it indicates the canonicalization algorithm changed
// which would invalidate existing content-addressed identifiers
// Hash is for: {"_canonVersion":"stella:canon:v1","message":"hello","number":42}
Assert.Equal(64, hash.Length); // SHA-256 hex is 64 chars
Assert.Matches("^[0-9a-f]{64}$", hash);
// Determinism check: same input always produces same hash
var hash2 = CanonJson.HashVersioned(obj, "stella:canon:v1");
Assert.Equal(hash, hash2);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void CanonicalizeVersioned_NestedObject_ProducesCorrectOutput()
{
var obj = new
{
outer = new { z = 9, a = 1 },
name = "nested"
};
var canonical = CanonJson.CanonicalizeVersioned(obj, "stella:canon:v1");
var json = Encoding.UTF8.GetString(canonical);
// Nested objects should also have sorted keys
Assert.Equal("""{"_canonVersion":"stella:canon:v1","name":"nested","outer":{"a":1,"z":9}}""", json);
}
#endregion
#region Backward Compatibility
[Trait("Category", TestCategories.Unit)]
[Fact]
public void CanVersion_CanDistinguishLegacyFromVersioned()
{
var obj = new { foo = "bar" };
var legacy = CanonJson.Canonicalize(obj);
var versioned = CanonJson.CanonicalizeVersioned(obj);
Assert.False(CanonVersion.IsVersioned(legacy));
Assert.True(CanonVersion.IsVersioned(versioned));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void LegacyCanonicalize_StillWorks()
{
// Ensure we haven't broken the legacy canonicalize method
var obj = new { z = 3, a = 1 };
var canonical = CanonJson.Canonicalize(obj);
var json = Encoding.UTF8.GetString(canonical);
Assert.Equal("""{"a":1,"z":3}""", json);
Assert.DoesNotContain("_canonVersion", json);
}
#endregion
#region Edge Cases
[Trait("Category", TestCategories.Unit)]
[Fact]
public void CanonicalizeVersioned_EmptyObject_IncludesOnlyVersion()
{
var obj = new { };
var canonical = CanonJson.CanonicalizeVersioned(obj);
var json = Encoding.UTF8.GetString(canonical);
Assert.Equal("""{"_canonVersion":"stella:canon:v1"}""", json);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void CanonicalizeVersioned_WithSpecialCharacters_HandlesCorrectly()
{
var obj = new { message = "hello\nworld", special = "quote:\"test\"" };
var canonical = CanonJson.CanonicalizeVersioned(obj);
var json = Encoding.UTF8.GetString(canonical);
// Should be valid JSON with escaped characters
var parsed = JsonSerializer.Deserialize(json);
Assert.Equal("hello\nworld", parsed.GetProperty("message").GetString());
Assert.Equal("quote:\"test\"", parsed.GetProperty("special").GetString());
Assert.Equal("stella:canon:v1", parsed.GetProperty("_canonVersion").GetString());
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void CanonicalizeVersioned_WithUnicodeCharacters_HandlesCorrectly()
{
var obj = new { greeting = "こんにちは", emoji = "🚀" };
var canonical = CanonJson.CanonicalizeVersioned(obj);
var json = Encoding.UTF8.GetString(canonical);
var parsed = JsonSerializer.Deserialize(json);
Assert.Equal("こんにちは", parsed.GetProperty("greeting").GetString());
Assert.Equal("🚀", parsed.GetProperty("emoji").GetString());
}
#endregion
}