git/orchard/main
tests/test_news.py
228 lines7 KB last change raw · history
"""Arch news.

Upstream uses the news feed to announce upgrades that will not complete on their
own, so a tool that applies updates for you should not let those pass unnoticed.
The feed is RSS 2.0 and parsed with the standard library; the interesting parts
are the judgement calls, not the parsing.
"""

from __future__ import annotations

import sys
import tempfile
import time
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent.parent))

from updater import news  # noqa: E402

FEED = """<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
<channel>
  <title>Arch Linux: Recent news updates</title>
  <item>
    <title>Mkinitcpio requires manual intervention for LUKS</title>
    <link>https://archlinux.org/news/mkinitcpio/</link>
    <description>&lt;p&gt;You must run &lt;code&gt;mkinitcpio -P&lt;/code&gt; before rebooting.&lt;/p&gt;</description>
    <dc:creator>David Runge</dc:creator>
    <pubDate>Tue, 22 Sep 2026 09:09:27 +0000</pubDate>
    <guid>tag:archlinux.org,2026-09-22:/news/mkinitcpio/</guid>
  </item>
  <item>
    <title>A routine package note</title>
    <link>https://archlinux.org/news/routine/</link>
    <description>&lt;p&gt;Nothing to do here.&lt;/p&gt;</description>
    <dc:creator>Someone</dc:creator>
    <pubDate>Mon, 01 Sep 2026 09:00:00 +0000</pubDate>
    <guid>tag:archlinux.org,2026-09-01:/news/routine/</guid>
  </item>
  <item>
    <title>Missing date</title>
    <link>https://archlinux.org/news/undated/</link>
    <description>no date at all</description>
    <guid>x</guid>
  </item>
</channel>
</rss>
"""


def parsed():
    return news._parse(FEED)


# --------------------------------------------------------------------------
# parsing
# --------------------------------------------------------------------------


def test_parses_titles_links_dates_and_authors():
    items = parsed()
    assert len(items) == 3, items
    newest = items[0]
    assert newest.title.startswith("Mkinitcpio")
    assert newest.link.endswith("/news/mkinitcpio/")
    assert newest.author == "David Runge", newest.author
    # the namespaced <dc:creator> must be found whichever prefix it uses
    assert newest.published > 0


def test_html_is_stripped_from_the_summary():
    """The description is markup; printing it raw would be a wall of tags."""
    item = parsed()[0]
    assert "<" not in item.summary and ">" not in item.summary
    assert "mkinitcpio -P" in item.summary


def test_items_are_newest_first():
    stamps = [i.published for i in parsed()]
    assert stamps == sorted(stamps, reverse=True), stamps


def test_an_item_without_a_date_is_kept_not_dropped():
    """Losing an item is worse than showing one with an odd timestamp.

    A stamp of zero is never greater than a positive cutoff, so an undated item
    was invisible in `recent()` for good -- an Arch news item nobody would ever
    see. It inherits the feed's newest stamp instead.
    """
    items = parsed()
    undated = [i for i in items if i.title == "Missing date"]
    assert len(undated) == 1
    assert undated[0].published > 0

    # and it survives the filter that used to hide it
    shown = news.recent(items, since=1.0)
    assert "Missing date" in [i.title for i in shown]


def test_a_broken_feed_is_not_an_exception():
    for bad in ("", "not xml at all", "<rss><channel>", "<html><body>x</body></html>"):
        assert news._parse(bad) == [], bad


def test_an_item_without_a_title_is_skipped():
    feed = (
        "<rss><channel><item><link>x</link></item><item><title>real</title></item></channel></rss>"
    )
    got = news._parse(feed)
    assert [i.title for i in got] == ["real"]


# --------------------------------------------------------------------------
# what needs a human
# --------------------------------------------------------------------------


def test_manual_intervention_is_recognised():
    item = parsed()[0]
    assert item.needs_action, item.title


def test_a_routine_item_is_not_flagged():
    routine = next(i for i in parsed() if i.title == "A routine package note")
    assert not routine.needs_action


def test_the_phrases_that_signal_required_action():
    for phrase in (
        "requires manual intervention",
        "manual action needed",
        "before upgrading, do this",
        "you will need to reboot",
    ):
        item = news.NewsItem(title=phrase, link="", published=time.time())
        assert item.needs_action, phrase


# --------------------------------------------------------------------------
# recency
# --------------------------------------------------------------------------


def test_recent_uses_the_recorded_timestamp():
    now = time.time()
    items = [
        news.NewsItem("new", "", now - 60),
        news.NewsItem("old", "", now - 100000),
    ]
    assert [i.title for i in news.recent(items, since=now - 3600)] == ["new"]


def test_recent_falls_back_to_a_window_when_nothing_is_recorded():
    now = time.time()
    items = [
        news.NewsItem("fresh", "", now - 3600),
        news.NewsItem("ancient", "", now - 400 * 24 * 3600),
    ]
    got = news.recent(items, since=0.0)
    assert [i.title for i in got] == ["fresh"]


# --------------------------------------------------------------------------
# presentation
# --------------------------------------------------------------------------


def test_the_summary_says_how_many_need_a_decision():
    items = parsed()
    line = news.summary_line(items)
    assert "needing a decision" in line, line
    assert news.summary_line([]) == "nothing new"


def test_the_explanation_leads_with_what_needs_action():
    items = parsed()
    text = "\n".join(news.explain(items))
    assert "READ BEFORE UPDATING" in text, text
    assert "ALSO RECENT" in text, text
    # the item that needs a decision comes before the routine one
    assert text.index("Mkinitcpio") < text.index("A routine package note"), text
    assert "archlinux.org" in text, "the link should be there to follow"


def test_explaining_nothing_is_reassuring_not_empty():
    lines = news.explain([])
    assert lines and "nothing new" in lines[0]


# --------------------------------------------------------------------------
# the real feed, when it is reachable
# --------------------------------------------------------------------------


def test_fetching_the_real_feed_never_raises():
    with tempfile.TemporaryDirectory() as td:
        import os

        os.environ["XDG_CACHE_HOME"] = td
        items, error = news.fetch_news(timeout=15)
        assert isinstance(items, list)
        if error:
            assert isinstance(error, str) and error
        else:
            assert items, "no error but no items either"
            for item in items:
                assert item.title
                assert item.link.startswith("https://")


def main() -> int:
    tests = [(n, f) for n, f in sorted(globals().items()) if n.startswith("test_") and callable(f)]
    failed = 0
    for name, fn in tests:
        try:
            fn()
        except Exception as exc:  # noqa: BLE001
            failed += 1
            print(f"FAIL  {name}\n        {type(exc).__name__}: {exc}")
        else:
            print(f"ok    {name}")
    print(f"\n{len(tests) - failed}/{len(tests)} passed")
    return 1 if failed else 0


if __name__ == "__main__":
    raise SystemExit(main())