56f7013314
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.
424 lines
18 KiB
C#
424 lines
18 KiB
C#
using System.Text.Json;
|
|
using DeepDrftModels.DTOs;
|
|
using DeepDrftModels.Enums;
|
|
using DeepDrftPublic.Client.Common;
|
|
|
|
namespace DeepDrftTests;
|
|
|
|
/// <summary>
|
|
/// Unit tests for the Phase 22 SEO typed builders (<see cref="SeoModel"/> factories + <see cref="SeoJsonLd"/>
|
|
/// nodes + <see cref="SeoUrls"/>). These are pure functions over the DTOs a page already holds — the
|
|
/// medium→schema mapping (AC3), graceful partial data (AC4), JSON-LD validity (AC5), and canonical
|
|
/// correctness (AC7) are all testable here without rendering. The JSON-LD is parsed back to a document so
|
|
/// each assertion checks real structure, not a substring.
|
|
/// </summary>
|
|
[TestFixture]
|
|
public class SeoModelTests
|
|
{
|
|
private static readonly SeoOptions Options = new()
|
|
{
|
|
BaseUrl = "https://deepdrft.com",
|
|
SiteName = "Deep DRFT",
|
|
TitleSuffix = "Deep DRFT",
|
|
DefaultDescription = "default description",
|
|
DefaultImageUrl = "/img/og-default.png",
|
|
Genre = "Electronic",
|
|
SameAs = ["https://instagram.com/deepdrft.music"],
|
|
};
|
|
|
|
private static ReleaseDto Release(
|
|
ReleaseMedium medium,
|
|
string? image = "cover.jpg",
|
|
string? description = "desc",
|
|
string title = "Test Release",
|
|
string artist = "Aphex Twin") => new()
|
|
{
|
|
EntryKey = "abc-key",
|
|
Title = title,
|
|
Artist = artist,
|
|
Genre = "House",
|
|
Description = description,
|
|
ImagePath = image,
|
|
ReleaseDate = new DateOnly(2026, 3, 14),
|
|
Medium = medium,
|
|
};
|
|
|
|
private static JsonElement Parse(string? json)
|
|
{
|
|
Assert.That(json, Is.Not.Null.And.Not.Empty, "expected a JSON-LD body");
|
|
return JsonDocument.Parse(json!).RootElement;
|
|
}
|
|
|
|
// --- AC3: per-medium schema -------------------------------------------------
|
|
|
|
[Test]
|
|
public void ForRelease_Cut_IsMusicAlbum_StudioAlbum_WithOrderedTrackList()
|
|
{
|
|
var tracks = new List<TrackDto>
|
|
{
|
|
new() { TrackName = "Second", TrackNumber = 2, DurationSeconds = 60 },
|
|
new() { TrackName = "First", TrackNumber = 1, DurationSeconds = 30 },
|
|
};
|
|
|
|
var model = SeoModel.ForRelease(Options, Release(ReleaseMedium.Cut), tracks);
|
|
var node = Parse(model.JsonLd);
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(node.GetProperty("@type").GetString(), Is.EqualTo("MusicAlbum"));
|
|
Assert.That(node.GetProperty("albumProductionType").GetString(), Is.EqualTo("https://schema.org/StudioAlbum"));
|
|
Assert.That(model.OgType, Is.EqualTo(SeoOgType.MusicAlbum));
|
|
|
|
var trackArray = node.GetProperty("track");
|
|
Assert.That(trackArray.GetArrayLength(), Is.EqualTo(2));
|
|
// Ordered by TrackNumber, not input order.
|
|
Assert.That(trackArray[0].GetProperty("name").GetString(), Is.EqualTo("First"));
|
|
Assert.That(trackArray[1].GetProperty("name").GetString(), Is.EqualTo("Second"));
|
|
Assert.That(trackArray[0].GetProperty("duration").GetString(), Is.EqualTo("PT30S"));
|
|
});
|
|
}
|
|
|
|
[Test]
|
|
public void ForRelease_Session_IsMusicAlbum_LiveAlbum()
|
|
{
|
|
var model = SeoModel.ForRelease(Options, Release(ReleaseMedium.Session));
|
|
var node = Parse(model.JsonLd);
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(node.GetProperty("@type").GetString(), Is.EqualTo("MusicAlbum"));
|
|
Assert.That(node.GetProperty("albumProductionType").GetString(), Is.EqualTo("https://schema.org/LiveAlbum"));
|
|
Assert.That(model.OgType, Is.EqualTo(SeoOgType.MusicAlbum));
|
|
});
|
|
}
|
|
|
|
[Test]
|
|
public void ForRelease_Mix_IsMusicRecording_WithIsoDuration()
|
|
{
|
|
var tracks = new List<TrackDto> { new() { TrackName = "The Mix", DurationSeconds = 3723 } }; // 1h 2m 3s
|
|
var model = SeoModel.ForRelease(Options, Release(ReleaseMedium.Mix), tracks);
|
|
var node = Parse(model.JsonLd);
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(node.GetProperty("@type").GetString(), Is.EqualTo("MusicRecording"));
|
|
Assert.That(node.GetProperty("duration").GetString(), Is.EqualTo("PT1H2M3S"));
|
|
Assert.That(model.OgType, Is.EqualTo(SeoOgType.MusicSong));
|
|
Assert.That(model.DurationSeconds, Is.EqualTo(3723));
|
|
// A mix is one recording, not an album — it carries no track list.
|
|
Assert.That(node.TryGetProperty("track", out _), Is.False);
|
|
});
|
|
}
|
|
|
|
[Test]
|
|
public void ForRelease_AllNodes_DeclareSchemaOrgContext()
|
|
{
|
|
foreach (var medium in Enum.GetValues<ReleaseMedium>())
|
|
{
|
|
var node = Parse(SeoModel.ForRelease(Options, Release(medium)).JsonLd);
|
|
Assert.That(node.GetProperty("@context").GetString(), Is.EqualTo("https://schema.org"),
|
|
$"{medium} node must declare the schema.org context");
|
|
}
|
|
}
|
|
|
|
// --- AC4: graceful partial data ---------------------------------------------
|
|
|
|
[Test]
|
|
public void ForRelease_NoDescription_FallsBackToDefault()
|
|
{
|
|
var model = SeoModel.ForRelease(Options, Release(ReleaseMedium.Cut, description: null));
|
|
Assert.That(model.Description, Is.EqualTo(Options.DefaultDescription));
|
|
}
|
|
|
|
[Test]
|
|
public void ForRelease_NoCover_OmitsImagePath_SoHeadFallsBackToDefault()
|
|
{
|
|
var model = SeoModel.ForRelease(Options, Release(ReleaseMedium.Cut, image: null));
|
|
// The model carries no relative ImagePath; the JSON-LD image is absolutised to the default.
|
|
var node = Parse(model.JsonLd);
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(model.ImagePath, Is.Null);
|
|
Assert.That(node.GetProperty("image").GetString(), Is.EqualTo("https://deepdrft.com/img/og-default.png"));
|
|
});
|
|
}
|
|
|
|
[Test]
|
|
public void ForRelease_NoGenre_OmitsGenreProperty_NoEmptyValue()
|
|
{
|
|
var release = Release(ReleaseMedium.Cut);
|
|
release.Genre = null;
|
|
|
|
var node = Parse(SeoModel.ForRelease(Options, release).JsonLd);
|
|
Assert.That(node.TryGetProperty("genre", out _), Is.False, "a null genre must be omitted, not emitted empty");
|
|
}
|
|
|
|
[Test]
|
|
public void ForRelease_Mix_NoTrack_OmitsDuration()
|
|
{
|
|
var node = Parse(SeoModel.ForRelease(Options, Release(ReleaseMedium.Mix), tracks: null).JsonLd);
|
|
Assert.That(node.TryGetProperty("duration", out _), Is.False, "a mix with no track must omit duration");
|
|
}
|
|
|
|
// --- AC7: canonical correctness ---------------------------------------------
|
|
|
|
[TestCase(ReleaseMedium.Cut, "https://deepdrft.com/cuts/abc-key")]
|
|
[TestCase(ReleaseMedium.Session, "https://deepdrft.com/sessions/abc-key")]
|
|
[TestCase(ReleaseMedium.Mix, "https://deepdrft.com/mixes/abc-key")]
|
|
public void ForRelease_CanonicalPath_IsDedicatedRoute_AndJsonLdUrlAgrees(ReleaseMedium medium, string expectedAbsolute)
|
|
{
|
|
var model = SeoModel.ForRelease(Options, Release(medium));
|
|
var node = Parse(model.JsonLd);
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(SeoUrls.Absolute(Options, model.CanonicalPath!), Is.EqualTo(expectedAbsolute));
|
|
// The JSON-LD url must be the same absolute canonical (AC7: canonical == og:url == node url).
|
|
Assert.That(node.GetProperty("url").GetString(), Is.EqualTo(expectedAbsolute));
|
|
});
|
|
}
|
|
|
|
// --- Home / About / Browse / NotFound ---------------------------------------
|
|
|
|
[Test]
|
|
public void ForHome_IsMusicGroup_WithSameAs()
|
|
{
|
|
var model = SeoModel.ForHome(Options);
|
|
var node = Parse(model.JsonLd);
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(node.GetProperty("@type").GetString(), Is.EqualTo("MusicGroup"));
|
|
Assert.That(node.GetProperty("name").GetString(), Is.EqualTo("Deep DRFT"));
|
|
Assert.That(node.GetProperty("sameAs")[0].GetString(), Is.EqualTo("https://instagram.com/deepdrft.music"));
|
|
Assert.That(model.CanonicalPath, Is.EqualTo("/"));
|
|
});
|
|
}
|
|
|
|
[Test]
|
|
public void ForAbout_IsMusicGroup()
|
|
{
|
|
var node = Parse(SeoModel.ForAbout(Options).JsonLd);
|
|
Assert.That(node.GetProperty("@type").GetString(), Is.EqualTo("MusicGroup"));
|
|
}
|
|
|
|
[TestCase(ReleaseMedium.Cut, "/cuts")]
|
|
[TestCase(ReleaseMedium.Session, "/sessions")]
|
|
[TestCase(ReleaseMedium.Mix, "/mixes")]
|
|
public void ForBrowse_IsCollectionPage_WithAbsoluteUrl(ReleaseMedium medium, string path)
|
|
{
|
|
var model = SeoModel.ForBrowse(Options, medium, path);
|
|
var node = Parse(model.JsonLd);
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(node.GetProperty("@type").GetString(), Is.EqualTo("CollectionPage"));
|
|
Assert.That(node.GetProperty("url").GetString(), Is.EqualTo($"https://deepdrft.com{path}"));
|
|
Assert.That(model.CanonicalPath, Is.EqualTo(path));
|
|
});
|
|
}
|
|
|
|
[Test]
|
|
public void ForBrowse_NullMedium_IsArchive()
|
|
{
|
|
var model = SeoModel.ForBrowse(Options, null, "/archive");
|
|
Assert.That(model.Title, Is.EqualTo("Archive"));
|
|
}
|
|
|
|
[Test]
|
|
public void ForNotFound_IsNoindex_NoCanonical_NoJsonLd()
|
|
{
|
|
var model = SeoModel.ForNotFound(Options);
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(model.Robots, Is.EqualTo("noindex,follow"));
|
|
Assert.That(model.CanonicalPath, Is.Null);
|
|
Assert.That(model.JsonLd, Is.Null);
|
|
});
|
|
}
|
|
|
|
// --- byArtist consistency: per-release artist, matching music:musician -------
|
|
|
|
[TestCase(ReleaseMedium.Cut)]
|
|
[TestCase(ReleaseMedium.Session)]
|
|
[TestCase(ReleaseMedium.Mix)]
|
|
public void ForRelease_ByArtist_IsPerReleaseArtist_NotCollectiveName(ReleaseMedium medium)
|
|
{
|
|
var model = SeoModel.ForRelease(Options, Release(medium, artist: "Aphex Twin"));
|
|
var node = Parse(model.JsonLd);
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
// byArtist mirrors the OG music:musician value (the release's own artist), not the SiteName.
|
|
Assert.That(node.GetProperty("byArtist").GetProperty("name").GetString(), Is.EqualTo("Aphex Twin"));
|
|
Assert.That(node.GetProperty("byArtist").GetProperty("name").GetString(), Is.Not.EqualTo(Options.SiteName));
|
|
Assert.That(model.Artist, Is.EqualTo("Aphex Twin"));
|
|
});
|
|
}
|
|
|
|
[Test]
|
|
public void ForRelease_Cut_AlbumTracks_ByArtist_IsPerReleaseArtist()
|
|
{
|
|
var tracks = new List<TrackDto> { new() { TrackName = "Track", TrackNumber = 1, DurationSeconds = 30 } };
|
|
var node = Parse(SeoModel.ForRelease(Options, Release(ReleaseMedium.Cut, artist: "Aphex Twin"), tracks).JsonLd);
|
|
|
|
Assert.That(node.GetProperty("track")[0].GetProperty("byArtist").GetProperty("name").GetString(),
|
|
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]
|
|
public void ForRelease_TitleWithScriptClose_DoesNotEmitRawAngleBracket()
|
|
{
|
|
// A CMS-authored title containing </script> / < must not survive raw in the inline script body —
|
|
// that is a breakout/XSS vector. The escaped < keeps the JSON parseable while neutralising it.
|
|
var release = Release(ReleaseMedium.Cut, title: "Evil</script><script>alert(1)</script>");
|
|
var body = SeoModel.ForRelease(Options, release).JsonLd;
|
|
|
|
Assert.That(body, Is.Not.Null);
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(body, Does.Not.Contain("<"), "no raw < may appear in the inline JSON-LD body");
|
|
Assert.That(body, Does.Not.Contain(">"), "no raw > may appear in the inline JSON-LD body");
|
|
Assert.That(body, Does.Contain("\\u003C"), "< must be emitted as its \\u003C JSON escape");
|
|
// The escaped body still parses as JSON and round-trips the original value.
|
|
var node = Parse(body);
|
|
Assert.That(node.GetProperty("name").GetString(), Is.EqualTo("Evil</script><script>alert(1)</script>"));
|
|
});
|
|
}
|
|
|
|
[Test]
|
|
public void ForRelease_TitleWithAmpersand_EscapesAmpersand_StillParses()
|
|
{
|
|
// The title is serialized into the JSON-LD `name`, so an ampersand there exercises the & escape.
|
|
var release = Release(ReleaseMedium.Mix, title: "Sound & Vision");
|
|
var body = SeoModel.ForRelease(Options, release).JsonLd;
|
|
|
|
Assert.That(body, Is.Not.Null);
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(body, Does.Not.Contain("&"), "no raw & may appear in the inline JSON-LD body");
|
|
Assert.That(body, Does.Contain("\\u0026"), "& must be emitted as its \\u0026 JSON escape");
|
|
Assert.That(Parse(body).GetProperty("name").GetString(), Is.EqualTo("Sound & Vision"));
|
|
});
|
|
}
|
|
}
|