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
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>
256 lines
12 KiB
C#
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");
|
|
}
|
|
}
|