add URL redirects with ETS-cached plug, broken URL tracking, and admin UI
All checks were successful
deploy / deploy (push) Successful in 3m30s
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:
26
test/berrypod_web/plugs/broken_url_tracker_test.exs
Normal file
26
test/berrypod_web/plugs/broken_url_tracker_test.exs
Normal 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
|
||||
92
test/berrypod_web/plugs/redirects_test.exs
Normal file
92
test/berrypod_web/plugs/redirects_test.exs
Normal 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
|
||||
Reference in New Issue
Block a user