This commit is contained in:
@@ -0,0 +1,312 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user