Files
ersatztv/ErsatzTV.Tests/LegacyUiRedirectsTests.cs
T
timothyandClaude Opus 4.8 408b0deb89
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 8s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 6m39s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m3s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
feat(91b): remove legacy Blazor Server UI (#91 phase b)
The ChicoryTV React SPA (web/, served at /app) now has full parity for every
route the Blazor UI served, so the legacy Blazor Server / MudBlazor UI is
deleted. This is the milestone-capping removal of #91 phase (b).

Deleted: ErsatzTV/Pages/**, Shared/**, ViewModels/** (39 edit VMs),
Validators/** (10 edit-VM validators), App.razor, _Imports.razor,
Locals/{Shared,Pages}/** (Blazor loc resx; Locals/Resources.* kept),
wwwroot/css + wwwroot/lib, libman.json, and the orphaned MultiSelectBaseTests.

Startup.cs (surgical, not wholesale): removed AddRazorPages/AuthorizeFolder,
AddServerSideBlazor, AddMudServices, AddSortable, AddCourier, the HtmlSanitizer
registration, the Blazor-attached OIDC UseAuthentication/UseAuthorization
middleware (per the #206 auth-posture sign-off), MapBlazorHub, and
MapFallbackToPage("/_Host"). Renamed the branch blazor->legacy; it still
co-hosts MapControllers, /docs (Scalar), dev MapOpenApi and the redirect
middleware. Replaced the _Host fallback with a catch-all (MapFallback ->
302 /app) that excludes /api|/artwork|/docs|/openapi (genuine 404) per #204.
Kept all OIDC/JWT/API-key service wiring (inert unless configured; real auth
is #197), ConditionalIptvAuthorizeFilter, ApiKeyAuthorizationFilter.

Pruned 9 now-unused packages (all verified zero remaining consumers) from
Directory.Packages.props + ErsatzTV.csproj: MudBlazor, Heron.MudCalendar,
Blazored.FluentValidation, BlazorSortable, MediatR.Courier.DependencyInjection,
Markdig, HtmlSanitizer, Chronic.Core, NaturalSort.Extension. Also removed the
now-dead #25 razor-Sonar NoWarn.

LegacyUiRedirects: added the 14 /media/sources/* -> /app/libraries/* redirects
(SPA screens landed in #202) and lifted the #204-era /media/sources prefix ban.

Tests: Release build clean; full solution suite green. Updated Startup
source-text tests + added regression coverage that Blazor wiring is gone, the
catch-all is wired, and all 14 media-sources routes redirect.

Docs: blazor-route-parity.md (phase b COMPLETE), decisions.md (removal entry),
CLAUDE.md, contributing.md, README.md all updated in this PR.

Rollback: tag blazor-final is cut on pre-merge main as the first merge action.

Part of #91.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 21:01:04 +02:00

256 lines
12 KiB
C#

using Microsoft.AspNetCore.Http;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests;
[TestFixture]
public class LegacyUiRedirectsTests
{
private static readonly string StartupSource = File.ReadAllText(FindStartupPath());
[Test]
public void Every_Mapping_Should_Resolve()
{
foreach ((string from, string to) in LegacyUiRedirects.Map)
{
LegacyUiRedirects.TryGetRedirect(new PathString(from), out string target).ShouldBeTrue();
target.ShouldBe(to);
}
}
[TestCase("/", "/app")]
[TestCase("/channels", "/app/channels")]
[TestCase("/channels/add", "/app/new-channel")]
[TestCase("/schedules", "/app/schedules")]
[TestCase("/playouts", "/app/playouts")]
[TestCase("/media/libraries", "/app/libraries")]
[TestCase("/settings/ffmpeg", "/app/settings/streaming")]
[TestCase("/settings/hdhr", "/app/settings/system")]
[TestCase("/settings/logging", "/app/settings/logging")]
[TestCase("/settings/playout", "/app/settings/playout")]
[TestCase("/settings/scanner", "/app/settings/scanner")]
[TestCase("/settings/ui", "/app/settings/general")]
[TestCase("/settings/xmltv", "/app/settings/xmltv")]
// (A) parameterless routes migrated in #204 (one spot-check per new group)
[TestCase("/channels/numbers", "/app/channels")]
[TestCase("/system/troubleshooting/block-playout", "/app/troubleshooting/blocks")]
[TestCase("/media/browser/images", "/app/media/images/browser")]
[TestCase("/schedules/add", "/app/schedules")]
// (B) ".../add" sub-actions
[TestCase("/playouts/add", "/app/playouts")]
[TestCase("/ffmpeg/add", "/app/ffmpeg-profiles")]
// (E-base) browse roots
[TestCase("/media/movies", "/app/media?kind=movies")]
[TestCase("/media/remote/streams", "/app/media?kind=remote-streams")]
public void Known_Route_Should_Redirect(string path, string expected)
{
LegacyUiRedirects.TryGetRedirect(new PathString(path), out string target).ShouldBeTrue();
target.ShouldBe(expected);
}
// (C) id-substituting
[TestCase("/channels/5", "/app/edit-channel/5")]
[TestCase("/blocks/12", "/app/blocks/12")]
[TestCase("/deco-templates/3", "/app/deco-templates/3")]
[TestCase("/media/trakt/lists/11", "/app/trakt-lists/11")]
[TestCase("/media/movies/42", "/app/media/movies/42")]
[TestCase("/media/tv/shows/7", "/app/media/shows/7")]
[TestCase("/media/tv/seasons/8", "/app/media/seasons/8")]
[TestCase("/media/music/artists/9", "/app/media/artists/9")]
// (C2) mid-path
[TestCase("/playouts/9/templates", "/app/playouts/9/templates")]
[TestCase("/playouts/9/alternate-schedules", "/app/playouts/9/alternate-schedules")]
// (D) id-dropping
[TestCase("/schedules/4", "/app/schedules")]
[TestCase("/schedules/4/items", "/app/schedules")]
[TestCase("/playouts/classic/2", "/app/playouts")]
[TestCase("/playouts/block/2", "/app/playouts")]
[TestCase("/playouts/scripted/2", "/app/playouts")]
[TestCase("/playouts/sequential/2", "/app/playouts")]
[TestCase("/playouts/add/classic", "/app/playouts")]
[TestCase("/media/collections/8", "/app/collections")]
[TestCase("/media/collections/8/edit", "/app/collections")]
[TestCase("/media/multi-collections/8/edit", "/app/multi-collections")]
[TestCase("/media/rerun-collections/8/edit", "/app/rerun-collections")]
[TestCase("/media/filler/presets/8/edit", "/app/filler-presets")]
[TestCase("/ffmpeg/6", "/app/ffmpeg-profiles")]
[TestCase("/watermarks/6", "/app/watermarks")]
// (E-page) paging
[TestCase("/media/movies/page/3", "/app/media?kind=movies")]
[TestCase("/media/tv/shows/page/2", "/app/media?kind=shows")]
[TestCase("/media/tv/seasons/page/2", "/app/media?kind=seasons")]
[TestCase("/media/tv/episodes/page/2", "/app/media?kind=episodes")]
[TestCase("/media/music/artists/page/2", "/app/media?kind=artists")]
[TestCase("/media/music/videos/page/2", "/app/media?kind=music-videos")]
[TestCase("/media/music/songs/page/2", "/app/media?kind=songs")]
[TestCase("/media/other/videos/page/2", "/app/media?kind=other-videos")]
[TestCase("/media/remote/streams/page/2", "/app/media?kind=remote-streams")]
[TestCase("/media/images/page/2", "/app/media?kind=images")]
public void Pattern_Route_Should_Redirect(string path, string expected)
{
LegacyUiRedirects.TryGetRedirect(new PathString(path), out string target).ShouldBeTrue();
target.ShouldBe(expected);
}
[TestCase("/channels/", "/app/channels")]
[TestCase("/schedules/", "/app/schedules")]
[TestCase("/settings/ffmpeg/", "/app/settings/streaming")]
// trailing slash on parameterized/pattern routes
[TestCase("/channels/5/", "/app/edit-channel/5")]
[TestCase("/playouts/9/templates/", "/app/playouts/9/templates")]
[TestCase("/media/movies/", "/app/media?kind=movies")]
public void Trailing_Slash_Should_Match(string path, string expected)
{
LegacyUiRedirects.TryGetRedirect(new PathString(path), out string target).ShouldBeTrue();
target.ShouldBe(expected);
}
[Test]
public void Lookup_Should_Be_Case_Insensitive()
{
LegacyUiRedirects.TryGetRedirect(new PathString("/Channels"), out string target).ShouldBeTrue();
target.ShouldBe("/app/channels");
}
[TestCase("/Channels/5", "/app/edit-channel/5")]
[TestCase("/MEDIA/TV/SHOWS/7", "/app/media/shows/7")]
public void Pattern_Lookup_Should_Be_Case_Insensitive(string path, string expected)
{
LegacyUiRedirects.TryGetRedirect(new PathString(path), out string target).ShouldBeTrue();
target.ShouldBe(expected);
}
// #202 replaced the Blazor /media/sources/* pages with the /app/libraries SPA screens;
// #91b deletes the Blazor pages and adds these redirects (the #204-era prefix ban is lifted).
[TestCase("/media/sources/local", "/app/libraries")]
[TestCase("/media/sources/local/add", "/app/libraries/local/new")]
[TestCase("/media/sources/local/5/edit", "/app/libraries/local/5")]
[TestCase("/media/sources/plex", "/app/libraries/plex")]
[TestCase("/media/sources/plex/5/libraries", "/app/libraries/plex/5/sync")]
[TestCase("/media/sources/plex/5/paths", "/app/libraries/plex/5/path-replacements")]
[TestCase("/media/sources/jellyfin", "/app/libraries/jellyfin")]
[TestCase("/media/sources/jellyfin/edit", "/app/libraries/jellyfin/connection")]
[TestCase("/media/sources/jellyfin/5/libraries", "/app/libraries/jellyfin/5/sync")]
[TestCase("/media/sources/jellyfin/5/paths", "/app/libraries/jellyfin/5/path-replacements")]
[TestCase("/media/sources/emby", "/app/libraries/emby")]
[TestCase("/media/sources/emby/edit", "/app/libraries/emby/connection")]
[TestCase("/media/sources/emby/5/libraries", "/app/libraries/emby/5/sync")]
[TestCase("/media/sources/emby/5/paths", "/app/libraries/emby/5/path-replacements")]
public void Media_Sources_Routes_Should_Redirect_To_Libraries_Screens(string path, string expected)
{
LegacyUiRedirects.TryGetRedirect(new PathString(path), out string target).ShouldBeTrue();
target.ShouldBe(expected);
}
[TestCase("/system/health")] // former Blazor home; not mapped (Startup catch-all -> /app)
[TestCase("/media/sources")] // bare prefix, no such route/screen
[TestCase("/media/sources/plex/paths")] // 4-seg, non-numeric id position
[TestCase("/media/sources/plex/abc/libraries")] // id must be a positive integer
[TestCase("/api/foo")] // API surface
[TestCase("/artwork/1")] // artwork surface
[TestCase("/docs")] // docs surface
[TestCase("/openapi")] // openapi surface
[TestCase("/app")] // already the SPA
[TestCase("/app/channels")] // already the SPA
[TestCase("/iptv/channels.m3u")] // IPTV surface
[TestCase("/api/health")] // API surface
[TestCase("")] // empty
[TestCase("//")] // all-slash path must not collapse to root "/"
[TestCase("/channels//")] // double trailing slash is not normalized to a match
// strict id validation — these must all fall through
[TestCase("/channels/abc")]
[TestCase("/channels/0")]
[TestCase("/channels/-1")]
[TestCase("/channels/1.5")]
[TestCase("/channels/999999999999")]
[TestCase("/channels//5")] // empty segment: must not match /channels/{id}
[TestCase("/media/movies/page/abc")]
[TestCase("/media/movies/page/0")]
[TestCase("/playouts/templates")] // no id segment
[TestCase("/media/movies/page")] // 3-seg, non-numeric tail
public void Non_Migrated_Route_Should_Not_Redirect(string path)
{
LegacyUiRedirects.TryGetRedirect(new PathString(path), out string target).ShouldBeFalse();
target.ShouldBe(string.Empty);
}
[TestCase("/app/media?kind=movies", "?page=2", "/app/media?kind=movies&page=2")]
[TestCase("/app/media?kind=movies", "", "/app/media?kind=movies")]
[TestCase("/app/channels", "?x=1", "/app/channels?x=1")]
[TestCase("/app/channels", "", "/app/channels")]
[TestCase("/app/media?kind=movies", "?kind=shows", "/app/media?kind=movies&kind=shows")]
public void AppendQueryString_Should_Merge(string target, string query, string expected)
{
LegacyUiRedirects.AppendQueryString(target, new QueryString(query)).ShouldBe(expected);
}
[Test]
public void Map_Keys_Should_Not_Begin_With_Forbidden_Prefix()
{
string[] forbidden =
{
"/api", "/artwork", "/docs", "/openapi", "/iptv", "/app"
};
foreach (string key in LegacyUiRedirects.Map.Keys)
{
foreach (string prefix in forbidden)
{
key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)
.ShouldBeFalse($"Map key '{key}' must not begin with forbidden prefix '{prefix}'");
}
}
// The guard invariant is load-bearing for the un-prefix-guarded /api|/artwork|
// /docs|/openapi surface, so it must cover the Tier-2 pattern templates too —
// not only the Tier-1 Map keys — or a future prefix-violating rule would slip in.
foreach (string template in LegacyUiRedirects.PatternTemplates)
{
foreach (string prefix in forbidden)
{
template.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)
.ShouldBeFalse($"Pattern template '{template}' must not begin with forbidden prefix '{prefix}'");
}
}
}
[Test]
public void Startup_Should_Redirect_Legacy_Routes_In_Legacy_Branch_Before_Routing()
{
// The redirect middleware must be wired inside the legacy branch and run before
// UseRouting so migrated routes never reach the SPA catch-all fallback (#91 phase b:
// the Blazor host is gone; unmatched non-API routes 302 to /app).
int redirectIndex = StartupSource.IndexOf("LegacyUiRedirects.TryGetRedirect", StringComparison.Ordinal);
redirectIndex.ShouldBeGreaterThan(-1);
// The legacy branch's UseRouting call that follows the redirect middleware.
int routingIndex = StartupSource.IndexOf("legacy.UseRouting()", StringComparison.Ordinal);
routingIndex.ShouldBeGreaterThan(-1);
redirectIndex.ShouldBeLessThan(routingIndex);
// 302 (temporary), not a permanent redirect; incoming query merged via AppendQueryString.
StartupSource.ShouldContain("context.Request.PathBase + LegacyUiRedirects.AppendQueryString(target");
StartupSource.ShouldNotContain("RedirectPermanent(target");
}
private static string FindStartupPath()
{
DirectoryInfo? directory = new(TestContext.CurrentContext.TestDirectory);
while (directory is not null)
{
string candidate = Path.Combine(directory.FullName, "ErsatzTV", "Startup.cs");
if (File.Exists(candidate))
{
return candidate;
}
directory = directory.Parent;
}
throw new FileNotFoundException("Could not find ErsatzTV/Startup.cs");
}
}