178 lines
6.3 KiB
Python
178 lines
6.3 KiB
Python
"""Coverage tests for feed helpers — RSS, JSON Feed, sitemap."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import date
|
|
from typing import Any
|
|
|
|
from volumen.feed_helpers import (
|
|
_iso_date,
|
|
_rfc822_date,
|
|
render_json_feed,
|
|
render_rss_feed,
|
|
render_sitemap,
|
|
)
|
|
from volumen.post import Post
|
|
|
|
SITE: dict[str, Any] = {
|
|
"title": "My Blog",
|
|
"description": "A blog.",
|
|
"language": "en",
|
|
}
|
|
|
|
|
|
def _post(
|
|
*,
|
|
title: str = "Hello",
|
|
slug: str = "hello",
|
|
post_date: str = "2026-01-15",
|
|
tags: list[str] | None = None,
|
|
excerpt: str = "",
|
|
fediverse_creator: str = "",
|
|
body: str = "Body.",
|
|
) -> Post:
|
|
"""Build a Post with optional fields, simulating parse() without frontmatter I/O."""
|
|
metadata: dict[str, Any] = {"title": title, "slug": slug}
|
|
if post_date:
|
|
metadata["date"] = post_date
|
|
if tags is not None:
|
|
metadata["tags"] = tags
|
|
if excerpt:
|
|
metadata["excerpt"] = excerpt
|
|
if fediverse_creator:
|
|
metadata["fediverse_creator"] = fediverse_creator
|
|
return Post(metadata=metadata, body=body)
|
|
|
|
|
|
class TestRenderRSSFeed:
|
|
def test_basic_two_posts(self) -> None:
|
|
posts = [
|
|
_post(slug="a", title="Alpha"),
|
|
_post(slug="b", title="Beta"),
|
|
]
|
|
xml = render_rss_feed(posts, SITE, "https://example.com")
|
|
assert '<?xml version="1.0"' in xml
|
|
assert "<rss version=" in xml
|
|
assert "<title>My Blog</title>" in xml
|
|
assert "<link>https://example.com</link>" in xml
|
|
assert "<description>A blog.</description>" in xml
|
|
assert "<language>en</language>" in xml
|
|
assert "<title>Alpha</title>" in xml
|
|
assert "<title>Beta</title>" in xml
|
|
assert "<link>https://example.com/a</link>" in xml
|
|
assert "<link>https://example.com/b</link>" in xml
|
|
assert "pubDate" in xml
|
|
|
|
def test_truncates_to_20_items(self) -> None:
|
|
posts = [_post(slug=f"p{i:02d}") for i in range(25)]
|
|
xml = render_rss_feed(posts, SITE, "https://x")
|
|
assert xml.count("<item>") == 20
|
|
|
|
def test_escapes_special_characters_in_title(self) -> None:
|
|
xml = render_rss_feed(
|
|
[_post(title="A & B <C>", slug="x")],
|
|
SITE,
|
|
"https://x",
|
|
)
|
|
assert "A & B <C>" in xml
|
|
|
|
def test_no_pub_date_when_date_missing(self) -> None:
|
|
# No `date` in metadata → date_string is None → no <pubDate> in the item.
|
|
xml = render_rss_feed([_post(post_date="")], SITE, "https://x")
|
|
item_block = xml.split("<item>")[1].split("</item>")[0]
|
|
assert "<pubDate>" not in item_block
|
|
|
|
def test_dc_creator_present_when_fediverse_set(self) -> None:
|
|
xml = render_rss_feed(
|
|
[_post(fediverse_creator="@alice@example.com")],
|
|
SITE,
|
|
"https://x",
|
|
)
|
|
assert "<dc:creator>@alice@example.com</dc:creator>" in xml
|
|
|
|
def test_no_dc_creator_when_fediverse_empty(self) -> None:
|
|
xml = render_rss_feed([_post()], SITE, "https://x")
|
|
assert "<dc:creator>" not in xml
|
|
|
|
|
|
class TestRenderJSONFeed:
|
|
def test_basic_shape(self) -> None:
|
|
feed = render_json_feed([_post(slug="a", tags=["intro"])], SITE, "https://x")
|
|
assert feed["version"] == "https://jsonfeed.org/version/1.1"
|
|
assert feed["title"] == "My Blog"
|
|
assert feed["home_page_url"] == "https://x"
|
|
assert feed["feed_url"] == "https://x/api/volumen/feed.json"
|
|
assert feed["language"] == "en"
|
|
assert feed["items"][0]["id"] == "https://x/a"
|
|
assert feed["items"][0]["url"] == "https://x/a"
|
|
assert feed["items"][0]["title"] == "Hello"
|
|
assert feed["items"][0]["tags"] == ["intro"]
|
|
|
|
def test_filters_empty_authors_and_tags(self) -> None:
|
|
# Default _post has no fediverse handle, no tags. ``summary`` is also
|
|
# present (it falls back to the derived excerpt from ``body``).
|
|
feed = render_json_feed([_post()], SITE, "https://x")
|
|
item = feed["items"][0]
|
|
assert "authors" not in item
|
|
assert "tags" not in item
|
|
|
|
def test_author_from_site_wide_fediverse(self) -> None:
|
|
site_with_creator = {**SITE, "fediverse_creator": "@site@example.com"}
|
|
feed = render_json_feed([_post()], site_with_creator, "https://x")
|
|
assert feed["items"][0]["authors"] == [{"name": "@site@example.com"}]
|
|
|
|
def test_author_from_post_overrides_site(self) -> None:
|
|
site_with_creator = {**SITE, "fediverse_creator": "@site@example.com"}
|
|
feed = render_json_feed(
|
|
[_post(fediverse_creator="@post@example.com")],
|
|
site_with_creator,
|
|
"https://x",
|
|
)
|
|
assert feed["items"][0]["authors"] == [{"name": "@post@example.com"}]
|
|
|
|
|
|
class TestRenderSitemap:
|
|
def test_basic_urlset(self) -> None:
|
|
xml = render_sitemap([_post(slug="x"), _post(slug="y")], "https://x")
|
|
assert "<urlset" in xml
|
|
assert "<loc>https://x/x</loc>" in xml
|
|
assert "<loc>https://x/y</loc>" in xml
|
|
assert "</urlset>" in xml
|
|
|
|
def test_lastmod_when_date_present(self) -> None:
|
|
xml = render_sitemap([_post()], "https://x")
|
|
assert "<lastmod>2026-01-15</lastmod>" in xml
|
|
|
|
def test_no_lastmod_when_date_empty(self) -> None:
|
|
# When post_date="" is passed, _post() omits "date" → date_string is None
|
|
# → no <lastmod> rendered.
|
|
xml = render_sitemap([_post(post_date="")], "https://x")
|
|
url_block = xml.split("<url>")[1].split("</url>")[0]
|
|
assert "<lastmod>" not in url_block
|
|
|
|
|
|
class TestDateHelpers:
|
|
def test_rfc822_from_date_object(self) -> None:
|
|
out = _rfc822_date(date(2026, 1, 15))
|
|
assert "2026" in out and "GMT" in out
|
|
|
|
def test_rfc822_from_iso_string(self) -> None:
|
|
out = _rfc822_date("2026-01-15")
|
|
assert "2026" in out
|
|
assert "GMT" in out
|
|
|
|
def test_rfc822_invalid_string_returns_as_is(self) -> None:
|
|
assert _rfc822_date("not-a-date") == "not-a-date"
|
|
|
|
def test_iso_none(self) -> None:
|
|
assert _iso_date(None) is None
|
|
|
|
def test_iso_date_object(self) -> None:
|
|
assert _iso_date(date(2026, 1, 15)) == "2026-01-15"
|
|
|
|
def test_iso_valid_string_passes_through(self) -> None:
|
|
assert _iso_date("2026-01-15") == "2026-01-15"
|
|
|
|
def test_iso_invalid_string_returns_as_is(self) -> None:
|
|
assert _iso_date("not-a-date") == "not-a-date"
|