Files
labyricorn-site/tests/test_feed_generator.py
Labyricorn a88092631a
Deploy production / deploy (push) Successful in 21s
Add Atom and RSS feeds
2026-08-23 17:37:05 -07:00

313 lines
9.8 KiB
Python

from __future__ import annotations
from datetime import datetime, timezone
from pathlib import Path
import sys
import unittest
import xml.etree.ElementTree as ET
SCRIPTS = Path(__file__).resolve().parents[1] / "scripts"
sys.path.insert(0, str(SCRIPTS))
from feed_generator import ( # noqa: E402
ATOM_NS,
CONTENT_NS,
Feed,
FeedEntry,
absolute_html_urls,
atom_xml,
build_feed_catalog,
rss_xml,
select_recent_activity,
)
class FakeQuery:
def __init__(self, records):
self.records = list(records)
def include_hidden(self, include):
records = (
self.records
if include
else [item for item in self.records if not item.is_hidden]
)
return FakeQuery(records)
def order_by(self, *fields):
records = list(self.records)
for field in reversed(fields):
reverse = field.startswith("-")
name = field.lstrip("-")
records.sort(key=lambda item: str(item.data.get(name, "")), reverse=reverse)
return FakeQuery(records)
def __iter__(self):
return iter(self.records)
class FakeRecord:
def __init__(self, path, *, children=(), hidden=False, discoverable=True, **data):
self.path = path
self.url_path = f"{path}/" if path != "/" else "/"
self.children = FakeQuery(children)
self.is_hidden = hidden
self.is_undiscoverable = not discoverable
self.data = {"body": None, **data}
self.pad = None
def __getitem__(self, name):
if name == "_model":
return self.data.get("model")
return self.data[name]
class FakePad:
def __init__(self, records):
self.records = records
for record in records.values():
record.pad = self
def get(self, path):
return self.records.get(path)
def make_pad():
article_old = FakeRecord(
"/articles/older",
model="entry",
title="Older & wiser",
summary="An <older> article",
date="2026-01-01",
updated="2026-01-03",
author="Article Author",
)
article_new = FakeRecord(
"/articles/newer",
model="entry",
title="Newer article",
summary="Newest article",
date="2026-04-01",
author="Article Author",
)
article_draft = FakeRecord(
"/articles/draft",
model="entry",
title="Draft article",
date="2026-05-01",
hidden=True,
)
blog = FakeRecord(
"/blog/dispatch",
model="entry",
title="Dispatch",
summary="A dispatch",
date="2026-03-01",
author="Blog Author",
)
alpha_old = FakeRecord(
"/projects/alpha/devlog/one",
model="devlog-entry",
title="Alpha one",
summary="First",
date="2026-01-10",
source_commit_time="2026-01-10 12:00:00 +0000",
author="Alpha Author",
)
alpha_new = FakeRecord(
"/projects/alpha/devlog/two",
model="devlog-entry",
title="Alpha two",
summary="Second",
date="2026-03-15",
source_commit_time="2026-03-15 12:00:00 +0000",
author="Alpha Author",
)
alpha_hidden = FakeRecord(
"/projects/alpha/devlog/hidden",
model="devlog-entry",
title="Hidden alpha entry",
date="2026-04-15",
hidden=True,
)
beta_entry = FakeRecord(
"/projects/beta/devlog/only",
model="devlog-entry",
title="Beta only",
summary="Beta history",
date="2026-02-01",
source_commit_time="2026-02-01 12:00:00 +0000",
author="Beta Author",
)
alpha = FakeRecord(
"/projects/alpha",
model="project",
title="Alpha",
summary="Alpha summary",
date="2025-12-01",
repository_commit_date="2026-03-15 12:00:00 +0000",
author="Alpha Author",
)
beta = FakeRecord(
"/projects/beta",
model="project",
title="Beta",
summary="Beta summary",
date="2026-01-01",
repository_commit_date="2026-02-01 12:00:00 +0000",
author="Beta Author",
)
records = {
"/articles": FakeRecord(
"/articles",
children=[article_old, article_new, article_draft],
model="section",
summary="Article feed description",
),
"/blog": FakeRecord(
"/blog", children=[blog], model="section", summary="Blog feed description"
),
"/projects": FakeRecord(
"/projects", children=[alpha, beta], model="section"
),
"/projects/alpha/devlog": FakeRecord(
"/projects/alpha/devlog",
children=[alpha_old, alpha_new, alpha_hidden],
model="devlog",
),
"/projects/beta/devlog": FakeRecord(
"/projects/beta/devlog", children=[beta_entry], model="devlog"
),
}
for record in [
article_old,
article_new,
article_draft,
blog,
alpha,
beta,
alpha_old,
alpha_new,
alpha_hidden,
beta_entry,
]:
records[record.path] = record
return FakePad(records)
class FeedGeneratorTests(unittest.TestCase):
def test_recent_activity_matches_home_rules_and_excludes_hidden_records(self):
paths = [record.path for record in select_recent_activity(make_pad())]
self.assertEqual(
[
"/articles/newer",
"/projects/alpha/devlog/two",
"/blog/dispatch",
"/projects/beta/devlog/only",
"/articles/older",
],
paths,
)
self.assertNotIn("/articles/draft", paths)
self.assertNotIn("/projects/alpha/devlog/hidden", paths)
self.assertNotIn("/projects/alpha/devlog/one", paths)
def test_atom_and_rss_use_equivalent_entries_and_stable_canonical_ids(self):
_, feeds = build_feed_catalog(make_pad())
recent = feeds[0]
atom = ET.fromstring(atom_xml(recent))
rss = ET.fromstring(rss_xml(recent))
atom_ids = [
node.text
for node in atom.findall(f"{{{ATOM_NS}}}entry/{{{ATOM_NS}}}id")
]
rss_ids = [node.text for node in rss.findall("channel/item/guid")]
self.assertEqual(atom_ids, rss_ids)
self.assertEqual([entry.stable_id for entry in recent.entries], atom_ids)
self.assertTrue(
all(value.startswith("https://www.labyricorn.com/") for value in atom_ids)
)
self.assertNotIn("Draft article", atom_xml(recent).decode("utf-8"))
articles = feeds[1]
older = next(entry for entry in articles.entries if entry.title == "Older & wiser")
self.assertEqual("2026-01-01", older.published.date().isoformat())
self.assertEqual("2026-01-03", older.updated.date().isoformat())
def test_project_feeds_start_with_overview_then_oldest_devlogs(self):
_, feeds = build_feed_catalog(make_pad())
alpha = next(
feed for feed in feeds if feed.atom_url.endswith("/projects/alpha/feed.xml")
)
beta = next(
feed for feed in feeds if feed.atom_url.endswith("/projects/beta/feed.xml")
)
self.assertEqual(
["Alpha", "Alpha one", "Alpha two"],
[item.title for item in alpha.entries],
)
self.assertEqual(["Beta", "Beta only"], [item.title for item in beta.entries])
self.assertNotIn("Beta only", [item.title for item in alpha.entries])
self.assertEqual(
"https://www.labyricorn.com/projects/alpha/",
alpha.entries[0].stable_id,
)
def test_full_content_urls_become_absolute(self):
rendered = absolute_html_urls(
'<p><a href="notes/">Notes</a><img src="cover.png" '
'srcset="small.png 1x, /large.png 2x"><a href="mailto:[email protected]">Mail</a></p>',
"https://www.labyricorn.com/articles/example/",
)
self.assertIn('href="https://www.labyricorn.com/articles/example/notes/"', rendered)
self.assertIn('src="https://www.labyricorn.com/articles/example/cover.png"', rendered)
self.assertIn("https://www.labyricorn.com/large.png 2x", rendered)
self.assertIn('href="mailto:[email protected]"', rendered)
unsafe = absolute_html_urls(
'<a href="javascript:alert(1)">Unsafe</a>',
"https://www.labyricorn.com/",
)
self.assertEqual('<a href="#">Unsafe</a>', unsafe)
def test_xml_serializers_preserve_full_html_and_special_characters(self):
entry = FeedEntry(
title="Signals & systems",
canonical_url="https://www.labyricorn.com/articles/signals/",
stable_id="https://www.labyricorn.com/articles/signals/",
summary="A <summary> & test",
content_html='<p>Readable &amp; complete <a href="https://example.test/">content</a>.</p>',
published=datetime(2026, 1, 1, tzinfo=timezone.utc),
updated=datetime(2026, 1, 2, tzinfo=timezone.utc),
author="Test Author",
)
feed = Feed(
title="Test & Feed",
description="Description & details",
canonical_url="https://www.labyricorn.com/",
atom_url="https://www.labyricorn.com/feed.xml",
rss_url="https://www.labyricorn.com/rss.xml",
entries=(entry,),
)
atom = ET.fromstring(atom_xml(feed))
rss = ET.fromstring(rss_xml(feed))
atom_content = atom.find(f"{{{ATOM_NS}}}entry/{{{ATOM_NS}}}content")
rss_content = rss.find(f"channel/item/{{{CONTENT_NS}}}encoded")
self.assertEqual(entry.content_html, atom_content.text)
self.assertEqual(entry.content_html, rss_content.text)
self.assertEqual(entry.stable_id, rss.findtext("channel/item/guid"))
if __name__ == "__main__":
unittest.main()