Merge p22-w2-jsonld-type-fix into dev
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:
@@ -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; }
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user