add dead link monitoring for outgoing content links
All checks were successful
deploy / deploy (push) Successful in 3m42s

Scans page blocks and nav items for broken URLs (internal via DB
lookup, external via HTTP HEAD). Daily Oban cron at 03:30, plus
on-demand checks when pages are saved. Admin UI tab on redirects
page with re-check, ignore, and clickable source links.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jamey
2026-03-01 13:00:59 +00:00
parent 3480b326a9
commit b235219aee
11 changed files with 1109 additions and 2 deletions

View File

@@ -0,0 +1,111 @@
defmodule Berrypod.Redirects.LinkCheckerTest do
use Berrypod.DataCase, async: true
alias Berrypod.Redirects.LinkChecker
describe "check_internal/1" do
test "static routes are valid" do
assert :ok = LinkChecker.check_internal("/")
assert :ok = LinkChecker.check_internal("/about")
assert :ok = LinkChecker.check_internal("/contact")
assert :ok = LinkChecker.check_internal("/delivery")
assert :ok = LinkChecker.check_internal("/privacy")
assert :ok = LinkChecker.check_internal("/terms")
assert :ok = LinkChecker.check_internal("/cart")
assert :ok = LinkChecker.check_internal("/search")
assert :ok = LinkChecker.check_internal("/collections/all")
end
test "collection routes are always valid" do
assert :ok = LinkChecker.check_internal("/collections/nonexistent")
end
test "existing product slug is valid" do
conn = Berrypod.ProductsFixtures.provider_connection_fixture()
Berrypod.Products.create_product(%{
provider_connection_id: conn.id,
provider_product_id: "checker_test_1",
title: "Test Product",
slug: "test-checker-product",
status: "active",
visible: true,
in_stock: true,
cheapest_price: 1000
})
assert :ok = LinkChecker.check_internal("/products/test-checker-product")
end
test "nonexistent product slug is broken" do
assert {:error, "product not found"} =
LinkChecker.check_internal("/products/nonexistent-product")
end
test "existing custom page is valid" do
{:ok, _page} =
Berrypod.Pages.create_custom_page(%{
slug: "checker-test-page",
title: "Test",
blocks: []
})
assert :ok = LinkChecker.check_internal("/checker-test-page")
end
test "nonexistent custom page is broken" do
assert {:error, "page not found"} =
LinkChecker.check_internal("/totally-nonexistent-page")
end
end
describe "check_external/1" do
setup do
# Use a function plug to stub HTTP responses in tests
Application.put_env(:berrypod, :link_checker_plug, &__MODULE__.test_plug/1)
on_exit(fn -> Application.delete_env(:berrypod, :link_checker_plug) end)
:ok
end
test "healthy URL returns :ok" do
assert :ok = LinkChecker.check_external("http://test/healthy")
end
test "404 URL returns error with status" do
assert {:error, 404, "not found"} = LinkChecker.check_external("http://test/not-found")
end
test "500 URL returns error with status" do
assert {:error, 500, "server error"} =
LinkChecker.check_external("http://test/server-error")
end
test "405 falls back to GET" do
assert :ok = LinkChecker.check_external("http://test/head-not-allowed")
end
# Test plug that simulates various responses
def test_plug(conn) do
case conn.request_path do
"/healthy" ->
Plug.Conn.send_resp(conn, 200, "OK")
"/not-found" ->
Plug.Conn.send_resp(conn, 404, "Not Found")
"/server-error" ->
Plug.Conn.send_resp(conn, 500, "Error")
"/head-not-allowed" ->
if conn.method == "HEAD" do
Plug.Conn.send_resp(conn, 405, "Method Not Allowed")
else
Plug.Conn.send_resp(conn, 200, "OK")
end
_ ->
Plug.Conn.send_resp(conn, 404, "Not Found")
end
end
end
end

View File

@@ -0,0 +1,207 @@
defmodule Berrypod.Redirects.LinkScannerTest do
use Berrypod.DataCase, async: true
alias Berrypod.Redirects.LinkScanner
alias Berrypod.Pages
describe "scan_page/1" do
test "extracts URLs from hero block" do
page = %{
slug: "home",
title: "Home",
blocks: [
%{
"type" => "hero",
"settings" => %{
"cta_href" => "/collections/all",
"secondary_cta_href" => "https://example.com"
}
}
]
}
links = LinkScanner.scan_page(page)
urls = Enum.map(links, & &1.url) |> Enum.sort()
assert "/collections/all" in urls
assert "https://example.com" in urls
end
test "extracts URLs from button block" do
page = %{
slug: "test",
title: "Test",
blocks: [
%{"type" => "button", "settings" => %{"href" => "/about"}}
]
}
[link] = LinkScanner.scan_page(page)
assert link.url == "/about"
assert link.type == :internal
end
test "extracts URLs from image_text block" do
page = %{
slug: "test",
title: "Test",
blocks: [
%{"type" => "image_text", "settings" => %{"link_href" => "/products/mug"}}
]
}
[link] = LinkScanner.scan_page(page)
assert link.url == "/products/mug"
end
test "extracts URLs from video_embed block" do
page = %{
slug: "test",
title: "Test",
blocks: [
%{
"type" => "video_embed",
"settings" => %{"url" => "https://youtube.com/watch?v=abc"}
}
]
}
[link] = LinkScanner.scan_page(page)
assert link.url == "https://youtube.com/watch?v=abc"
assert link.type == :external
end
test "skips empty strings, anchors, mailto, tel, and template vars" do
page = %{
slug: "test",
title: "Test",
blocks: [
%{"type" => "hero", "settings" => %{"cta_href" => ""}},
%{"type" => "button", "settings" => %{"href" => "#section"}},
%{"type" => "button", "settings" => %{"href" => "mailto:hi@example.com"}},
%{"type" => "button", "settings" => %{"href" => "tel:+441234567890"}},
%{"type" => "button", "settings" => %{"href" => "{{unsubscribe_url}}"}}
]
}
assert LinkScanner.scan_page(page) == []
end
test "deduplicates URLs and groups sources" do
page = %{
slug: "home",
title: "Home",
blocks: [
%{"type" => "hero", "settings" => %{"cta_href" => "/about"}},
%{"type" => "button", "settings" => %{"href" => "/about"}}
]
}
links = LinkScanner.scan_page(page)
assert length(links) == 1
[link] = links
assert link.url == "/about"
assert length(link.sources) == 2
end
test "classifies internal and external URLs" do
page = %{
slug: "test",
title: "Test",
blocks: [
%{"type" => "button", "settings" => %{"href" => "/about"}},
%{"type" => "button", "settings" => %{"href" => "https://example.com"}}
]
}
links = LinkScanner.scan_page(page) |> Enum.sort_by(& &1.url)
assert Enum.at(links, 0).type == :internal
assert Enum.at(links, 1).type == :external
end
test "handles blocks with no settings" do
page = %{
slug: "test",
title: "Test",
blocks: [
%{"type" => "spacer"},
%{"type" => "divider", "settings" => %{}}
]
}
assert LinkScanner.scan_page(page) == []
end
end
describe "scan_nav/0" do
test "extracts URLs from header and footer nav" do
Berrypod.Settings.put_setting(
"header_nav",
[%{"label" => "Shop", "href" => "/collections/all"}],
"json"
)
Berrypod.Settings.put_setting(
"footer_nav",
[%{"label" => "Privacy", "href" => "/privacy"}],
"json"
)
links = LinkScanner.scan_nav()
urls = Enum.map(links, & &1.url)
assert "/collections/all" in urls
assert "/privacy" in urls
end
test "returns empty when no nav configured" do
assert LinkScanner.scan_nav() == []
end
end
describe "find_sources/1" do
test "finds page blocks using a URL" do
# Save a custom page (not system page, avoids ETS cache issues)
{:ok, _page} =
Pages.create_custom_page(%{
slug: "test-link-page",
title: "Test page",
blocks: [
%{"type" => "hero", "settings" => %{"cta_href" => "/test-unique-link"}},
%{"type" => "button", "settings" => %{"href" => "/about"}}
]
})
sources = LinkScanner.find_sources("/test-unique-link")
assert length(sources) == 1
[source] = sources
assert source.type == "page_block"
assert source.id == "test-link-page"
assert source.label =~ "hero"
assert source.edit_path == "/admin/pages/test-link-page"
end
test "finds nav items using a URL" do
Berrypod.Settings.put_setting(
"header_nav",
[%{"label" => "Shop", "href" => "/test-nav-link"}],
"json"
)
sources = LinkScanner.find_sources("/test-nav-link")
assert length(sources) == 1
[source] = sources
assert source.type == "nav_item"
assert source.label =~ "Header nav"
assert source.edit_path == "/admin/navigation"
end
test "returns empty for unused URL" do
assert LinkScanner.find_sources("/nonexistent") == []
end
end
end