fix: put [JsonPropertyName("@type")] on each concrete JsonLdNode override

System.Text.Json emitted both "@type" and a bare "Type" because the attribute was only on the abstract base member. Adds regression assertions for all node types.
This commit is contained in:
daniel-c-harvey
2026-06-23 06:57:05 -04:00
parent 2653e62eeb
commit 56f7013314
2 changed files with 119 additions and 4 deletions
+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]