add dead link monitoring for outgoing content links
All checks were successful
deploy / deploy (push) Successful in 3m42s
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:
111
test/berrypod/redirects/link_checker_test.exs
Normal file
111
test/berrypod/redirects/link_checker_test.exs
Normal 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
|
||||
207
test/berrypod/redirects/link_scanner_test.exs
Normal file
207
test/berrypod/redirects/link_scanner_test.exs
Normal 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
|
||||
Reference in New Issue
Block a user