feature: Phase 22 SEO metadata component for public site

One presentational SeoHead renders the full OG/Twitter/JSON-LD head surface at prerender via typed schema.org builders. Per-medium release schema, config-sourced canonicals, 404 noindex. Zero CMS change.
This commit is contained in:
daniel-c-harvey
2026-06-23 05:41:55 -04:00
parent e3a4364b8c
commit f3b89ca9d7
18 changed files with 870 additions and 12 deletions
+236
View File
@@ -0,0 +1,236 @@
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") => 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<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);
});
}
}
+68
View File
@@ -0,0 +1,68 @@
using DeepDrftPublic.Client.Common;
namespace DeepDrftTests;
/// <summary>
/// Unit tests for <see cref="SeoUrls"/> — 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.
/// </summary>
[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);
}
}