diff --git a/tests/mkdocs_hooks_test.py b/tests/mkdocs_hooks_test.py
new file mode 100644
index 0000000..c668516
--- /dev/null
+++ b/tests/mkdocs_hooks_test.py
@@ -0,0 +1,120 @@
+"""Tests for the redirect stubs ``scripts/mkdocs_hooks.py`` writes after a build.
+
+Run them with the rest of the suite::
+
+ pytest tests/
+"""
+
+from __future__ import annotations
+
+import importlib.util
+from pathlib import Path
+
+import pytest
+
+HOOKS_PATH = Path(__file__).parent.parent / "scripts" / "mkdocs_hooks.py"
+
+# ``scripts/`` is not a package: MkDocs loads the hook by path, and so does this.
+_spec = importlib.util.spec_from_file_location("mkdocs_hooks", HOOKS_PATH)
+assert _spec is not None and _spec.loader is not None
+hooks = importlib.util.module_from_spec(_spec)
+_spec.loader.exec_module(hooks)
+
+
+def _build(tmp_path: Path, site_url: str | None) -> Path:
+ site = tmp_path / "site"
+ site.mkdir()
+ hooks.on_post_build({"site_dir": str(site), "site_url": site_url})
+ return site
+
+
+def _target(stub: Path) -> str:
+ """The URL a stub sends the browser to."""
+ text = stub.read_text(encoding="utf-8")
+ start = text.index('var target = "') + len('var target = "')
+ return text[start : text.index('"', start)]
+
+
+@pytest.fixture
+def not_netlify(monkeypatch):
+ monkeypatch.delenv("NETLIFY", raising=False)
+
+
+def test_every_retired_and_legacy_url_gets_a_stub(tmp_path, not_netlify):
+ site = _build(tmp_path, "https://commit-check.com/")
+
+ for old, target in hooks.MOVED_URLS.items():
+ assert (
+ _target(site / old / "index.html") == f"https://commit-check.com/{target}"
+ )
+ for legacy, target in hooks.LEGACY_URLS.items():
+ assert _target(site / f"{legacy}.html") == f"https://commit-check.com/{target}"
+
+
+def test_a_stub_redirects_three_ways_and_keeps_the_fragment(tmp_path, not_netlify):
+ site = _build(tmp_path, "https://commit-check.com/")
+ text = (site / "rules.html").read_text(encoding="utf-8")
+
+ url = "https://commit-check.com/rules/"
+ assert f'' in text
+ assert f'' in text
+ # The script, not the meta refresh, is what carries ``#cc003`` across.
+ assert "location.replace(target + location.hash)" in text
+ assert f'{url}' in text
+
+
+def test_a_site_url_without_a_trailing_slash_still_joins(tmp_path, not_netlify):
+ # Deploy previews pass their own URL in, sometimes without the slash.
+ site = _build(tmp_path, "https://deploy-preview-7--commit-check.netlify.app")
+
+ assert _target(site / "rules.html") == (
+ "https://deploy-preview-7--commit-check.netlify.app/rules/"
+ )
+
+
+def test_without_a_site_url_the_targets_are_root_relative(tmp_path, not_netlify):
+ site = _build(tmp_path, None)
+
+ assert _target(site / "rules.html") == "/rules/"
+ assert _target(site / "projects" / "index.html") == "/"
+
+
+def test_on_netlify_only_the_retired_pages_get_stubs(tmp_path, monkeypatch):
+ # netlify.toml redirects the .html URLs; a stub there would shadow
+ # rules/index.html and loop.
+ monkeypatch.setenv("NETLIFY", "true")
+ site = _build(tmp_path, "https://commit-check.com/")
+
+ for old in hooks.MOVED_URLS:
+ assert (site / old / "index.html").is_file()
+ assert not any(site.glob("*.html"))
+
+
+def test_a_stub_never_overwrites_a_page_the_build_wrote(tmp_path, not_netlify):
+ # The stubs run after the build, so a retired URL that became a real page
+ # again would be replaced by a redirect away from itself.
+ site = tmp_path / "site"
+ site.mkdir()
+ page = site / "guides" / "pre-commit" / "index.html"
+ page.parent.mkdir(parents=True)
+ page.write_text("real page", encoding="utf-8")
+
+ hooks.on_post_build(
+ {"site_dir": str(site), "site_url": "https://commit-check.com/"}
+ )
+
+ assert page.read_text(encoding="utf-8") == "real page"
+
+
+def test_no_stub_redirects_to_another_stub():
+ # A chain would cost a second hop, and a cycle would never end.
+ retired = {f"{old}/" for old in hooks.MOVED_URLS}
+ for target in [*hooks.MOVED_URLS.values(), *hooks.LEGACY_URLS.values()]:
+ assert target not in retired, target
+
+
+def test_the_pages_the_cli_links_to_never_move():
+ # Every released commit-check prints commit-check.com/rules/#ccNNN, and
+ # links to configuration/; those two pages must stay where they are.
+ assert "rules" not in hooks.MOVED_URLS
+ assert "configuration" not in hooks.MOVED_URLS