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:
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user