fix(ui): pattern-based legacy→SPA redirect matcher for parameterized routes (#204) #260

Merged
timothy merged 2 commits from fix/204-redirect-patterns into main 2026-07-11 17:00:03 +02:00
Owner

Closes #204 ([PLAN-MODE], #91 phase (b) removal-plan prerequisite).

Problem

LegacyUiRedirects matched exact paths only (a Dictionary lookup). The removal plan's Step 1 — "add every SPA-READY route incl. id-carrying detail/editor routes → their /app/... equivalents" — was not expressible: routes like /channels/{id}, /media/movies/{id}, /playouts/{id}/templates can't be static keys. After Blazor deletion they would hard-404.

Change (two-tier matcher, signature unchanged)

  • Tier 1 — the exact Map, grown to 52 entries (the 13 foundational routes + the #204 parameterless (A)/(B) routes + the (E-base) browse roots whose targets carry ?kind=…).
  • Tier 2 — an ordered list of 36 PatternRule segment templates ((C) id-substituting, (C2) mid-path id, (D) id-dropping detail→list, (E-page) /…/page/{n} → ?kind=). First-match-wins; consulted only on a Tier-1 miss.
  • {id} = strict positive integer (NumberStyles.None, > 0); non-int/0/negative/overflow falls through to Blazor (never a broken /app/edit-channel/abc). This strictness is also what makes the rule set collision-free by construction.
  • New AppendQueryString(target, QueryString) helper &-merges the incoming query into ?kind= targets, fixing the malformed double-? (/media/movies?page=2 → /app/media?kind=movies&page=2); plain targets keep verbatim-append behavior byte-for-byte. One-line Startup.cs change; redirect stays GET/HEAD-only 302 before UseRouting().

Guard invariant (comment + Map-keys meta-test): no rule may begin with /api, /artwork, /docs, /openapi, /iptv, /app, /media/sources — rules are always full templates, never prefix wildcards.

Scope

Completes phase-(a) Step 1 for every PARITY-OK route (docs/blazor-route-parity.md §2 → §1). Deliberately out of scope: the catch-all fallback replacing MapFallbackToPage("/_Host") (coupled to actual Blazor deletion — the removal PR), /media/sources/* (#202, other session), and /system/health (kept escape hatch) — both encoded as negative tests.

Verification

  • dotnet build ErsatzTV.sln — 0 errors.
  • dotnet test ErsatzTV.Tests — 1132 passed, 0 failed (adds positive/negative/id-validation/query-merge/guard-meta cases).
  • Design: [PLAN-MODE] pass (mechanism, query-collision, id-validation, ordering); recorded in docs/decisions.md.

Docs updated same-PR: docs/blazor-route-parity.md (§2 rows → §1), docs/decisions.md (append).

🤖 Generated with Claude Code

Closes #204 (`[PLAN-MODE]`, #91 phase (b) removal-plan prerequisite). ## Problem `LegacyUiRedirects` matched **exact paths only** (a `Dictionary` lookup). The removal plan's Step 1 — "add every SPA-READY route incl. id-carrying detail/editor routes → their `/app/...` equivalents" — was **not expressible**: routes like `/channels/{id}`, `/media/movies/{id}`, `/playouts/{id}/templates` can't be static keys. After Blazor deletion they would hard-404. ## Change (two-tier matcher, signature unchanged) - **Tier 1** — the exact `Map`, grown to **52 entries** (the 13 foundational routes + the #204 parameterless (A)/(B) routes + the (E-base) browse roots whose targets carry `?kind=…`). - **Tier 2** — an ordered list of **36 `PatternRule` segment templates** ((C) id-substituting, (C2) mid-path id, (D) id-dropping detail→list, (E-page) `/…/page/{n}` → `?kind=`). First-match-wins; consulted only on a Tier-1 miss. - `{id}` = **strict positive integer** (`NumberStyles.None`, `> 0`); non-int/`0`/negative/overflow **falls through** to Blazor (never a broken `/app/edit-channel/abc`). This strictness is also what makes the rule set **collision-free by construction**. - New `AppendQueryString(target, QueryString)` helper **`&`-merges** the incoming query into `?kind=` targets, fixing the malformed double-`?` (`/media/movies?page=2` → `/app/media?kind=movies&page=2`); plain targets keep verbatim-append behavior byte-for-byte. One-line `Startup.cs` change; redirect stays GET/HEAD-only **302** before `UseRouting()`. **Guard invariant** (comment + `Map`-keys meta-test): no rule may begin with `/api`, `/artwork`, `/docs`, `/openapi`, `/iptv`, `/app`, `/media/sources` — rules are always full templates, never prefix wildcards. ## Scope Completes phase-(a) Step 1 for every **PARITY-OK** route (`docs/blazor-route-parity.md` §2 → §1). Deliberately **out of scope**: the catch-all fallback replacing `MapFallbackToPage("/_Host")` (coupled to actual Blazor deletion — the removal PR), `/media/sources/*` (#202, other session), and `/system/health` (kept escape hatch) — both encoded as negative tests. ## Verification - `dotnet build ErsatzTV.sln` — 0 errors. - `dotnet test ErsatzTV.Tests` — **1132 passed, 0 failed** (adds positive/negative/id-validation/query-merge/guard-meta cases). - Design: `[PLAN-MODE]` pass (mechanism, query-collision, id-validation, ordering); recorded in `docs/decisions.md`. Docs updated same-PR: `docs/blazor-route-parity.md` (§2 rows → §1), `docs/decisions.md` (append). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
timothy added 1 commit 2026-07-11 16:40:22 +02:00
fix(ui): pattern-based legacy→SPA redirect matcher for parameterized routes (#204)
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 5s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m41s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 10m28s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
8a2238b62e
Extend LegacyUiRedirects from an exact-match dictionary to a two-tier matcher:
Tier 1 keeps the exact Map (now 52 entries incl. the ?kind= browse roots),
Tier 2 adds 36 ordered segment-template PatternRules for id-carrying routes.
{id} is a strict positive integer (non-int/0/neg/overflow falls through), which
also makes the rule set collision-free by construction. New AppendQueryString
helper merges the incoming query into ?kind= targets with '&' (kills the
double-'?' bug); one-line Startup change keeps the redirect GET/HEAD-only 302
before UseRouting.

Completes phase-(a) Step 1 for every PARITY-OK route (#91 phase b); the
catch-all fallback replacing MapFallbackToPage stays with the removal PR.
/media/sources/* (#202) and /system/health remain deliberately un-redirected.

fixes #204

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
timothy added 1 commit 2026-07-11 16:45:06 +02:00
test(ui): extend redirect guard meta-test to Tier-2 pattern templates (#204 review)
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 6s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m41s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m33s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m11s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 6m48s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m42s
495e450e2c
Fork adversarial review nit: Map_Keys_Should_Not_Begin_With_Forbidden_Prefix
covered only Tier-1 Map keys, not the Tier-2 PatternRule templates. That guard
invariant is the load-bearing protection for the un-prefix-guarded /api|/artwork|
/docs|/openapi surface, so make it self-enforcing over ALL rules — a future
prefix-violating template now fails the test instead of slipping through.

Exposes internal LegacyUiRedirects.PatternTemplates (InternalsVisibleTo already
set for ErsatzTV.Tests); stores the raw template on PatternRule.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
timothy merged commit 495e450e2c into main 2026-07-11 17:00:03 +02:00
timothy deleted branch fix/204-redirect-patterns 2026-07-11 17:00:03 +02:00
Sign in to join this conversation.