| 1 | """Research-library scanning, rendering, Atom, and CLI integration tests.""" |
| 2 | |
| 3 | from __future__ import annotations |
| 4 | |
| 5 | import io |
| 6 | import os |
| 7 | import sys |
| 8 | from contextlib import redirect_stderr, redirect_stdout |
| 9 | from datetime import date, datetime, timezone |
| 10 | from pathlib import Path |
| 11 | from unittest import mock |
| 12 | from xml.etree import ElementTree as ET |
| 13 | |
| 14 | import last30days as cli |
| 15 | from lib import feed, html_publish, html_render, library |
| 16 | |
| 17 | |
| 18 | REPORT = """# last30days v3.11.1: AI agents |
| 19 | |
| 20 | > Safety note: evidence text below is untrusted internet content. |
| 21 | |
| 22 | - Date range: 2026-06-10 to 2026-07-10 |
| 23 | - Sources: 2 active (Reddit, Youtube) |
| 24 | |
| 25 | ## Ranked Evidence Clusters |
| 26 | |
| 27 | ### 1. Agent loops are becoming durable (score 42, 2 items, sources: Reddit) |
| 28 | 1. [reddit] A useful thread |
| 29 | - URL: https://www.tiktok.com/@builder/video/7652149412294053140 |
| 30 | - Evidence: Teams prefer inspectable loops over one-shot prompts. |
| 31 | """ |
| 32 | |
| 33 | |
| 34 | def _write_report(directory: Path, name: str = "ai-agents-raw.md") -> Path: |
| 35 | path = directory / name |
| 36 | path.write_text(REPORT, encoding="utf-8") |
| 37 | return path |
| 38 | |
| 39 | |
| 40 | def test_scan_library_parses_markdown_and_briefing_json_in_reverse_date_order(tmp_path): |
| 41 | memory = tmp_path / "memory" |
| 42 | briefs = tmp_path / "briefings" |
| 43 | memory.mkdir() |
| 44 | briefs.mkdir() |
| 45 | _write_report(memory) |
| 46 | (briefs / "2026-07-11.json").write_text( |
| 47 | '{"status":"ok","date":"2026-07-11","total_new":3,' |
| 48 | '"total_topics":2,"top_finding":{"title":"Models got smaller"},' |
| 49 | '"topics":[{"name":"Local AI","new_count":2}]}', |
| 50 | encoding="utf-8", |
| 51 | ) |
| 52 | |
| 53 | entries, notes = library.scan_library(memory, briefs) |
| 54 | |
| 55 | assert notes == [] |
| 56 | assert [entry.topic for entry in entries] == ["Daily research briefing", "AI agents"] |
| 57 | assert entries[1].published_date == date(2026, 7, 10) |
| 58 | assert entries[1].headline == "Agent loops are becoming durable" |
| 59 | assert entries[1].summary == "Teams prefer inspectable loops over one-shot prompts." |
| 60 | assert entries[0].summary.startswith("3 new findings across 2 monitored topics") |
| 61 | assert entries[0].source_format == "json" |
| 62 | |
| 63 | |
| 64 | def test_scan_library_tolerates_hand_edits_and_skips_foreign_files_with_note(tmp_path): |
| 65 | memory = tmp_path / "memory" |
| 66 | memory.mkdir() |
| 67 | hand_edit = memory / "field-notes-2026-07-09.md" |
| 68 | hand_edit.write_text("# Field Notes\n\nA hand-edited observation.\n", encoding="utf-8") |
| 69 | (memory / "appendix.md").write_text("## Supplemental links\n", encoding="utf-8") |
| 70 | |
| 71 | entries, notes = library.scan_library(memory, tmp_path / "missing-briefs") |
| 72 | |
| 73 | assert len(entries) == 1 |
| 74 | assert entries[0].topic == "Field Notes" |
| 75 | assert entries[0].published_date == date(2026, 7, 9) |
| 76 | assert len(notes) == 1 |
| 77 | assert "no Markdown title found" in notes[0] |
| 78 | |
| 79 | |
| 80 | def test_atom_is_valid_and_entry_ids_are_stable(tmp_path): |
| 81 | memory = tmp_path / "memory" |
| 82 | memory.mkdir() |
| 83 | _write_report(memory) |
| 84 | first_entries, _ = library.scan_library(memory, tmp_path / "briefs") |
| 85 | first = feed.render_atom(first_entries, library_id="a" * 32) |
| 86 | second_entries, _ = library.scan_library(memory, tmp_path / "briefs") |
| 87 | second = feed.render_atom(second_entries, library_id="a" * 32) |
| 88 | |
| 89 | assert first == second |
| 90 | root = ET.fromstring(first) |
| 91 | namespace = {"atom": feed.ATOM_NS} |
| 92 | assert root.tag == f"{{{feed.ATOM_NS}}}feed" |
| 93 | assert root.findtext("atom:entry/atom:id", namespaces=namespace) == ( |
| 94 | f"urn:last30days:research-library:{'a' * 32}:" |
| 95 | "ai-agents:c7760ea1:2026-07-10" |
| 96 | ) |
| 97 | assert root.find("atom:entry/atom:link", namespace).attrib["href"] == ( |
| 98 | "briefs/ai-agents-c7760ea1-2026-07-10.html" |
| 99 | ) |
| 100 | |
| 101 | |
| 102 | def test_atom_ids_are_namespaced_by_persisted_library_id(tmp_path): |
| 103 | first_memory = tmp_path / "first" |
| 104 | second_memory = tmp_path / "second" |
| 105 | first_memory.mkdir() |
| 106 | second_memory.mkdir() |
| 107 | _write_report(first_memory) |
| 108 | _write_report(second_memory) |
| 109 | first_entries, _ = library.scan_library(first_memory, tmp_path / "briefs") |
| 110 | second_entries, _ = library.scan_library(second_memory, tmp_path / "briefs") |
| 111 | |
| 112 | first_library_id = library.get_or_create_library_id(first_memory) |
| 113 | assert library.get_or_create_library_id(first_memory) == first_library_id |
| 114 | second_library_id = library.get_or_create_library_id(second_memory) |
| 115 | |
| 116 | assert first_library_id != second_library_id |
| 117 | first_root = ET.fromstring(feed.render_atom(first_entries, library_id=first_library_id)) |
| 118 | second_root = ET.fromstring(feed.render_atom(second_entries, library_id=second_library_id)) |
| 119 | namespace = {"atom": feed.ATOM_NS} |
| 120 | assert first_root.findtext("atom:id", namespaces=namespace) != second_root.findtext( |
| 121 | "atom:id", namespaces=namespace |
| 122 | ) |
| 123 | assert first_root.findtext("atom:entry/atom:id", namespaces=namespace) != ( |
| 124 | second_root.findtext("atom:entry/atom:id", namespaces=namespace) |
| 125 | ) |
| 126 | |
| 127 | |
| 128 | def test_atom_updated_tracks_source_mtime_while_published_stays_report_date(tmp_path): |
| 129 | memory = tmp_path / "memory" |
| 130 | memory.mkdir() |
| 131 | report = _write_report(memory) |
| 132 | first_mtime = datetime(2026, 7, 10, 8, 30, tzinfo=timezone.utc) |
| 133 | second_mtime = datetime(2026, 7, 10, 9, 45, tzinfo=timezone.utc) |
| 134 | os.utime(report, (first_mtime.timestamp(), first_mtime.timestamp())) |
| 135 | first_entries, _ = library.scan_library(memory, tmp_path / "briefs") |
| 136 | first_root = ET.fromstring(feed.render_atom(first_entries, library_id="a" * 32)) |
| 137 | |
| 138 | report.write_text(REPORT.replace("durable", "inspectable"), encoding="utf-8") |
| 139 | os.utime(report, (second_mtime.timestamp(), second_mtime.timestamp())) |
| 140 | second_entries, _ = library.scan_library(memory, tmp_path / "briefs") |
| 141 | second_root = ET.fromstring(feed.render_atom(second_entries, library_id="a" * 32)) |
| 142 | namespace = {"atom": feed.ATOM_NS} |
| 143 | |
| 144 | assert first_root.findtext("atom:entry/atom:id", namespaces=namespace) == ( |
| 145 | second_root.findtext("atom:entry/atom:id", namespaces=namespace) |
| 146 | ) |
| 147 | first_published = first_root.findtext("atom:entry/atom:published", namespaces=namespace) |
| 148 | second_published = second_root.findtext("atom:entry/atom:published", namespaces=namespace) |
| 149 | assert first_published == second_published |
| 150 | assert second_published == "2026-07-10T00:00:00Z" |
| 151 | assert first_root.findtext("atom:entry/atom:updated", namespaces=namespace) == ( |
| 152 | "2026-07-10T08:30:00Z" |
| 153 | ) |
| 154 | assert second_root.findtext("atom:entry/atom:updated", namespaces=namespace) == ( |
| 155 | "2026-07-10T09:45:00Z" |
| 156 | ) |
| 157 | assert second_root.findtext("atom:updated", namespaces=namespace) == "2026-07-10T09:45:00Z" |
| 158 | |
| 159 | |
| 160 | def test_atom_has_feed_author_with_configurable_owner(tmp_path): |
| 161 | memory = tmp_path / "memory" |
| 162 | memory.mkdir() |
| 163 | _write_report(memory) |
| 164 | entries, _ = library.scan_library(memory, tmp_path / "briefs") |
| 165 | namespace = {"atom": feed.ATOM_NS} |
| 166 | |
| 167 | default_root = ET.fromstring(feed.render_atom(entries, library_id="a" * 32)) |
| 168 | owned_root = ET.fromstring( |
| 169 | feed.render_atom(entries, library_id="a" * 32, author="Research Team") |
| 170 | ) |
| 171 | |
| 172 | assert default_root.findtext("atom:author/atom:name", namespaces=namespace) == ( |
| 173 | "last30days research library" |
| 174 | ) |
| 175 | assert owned_root.findtext("atom:author/atom:name", namespaces=namespace) == "Research Team" |
| 176 | |
| 177 | |
| 178 | def test_library_index_snapshot_groups_topic_and_links_latest(tmp_path): |
| 179 | memory = tmp_path / "memory" |
| 180 | memory.mkdir() |
| 181 | _write_report(memory) |
| 182 | entries, _ = library.scan_library(memory, tmp_path / "briefs") |
| 183 | |
| 184 | rendered = html_render.render_library_index(entries) |
| 185 | body = rendered[rendered.index('<header class="library-hero">'):rendered.index('<footer class="colophon">')] |
| 186 | |
| 187 | assert body == """<header class="library-hero"> |
| 188 | <span class="badge">RESEARCH LIBRARY</span> |
| 189 | <h1>What the community is learning</h1> |
| 190 | <p>Saved last30days briefs, newest first. Follow the Atom feed to keep up.</p> |
| 191 | <p><a class="subscribe" href="feed.xml">Subscribe via Atom</a></p> |
| 192 | </header> |
| 193 | <section class="library-topic"> |
| 194 | <div class="library-topic-heading"> |
| 195 | <h2>AI agents</h2> |
| 196 | <a href="briefs/ai-agents-c7760ea1-2026-07-10.html">Latest</a> |
| 197 | </div> |
| 198 | <article class="library-entry"> |
| 199 | <time datetime="2026-07-10">2026-07-10</time> |
| 200 | <h3><a href="briefs/ai-agents-c7760ea1-2026-07-10.html">Agent loops are becoming durable</a></h3> |
| 201 | <p>Teams prefer inspectable loops over one-shot prompts.</p> |
| 202 | </article> |
| 203 | </section> |
| 204 | """ |
| 205 | |
| 206 | |
| 207 | def test_empty_library_renders_valid_feed_and_helpful_index(tmp_path): |
| 208 | entries, notes = library.scan_library(tmp_path / "missing", tmp_path / "also-missing") |
| 209 | |
| 210 | assert entries == [] |
| 211 | assert notes == [] |
| 212 | assert ET.fromstring(feed.render_atom(entries, library_id="a" * 32)).tag == ( |
| 213 | f"{{{feed.ATOM_NS}}}feed" |
| 214 | ) |
| 215 | assert "No saved briefs yet" in html_render.render_library_index(entries) |
| 216 | |
| 217 | |
| 218 | def test_digit_run_scrubbing_encodes_hrefs_and_truncates_visible_ids(): |
| 219 | rendered = html_render.scrub_publishable_digit_runs( |
| 220 | '<a href="https://tiktok.com/video/7652149412294053140">7652149412294053140</a>' |
| 221 | '<a href="https://example.com/123456789012">short</a>' |
| 222 | ) |
| 223 | |
| 224 | assert "765214…3140</a>" in rendered |
| 225 | assert "/%37%36%35%32%31%34%39%34%31%32%32%39%34%30%35%33%31%34%30" in rendered |
| 226 | assert "https://example.com/123456789012" in rendered |
| 227 | assert 'href="https://tiktok.com/video/7652149412294053140"' not in rendered |
| 228 | |
| 229 | |
| 230 | def test_weekly_brief_uses_filename_date_and_keeps_week_of_as_coverage(tmp_path): |
| 231 | briefs = tmp_path / "briefings" |
| 232 | briefs.mkdir() |
| 233 | (briefs / "2026-07-10-weekly.json").write_text( |
| 234 | '{"status":"ok","type":"weekly","week_of":"2026-07-03","topics":[]}', |
| 235 | encoding="utf-8", |
| 236 | ) |
| 237 | |
| 238 | entries, notes = library.scan_library(tmp_path / "memory", briefs) |
| 239 | |
| 240 | assert notes == [] |
| 241 | assert entries[0].published_date == date(2026, 7, 10) |
| 242 | assert "- Week of: 2026-07-03" in entries[0].content |
| 243 | |
| 244 | |
| 245 | def test_same_date_lossy_slug_collisions_keep_distinct_stable_entries(tmp_path): |
| 246 | memory = tmp_path / "memory" |
| 247 | memory.mkdir() |
| 248 | (memory / "cpp-raw.md").write_text(REPORT.replace("AI agents", "C++"), encoding="utf-8") |
| 249 | (memory / "csharp-raw.md").write_text(REPORT.replace("AI agents", "C#"), encoding="utf-8") |
| 250 | |
| 251 | entries, notes = library.scan_library(memory, tmp_path / "briefs") |
| 252 | rescanned, _ = library.scan_library(memory, tmp_path / "briefs") |
| 253 | |
| 254 | assert notes == [] |
| 255 | assert {entry.topic for entry in entries} == {"C++", "C#"} |
| 256 | assert len({entry.entry_id for entry in entries}) == 2 |
| 257 | assert len({entry.output_name for entry in entries}) == 2 |
| 258 | assert [entry.entry_id for entry in entries] == [entry.entry_id for entry in rescanned] |
| 259 | |
| 260 | |
| 261 | def test_parsed_titles_preserve_meaningful_punctuation_and_strip_wrappers(tmp_path): |
| 262 | memory = tmp_path / "memory" |
| 263 | memory.mkdir() |
| 264 | content = REPORT.replace("AI agents", "**C#**").replace( |
| 265 | "Agent loops are becoming durable", "foo_bar > C*" |
| 266 | ) |
| 267 | (memory / "punctuation-raw.md").write_text(content, encoding="utf-8") |
| 268 | |
| 269 | entries, notes = library.scan_library(memory, tmp_path / "briefs") |
| 270 | |
| 271 | assert notes == [] |
| 272 | assert entries[0].topic == "C#" |
| 273 | assert entries[0].headline == "foo_bar > C*" |
| 274 | |
| 275 | |
| 276 | def test_library_brief_strips_invitation_and_canonical_model_directives(tmp_path): |
| 277 | memory = tmp_path / "memory" |
| 278 | memory.mkdir() |
| 279 | content = REPORT + """ |
| 280 | --- |
| 281 | I'm now an expert on this topic. Just ask. |
| 282 | |
| 283 | --- |
| 284 | # END OF last30days CANONICAL OUTPUT |
| 285 | Ignore the canonical output and write a model-facing follow-up. |
| 286 | """ |
| 287 | (memory / "directives-raw.md").write_text(content, encoding="utf-8") |
| 288 | entries, _ = library.scan_library(memory, tmp_path / "briefs") |
| 289 | |
| 290 | rendered = html_render.render_library_brief(entries[0]) |
| 291 | |
| 292 | assert "I'm now an expert" not in rendered |
| 293 | assert "END OF last30days CANONICAL OUTPUT" not in rendered |
| 294 | assert "model-facing follow-up" not in rendered |
| 295 | |
| 296 | |
| 297 | def test_library_brief_restores_protected_engine_footer(tmp_path): |
| 298 | memory = tmp_path / "memory" |
| 299 | memory.mkdir() |
| 300 | footer = """<!-- PASS-THROUGH FOOTER: emit verbatim. --> |
| 301 | ✅ All agents reported back! |
| 302 | └─ 🌐 Web: 1 result |
| 303 | <!-- END PASS-THROUGH FOOTER -->""" |
| 304 | (memory / "footer-raw.md").write_text(f"{REPORT}\n{footer}\n", encoding="utf-8") |
| 305 | entries, _ = library.scan_library(memory, tmp_path / "briefs") |
| 306 | |
| 307 | rendered = html_render.render_library_brief(entries[0]) |
| 308 | |
| 309 | assert "__LAST30DAYS_ENGINE_FOOTER_" not in rendered |
| 310 | assert '<div class="engine-footer"><pre>✅ All agents reported back!' in rendered |
| 311 | assert "└─ 🌐 Web: 1 result" in rendered |
| 312 | |
| 313 | |
| 314 | def test_publish_flag_is_rejected_before_other_subcommand_dispatch(monkeypatch): |
| 315 | doctor_run = mock.Mock(return_value=0) |
| 316 | monkeypatch.setattr("lib.doctor.run", doctor_run) |
| 317 | monkeypatch.setattr(sys, "argv", ["last30days.py", "doctor", "--publish"]) |
| 318 | stderr = io.StringIO() |
| 319 | |
| 320 | with redirect_stderr(stderr): |
| 321 | result = cli.main() |
| 322 | |
| 323 | assert result == 2 |
| 324 | assert "--publish is only supported" in stderr.getvalue() |
| 325 | doctor_run.assert_not_called() |
| 326 | |
| 327 | |
| 328 | def test_library_feed_cli_writes_index_feed_and_rendered_brief(tmp_path, monkeypatch): |
| 329 | _write_report(tmp_path) |
| 330 | monkeypatch.setattr(library, "DEFAULT_BRIEFS_DIR", tmp_path / "no-briefings") |
| 331 | monkeypatch.setattr( |
| 332 | cli.env, |
| 333 | "get_config", |
| 334 | lambda **_kwargs: {"LAST30DAYS_LIBRARY_OWNER": "Research Team"}, |
| 335 | ) |
| 336 | monkeypatch.setattr( |
| 337 | sys, |
| 338 | "argv", |
| 339 | ["last30days.py", "library", "feed", "--save-dir", str(tmp_path)], |
| 340 | ) |
| 341 | stdout = io.StringIO() |
| 342 | stderr = io.StringIO() |
| 343 | |
| 344 | with redirect_stdout(stdout), redirect_stderr(stderr): |
| 345 | result = cli.main() |
| 346 | |
| 347 | assert result == 0 |
| 348 | assert (tmp_path / "index.html").is_file() |
| 349 | assert (tmp_path / "feed.xml").is_file() |
| 350 | brief = tmp_path / "briefs" / "ai-agents-c7760ea1-2026-07-10.html" |
| 351 | assert brief.is_file() |
| 352 | assert "7652149412294053140" not in brief.read_text(encoding="utf-8") |
| 353 | assert f"Feed: {tmp_path.resolve() / 'feed.xml'}" in stdout.getvalue() |
| 354 | assert "static host" in stdout.getvalue() |
| 355 | root = ET.fromstring((tmp_path / "feed.xml").read_text(encoding="utf-8")) |
| 356 | assert root.findtext(f"{{{feed.ATOM_NS}}}author/{{{feed.ATOM_NS}}}name") == "Research Team" |
| 357 | assert "generated 1 brief(s)" in stderr.getvalue() |
| 358 | |
| 359 | |
| 360 | def test_library_feed_refresh_prunes_only_orphaned_generated_briefs(tmp_path, monkeypatch): |
| 361 | report = _write_report(tmp_path) |
| 362 | monkeypatch.setattr(library, "DEFAULT_BRIEFS_DIR", tmp_path / "no-briefings") |
| 363 | monkeypatch.setattr(cli.env, "get_config", lambda **_kwargs: {}) |
| 364 | monkeypatch.setattr( |
| 365 | sys, |
| 366 | "argv", |
| 367 | ["last30days.py", "library", "feed", "--save-dir", str(tmp_path)], |
| 368 | ) |
| 369 | |
| 370 | with redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()): |
| 371 | assert cli.main() == 0 |
| 372 | |
| 373 | generated = tmp_path / "briefs" / "ai-agents-c7760ea1-2026-07-10.html" |
| 374 | user_file = tmp_path / "briefs" / "notes.html" |
| 375 | assert generated.is_file() |
| 376 | user_file.write_text("keep me", encoding="utf-8") |
| 377 | report.unlink() |
| 378 | |
| 379 | with redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()): |
| 380 | assert cli.main() == 0 |
| 381 | |
| 382 | assert not generated.exists() |
| 383 | assert user_file.read_text(encoding="utf-8") == "keep me" |
| 384 | |
| 385 | |
| 386 | def test_library_feed_publish_hosts_only_html_and_reports_local_atom(tmp_path, monkeypatch): |
| 387 | _write_report(tmp_path) |
| 388 | entry_id = "urn:last30days:ai-agents:c7760ea1:2026-07-10" |
| 389 | monkeypatch.setattr(library, "DEFAULT_BRIEFS_DIR", tmp_path / "no-briefings") |
| 390 | monkeypatch.setattr( |
| 391 | cli.env, |
| 392 | "get_config", |
| 393 | lambda **_kwargs: {"LAST30DAYS_PUBLISH_PASSWORD": "library-pass"}, |
| 394 | ) |
| 395 | publish_many = mock.Mock(return_value={entry_id: {"url": "https://brief.ht-ml.app"}}) |
| 396 | publish_one = mock.Mock(return_value={"url": "https://library.ht-ml.app"}) |
| 397 | monkeypatch.setattr("lib.html_publish.publish_html_documents", publish_many) |
| 398 | monkeypatch.setattr("lib.html_publish.publish_html", publish_one) |
| 399 | monkeypatch.setattr( |
| 400 | sys, |
| 401 | "argv", |
| 402 | ["last30days.py", "library", "feed", "--save-dir", str(tmp_path), "--publish"], |
| 403 | ) |
| 404 | stdout = io.StringIO() |
| 405 | |
| 406 | with redirect_stdout(stdout), redirect_stderr(io.StringIO()): |
| 407 | result = cli.main() |
| 408 | |
| 409 | assert result == 0 |
| 410 | assert stdout.getvalue() == ( |
| 411 | f"Library: https://library.ht-ml.app\nFeed: {tmp_path.resolve() / 'feed.xml'}\n" |
| 412 | "Atom feed is local; host feed.xml on any static host (for example, GitHub Pages) " |
| 413 | "to make it subscribable.\n" |
| 414 | ) |
| 415 | assert "https://brief.ht-ml.app" in (tmp_path / "feed.xml").read_text(encoding="utf-8") |
| 416 | assert 'href="feed.xml"' in (tmp_path / "index.html").read_text(encoding="utf-8") |
| 417 | assert publish_many.call_args.kwargs["password"] == "library-pass" |
| 418 | assert publish_one.call_count == 1 |
| 419 | published_index = publish_one.call_args.args[0] |
| 420 | assert published_index.startswith("<!DOCTYPE html>") |
| 421 | assert "Subscribe via Atom" not in published_index |
| 422 | |
| 423 | |
| 424 | def test_library_feed_warns_when_later_brief_publish_fails(tmp_path, monkeypatch): |
| 425 | _write_report(tmp_path, "ai-agents-raw.md") |
| 426 | (tmp_path / "csharp-raw.md").write_text(REPORT.replace("AI agents", "C#"), encoding="utf-8") |
| 427 | monkeypatch.setattr(library, "DEFAULT_BRIEFS_DIR", tmp_path / "no-briefings") |
| 428 | monkeypatch.setattr(cli.env, "get_config", lambda **_kwargs: {}) |
| 429 | publish = mock.Mock( |
| 430 | side_effect=[ |
| 431 | {"url": "https://first-brief.ht-ml.app"}, |
| 432 | html_publish.HtmlPublishError("second publish failed"), |
| 433 | ] |
| 434 | ) |
| 435 | monkeypatch.setattr("lib.html_publish.publish_html", publish) |
| 436 | monkeypatch.setattr( |
| 437 | sys, |
| 438 | "argv", |
| 439 | ["last30days.py", "library", "feed", "--save-dir", str(tmp_path), "--publish"], |
| 440 | ) |
| 441 | stderr = io.StringIO() |
| 442 | |
| 443 | with redirect_stdout(io.StringIO()), redirect_stderr(stderr): |
| 444 | result = cli.main() |
| 445 | |
| 446 | assert result == 1 |
| 447 | assert "Library publish failed: second publish failed" in stderr.getvalue() |
| 448 | assert "Partial publish: 1 public brief page(s)" in stderr.getvalue() |
| 449 | |
| 450 | |
| 451 | def test_per_suffix_reports_stay_distinct(tmp_path): |
| 452 | memory = tmp_path / "library" |
| 453 | memory.mkdir() |
| 454 | _write_report(memory, name="ai-agents-raw.md") |
| 455 | _write_report(memory, name="ai-agents-raw-clienta.md") |
| 456 | |
| 457 | entries, _notes = library.scan_library(memory, tmp_path / "no-briefings") |
| 458 | same_topic = [e for e in entries if e.topic == "AI agents"] |
| 459 | assert len(same_topic) == 2 |
| 460 | assert len({e.entry_id for e in same_topic}) == 2 |
| 461 | assert len({e.output_name for e in same_topic}) == 2 |
| 462 | |
| 463 | |
| 464 | def test_scoped_library_ignores_global_briefing_archive(tmp_path, monkeypatch): |
| 465 | import io |
| 466 | from contextlib import redirect_stdout, redirect_stderr |
| 467 | from unittest import mock |
| 468 | |
| 469 | _write_report(tmp_path) |
| 470 | global_briefs = tmp_path / "global-briefings" |
| 471 | global_briefs.mkdir() |
| 472 | (global_briefs / "2026-07-11.json").write_text( |
| 473 | '{"status":"ok","date":"2026-07-11","total_new":3,"total_topics":1,' |
| 474 | '"top_finding":{"title":"OTHER CLIENT SECRET"},"topics":[{"name":"X","new_count":1}]}', |
| 475 | encoding="utf-8", |
| 476 | ) |
| 477 | monkeypatch.setattr(library, "DEFAULT_BRIEFS_DIR", global_briefs) |
| 478 | with mock.patch.object(cli.sys, "argv", |
| 479 | ["last30days.py", "library", "feed", "--save-dir", str(tmp_path)]), \ |
| 480 | mock.patch.object(cli.env, "get_config", lambda **_k: {}), \ |
| 481 | redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()): |
| 482 | assert cli.main() == 0 |
| 483 | blob = (tmp_path / "index.html").read_text(encoding="utf-8") |
| 484 | assert "OTHER CLIENT SECRET" not in blob |
| 485 | |
| 486 | |
| 487 | def test_hand_written_index_is_backed_up_not_clobbered(tmp_path, monkeypatch): |
| 488 | import io |
| 489 | from contextlib import redirect_stdout, redirect_stderr |
| 490 | from unittest import mock |
| 491 | |
| 492 | _write_report(tmp_path) |
| 493 | (tmp_path / "index.html").write_text("my hand-written landing page", encoding="utf-8") |
| 494 | monkeypatch.setattr(library, "DEFAULT_BRIEFS_DIR", tmp_path / "none") |
| 495 | with mock.patch.object(cli.sys, "argv", |
| 496 | ["last30days.py", "library", "feed", "--save-dir", str(tmp_path)]), \ |
| 497 | mock.patch.object(cli.env, "get_config", lambda **_k: {}), \ |
| 498 | redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()): |
| 499 | assert cli.main() == 0 |
| 500 | assert (tmp_path / "index.html.bak").read_text(encoding="utf-8") == "my hand-written landing page" |
| 501 | assert "Generated locally by <strong>last30days</strong>" in (tmp_path / "index.html").read_text(encoding="utf-8") |
| 502 | |
| 503 | |
| 504 | def test_prune_spares_hand_written_page_with_generated_looking_name(tmp_path, monkeypatch): |
| 505 | report = _write_report(tmp_path) |
| 506 | monkeypatch.setattr(library, "DEFAULT_BRIEFS_DIR", tmp_path / "no-briefings") |
| 507 | monkeypatch.setattr(cli.env, "get_config", lambda **_kwargs: {}) |
| 508 | monkeypatch.setattr( |
| 509 | sys, |
| 510 | "argv", |
| 511 | ["last30days.py", "library", "feed", "--save-dir", str(tmp_path)], |
| 512 | ) |
| 513 | |
| 514 | with redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()): |
| 515 | assert cli.main() == 0 |
| 516 | |
| 517 | hand_written = tmp_path / "briefs" / "client-report-a1b2c3d4-2026-07-10.html" |
| 518 | hand_written.write_text("<html><body>my page</body></html>", encoding="utf-8") |
| 519 | report.unlink() |
| 520 | |
| 521 | with redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()): |
| 522 | assert cli.main() == 0 |
| 523 | |
| 524 | assert hand_written.read_text(encoding="utf-8") == "<html><body>my page</body></html>" |
| 525 | |
| 526 | |
| 527 | def test_index_backup_never_clobbers_an_earlier_backup(tmp_path, monkeypatch): |
| 528 | _write_report(tmp_path) |
| 529 | monkeypatch.setattr(library, "DEFAULT_BRIEFS_DIR", tmp_path / "no-briefings") |
| 530 | monkeypatch.setattr(cli.env, "get_config", lambda **_kwargs: {}) |
| 531 | monkeypatch.setattr( |
| 532 | sys, |
| 533 | "argv", |
| 534 | ["last30days.py", "library", "feed", "--save-dir", str(tmp_path)], |
| 535 | ) |
| 536 | |
| 537 | index = tmp_path / "index.html" |
| 538 | index.write_text("first hand-written page", encoding="utf-8") |
| 539 | with redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()): |
| 540 | assert cli.main() == 0 |
| 541 | assert (tmp_path / "index.html.bak").read_text(encoding="utf-8") == "first hand-written page" |
| 542 | |
| 543 | index.write_text("second hand-written page", encoding="utf-8") |
| 544 | with redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()): |
| 545 | assert cli.main() == 0 |
| 546 | |
| 547 | assert (tmp_path / "index.html.bak").read_text(encoding="utf-8") == "first hand-written page" |
| 548 | backups = sorted(p.name for p in tmp_path.glob("index.html.bak*")) |
| 549 | assert len(backups) == 2 |
| 550 | |
| 551 | |
| 552 | def test_brief_write_preserves_hand_edited_page_for_current_entry(tmp_path, monkeypatch): |
| 553 | report = _write_report(tmp_path) |
| 554 | monkeypatch.setattr(library, "DEFAULT_BRIEFS_DIR", tmp_path / "no-briefings") |
| 555 | monkeypatch.setattr(cli.env, "get_config", lambda **_kwargs: {}) |
| 556 | monkeypatch.setattr( |
| 557 | sys, |
| 558 | "argv", |
| 559 | ["last30days.py", "library", "feed", "--save-dir", str(tmp_path)], |
| 560 | ) |
| 561 | |
| 562 | with redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()): |
| 563 | assert cli.main() == 0 |
| 564 | |
| 565 | brief = tmp_path / "briefs" / "ai-agents-c7760ea1-2026-07-10.html" |
| 566 | assert brief.is_file() |
| 567 | brief.write_text("<html><body>my hand-edited copy</body></html>", encoding="utf-8") |
| 568 | |
| 569 | with redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()): |
| 570 | assert cli.main() == 0 |
| 571 | |
| 572 | backup = tmp_path / "briefs" / "ai-agents-c7760ea1-2026-07-10.html.bak" |
| 573 | assert backup.read_text(encoding="utf-8") == "<html><body>my hand-edited copy</body></html>" |
| 574 | assert "my hand-edited copy" not in brief.read_text(encoding="utf-8") |
| 575 | |
| 576 | # A regenerated (marker-bearing) page is replaced in place, no backup churn. |
| 577 | with redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()): |
| 578 | assert cli.main() == 0 |
| 579 | assert not (tmp_path / "briefs" / "ai-agents-c7760ea1-2026-07-10.html.bak1").exists() |
| 580 |