Files
deepdrft/DeepDrftTests/SeoModelTests.cs
T
daniel-c-harvey f3b89ca9d7 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.
2026-06-23 05:41:55 -04:00

237 lines
9.2 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") => 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);
});
}
}