diff --git a/DeepDrftPublic.Client/Common/SeoEnvironment.cs b/DeepDrftPublic.Client/Common/SeoEnvironment.cs new file mode 100644 index 0000000..a9e017d --- /dev/null +++ b/DeepDrftPublic.Client/Common/SeoEnvironment.cs @@ -0,0 +1,33 @@ +using Microsoft.AspNetCore.Components; + +namespace DeepDrftPublic.Client.Common; + +/// +/// Environment-gated robots bridge (Phase 22 remediation §4). The beta/staging site is web-hosted and must +/// not be crawled, so the default robots directive is environment-gated: index,follow only in +/// Production, noindex,nofollow everywhere else. A per-page override +/// still wins — this only sets the default. +/// +/// +/// Crawlers read the server-prerendered HTML, so correctness lives in the server prerender pass — but the +/// value must be identical across the InteractiveAuto double render (AC6), so the WASM pass has to resolve +/// the same flag. The WASM assembly has no IWebHostEnvironment (config comes from the server). This +/// mirrors the DarkMode bridge exactly: a scoped service the server seeds during prerender (from +/// IWebHostEnvironment.IsProduction()) and [PersistentState] rounds to the client, so both +/// passes resolve the identical value. SeoHead injects this rather than an environment dependency, +/// honouring the no-environment-in-the-component constraint. +/// +/// +public class SeoEnvironment +{ + /// + /// True only in Production. Seeded server-side and persisted across the WASM boot. Defaults to + /// false so the fail-safe is "do not index" — a missing bridge never accidentally opens a + /// non-production site to crawlers. + /// + [PersistentState] + public bool IsProduction { get; set; } + + /// The environment-gated default robots directive. Explicit page values override this. + public string DefaultRobots => IsProduction ? "index,follow" : "noindex,nofollow"; +} diff --git a/DeepDrftPublic.Client/Common/SeoJsonLd.cs b/DeepDrftPublic.Client/Common/SeoJsonLd.cs new file mode 100644 index 0000000..9c256fa --- /dev/null +++ b/DeepDrftPublic.Client/Common/SeoJsonLd.cs @@ -0,0 +1,127 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace DeepDrftPublic.Client.Common; + +/// +/// Typed schema.org JSON-LD nodes (Phase 22, OQ5 — the typed-builder option). Each record mirrors one +/// schema.org type; renders a node to the <script type="application/ld+json"> +/// body. Keeping the shape in C# (not hand-written JSON in pages) is what makes the medium→type mapping +/// live in one place (DRY, §4.3) and the output unit-testable (AC5) rather than a manual validator pass. +/// +/// +/// All nodes share so the @context/@type pair serialises first and +/// once. Null properties are omitted (the serializer ignores nulls) so partial data never emits an empty +/// or broken node (C6/AC4). +/// +/// +public static class SeoJsonLd +{ + private static readonly JsonSerializerOptions Options = new() + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + // schema.org keys are PascalCase ("@type", "byArtist", "datePublished"); JsonPropertyName drives + // each. Encoder relaxed so the JSON sits inline in HTML without over-escaping apostrophes etc. + // Note: the relaxed encoder leaves <, >, & raw — InlineSafe re-escapes exactly those before the + // body is injected into the + } + + +@code { + /// The page's resolved SEO input, built via a factory. + [Parameter, EditorRequired] public required SeoModel Model { get; set; } + + private string _fullTitle = string.Empty; + private string _description = string.Empty; + private string _canonical = string.Empty; + private string _robots = string.Empty; + private string _ogType = "website"; + private string _image = string.Empty; + private bool _hasCover; + private string? _isoDuration; + + protected override void OnParametersSet() + { + _fullTitle = $"{Model.Title} · {Seo.TitleSuffix}"; + _description = string.IsNullOrWhiteSpace(Model.Description) ? Seo.DefaultDescription : Model.Description; + // Default robots is environment-gated (non-production → noindex,nofollow) so beta/staging is never + // crawled; an explicit per-page Robots still wins (e.g. the 404's / soft-404's noindex,follow). + _robots = string.IsNullOrWhiteSpace(Model.Robots) ? SeoEnv.DefaultRobots : Model.Robots; + _ogType = OgTypeString(Model.OgType); + + // Canonical: BaseUrl + the model's path, defaulting to the current relative path. The origin is + // always config (no browser API) so prerender and WASM agree (§5). + var path = Model.CanonicalPath ?? RelativePath(); + _canonical = SeoUrls.Absolute(Seo, path); + + _hasCover = !string.IsNullOrWhiteSpace(Model.ImagePath); + _image = SeoUrls.CoverOrDefault(Seo, Model.ImagePath); + _isoDuration = SeoUrls.IsoDuration(Model.DurationSeconds); + } + + private string RelativePath() + { + var path = Nav.ToBaseRelativePath(Nav.Uri); + var query = path.IndexOf('?'); + if (query >= 0) path = path[..query]; + return "/" + path; + } + + private static string OgTypeString(SeoOgType type) => type switch + { + SeoOgType.MusicAlbum => "music.album", + SeoOgType.MusicSong => "music.song", + _ => "website", + }; +} diff --git a/DeepDrftPublic.Client/Pages/About.razor b/DeepDrftPublic.Client/Pages/About.razor index 57247ca..5e13d18 100644 --- a/DeepDrftPublic.Client/Pages/About.razor +++ b/DeepDrftPublic.Client/Pages/About.razor @@ -2,8 +2,9 @@ @using DeepDrftPublic.Client.Controls @implements IAsyncDisposable @inject IJSRuntime JsRuntime +@inject SeoOptions Seo -The Collective - Deep DRFT + @* ────────────────────────────────────────────────────────────────────────────── THE LINER NOTES — a numbered three-movement editorial essay. diff --git a/DeepDrftPublic.Client/Pages/AlbumsView.razor b/DeepDrftPublic.Client/Pages/AlbumsView.razor index 071ee68..d9d2a1a 100644 --- a/DeepDrftPublic.Client/Pages/AlbumsView.razor +++ b/DeepDrftPublic.Client/Pages/AlbumsView.razor @@ -1,7 +1,9 @@ @page "/cuts" +@using DeepDrftModels.Enums @using DeepDrftPublic.Client.Controls +@inject SeoOptions Seo -DeepDrft Cuts + @* The shared release-card grid; each card routes to /cuts/{entryKey} via the one ReleaseRoutes table. Cuts show a track count where other media show the artist, supplied via SubtitleResolver. *@ diff --git a/DeepDrftPublic.Client/Pages/ArchiveView.razor b/DeepDrftPublic.Client/Pages/ArchiveView.razor index 2a5069e..9b121d4 100644 --- a/DeepDrftPublic.Client/Pages/ArchiveView.razor +++ b/DeepDrftPublic.Client/Pages/ArchiveView.razor @@ -1,8 +1,9 @@ @page "/archive" @using DeepDrftModels.Enums @using DeepDrftPublic.Client.Controls +@inject SeoOptions Seo -DeepDrft Archive +
diff --git a/DeepDrftPublic.Client/Pages/CutDetail.razor b/DeepDrftPublic.Client/Pages/CutDetail.razor index 8d2809b..67721de 100644 --- a/DeepDrftPublic.Client/Pages/CutDetail.razor +++ b/DeepDrftPublic.Client/Pages/CutDetail.razor @@ -3,8 +3,7 @@ @using DeepDrftPublic.Client.Controls @using DeepDrftPublic.Client.Services @inherits CutDetailBase - -@(ViewModel.Release?.Title ?? "Cut") - DeepDrft +@inject SeoOptions Seo @if (ViewModel.IsLoading) { @@ -17,6 +16,9 @@ } else if (ViewModel.NotFound || ViewModel.Release is null) { + @* Soft-404: a bad key renders a 200 "not found" view, so it must carry noindex so it is not indexed + (mirrors the dedicated /404 NotFound page). *@ +
Cut not found. @@ -37,6 +39,10 @@ else var hasYear = release.ReleaseDate is not null; var firstTrack = ViewModel.Tracks.Count > 0 ? ViewModel.Tracks[0] : null; + @* SEO head — fed from the same bridged release + ordered tracks, so the prerender and WASM passes + render identical tags (AC6). MusicAlbum/StudioAlbum with the ordered track list (§3.4). *@ + + @* Full-screen content body (Phase 20 Wave 2 §1): the scaffold has no Class param, so a thin wrapper carries the min-height. dd-detail-fill keeps the body >= viewport height (below the nav) so the ambient visualizer reads full-screen and the site footer is pushed below the fold. *@ diff --git a/DeepDrftPublic.Client/Pages/Home.razor b/DeepDrftPublic.Client/Pages/Home.razor index 4e24e94..65feb10 100644 --- a/DeepDrftPublic.Client/Pages/Home.razor +++ b/DeepDrftPublic.Client/Pages/Home.razor @@ -1,8 +1,9 @@ @page "/" @using DeepDrftPublic.Client.Controls @using DeepDrftPublic.Client.Services +@inject SeoOptions Seo -Deep DRFT - Electronic Music Collective + @* Hero - split 50/50 *@
diff --git a/DeepDrftPublic.Client/Pages/MixDetail.razor b/DeepDrftPublic.Client/Pages/MixDetail.razor index 1800076..85ade2e 100644 --- a/DeepDrftPublic.Client/Pages/MixDetail.razor +++ b/DeepDrftPublic.Client/Pages/MixDetail.razor @@ -2,8 +2,7 @@ @using DeepDrftPublic.Client.Controls @using DeepDrftPublic.Client.Services @inherits ReleaseDetailBase - -@(ViewModel.Release?.Title ?? "Mix") - DeepDrft +@inject SeoOptions Seo @if (ViewModel.IsLoading) { @@ -16,6 +15,9 @@ } else if (ViewModel.NotFound || ViewModel.Release is null) { + @* Soft-404: a bad key renders a 200 "not found" view, so it must carry noindex so it is not indexed + (mirrors the dedicated /404 NotFound page). *@ +
Mix not found. @@ -32,6 +34,11 @@ else if (ViewModel.NotFound || ViewModel.Release is null) else { var release = ViewModel.Release; + var mixTracks = ViewModel.Track is not null ? new[] { ViewModel.Track } : null; + + @* SEO head — fed from the same bridged release + single track, so prerender and WASM render identical + tags (AC6). MusicRecording with ISO-8601 duration from the track (§3.4). *@ + @* Full-page waveform sits behind the scaffold content. The scaffold's container is positioned above it via the mix-detail-foreground stacking context. TrackId lets the visualizer couple to diff --git a/DeepDrftPublic.Client/Pages/MixesView.razor b/DeepDrftPublic.Client/Pages/MixesView.razor index b4eb946..d5dac4c 100644 --- a/DeepDrftPublic.Client/Pages/MixesView.razor +++ b/DeepDrftPublic.Client/Pages/MixesView.razor @@ -1,8 +1,10 @@ @page "/mixes" +@using DeepDrftModels.Enums @using DeepDrftPublic.Client.Controls @inherits MediumBrowseBase +@inject SeoOptions Seo -DeepDrft Mixes + Not Found diff --git a/DeepDrftPublic.Client/Pages/SessionDetail.razor b/DeepDrftPublic.Client/Pages/SessionDetail.razor index dd954ae..1a16c77 100644 --- a/DeepDrftPublic.Client/Pages/SessionDetail.razor +++ b/DeepDrftPublic.Client/Pages/SessionDetail.razor @@ -3,8 +3,7 @@ @using DeepDrftPublic.Client.Controls @using DeepDrftPublic.Client.Services @inherits ReleaseDetailBase - -@(ViewModel.Release?.Title ?? "Session") - DeepDrft +@inject SeoOptions Seo @if (ViewModel.IsLoading) { @@ -20,6 +19,9 @@ } else if (ViewModel.NotFound || ViewModel.Release is null) { + @* Soft-404: a bad key renders a 200 "not found" view, so it must carry noindex so it is not indexed + (mirrors the dedicated /404 NotFound page). *@ +
Session not found. @@ -40,6 +42,10 @@ else // Hero image precedence: the session's dedicated hero, then the release cover, then a placeholder. var heroImage = !string.IsNullOrEmpty(heroKey) ? heroKey : release.ImagePath; + @* SEO head — fed from the same bridged release, so prerender and WASM render identical tags (AC6). + MusicAlbum/LiveAlbum (a session is a live release, §3.4/OQ6). *@ + + @* Ambient living waveform behind the hero overlay (Phase 12 §3e option b / §3f mode B). Session does NOT compose ReleaseDetailScaffold, so it mounts the shared engine directly with its own thin full-bleed wrapper — the engine is single-source either way, only the mount differs (§3b). The diff --git a/DeepDrftPublic.Client/Pages/SessionsView.razor b/DeepDrftPublic.Client/Pages/SessionsView.razor index 626497e..d2e9768 100644 --- a/DeepDrftPublic.Client/Pages/SessionsView.razor +++ b/DeepDrftPublic.Client/Pages/SessionsView.razor @@ -1,8 +1,10 @@ @page "/sessions" +@using DeepDrftModels.Enums @using DeepDrftPublic.Client.Controls @inherits MediumBrowseBase +@inject SeoOptions Seo -DeepDrft Sessions + (); services.AddScoped(); services.AddScoped(); + + // Phase 22 SEO defaults — non-secret brand constants (canonical origin, site name, default share + // image, social links). Singleton: stateless config, identical in the server-prerender and WASM + // passes (this method runs in both), which is what makes SeoHead's double-render output identical. + services.AddSingleton(new SeoOptions()); + + // Environment-gated robots bridge. Scoped + [PersistentState] like DarkModeSettings: the server + // seeds IsProduction during prerender and it rounds to the WASM pass, so SeoHead resolves the same + // default robots in both render passes (non-production → noindex,nofollow, keeping beta uncrawled). + services.AddScoped(); } public static void ConfigureApiHttpClient(IServiceCollection services, string baseAddress) diff --git a/DeepDrftPublic/Components/App.razor b/DeepDrftPublic/Components/App.razor index e36ba5b..bbec5d5 100644 --- a/DeepDrftPublic/Components/App.razor +++ b/DeepDrftPublic/Components/App.razor @@ -1,4 +1,5 @@ @using DeepDrftPublic.Client +@using DeepDrftPublic.Client.Common @using DeepDrftPublic.Services @using DeepDrftShared.Client.Components @@ -34,11 +35,16 @@ @code { [Inject] public required DarkModeService DarkModeService { get; set; } + [Inject] public required SeoEnvironment SeoEnvironment { get; set; } + [Inject] public required IWebHostEnvironment HostEnvironment { get; set; } protected override void OnInitialized() { base.OnInitialized(); DarkModeService.CheckDarkMode(); + // Seed the environment-gated robots bridge during prerender; [PersistentState] rounds it to WASM + // so both render passes resolve the same default robots (Production → index, else noindex). + SeoEnvironment.IsProduction = HostEnvironment.IsProduction(); } } diff --git a/DeepDrftTests/SeoModelTests.cs b/DeepDrftTests/SeoModelTests.cs new file mode 100644 index 0000000..b6dc92a --- /dev/null +++ b/DeepDrftTests/SeoModelTests.cs @@ -0,0 +1,308 @@ +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", + 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 + { + 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); + }); + } + + // --- 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 { 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")); + } + + // --- Critical: inline JSON-LD script-breakout escaping (XSS) ----------------- + + [Test] + public void ForRelease_TitleWithScriptClose_DoesNotEmitRawAngleBracket() + { + // A CMS-authored title containing / < 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"); + 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")); + }); + } + + [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")); + }); + } +} diff --git a/DeepDrftTests/SeoUrlsTests.cs b/DeepDrftTests/SeoUrlsTests.cs new file mode 100644 index 0000000..fd2ad4c --- /dev/null +++ b/DeepDrftTests/SeoUrlsTests.cs @@ -0,0 +1,68 @@ +using DeepDrftPublic.Client.Common; + +namespace DeepDrftTests; + +/// +/// Unit tests for — the absolute-URL composition shared by the SeoModel factories +/// and SeoHead (Phase 22). Origin always comes from config (never a browser API), so these pin the +/// slash-join, the cover-vs-default fallback (C6/AC4), and the ISO-8601 duration edge cases. +/// +[TestFixture] +public class SeoUrlsTests +{ + private static readonly SeoOptions Options = new() + { + BaseUrl = "https://deepdrft.com", + DefaultImageUrl = "/img/og-default.png", + }; + + [TestCase("/cuts/key", "https://deepdrft.com/cuts/key")] + [TestCase("cuts/key", "https://deepdrft.com/cuts/key")] + [TestCase("/", "https://deepdrft.com/")] + [TestCase("", "https://deepdrft.com")] + public void Absolute_JoinsOriginAndPath_WithoutDoublingOrDroppingSlash(string path, string expected) + { + Assert.That(SeoUrls.Absolute(Options, path), Is.EqualTo(expected)); + } + + [Test] + public void Absolute_TrimsTrailingSlashOnBaseUrl() + { + var withSlash = Options with { BaseUrl = "https://deepdrft.com/" }; + Assert.That(SeoUrls.Absolute(withSlash, "/cuts"), Is.EqualTo("https://deepdrft.com/cuts")); + } + + [Test] + public void CoverOrDefault_WithCover_BuildsEscapedImageRoute() + { + Assert.That(SeoUrls.CoverOrDefault(Options, "my cover.jpg"), + Is.EqualTo("https://deepdrft.com/api/image/my%20cover.jpg")); + } + + [TestCase(null)] + [TestCase("")] + [TestCase(" ")] + public void CoverOrDefault_WithoutCover_FallsBackToDefaultImage(string? image) + { + Assert.That(SeoUrls.CoverOrDefault(Options, image), + Is.EqualTo("https://deepdrft.com/img/og-default.png")); + } + + [TestCase(30.0, "PT30S")] + [TestCase(90.0, "PT1M30S")] + [TestCase(3723.0, "PT1H2M3S")] + public void IsoDuration_PositiveSeconds_RendersIso8601(double seconds, string expected) + { + Assert.That(SeoUrls.IsoDuration(seconds), Is.EqualTo(expected)); + } + + [TestCase(null)] + [TestCase(0.0)] + [TestCase(-5.0)] + [TestCase(double.NaN)] + [TestCase(double.PositiveInfinity)] + public void IsoDuration_NonPositiveOrNonFinite_ReturnsNull(double? seconds) + { + Assert.That(SeoUrls.IsoDuration(seconds), Is.Null); + } +}