e1810facd6
tests / pytest (push) Successful in 7s
Every auto-published page, in all three pipelines, carried a source link to
`localhost:3005`. The template was never at fault — it links `{{{source}}}`
verbatim; the bad URL was baked into each page's wikitext by the publisher.
Root cause was one variable serving two incompatible purposes. `URL_TO_GITEA`
has to be an address the runner container can clone from, and since the runner
uses host networking that is `http://localhost:3005`. All three workflows then
composed `GITEA_REPO_URL` — the base of the public banner link, written into
the wiki for anyone to read — out of that same loopback value.
Adds a second org variable, `PUBLIC_URL_TO_GITEA`, for the public base
(`https://git.novoyuuparosk.org`), and points the banner at it. Cloning keeps
using `URL_TO_GITEA`. The two are now documented as deliberately separate,
because collapsing them looks like tidying up.
`lib/wiki.public_source_base()` validates the banner base rather than trusting
it: unset, relative, loopback, mDNS (`*.local`) or private-range values raise
with the variable to set. A misconfiguration that would publish an unreachable
link now stops the run instead. Note the failure mode this creates on purpose:
until `PUBLIC_URL_TO_GITEA` exists, publishing aborts rather than proceeding
with a wrong URL.
Also builds the source path with `rel.as_posix()`, so the separator in a URL
does not depend on the OS the publisher runs on — it produced backslashes when
the renderer was exercised on Windows.
Prerequisites before this can publish:
1. Add the org variable `PUBLIC_URL_TO_GITEA` = https://git.novoyuuparosk.org
2. Flush the act_runner callee cache — `docker compose up -d --force-recreate
act_runner`. Per the cross-cutting decisions log, edits to `publish-*.yml`
keep executing the stale cached copy, and this change is in those files.
Pages already published still carry the old URL in their stored wikitext. They
are only corrected by re-publishing each pipeline in full.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
287 lines
12 KiB
Python
287 lines
12 KiB
Python
"""Tests for the shared render helpers in lib/wiki.py.
|
|
|
|
All pure string in, string out, except the pandoc contract test at the bottom,
|
|
which is the canary for a pandoc upgrade changing how relative links are written.
|
|
"""
|
|
|
|
import shutil
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from lib.wiki import (
|
|
assert_no_unresolved_md_links,
|
|
expand_columns,
|
|
extract_md_link_targets,
|
|
markdown_to_wikitext,
|
|
public_source_base,
|
|
resolve_md_links,
|
|
strip_first_h1,
|
|
)
|
|
|
|
# Paths need not exist on disk: resolution is non-strict, and only the missing
|
|
# vs. unpublished distinction consults the filesystem (covered with tmp_path).
|
|
SONGS = Path(__file__).resolve().parent / "_paths" / "songs"
|
|
HERE = SONGS / "pulse.md"
|
|
INDEX = {
|
|
str((SONGS / "pulse.md").resolve()): "pulse under latent semantic envelopes",
|
|
str((SONGS / "pulse_dev.md").resolve()): (
|
|
"pulse under latent semantic envelopes - development notes"
|
|
),
|
|
str((SONGS / "two words.md").resolve()): "Two words",
|
|
}
|
|
|
|
|
|
def resolve(wikitext, source=HERE):
|
|
return resolve_md_links(wikitext, source, INDEX)
|
|
|
|
|
|
class TestStripFirstH1:
|
|
def test_removes_heading_and_following_blank_line(self):
|
|
assert strip_first_h1("# Title\n\nbody") == "body"
|
|
|
|
def test_removes_only_the_first(self):
|
|
assert strip_first_h1("# One\n\n# Two\n") == "# Two\n"
|
|
|
|
def test_leaves_body_without_h1_alone(self):
|
|
assert strip_first_h1("## Sub\n\nbody") == "## Sub\n\nbody"
|
|
|
|
def test_finds_the_h1_even_when_a_deeper_heading_precedes_it(self):
|
|
assert strip_first_h1("### Deep\n\n# Title\n") == "### Deep\n"
|
|
|
|
|
|
class TestExpandColumns:
|
|
def test_splits_on_separator_and_wraps_each_in_poem(self):
|
|
out = expand_columns('<pre class="columns">a\nb\n===\nc</pre>')
|
|
assert out.count("<poem>") == 2
|
|
assert "<poem>\na\nb\n</poem>" in out
|
|
assert "<poem>\nc\n</poem>" in out
|
|
assert out.startswith("<div style=")
|
|
|
|
def test_three_columns_need_two_separators(self):
|
|
out = expand_columns('<pre class="columns">a\n===\nb\n===\nc</pre>')
|
|
assert out.count("<poem>") == 3
|
|
|
|
def test_converts_markdown_emphasis(self):
|
|
out = expand_columns('<pre class="columns">***a*** **b** *c*</pre>')
|
|
assert "'''''a'''''" in out
|
|
assert "'''b'''" in out
|
|
assert "''c''" in out
|
|
|
|
def test_unescapes_inline_html(self):
|
|
out = expand_columns('<pre class="columns"><b>x</b></pre>')
|
|
assert "<b>x</b>" in out
|
|
|
|
def test_leaves_wikitext_without_a_columns_block_untouched(self):
|
|
assert expand_columns("plain '''wikitext'''") == "plain '''wikitext'''"
|
|
|
|
|
|
class TestResolveMdLinks:
|
|
def test_labelled_link_becomes_piped_wikilink(self):
|
|
assert resolve("see [[pulse_dev.md|dev notes]] ok") == (
|
|
"see [[pulse under latent semantic envelopes - development notes|dev notes]] ok"
|
|
)
|
|
|
|
def test_unlabelled_link_uses_the_page_title(self):
|
|
assert resolve("[[pulse_dev.md]]") == (
|
|
"[[pulse under latent semantic envelopes - development notes]]"
|
|
)
|
|
|
|
def test_label_identical_to_target_drops_the_pipe(self):
|
|
assert resolve("[[pulse_dev.md|pulse_dev.md]]") == (
|
|
"[[pulse under latent semantic envelopes - development notes]]"
|
|
)
|
|
|
|
def test_label_identical_to_the_page_title_drops_the_pipe(self):
|
|
assert resolve("[[pulse_dev.md|pulse under latent semantic envelopes - development notes]]") == (
|
|
"[[pulse under latent semantic envelopes - development notes]]"
|
|
)
|
|
|
|
def test_fragment_is_carried_onto_the_title(self):
|
|
assert resolve("[[pulse_dev.md#Notes|x]]") == (
|
|
"[[pulse under latent semantic envelopes - development notes#Notes|x]]"
|
|
)
|
|
|
|
def test_percent_encoding_in_target_is_decoded(self):
|
|
assert resolve("[[two%20words.md|x]]") == "[[Two words|x]]"
|
|
|
|
def test_parent_directory_target_resolves(self):
|
|
assert resolve_md_links(
|
|
"[[../pulse_dev.md|x]]", SONGS / "sub" / "a.md", INDEX
|
|
) == "[[pulse under latent semantic envelopes - development notes|x]]"
|
|
|
|
def test_external_style_link_shape_also_resolves(self):
|
|
# Insurance against a pandoc version writing relative targets this way.
|
|
assert resolve("[pulse_dev.md dev notes]") == (
|
|
"[[pulse under latent semantic envelopes - development notes|dev notes]]"
|
|
)
|
|
|
|
def test_absolute_url_is_left_alone(self):
|
|
assert resolve("[https://example.com/x label]") == "[https://example.com/x label]"
|
|
|
|
def test_non_md_wikilink_is_left_alone(self):
|
|
assert resolve("[[Category:Songs]] [[Media:x.lrc|y]]") == (
|
|
"[[Category:Songs]] [[Media:x.lrc|y]]"
|
|
)
|
|
|
|
def test_label_containing_a_pipe_is_preserved(self):
|
|
assert resolve("[[pulse_dev.md|a|b]]").endswith("|a|b]]")
|
|
|
|
def test_several_links_in_one_body(self):
|
|
out = resolve("[[pulse_dev.md|a]] then [[two%20words.md|b]]")
|
|
assert "|a]]" in out and "[[Two words|b]]" in out
|
|
|
|
def test_missing_file_is_an_error(self):
|
|
with pytest.raises(ValueError, match="does not exist"):
|
|
resolve("[[nope.md|x]]")
|
|
|
|
def test_existing_but_unpublished_file_is_an_error(self, tmp_path):
|
|
(tmp_path / "draft.md").write_text("x", encoding="utf-8")
|
|
with pytest.raises(ValueError, match="not published"):
|
|
resolve_md_links("[[draft.md|x]]", tmp_path / "song.md", {})
|
|
|
|
def test_error_names_the_source_file(self):
|
|
with pytest.raises(ValueError, match=r"pulse\.md"):
|
|
resolve("[[nope.md|x]]")
|
|
|
|
|
|
class TestUnresolvedLinkGuard:
|
|
def test_accepts_output_with_no_md_reference(self):
|
|
assert_no_unresolved_md_links("[[A page|x]] and [http://e.com y]", HERE)
|
|
|
|
def test_rejects_leftover_wikilink(self):
|
|
with pytest.raises(ValueError, match="unresolved wikilink"):
|
|
assert_no_unresolved_md_links("[[notes.md|x]]", HERE)
|
|
|
|
def test_rejects_leftover_external_style_link(self):
|
|
with pytest.raises(ValueError, match="external-style"):
|
|
assert_no_unresolved_md_links("[notes.md x]", HERE)
|
|
|
|
def test_rejects_raw_markdown_link_left_in_a_columns_fence(self):
|
|
with pytest.raises(ValueError, match="columns"):
|
|
assert_no_unresolved_md_links("<poem>\nsee [x](notes.md)\n</poem>", HERE)
|
|
|
|
def test_prose_mentioning_a_filename_is_not_a_link(self):
|
|
assert_no_unresolved_md_links("edit <code>pulse.md</code> by hand", HERE)
|
|
|
|
|
|
class TestExtractMdLinkTargets:
|
|
def test_finds_inline_targets(self):
|
|
assert extract_md_link_targets("a [x](one.md) b [y](../two.md#f)") == [
|
|
"one.md",
|
|
"../two.md#f",
|
|
]
|
|
|
|
def test_ignores_bodies_without_links(self):
|
|
assert extract_md_link_targets("no links here") == []
|
|
|
|
def test_finds_targets_inside_a_columns_fence(self):
|
|
# The fence cannot render them, but the graph must still see the edge.
|
|
assert extract_md_link_targets("```columns\n[x](one.md)\n```") == ["one.md"]
|
|
|
|
|
|
@pytest.mark.skipif(shutil.which("pandoc") is None, reason="pandoc not installed")
|
|
class TestPandocContract:
|
|
"""Pins the pandoc output shape that resolve_md_links depends on.
|
|
|
|
If a pandoc upgrade starts writing relative link targets differently, this
|
|
fails here instead of silently publishing a broken link.
|
|
"""
|
|
|
|
def test_version(self):
|
|
out = subprocess.run(
|
|
["pandoc", "--version"], capture_output=True, text=True, check=True
|
|
).stdout
|
|
print(out.splitlines()[0])
|
|
|
|
def test_relative_link_survives_the_full_render(self):
|
|
wikitext = markdown_to_wikitext("see [dev notes](pulse_dev.md) here")
|
|
assert resolve(wikitext).strip() == (
|
|
"see [[pulse under latent semantic envelopes - development notes|dev notes]] here"
|
|
)
|
|
|
|
def test_unlabelled_relative_link_survives_the_full_render(self):
|
|
wikitext = markdown_to_wikitext("[pulse_dev.md](pulse_dev.md)")
|
|
assert resolve(wikitext).strip() == (
|
|
"[[pulse under latent semantic envelopes - development notes]]"
|
|
)
|
|
|
|
def test_absolute_url_survives_untouched(self):
|
|
wikitext = markdown_to_wikitext("[site](https://example.com/x)")
|
|
assert resolve(wikitext).strip() == "[https://example.com/x site]"
|
|
|
|
def test_columns_fence_renders_to_side_by_side_poems(self):
|
|
wikitext = markdown_to_wikitext("```columns\n**A**\nl1\n===\n**B**\nl2\n```")
|
|
assert wikitext.count("<poem>") == 2
|
|
assert "'''A'''" in wikitext
|
|
|
|
|
|
class TestPublicSourceBase:
|
|
"""The banner URL is read by the public, so a runner-only address must fail.
|
|
|
|
Every one of these rejections was a page published with a link to
|
|
localhost:3005, which is what the whole guard exists to prevent.
|
|
"""
|
|
|
|
def test_accepts_a_public_https_url(self, monkeypatch):
|
|
monkeypatch.setenv("GITEA_REPO_URL", "https://git.novoyuuparosk.org/org/repo")
|
|
assert public_source_base() == "https://git.novoyuuparosk.org/org/repo"
|
|
|
|
def test_accepts_a_public_host_with_a_port(self, monkeypatch):
|
|
monkeypatch.setenv("GITEA_REPO_URL", "http://git.novoyuuparosk.org:1551/org/repo")
|
|
assert public_source_base() == "http://git.novoyuuparosk.org:1551/org/repo"
|
|
|
|
def test_strips_a_trailing_slash(self, monkeypatch):
|
|
monkeypatch.setenv("GITEA_REPO_URL", "https://git.example.org/org/repo/")
|
|
assert public_source_base() == "https://git.example.org/org/repo"
|
|
|
|
def test_strips_surrounding_whitespace(self, monkeypatch):
|
|
monkeypatch.setenv("GITEA_REPO_URL", " https://git.example.org/org/repo ")
|
|
assert public_source_base() == "https://git.example.org/org/repo"
|
|
|
|
def test_unset_is_rejected(self, monkeypatch):
|
|
monkeypatch.delenv("GITEA_REPO_URL", raising=False)
|
|
with pytest.raises(RuntimeError, match="is unset"):
|
|
public_source_base()
|
|
|
|
def test_empty_is_rejected(self, monkeypatch):
|
|
monkeypatch.setenv("GITEA_REPO_URL", "")
|
|
with pytest.raises(RuntimeError, match="is unset"):
|
|
public_source_base()
|
|
|
|
def test_a_bare_path_is_rejected(self, monkeypatch):
|
|
# What an unset PUBLIC_URL_TO_GITEA leaves the workflow composing.
|
|
monkeypatch.setenv("GITEA_REPO_URL", "/novoyuuparosk-wiki/ncmr-songs")
|
|
with pytest.raises(RuntimeError, match="not an absolute URL"):
|
|
public_source_base()
|
|
|
|
def test_localhost_is_rejected(self, monkeypatch):
|
|
monkeypatch.setenv("GITEA_REPO_URL", "http://localhost:3005/org/repo")
|
|
with pytest.raises(RuntimeError, match="loopback hostname"):
|
|
public_source_base()
|
|
|
|
def test_loopback_ip_is_rejected(self, monkeypatch):
|
|
monkeypatch.setenv("GITEA_REPO_URL", "http://127.0.0.1:3005/org/repo")
|
|
with pytest.raises(RuntimeError, match="loopback or private-range"):
|
|
public_source_base()
|
|
|
|
def test_private_range_ip_is_rejected(self, monkeypatch):
|
|
monkeypatch.setenv("GITEA_REPO_URL", "http://192.168.1.20:3005/org/repo")
|
|
with pytest.raises(RuntimeError, match="loopback or private-range"):
|
|
public_source_base()
|
|
|
|
def test_mdns_name_is_rejected(self, monkeypatch):
|
|
monkeypatch.setenv("GITEA_REPO_URL", "http://pi5-16.local:3005/org/repo")
|
|
with pytest.raises(RuntimeError, match="mDNS"):
|
|
public_source_base()
|
|
|
|
def test_rejection_names_the_variable_to_set(self, monkeypatch):
|
|
monkeypatch.setenv("GITEA_REPO_URL", "http://localhost:3005/org/repo")
|
|
with pytest.raises(RuntimeError, match="PUBLIC_URL_TO_GITEA"):
|
|
public_source_base()
|
|
|
|
def test_reads_the_named_variable(self, monkeypatch):
|
|
monkeypatch.setenv("OTHER_BASE", "https://git.example.org/org/repo")
|
|
assert public_source_base("OTHER_BASE") == "https://git.example.org/org/repo"
|