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>
226 lines
6.6 KiB
Elixir
226 lines
6.6 KiB
Elixir
defmodule Berrypod.RedirectsTest do
|
|
use Berrypod.DataCase, async: true
|
|
|
|
alias Berrypod.Redirects
|
|
alias Berrypod.Redirects.Redirect
|
|
|
|
setup do
|
|
Redirects.create_table()
|
|
:ok
|
|
end
|
|
|
|
describe "create_auto/1" do
|
|
test "creates a redirect" do
|
|
assert {:ok, redirect} =
|
|
Redirects.create_auto(%{
|
|
from_path: "/products/old-slug",
|
|
to_path: "/products/new-slug",
|
|
source: "auto_slug_change"
|
|
})
|
|
|
|
assert redirect.from_path == "/products/old-slug"
|
|
assert redirect.to_path == "/products/new-slug"
|
|
assert redirect.status_code == 301
|
|
assert redirect.source == "auto_slug_change"
|
|
assert redirect.hit_count == 0
|
|
end
|
|
|
|
test "is idempotent on conflict" do
|
|
attrs = %{
|
|
from_path: "/products/idempotent",
|
|
to_path: "/products/new",
|
|
source: "auto_slug_change"
|
|
}
|
|
|
|
assert {:ok, _} = Redirects.create_auto(attrs)
|
|
assert {:ok, _} = Redirects.create_auto(attrs)
|
|
|
|
# Only one redirect exists
|
|
assert [_] = Repo.all(from r in Redirect, where: r.from_path == "/products/idempotent")
|
|
end
|
|
|
|
test "flattens redirect chains on creation" do
|
|
# A -> B
|
|
{:ok, _} =
|
|
Redirects.create_auto(%{
|
|
from_path: "/products/a",
|
|
to_path: "/products/b",
|
|
source: "auto_slug_change"
|
|
})
|
|
|
|
# B -> C (should flatten A -> C as well)
|
|
{:ok, _} =
|
|
Redirects.create_auto(%{
|
|
from_path: "/products/b",
|
|
to_path: "/products/c",
|
|
source: "auto_slug_change"
|
|
})
|
|
|
|
# A should now point directly to C
|
|
assert {:ok, %{to_path: "/products/c"}} = Redirects.lookup("/products/a")
|
|
# B -> C still works
|
|
assert {:ok, %{to_path: "/products/c"}} = Redirects.lookup("/products/b")
|
|
end
|
|
end
|
|
|
|
describe "create_manual/1" do
|
|
test "creates a manual redirect" do
|
|
assert {:ok, redirect} =
|
|
Redirects.create_manual(%{
|
|
from_path: "/old-page",
|
|
to_path: "/new-page"
|
|
})
|
|
|
|
assert redirect.source == "admin"
|
|
assert redirect.status_code == 301
|
|
end
|
|
end
|
|
|
|
describe "lookup/1" do
|
|
test "returns redirect when found" do
|
|
{:ok, _} =
|
|
Redirects.create_auto(%{
|
|
from_path: "/products/lookup-test",
|
|
to_path: "/products/new",
|
|
source: "auto_slug_change"
|
|
})
|
|
|
|
assert {:ok, %{to_path: "/products/new", status_code: 301}} =
|
|
Redirects.lookup("/products/lookup-test")
|
|
end
|
|
|
|
test "returns :not_found when no redirect exists" do
|
|
assert :not_found = Redirects.lookup("/products/nonexistent")
|
|
end
|
|
end
|
|
|
|
describe "increment_hit_count/1" do
|
|
test "increments the hit count" do
|
|
{:ok, redirect} =
|
|
Redirects.create_auto(%{
|
|
from_path: "/products/hits-test",
|
|
to_path: "/products/new",
|
|
source: "auto_slug_change"
|
|
})
|
|
|
|
assert redirect.hit_count == 0
|
|
|
|
Redirects.increment_hit_count(%{id: redirect.id})
|
|
|
|
updated = Redirects.get_redirect!(redirect.id)
|
|
assert updated.hit_count == 1
|
|
end
|
|
end
|
|
|
|
describe "delete_redirect/1" do
|
|
test "deletes a redirect and clears cache" do
|
|
{:ok, redirect} =
|
|
Redirects.create_auto(%{
|
|
from_path: "/products/delete-test",
|
|
to_path: "/products/new",
|
|
source: "auto_slug_change"
|
|
})
|
|
|
|
assert {:ok, %{to_path: "/products/new"}} = Redirects.lookup("/products/delete-test")
|
|
|
|
{:ok, _} = Redirects.delete_redirect(redirect)
|
|
|
|
assert :not_found = Redirects.lookup("/products/delete-test")
|
|
end
|
|
end
|
|
|
|
describe "broken URLs" do
|
|
test "record_broken_url creates a new entry" do
|
|
{:ok, broken_url} = Redirects.record_broken_url("/products/broken", 42)
|
|
|
|
assert broken_url.path == "/products/broken"
|
|
assert broken_url.prior_analytics_hits == 42
|
|
assert broken_url.recent_404_count == 1
|
|
end
|
|
|
|
test "record_broken_url increments count on existing entry" do
|
|
{:ok, _} = Redirects.record_broken_url("/products/repeat-404", 10)
|
|
{:ok, updated} = Redirects.record_broken_url("/products/repeat-404", 10)
|
|
|
|
assert updated.recent_404_count == 2
|
|
end
|
|
|
|
test "list_broken_urls returns pending sorted by impact" do
|
|
{:ok, _} = Redirects.record_broken_url("/products/low-traffic", 5)
|
|
{:ok, _} = Redirects.record_broken_url("/products/high-traffic", 100)
|
|
|
|
[first, second] = Redirects.list_broken_urls()
|
|
assert first.path == "/products/high-traffic"
|
|
assert second.path == "/products/low-traffic"
|
|
end
|
|
|
|
test "ignore_broken_url marks as ignored" do
|
|
{:ok, broken_url} = Redirects.record_broken_url("/products/ignore-me", 1)
|
|
{:ok, ignored} = Redirects.ignore_broken_url(broken_url)
|
|
|
|
assert ignored.status == "ignored"
|
|
assert Redirects.list_broken_urls("pending") == []
|
|
end
|
|
end
|
|
|
|
describe "prune_stale_redirects/1" do
|
|
test "prunes auto redirects with 0 hits older than threshold" do
|
|
# Insert a stale redirect directly
|
|
{:ok, _} =
|
|
%Redirect{}
|
|
|> Redirect.changeset(%{
|
|
from_path: "/products/stale",
|
|
to_path: "/products/new",
|
|
source: "auto_slug_change"
|
|
})
|
|
|> Repo.insert()
|
|
|
|
# Backdate it
|
|
Repo.update_all(
|
|
from(r in Redirect, where: r.from_path == "/products/stale"),
|
|
set: [inserted_at: DateTime.add(DateTime.utc_now(), -100, :day)]
|
|
)
|
|
|
|
{:ok, count} = Redirects.prune_stale_redirects(90)
|
|
assert count == 1
|
|
assert :not_found = Redirects.lookup("/products/stale")
|
|
end
|
|
|
|
test "preserves redirects with hits" do
|
|
{:ok, redirect} =
|
|
%Redirect{}
|
|
|> Redirect.changeset(%{
|
|
from_path: "/products/has-hits",
|
|
to_path: "/products/new",
|
|
source: "auto_slug_change"
|
|
})
|
|
|> Repo.insert()
|
|
|
|
# Add a hit and backdate
|
|
Repo.update_all(
|
|
from(r in Redirect, where: r.id == ^redirect.id),
|
|
set: [hit_count: 5, inserted_at: DateTime.add(DateTime.utc_now(), -100, :day)]
|
|
)
|
|
|
|
{:ok, count} = Redirects.prune_stale_redirects(90)
|
|
assert count == 0
|
|
end
|
|
|
|
test "preserves manual redirects regardless of age" do
|
|
{:ok, _} =
|
|
Redirects.create_manual(%{
|
|
from_path: "/products/manual-old",
|
|
to_path: "/products/new"
|
|
})
|
|
|
|
Repo.update_all(
|
|
from(r in Redirect, where: r.from_path == "/products/manual-old"),
|
|
set: [inserted_at: DateTime.add(DateTime.utc_now(), -200, :day)]
|
|
)
|
|
|
|
{:ok, count} = Redirects.prune_stale_redirects(90)
|
|
assert count == 0
|
|
end
|
|
end
|
|
end
|