add URL redirects with ETS-cached plug, broken URL tracking, and admin UI
All checks were successful
deploy / deploy (push) Successful in 3m30s

Redirects context with redirect/broken_url schemas, chain flattening,
ETS cache for fast lookups in the request pipeline. BrokenUrlTracker
plug logs 404s. Auto-redirect on product slug change via upsert_product
hook. Admin redirects page with active/broken tabs, manual create form.
RedirectPrunerWorker cleans up old broken URLs. 1227 tests passing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jamey
2026-02-26 14:14:14 +00:00
parent 23e95a3de6
commit 6e57af82fc
21 changed files with 1493 additions and 24 deletions

View File

@@ -0,0 +1,26 @@
defmodule BerrypodWeb.Plugs.BrokenUrlTrackerTest do
use BerrypodWeb.ConnCase, async: true
alias Berrypod.Redirects
setup do
Redirects.create_table()
:ok
end
test "records broken URL on 404", %{conn: conn} do
conn = get(conn, "/zz-nonexistent-path")
assert conn.status in [404, 500]
[broken_url] = Redirects.list_broken_urls()
assert broken_url.path == "/zz-nonexistent-path"
assert broken_url.recent_404_count == 1
end
test "skips static asset paths", %{conn: conn} do
get(conn, "/assets/missing-file.js")
assert Redirects.list_broken_urls() == []
end
end

View File

@@ -0,0 +1,92 @@
defmodule BerrypodWeb.Plugs.RedirectsTest do
use BerrypodWeb.ConnCase, async: true
alias Berrypod.Redirects
setup do
Redirects.create_table()
:ok
end
describe "trailing slash normalisation" do
test "strips trailing slash and redirects", %{conn: conn} do
conn = get(conn, "/products/foo/")
assert conn.status == 301
assert get_resp_header(conn, "location") == ["/products/foo"]
end
test "preserves query params on trailing slash redirect", %{conn: conn} do
conn = get(conn, "/products/foo/?Color=Sand&Size=S")
assert conn.status == 301
assert get_resp_header(conn, "location") == ["/products/foo?Color=Sand&Size=S"]
end
test "does not strip trailing slash from root path", %{conn: conn} do
conn = get(conn, "/")
# Should not redirect — just pass through to the app
refute conn.status == 301
end
end
describe "case normalisation" do
test "lowercases path and redirects", %{conn: conn} do
conn = get(conn, "/Products/Foo")
assert conn.status == 301
assert get_resp_header(conn, "location") == ["/products/foo"]
end
test "preserves query param casing", %{conn: conn} do
conn = get(conn, "/Products/Foo?Color=Sand")
assert conn.status == 301
assert get_resp_header(conn, "location") == ["/products/foo?Color=Sand"]
end
test "does not redirect already-lowercase paths", %{conn: conn} do
# This will get a 404 or 200 from the app, not a 301
conn = get(conn, "/products/nonexistent-lowercase-path")
refute conn.status == 301
end
end
describe "custom redirects" do
test "redirects matching path with 301", %{conn: conn} do
{:ok, _} =
Redirects.create_auto(%{
from_path: "/products/old-tee",
to_path: "/products/new-tee",
source: "auto_slug_change"
})
conn = get(conn, "/products/old-tee")
assert conn.status == 301
assert get_resp_header(conn, "location") == ["/products/new-tee"]
end
test "preserves query string on custom redirect", %{conn: conn} do
{:ok, _} =
Redirects.create_auto(%{
from_path: "/products/old-hoodie",
to_path: "/products/new-hoodie",
source: "auto_slug_change"
})
conn = get(conn, "/products/old-hoodie?Color=Sand&Size=S")
assert conn.status == 301
assert get_resp_header(conn, "location") == ["/products/new-hoodie?Color=Sand&Size=S"]
end
test "passes through when no redirect exists", %{conn: conn} do
conn = get(conn, "/products/no-redirect-here")
refute conn.status == 301
end
end
end