Merge p22-w2-jsonld-type-fix into dev
Deploy DeepDrftPublic / Build & Publish (push) Successful in 4m20s
Package install tarball / package (push) Successful in 6s
Deploy DeepDrftPublic / Deploy (push) Successful in 1m25s

Fix JSON-LD @type serialization: concrete nodes were emitting a bare Type alongside @type because the attribute sat only on the abstract base override. Validator now clean.
This commit is contained in:
daniel-c-harvey
2026-06-23 06:57:44 -04:00
2 changed files with 119 additions and 4 deletions
+4 -4
View File
@@ -63,7 +63,7 @@ public abstract record JsonLdNode
/// <summary>The Deep DRFT collective entity — the home/about node.</summary>
public sealed record MusicGroupNode : JsonLdNode
{
public override string Type => "MusicGroup";
[JsonPropertyName("@type")] public override string Type => "MusicGroup";
[JsonPropertyName("name")] public string Name { get; init; } = string.Empty;
[JsonPropertyName("url")] public string? Url { get; init; }
@@ -76,7 +76,7 @@ public sealed record MusicGroupNode : JsonLdNode
/// <summary>A studio cut or a live session release. <c>AlbumProductionType</c> distinguishes them.</summary>
public sealed record MusicAlbumNode : JsonLdNode
{
public override string Type => "MusicAlbum";
[JsonPropertyName("@type")] public override string Type => "MusicAlbum";
[JsonPropertyName("name")] public string Name { get; init; } = string.Empty;
[JsonPropertyName("byArtist")] public ArtistRef? ByArtist { get; init; }
@@ -96,7 +96,7 @@ public sealed record MusicAlbumNode : JsonLdNode
/// <summary>A single recording — a mix release, or one track inside an album's <c>track</c> list.</summary>
public sealed record MusicRecordingNode : JsonLdNode
{
public override string Type => "MusicRecording";
[JsonPropertyName("@type")] public override string Type => "MusicRecording";
[JsonPropertyName("name")] public string Name { get; init; } = string.Empty;
[JsonPropertyName("byArtist")] public ArtistRef? ByArtist { get; init; }
@@ -112,7 +112,7 @@ public sealed record MusicRecordingNode : JsonLdNode
/// <summary>A browse/index surface listing releases (cuts/sessions/mixes/archive).</summary>
public sealed record CollectionPageNode : JsonLdNode
{
public override string Type => "CollectionPage";
[JsonPropertyName("@type")] public override string Type => "CollectionPage";
[JsonPropertyName("name")] public string Name { get; init; } = string.Empty;
[JsonPropertyName("description")] public string? Description { get; init; }
+115
View File
@@ -268,6 +268,121 @@ public class SeoModelTests
Is.EqualTo("Aphex Twin"));
}
// --- AC5 regression: no stray CLR `Type` property emitted alongside `@type` ---
// System.Text.Json previously emitted both `@type` (from the base [JsonPropertyName]) and `Type`
// (the raw CLR override name) on concrete derived nodes, failing the schema.org validator.
// The fix: repeat [JsonPropertyName("@type")] directly on each concrete override.
[Test]
public void MusicAlbumNode_SerializesAtType_OnlyOnce_NoBareTypeProperty()
{
var tracks = new List<TrackDto> { new() { TrackName = "T", TrackNumber = 1, DurationSeconds = 30 } };
var node = Parse(SeoModel.ForRelease(Options, Release(ReleaseMedium.Cut), tracks).JsonLd);
Assert.Multiple(() =>
{
Assert.That(node.GetProperty("@type").GetString(), Is.EqualTo("MusicAlbum"),
"MusicAlbumNode must emit @type");
Assert.That(node.TryGetProperty("Type", out _), Is.False,
"MusicAlbumNode must NOT emit a bare Type property");
});
}
[Test]
public void MusicRecordingNode_TopLevel_SerializesAtType_NoBareTypeProperty()
{
var tracks = new List<TrackDto> { new() { TrackName = "The Mix", DurationSeconds = 3600 } };
var node = Parse(SeoModel.ForRelease(Options, Release(ReleaseMedium.Mix), tracks).JsonLd);
Assert.Multiple(() =>
{
Assert.That(node.GetProperty("@type").GetString(), Is.EqualTo("MusicRecording"),
"MusicRecordingNode must emit @type");
Assert.That(node.TryGetProperty("Type", out _), Is.False,
"MusicRecordingNode must NOT emit a bare Type property");
});
}
[Test]
public void MusicRecordingNode_NestedTrack_SerializesAtType_NoBareTypeProperty()
{
// The nested track[] MusicRecordingNode is a different code path from the top-level mix node.
var tracks = new List<TrackDto> { new() { TrackName = "T", TrackNumber = 1, DurationSeconds = 30 } };
var albumNode = Parse(SeoModel.ForRelease(Options, Release(ReleaseMedium.Cut), tracks).JsonLd);
var trackNode = albumNode.GetProperty("track")[0];
Assert.Multiple(() =>
{
Assert.That(trackNode.GetProperty("@type").GetString(), Is.EqualTo("MusicRecording"),
"nested MusicRecordingNode must emit @type");
Assert.That(trackNode.TryGetProperty("Type", out _), Is.False,
"nested MusicRecordingNode must NOT emit a bare Type property");
});
}
[Test]
public void MusicGroupNode_SerializesAtType_NoBareTypeProperty()
{
var node = Parse(SeoModel.ForHome(Options).JsonLd);
Assert.Multiple(() =>
{
Assert.That(node.GetProperty("@type").GetString(), Is.EqualTo("MusicGroup"),
"MusicGroupNode must emit @type");
Assert.That(node.TryGetProperty("Type", out _), Is.False,
"MusicGroupNode must NOT emit a bare Type property");
});
}
[Test]
public void CollectionPageNode_SerializesAtType_NoBareTypeProperty()
{
var node = Parse(SeoModel.ForBrowse(Options, ReleaseMedium.Cut, "/cuts").JsonLd);
Assert.Multiple(() =>
{
Assert.That(node.GetProperty("@type").GetString(), Is.EqualTo("CollectionPage"),
"CollectionPageNode must emit @type");
Assert.That(node.TryGetProperty("Type", out _), Is.False,
"CollectionPageNode must NOT emit a bare Type property");
});
}
[Test]
public void ArtistRef_NestedByArtist_SerializesAtType_NoBareTypeProperty()
{
// ArtistRef (byArtist) was already clean — this asserts it stays clean after the fix.
var node = Parse(SeoModel.ForRelease(Options, Release(ReleaseMedium.Cut)).JsonLd);
var byArtist = node.GetProperty("byArtist");
Assert.Multiple(() =>
{
Assert.That(byArtist.GetProperty("@type").GetString(), Is.EqualTo("MusicGroup"),
"ArtistRef must emit @type");
Assert.That(byArtist.TryGetProperty("Type", out _), Is.False,
"ArtistRef must NOT emit a bare Type property");
});
}
[Test]
public void AllNodes_ContextIsPresent_AndSchemaOrg()
{
// Belt-and-suspenders: @context must not regress alongside the @type fix.
var nodes = new[]
{
Parse(SeoModel.ForHome(Options).JsonLd),
Parse(SeoModel.ForAbout(Options).JsonLd),
Parse(SeoModel.ForBrowse(Options, ReleaseMedium.Cut, "/cuts").JsonLd),
Parse(SeoModel.ForRelease(Options, Release(ReleaseMedium.Cut)).JsonLd),
Parse(SeoModel.ForRelease(Options, Release(ReleaseMedium.Session)).JsonLd),
Parse(SeoModel.ForRelease(Options, Release(ReleaseMedium.Mix)).JsonLd),
};
foreach (var node in nodes)
Assert.That(node.GetProperty("@context").GetString(), Is.EqualTo("https://schema.org"),
"@context must remain present after the @type fix");
}
// --- Critical: inline JSON-LD script-breakout escaping (XSS) -----------------
[Test]