using System.Text.Json; using DeepDrftModels.DTOs; using DeepDrftModels.Enums; using DeepDrftPublic.Client.Common; namespace DeepDrftTests; /// /// Unit tests for the Phase 22 SEO typed builders ( factories + /// nodes + ). 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. /// [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") => new() { EntryKey = "abc-key", Title = "Test Release", Artist = "Deep DRFT", 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 { 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 { 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()) { 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); }); } }