313 lines
9.8 KiB
Python
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 & 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()
|