返回 last30days-skill
test_x_envelope.py
根目录 / tests / test_x_envelope.py
1 """Host-fetched X envelope (``--x-posts``): the U5 contract.
2
3 The envelope is a file of flat post rows the hosting model fetched through its
4 X connector. It is validated strictly at the top level (fail closed, exit 2)
5 and leniently per row (drop and count), every citation is rebuilt from a
6 numeric id and a grammar-valid handle, and the envelope replaces the engine's
7 X fetch for exactly one serve.
8 """
9
10 from __future__ import annotations
11
12 import contextlib
13 import hashlib
14 import io
15 import json
16 import os
17 import re
18 import stat
19 import sys
20 from contextlib import redirect_stderr, redirect_stdout
21 from datetime import date, datetime, timedelta, timezone
22 from pathlib import Path
23 from unittest import mock
24
25 import pytest
26
27 import last30days as cli
28 from lib import dates, env, health, html_render, pipeline, render, schema, x_api, x_envelope
29
30 TOPIC = "ai agents"
31 FROM, TO = dates.get_date_range(30)
32 SUBJECT = "steipete"
33 RELATED = "peer1"
34 ACCOUNT_ID = "acct-987654321-SECRET"
35
36
37 # ---------------------------------------------------------------------------
38 # Fixture builders
39 # ---------------------------------------------------------------------------
40
41
42 def _day(offset: int) -> str:
43 return (date.fromisoformat(TO) - timedelta(days=offset)).isoformat()
44
45
46 def _snowflake(day: str, *, hour: int = 12, minute: int = 0, low: int = 0) -> str:
47 when = datetime.strptime(day, "%Y-%m-%d").replace(tzinfo=timezone.utc)
48 when += timedelta(hours=hour, minutes=minute)
49 ms = int(when.timestamp() * 1000)
50 return str(((ms - x_api._SNOWFLAKE_EPOCH_MS) << 22) | (low & ((1 << 22) - 1)))
51
52
53 # Day offsets with irregular gaps so a normal fixture never reads as generated.
54 _OFFSETS = (1, 2, 4, 9, 11, 18)
55
56
57 def _row(
58 n: int,
59 handle: str = SUBJECT,
60 text: str = "ai agents are shipping fast",
61 *,
62 created_at: str | None | object = "auto",
63 likes: int = 10,
64 **extra,
65 ) -> dict:
66 day = _day(_OFFSETS[n % len(_OFFSETS)])
67 pid = _snowflake(day, hour=8 + n, minute=(n * 17) % 60, low=(n + 1) * 104729)
68 row = {
69 "id": pid,
70 "author_handle": handle,
71 "created_at": f"{day}T{8 + n:02d}:{(n * 17) % 60:02d}:00Z" if created_at == "auto" else created_at,
72 "text": text,
73 "likes": likes,
74 "reposts": 1,
75 "replies": 0,
76 "quotes": 0,
77 }
78 if created_at is None:
79 row.pop("created_at")
80 row.update(extra)
81 return row
82
83
84 def _call(lane: str = "topic", handles=(), posts=()) -> dict:
85 return {"lane": lane, "handles": list(handles), "posts": list(posts)}
86
87
88 def _envelope(calls, **over) -> dict:
89 payload = {
90 "schema": x_envelope.SCHEMA,
91 "generated_at": datetime.now(timezone.utc).isoformat(),
92 "topic": TOPIC,
93 "window": {"from": FROM, "to": TO},
94 "provider": "x-connector",
95 "status": "ok",
96 "calls": list(calls),
97 }
98 payload.update(over)
99 return payload
100
101
102 def _write(tmp_path: Path, payload, name: str = "posts.json") -> str:
103 path = tmp_path / name
104 text = payload if isinstance(payload, str) else json.dumps(payload)
105 path.write_text(text, encoding="utf-8")
106 return str(path)
107
108
109 def _read(path: str, *, topic: str = TOPIC, handles=(SUBJECT,), related=(), window=(FROM, TO)):
110 return x_envelope.read(path, window, topic, handles=list(handles), related=list(related))
111
112
113 def _basic(tmp_path: Path, **over) -> str:
114 """Topic call with two rows plus a from call with one subject row."""
115 return _write(tmp_path, _envelope([
116 _call("topic", posts=[_row(0, "alice", "ai agents review"), _row(1, "bob", "agents everywhere")]),
117 _call("from", handles=[SUBJECT], posts=[_row(2, SUBJECT, "shipping my agent today")]),
118 ], **over))
119
120
121 def _plan(sources=("x",)) -> dict:
122 return {
123 "intent": "general",
124 "freshness_mode": "balanced_recent",
125 "cluster_mode": "story",
126 "subqueries": [{
127 "label": "primary",
128 "search_query": TOPIC,
129 "ranking_query": f"What are people saying about {TOPIC}?",
130 "sources": list(sources),
131 }],
132 "source_weights": {s: 1.0 for s in sources},
133 }
134
135
136 def _two_x_plan() -> dict:
137 plan = _plan()
138 plan["subqueries"].append({
139 "label": "angle",
140 "search_query": "agent frameworks",
141 "ranking_query": "Which agent frameworks are people using?",
142 "sources": ["x"],
143 })
144 return plan
145
146
147 @contextlib.contextmanager
148 def _no_backend():
149 with mock.patch("lib.env.x_backend_chain", return_value=[]), \
150 mock.patch(
151 "lib.pipeline._fetch_x_backend",
152 side_effect=AssertionError("no X backend may be called on an envelope run"),
153 ) as fetch:
154 yield fetch
155
156
157 def _run(envelope, *, config=None, x_handle=None, x_related=None, depth="default",
158 plan=None, requested=("x",), topic=TOPIC, **kwargs):
159 with _no_backend():
160 return pipeline.run(
161 topic=topic, config=dict(config or {}), depth=depth,
162 requested_sources=list(requested) if requested is not None else None,
163 mock=False, x_handle=x_handle, x_related=x_related,
164 external_plan=plan or _plan(), x_posts=envelope,
165 web_backend="none", save_dir="", **kwargs,
166 )
167
168
169 def _capture(fn):
170 err = io.StringIO()
171 with redirect_stderr(err):
172 result = fn()
173 return result, err.getvalue()
174
175
176 # ---------------------------------------------------------------------------
177 # Ingestion: happy path and row trust (R9, R10, AE5, AE6, AE6a)
178 # ---------------------------------------------------------------------------
179
180
181 class TestIngestion:
182 def test_valid_envelope_serves_topic_and_from_lanes_without_a_backend(self, tmp_path):
183 envelope, stderr = _capture(lambda: _read(_basic(tmp_path)))
184 assert envelope.accepted == 3 and envelope.total == 3
185 assert envelope.status == "ok"
186 assert [c.lane for c in envelope.lane_calls] == ["from"]
187 assert len(envelope.topic_items) == 2
188 assert "host-fetched X: accepted 3 of 3" in stderr
189 assert "lanes: topic 2, from 1, mention 0, related 0" in stderr
190
191 with _no_backend() as fetch:
192 report = pipeline.run(
193 topic=TOPIC, config={}, depth="default", requested_sources=["x"],
194 mock=False, x_handle=SUBJECT, external_plan=_plan(), x_posts=envelope,
195 web_backend="none", save_dir="",
196 )
197 fetch.assert_not_called()
198 urls = {item.url for item in report.items_by_source["x"]}
199 assert len(urls) == 3
200 assert all(u.startswith("https://x.com/") for u in urls)
201 by_author = {item.author: item for item in report.items_by_source["x"]}
202 assert SUBJECT in by_author, "from-lane row must land under x"
203 assert report.source_status["x"].state == health.OK
204 assert "x" not in report.errors_by_source
205
206 def test_lane_calls_are_served_on_quick_runs(self, tmp_path):
207 """The host already paid for the lanes: --quick must not drop them."""
208 envelope = _read(_basic(tmp_path))
209 report = _run(envelope, x_handle=SUBJECT, depth="quick")
210 by_author = {item.author: item for item in report.items_by_source["x"]}
211 assert SUBJECT in by_author, "from-lane row must land under x on a quick run"
212 assert len(report.items_by_source["x"]) == 3
213
214 def test_mention_lane_rows_reach_the_report(self, tmp_path):
215 envelope = _read(_write(tmp_path, _envelope([
216 _call("topic", posts=[_row(0, "alice", "ai agents review")]),
217 _call("mention", handles=[SUBJECT], posts=[_row(4, "fan", f"@{SUBJECT} love the agent work")]),
218 ])))
219 assert envelope.lane_counts["mention"] == 1
220 report = _run(envelope, x_handle=SUBJECT)
221 by_author = {item.author: item for item in report.items_by_source["x"]}
222 assert "fan" in by_author, "mention-lane row must land under x"
223 assert len(report.items_by_source["x"]) == 2
224
225 def test_from_row_gets_first_party_handling(self, tmp_path):
226 # A subject post that never repeats the topic must survive the
227 # relevance floor: that is what first-party handling means.
228 path = _write(tmp_path, _envelope([
229 _call("topic", posts=[_row(0, "alice", "ai agents review")]),
230 _call("from", handles=[SUBJECT], posts=[_row(2, SUBJECT, "lunch was great")]),
231 ]))
232 envelope = _read(path)
233 report = _run(envelope, x_handle=SUBJECT)
234 authors = {item.author for item in report.items_by_source["x"]}
235 assert SUBJECT in authors
236
237 def test_created_at_disagreeing_with_snowflake_is_date_mismatch(self, tmp_path):
238 # created_at inside the window; the snowflake decodes two months earlier.
239 early = (date.fromisoformat(FROM) - timedelta(days=60)).isoformat()
240 forged = _row(0, "alice")
241 forged["id"] = _snowflake(early, low=99)
242 path = _write(tmp_path, _envelope([_call("topic", posts=[forged, _row(1, "bob")])]))
243 envelope = _read(path)
244 assert envelope.counters["date-mismatch"] == 1
245 assert envelope.accepted == 1
246
247 def test_off_domain_url_is_ignored_and_citation_rebuilt(self, tmp_path):
248 row = _row(0, "alice", url="https://evil.example/alice/status/1")
249 envelope = _read(_write(tmp_path, _envelope([_call("topic", posts=[row])])))
250 assert envelope.accepted == 1
251 item = envelope.topic_items[0]
252 assert item["url"] == f"https://x.com/i/status/{row['id']}"
253 assert "evil.example" not in json.dumps(envelope.topic_items)
254
255 def test_spoofed_subject_handle_with_off_domain_url_is_not_attributed(self, tmp_path):
256 """AE6a: the row is kept, its citation is rebuilt from the id, the
257 subject attribution is dropped, and handle-mismatch is counted."""
258 row = _row(0, SUBJECT, "totally the subject", url="https://evil.example/x")
259 envelope = _read(_write(tmp_path, _envelope([_call("topic", posts=[row])])), handles=[SUBJECT])
260 assert envelope.counters["handle-mismatch"] == 1
261 item = envelope.topic_items[0]
262 assert item["author_handle"] == ""
263 assert item["url"] == f"https://x.com/i/status/{row['id']}"
264
265 def test_url_handle_or_id_disagreement_is_handle_mismatch(self, tmp_path):
266 row = _row(0, SUBJECT)
267 row["url"] = f"https://x.com/someoneelse/status/{row['id']}"
268 other = _row(1, "bob")
269 other["url"] = "https://x.com/bob/status/1234567890"
270 envelope = _read(_write(tmp_path, _envelope([_call("topic", posts=[row, other])])))
271 assert envelope.counters["handle-mismatch"] == 2
272 assert all(item["author_handle"] == "" for item in envelope.topic_items)
273
274 def test_future_snowflake_is_dropped(self, tmp_path):
275 future = (date.today() + timedelta(days=40)).isoformat()
276 row = _row(0, "alice", created_at=None)
277 row["id"] = _snowflake(future)
278 envelope = _read(_write(tmp_path, _envelope([_call("topic", posts=[row, _row(1, "bob")])])))
279 assert envelope.accepted == 1
280 assert envelope.counters["out-of-window"] == 1
281
282 def test_uniform_id_sequence_rejects_the_whole_envelope(self, tmp_path):
283 base = int(_snowflake(_day(10)))
284 step = 1 << 30
285 rows = []
286 for i in range(5):
287 row = _row(i, "alice", f"post {i}", created_at=None)
288 row["id"] = str(base + i * step)
289 rows.append(row)
290 envelope = _read(_write(tmp_path, _envelope([_call("topic", posts=rows)])))
291 assert envelope.status == "error"
292 assert envelope.accepted == 0 and envelope.topic_items == []
293 report = _run(envelope)
294 assert report.source_status["x"].state == health.ERROR
295 assert "generated" in (report.source_status["x"].detail or "")
296
297 def test_duplicate_ids_across_calls_first_occurrence_wins(self, tmp_path):
298 first = _row(0, "alice", "first copy")
299 second = dict(first, text="second copy", author_handle="bob")
300 envelope = _read(_write(tmp_path, _envelope([
301 _call("topic", posts=[first]),
302 _call("topic", posts=[second]),
303 ])))
304 assert envelope.counters["duplicate"] == 1
305 assert envelope.accepted == 1
306 assert envelope.topic_items[0]["text"] == "first copy"
307
308 def test_missing_created_at_derives_date_from_snowflake(self, tmp_path):
309 row = _row(0, "alice", created_at=None)
310 envelope = _read(_write(tmp_path, _envelope([_call("topic", posts=[row])])))
311 assert envelope.accepted == 1
312 assert envelope.topic_items[0]["date"] == _day(_OFFSETS[0])
313
314 def test_unparseable_created_at_is_ignored(self, tmp_path):
315 row = _row(0, "alice", created_at="yesterday-ish")
316 envelope = _read(_write(tmp_path, _envelope([_call("topic", posts=[row])])))
317 assert envelope.accepted == 1
318 assert envelope.topic_items[0]["date"] == _day(_OFFSETS[0])
319
320 def test_row_outside_engine_window_is_dropped(self, tmp_path):
321 old = (date.fromisoformat(FROM) - timedelta(days=5)).isoformat()
322 row = _row(0, "alice", created_at=f"{old}T10:00:00Z")
323 row["id"] = _snowflake(old, hour=10)
324 envelope = _read(_write(tmp_path, _envelope([_call("topic", posts=[row])])))
325 assert envelope.accepted == 0
326 assert envelope.counters["out-of-window"] == 1
327
328 def test_missing_id_or_text_is_dropped(self, tmp_path):
329 no_id = _row(0, "alice")
330 no_id.pop("id")
331 no_text = _row(1, "bob", text=" ")
332 non_numeric = _row(2, "carol")
333 non_numeric["id"] = "abc123"
334 envelope = _read(_write(tmp_path, _envelope([_call("topic", posts=[no_id, no_text, non_numeric])])))
335 assert envelope.accepted == 0
336 assert envelope.counters["missing-id-text"] == 3
337
338 def test_missing_username_keeps_row_with_i_status_url(self, tmp_path):
339 row = _row(0, "")
340 placeholder = _row(1, "unknown")
341 envelope = _read(_write(tmp_path, _envelope([_call("topic", posts=[row, placeholder])])))
342 assert envelope.accepted == 2
343 assert envelope.counters["uncitable"] == 2
344 urls = {item["url"] for item in envelope.topic_items}
345 assert urls == {
346 f"https://x.com/i/status/{row['id']}",
347 f"https://x.com/i/status/{placeholder['id']}",
348 }
349
350 def test_handle_with_embedded_newline_is_rejected(self, tmp_path):
351 row = _row(0, "stei\npete")
352 envelope = _read(_write(tmp_path, _envelope([_call("topic", posts=[row])])))
353 assert envelope.topic_items[0]["author_handle"] == ""
354 assert envelope.counters["uncitable"] == 1
355
356 def test_control_characters_are_stripped_but_newlines_in_text_survive(self, tmp_path):
357 row = _row(0, "alice", text="line one\x07\x1b[31m\nline two…")
358 envelope = _read(_write(tmp_path, _envelope([_call("topic", posts=[row])])))
359 assert envelope.topic_items[0]["text"] == "line one[31m\nline two"
360
361 def test_markdown_link_tail_and_comment_opener_are_defanged(self, tmp_path):
362 row = _row(0, "alice", text="read [this](https://evil.example) <!-- hidden --> ok")
363 envelope = _read(_write(tmp_path, _envelope([_call("topic", posts=[row])])))
364 text = envelope.topic_items[0]["text"]
365 assert "](" not in text and "<!--" not in text
366 assert "evil.example" in text, "the characters stay visible as evidence"
367
368 def test_text_over_cap_is_truncated_and_counted(self, tmp_path):
369 row = _row(0, "alice", text="x" * (x_envelope.MAX_TEXT_CHARS + 50))
370 envelope = _read(_write(tmp_path, _envelope([_call("topic", posts=[row])])))
371 assert envelope.counters["truncated"] == 1
372 assert len(envelope.topic_items[0]["text"]) == x_envelope.MAX_TEXT_CHARS
373
374 def test_extra_keys_are_ignored_and_counted_once(self, tmp_path):
375 row = _row(0, "alice", public_metrics={"like_count": 5}, entities={}, edit_history_tweet_ids=["1"])
376 envelope = _read(_write(tmp_path, _envelope([_call("topic", posts=[row])])))
377 assert envelope.accepted == 1
378 assert envelope.counters["extra-fields"] == 1
379 item = envelope.topic_items[0]
380 assert item["engagement"]["likes"] == 10
381 assert "public_metrics" not in item
382
383 def test_rows_normalize_through_the_x_item_shape(self, tmp_path):
384 row = _row(0, "alice", "@bob @carol thoughts on ai agents?", likes=7)
385 envelope = _read(_write(tmp_path, _envelope([_call("topic", posts=[row])])))
386 item = envelope.topic_items[0]
387 assert item["id"].startswith("XHOST")
388 assert item["post_id"] == row["id"]
389 assert item["engagement"] == {"likes": 7, "reposts": 1, "replies": 0, "quotes": 0}
390 assert item["mentioned_handles"] == ["bob", "carol"]
391 assert item["url"] == f"https://x.com/alice/status/{row['id']}"
392 normalized = pipeline._normalize_score_dedupe(
393 "x", [item], FROM, TO, freshness_mode="balanced_recent", ranking_query=TOPIC,
394 )
395 assert normalized and normalized[0].author == "alice"
396 assert normalized[0].url == item["url"]
397
398 def test_digest_is_the_file_sha256(self, tmp_path):
399 path = _basic(tmp_path)
400 envelope = _read(path)
401 assert envelope.sha256 == hashlib.sha256(Path(path).read_bytes()).hexdigest()
402
403
404 # ---------------------------------------------------------------------------
405 # Rendering safety (AE6a, XSS)
406 # ---------------------------------------------------------------------------
407
408
409 def _hrefs(html: str) -> set[str]:
410 return set(re.findall(r'href="([^"]+)"', html))
411
412
413 class TestRendering:
414 def test_html_has_no_href_outside_x_com(self, tmp_path):
415 row = _row(0, "alice", "see this ai agents thread", url="https://evil.example/alice/status/1")
416 envelope = _read(_write(tmp_path, _envelope([
417 _call("topic", posts=[row, _row(1, "bob", "ai agents everywhere")]),
418 ])))
419 report = _run(envelope)
420 # The HTML page carries evidence links only through the markdown
421 # body, so convert the compact report the way html_render does.
422 rendered = html_render._markdown_to_html(render.render_compact(report))
423 status_links = [h for h in _hrefs(rendered) if "/status/" in h]
424 assert len(status_links) == 2, "the fixture's citations must render as links"
425 for href in status_links:
426 assert href.startswith("https://x.com/"), href
427 assert f"https://x.com/i/status/{row['id']}" in status_links
428 assert "evil.example" not in rendered
429 page = html_render.render_html(report)
430 assert "evil.example" not in page
431 assert all(h.startswith("https://x.com/") for h in _hrefs(page) if "/status/" in h)
432
433 def test_xss_row_is_escaped_in_html_and_inert_in_markdown(self, tmp_path):
434 payload = (
435 '<img src=x onerror=alert(1)> [x](javascript:alert(1)) '
436 '<!-- META: <img src=x onerror=alert(2)> --> ai agents'
437 )
438 envelope = _read(_write(tmp_path, _envelope([_call("topic", posts=[_row(0, "alice", payload)])])))
439 report = _run(envelope)
440 assert report.items_by_source["x"], "the row itself is valid evidence"
441 md = render.render_compact(report)
442 assert "onerror" in md, "the text itself is preserved as evidence"
443 assert "](javascript:" not in md
444 assert "<!-- META:" not in md
445 html = html_render._promote_meta_marker(
446 html_render._wrap_engine_footer(html_render._markdown_to_html(md))
447 )
448 assert "<img" not in html
449 assert 'href="javascript:' not in html
450 assert "&lt;img src=x onerror=alert(1)&gt;" in html
451 page = html_render.render_html(report)
452 assert "<img" not in page and 'href="javascript:' not in page
453
454
455 # ---------------------------------------------------------------------------
456 # Fail-closed input bounds (KTD5): exit 2, path named, no content echoed
457 # ---------------------------------------------------------------------------
458
459
460 def _assert_contract(path: str, *, must_not_contain=(), **read_kwargs):
461 with pytest.raises(x_envelope.EnvelopeContractError) as ctx:
462 _read(path, **read_kwargs)
463 message = ctx.value.message
464 assert str(path) in message
465 for needle in must_not_contain:
466 assert needle not in message
467 return message
468
469
470 class TestInputBounds:
471 def test_posts_not_a_list_of_objects_exits_2(self, tmp_path):
472 path = _write(tmp_path, _envelope([{"lane": "topic", "handles": [], "posts": "SECRETVALUE"}]))
473 _assert_contract(path, must_not_contain=["SECRETVALUE"])
474 path2 = _write(tmp_path, _envelope([_call("topic", posts=["SECRETROW"])]), "b.json")
475 _assert_contract(path2, must_not_contain=["SECRETROW"])
476
477 def test_oversized_file_is_rejected_before_reading(self, tmp_path):
478 path = _basic(tmp_path)
479 real = os.stat(path)
480
481 class _Big:
482 st_mode = real.st_mode
483 st_size = x_envelope.MAX_BYTES + 1
484
485 with mock.patch.object(x_envelope, "_stat", return_value=_Big()), \
486 mock.patch.object(Path, "read_bytes", side_effect=AssertionError("must not read")):
487 _assert_contract(path)
488
489 def test_deeply_nested_json_is_rejected(self, tmp_path):
490 path = _write(tmp_path, "[" * 200000 + "]" * 200000)
491 message = _assert_contract(path)
492 assert "[[[" not in message
493
494 def test_fifo_is_rejected_without_opening(self, tmp_path):
495 fifo = tmp_path / "pipe.json"
496 os.mkfifo(fifo)
497 with mock.patch("builtins.open", side_effect=AssertionError("must not open a FIFO")):
498 _assert_contract(str(fifo))
499
500 def test_non_utf8_is_rejected_without_echo(self, tmp_path):
501 path = tmp_path / "bad.json"
502 path.write_bytes(b'{"schema": "x", "secret": "SECRETVALUE"' + b"\xff\xfe" + b"}")
503 _assert_contract(str(path), must_not_contain=["SECRETVALUE"])
504
505 def test_env_shaped_input_is_rejected(self, tmp_path):
506 path = tmp_path / ".env"
507 path.write_text("X_BEARER_TOKEN=SECRETVALUE\n", encoding="utf-8")
508 _assert_contract(str(path), must_not_contain=["SECRETVALUE"])
509 as_json = tmp_path / "config.json"
510 as_json.write_text('{"X_BEARER_TOKEN": "SECRETVALUE"}', encoding="utf-8")
511 _assert_contract(str(as_json), must_not_contain=["SECRETVALUE"])
512
513 def test_config_dir_and_credential_stores_are_rejected(self, tmp_path, monkeypatch):
514 home = tmp_path / "home"
515 cfg = tmp_path / "cfg"
516 for directory in (home / ".grok", home / ".xurl", cfg):
517 directory.mkdir(parents=True)
518 monkeypatch.setenv("HOME", str(home))
519 monkeypatch.setattr(env, "CONFIG_DIR", cfg)
520 monkeypatch.setattr(env, "CONFIG_FILE", cfg / ".env")
521 for target in (home / ".grok" / "auth.json", home / ".xurl" / "tokens.json", cfg / "posts.json"):
522 target.write_text(json.dumps({"token": "SECRETVALUE"}), encoding="utf-8")
523 _assert_contract(str(target), must_not_contain=["SECRETVALUE"])
524 # A symlink into a store is resolved before the check.
525 link = tmp_path / "link.json"
526 link.symlink_to(home / ".grok" / "auth.json")
527 _assert_contract(str(link), must_not_contain=["SECRETVALUE"])
528
529 def test_wrong_suffix_directory_and_missing_file_are_rejected(self, tmp_path):
530 _assert_contract(_write(tmp_path, _envelope([]), "posts.txt"))
531 _assert_contract(str(tmp_path))
532 _assert_contract(str(tmp_path / "missing.json"))
533
534 def test_call_and_row_caps_are_enforced(self, tmp_path):
535 too_many_calls = _envelope([_call("topic", posts=[_row(0)]) for _ in range(x_envelope.MAX_CALLS + 1)])
536 _assert_contract(_write(tmp_path, too_many_calls, "calls.json"))
537 big_call = _envelope([_call("topic", posts=[_row(i % 6) for i in range(x_envelope.MAX_ROWS_PER_CALL + 1)])])
538 _assert_contract(_write(tmp_path, big_call, "rows.json"))
539 total = _envelope([
540 _call("topic", posts=[_row(i % 6) for i in range(x_envelope.MAX_ROWS_PER_CALL)])
541 for _ in range(3)
542 ])
543 _assert_contract(_write(tmp_path, total, "total.json"))
544
545
546 class TestMalformedEnvelope:
547 @pytest.mark.parametrize("mutation", [
548 {"schema": None},
549 {"schema": "last30days-x-posts/2"},
550 {"status": "great"},
551 {"calls": {"lane": "topic"}},
552 {"calls": []},
553 {"generated_at": None},
554 {"window": None},
555 {"topic": None},
556 ])
557 def test_malformed_exits_2_with_two_fix_remedy(self, tmp_path, mutation):
558 payload = _envelope([_call("topic", posts=[_row(0)])])
559 for key, value in mutation.items():
560 if value is None:
561 payload.pop(key)
562 else:
563 payload[key] = value
564 path = _write(tmp_path, payload)
565 message = _assert_contract(path, must_not_contain=["great", "last30days-x-posts/2"])
566 assert "rewrite" in message.lower()
567 assert "--x-posts" in message
568
569 def test_not_json_and_not_an_object_exit_2(self, tmp_path):
570 _assert_contract(_write(tmp_path, "{not json SECRETVALUE", "a.json"), must_not_contain=["SECRETVALUE"])
571 _assert_contract(_write(tmp_path, '["SECRETVALUE"]', "b.json"), must_not_contain=["SECRETVALUE"])
572
573 def test_future_generated_at_exits_2(self, tmp_path):
574 """A stamp ahead of the clock must not outlive the freshness gate."""
575 ahead = (datetime.now(timezone.utc) + timedelta(hours=2)).isoformat()
576 message = _assert_contract(_write(tmp_path, _envelope([_call("topic", posts=[_row(0)])], generated_at=ahead)))
577 assert "future" in message and ahead not in message
578 skew = (datetime.now(timezone.utc) + timedelta(minutes=2)).isoformat()
579 assert _read(_write(tmp_path, _envelope([_call("topic", posts=[_row(0)])], generated_at=skew), "ok.json")).accepted == 1
580
581 def test_stale_generated_at_exits_2(self, tmp_path):
582 stale = (datetime.now(timezone.utc) - timedelta(hours=7)).isoformat()
583 message = _assert_contract(_write(tmp_path, _envelope([_call("topic", posts=[_row(0)])], generated_at=stale)))
584 assert stale not in message
585 fresh = (datetime.now(timezone.utc) - timedelta(hours=5)).isoformat()
586 assert _read(_write(tmp_path, _envelope([_call("topic", posts=[_row(0)])], generated_at=fresh), "ok.json")).accepted == 1
587
588 def test_mismatched_topic_exits_2(self, tmp_path):
589 message = _assert_contract(
590 _write(tmp_path, _envelope([_call("topic", posts=[_row(0)])], topic="rust async SECRETTOPIC")),
591 must_not_contain=["SECRETTOPIC"],
592 )
593 assert "topic" in message
594 # Normalization: case and whitespace do not count as a mismatch.
595 assert _read(_write(tmp_path, _envelope([_call("topic", posts=[_row(0)])], topic=" AI Agents "), "ok.json")).accepted == 1
596
597 def test_window_entirely_before_engine_window_exits_2(self, tmp_path):
598 early_from = (date.fromisoformat(FROM) - timedelta(days=40)).isoformat()
599 early_to = (date.fromisoformat(FROM) - timedelta(days=10)).isoformat()
600 payload = _envelope([_call("topic", posts=[_row(0)])], window={"from": early_from, "to": early_to})
601 _assert_contract(_write(tmp_path, payload))
602
603 def test_narrower_window_is_a_receipt_warning_not_a_failure(self, tmp_path):
604 narrow_from = (date.fromisoformat(FROM) + timedelta(days=10)).isoformat()
605 payload = _envelope([_call("topic", posts=[_row(0)])], window={"from": narrow_from, "to": TO})
606 envelope, stderr = _capture(lambda: _read(_write(tmp_path, payload)))
607 assert envelope.accepted == 1
608 assert envelope.warnings and "window" in envelope.warnings[0]
609 assert "window" in stderr
610 report = _run(envelope)
611 assert any("window" in w for w in report.warnings)
612
613
614 # ---------------------------------------------------------------------------
615 # Envelope status classes (AE6b, R11)
616 # ---------------------------------------------------------------------------
617
618
619 class TestStatusOutcomes:
620 def test_error_with_account_identifiers_is_payment_required_with_fixed_detail(self, tmp_path):
621 payload = _envelope([], status="error", error=f"credits exhausted for account {ACCOUNT_ID}")
622 envelope, stderr = _capture(lambda: _read(_write(tmp_path, payload)))
623 assert ACCOUNT_ID not in stderr
624 report, run_stderr = _capture(lambda: _run(envelope))
625 outcome = report.source_status["x"]
626 assert outcome.state == schema.PAYMENT_REQUIRED
627 assert outcome.detail == x_envelope.DETAIL_CREDITS
628 assert ACCOUNT_ID not in run_stderr
629 for rendered in (
630 render.render_compact(report),
631 html_render.render_html(report),
632 json.dumps(schema.to_dict(report)),
633 json.dumps(schema.to_agent_export(report)),
634 render.render_context(report),
635 render.render_brief(report),
636 ):
637 assert ACCOUNT_ID not in rendered
638
639 @pytest.mark.parametrize("category,state,detail", [
640 ("not-connected", health.ERROR, x_envelope.DETAIL_NOT_CONNECTED),
641 ("unavailable", health.ERROR, x_envelope.DETAIL_UNAVAILABLE),
642 ("something-weird", health.ERROR, x_envelope.DETAIL_ERROR),
643 ("credits", schema.PAYMENT_REQUIRED, x_envelope.DETAIL_CREDITS),
644 ])
645 def test_error_categories_map_to_fixed_details(self, tmp_path, category, state, detail):
646 envelope = _read(_write(tmp_path, _envelope([], status="error", error=category)))
647 report = _run(envelope)
648 assert report.source_status["x"].state == state
649 assert report.source_status["x"].detail == detail
650
651 def test_raw_error_text_reaches_stderr_only_under_debug(self, tmp_path, monkeypatch):
652 payload = _envelope([], status="error", error=f"unavailable {ACCOUNT_ID}")
653 monkeypatch.delenv("LAST30DAYS_DEBUG", raising=False)
654 _, quiet = _capture(lambda: _read(_write(tmp_path, payload)))
655 assert ACCOUNT_ID not in quiet
656 monkeypatch.setenv("LAST30DAYS_DEBUG", "1")
657 _, loud = _capture(lambda: _read(_write(tmp_path, payload, "b.json")))
658 assert ACCOUNT_ID in loud
659
660 def test_ok_with_zero_rows_is_no_results_and_omission_note_does_not_fire(self, tmp_path):
661 envelope = _read(_write(tmp_path, _envelope([_call("topic", posts=[])])))
662 report = _run(envelope)
663 assert report.source_status["x"].state == schema.NO_RESULTS
664 assert "x" not in report.errors_by_source
665 diag = pipeline.diagnose({}, None, safe=True, x_envelope=True)
666 assert "x" in diag["available_sources"]
667 assert cli._optional_x_omission_text(diag, None) is None
668
669 def test_partial_with_window_unsupported_names_the_unwindowed_call(self, tmp_path):
670 payload = _envelope([
671 _call("topic", posts=[_row(0, "alice", "ai agents review from alice")]),
672 _call("from", handles=[SUBJECT], posts=[_row(2, SUBJECT, "shipping my agent today")]),
673 ], status="partial", error="window-unsupported")
674 envelope, stderr = _capture(lambda: _read(_write(tmp_path, payload)))
675 assert "window-unsupported" in stderr
676 report = _run(envelope, x_handle=SUBJECT)
677 outcome = report.source_status["x"]
678 assert outcome.state == schema.PARTIAL
679 assert "window-unsupported" in (outcome.detail or "")
680 assert "topic" in outcome.detail and "from" in outcome.detail
681 assert len(report.items_by_source["x"]) == 2
682
683
684 # ---------------------------------------------------------------------------
685 # Lane metadata (R11)
686 # ---------------------------------------------------------------------------
687
688
689 class TestLanes:
690 def test_from_row_by_foreign_author_is_lane_mismatch(self, tmp_path):
691 envelope = _read(_write(tmp_path, _envelope([
692 _call("from", handles=[SUBJECT], posts=[_row(0, "impostor", "hi"), _row(2, SUBJECT, "mine")]),
693 ])))
694 assert envelope.counters["lane-mismatch"] == 1
695 assert envelope.lane_counts["from"] == 1
696
697 def test_related_lane_claiming_the_primary_handle_is_served_as_topic(self, tmp_path):
698 """The related lane is narrowed to --x-related: a --x-handle handle is
699 allowed for from/mention but not for related."""
700 envelope = _read(
701 _write(tmp_path, _envelope([_call("related", handles=[SUBJECT], posts=[_row(0, SUBJECT, "x")])])),
702 handles=[SUBJECT], related=[RELATED],
703 )
704 assert envelope.lane_counts["related"] == 0
705 assert envelope.lane_counts["topic"] == 1
706 assert envelope.counters["lane-mismatch"] == 1
707 ok = _read(
708 _write(tmp_path, _envelope([_call("related", handles=[RELATED], posts=[_row(0, RELATED, "x")])]), "ok.json"),
709 handles=[SUBJECT], related=[RELATED],
710 )
711 assert ok.lane_counts["related"] == 1
712
713 def test_related_handle_absent_from_x_related_is_served_as_topic(self, tmp_path):
714 envelope, stderr = _capture(lambda: _read(
715 _write(tmp_path, _envelope([_call("related", handles=["stranger"], posts=[_row(0, "stranger", "x")])])),
716 handles=[SUBJECT], related=[RELATED],
717 ))
718 assert envelope.lane_counts["related"] == 0
719 assert envelope.lane_counts["topic"] == 1
720 assert envelope.counters["lane-mismatch"] == 1
721 assert "topic" in stderr and "stranger" not in stderr
722
723 def test_handle_outside_grammar_demotes_the_call(self, tmp_path):
724 envelope = _read(
725 _write(tmp_path, _envelope([_call("from", handles=["bad handle!"], posts=[_row(0, SUBJECT, "x")])])),
726 handles=[SUBJECT],
727 )
728 assert envelope.lane_counts["topic"] == 1
729 assert not envelope.lane_calls
730
731 def test_mention_lane_drops_the_subjects_own_post(self, tmp_path):
732 envelope = _read(_write(tmp_path, _envelope([
733 _call("mention", handles=[SUBJECT], posts=[
734 _row(0, SUBJECT, f"@{SUBJECT} talking to myself"),
735 _row(1, "fan", f"@{SUBJECT} love the work"),
736 ]),
737 ])))
738 assert envelope.lane_counts["mention"] == 1
739 assert envelope.counters["lane-mismatch"] == 1
740
741 def test_related_rows_get_the_related_weight_and_first_party(self, tmp_path):
742 envelope = _read(_write(tmp_path, _envelope([
743 _call("topic", posts=[_row(0, "alice", "ai agents review")]),
744 _call("related", handles=[RELATED], posts=[_row(3, RELATED, "peer news")]),
745 ])), handles=[SUBJECT], related=[RELATED])
746 report = _run(envelope, x_handle=SUBJECT, x_related=[RELATED])
747 related_sq = [sq for sq in report.query_plan.subqueries if sq.label == "supplemental-related"]
748 assert related_sq and related_sq[0].weight == 0.3
749 assert RELATED in {item.author for item in report.items_by_source["x"]}
750
751 def test_from_lane_respects_per_handle_count(self, tmp_path):
752 subjects = (
753 "shipping the agent runtime", "benchmarks for tool calling", "memory layer rewrite",
754 "why evals matter", "latency budget notes", "open sourcing the planner",
755 "hiring for infra", "conference talk recap", "pricing update", "roadmap thread",
756 )
757 rows = [_row(i, SUBJECT, subjects[i]) for i in range(pipeline.FROM_LANE_COUNT_PER + 2)]
758 # Vary ids beyond the offset cycle so the sequence never reads as generated.
759 for i, row in enumerate(rows):
760 row["id"] = _snowflake(_day(1 + (i * 3) % 25), hour=i % 24, minute=(i * 13) % 60, low=i * 7919)
761 row["created_at"] = None
762 row.pop("created_at")
763 envelope = _read(_write(tmp_path, _envelope([_call("from", handles=[SUBJECT], posts=rows)])))
764 assert envelope.accepted == len(rows)
765 report = _run(envelope, x_handle=SUBJECT)
766 assert len(report.items_by_source["x"]) == pipeline.FROM_LANE_COUNT_PER
767
768 def test_extracted_handle_promotion_is_skipped(self, tmp_path):
769 envelope = _read(_basic(tmp_path))
770 with mock.patch("lib.x_judge.promotable_handles", side_effect=AssertionError("no promotion")):
771 report = _run(envelope, x_handle=SUBJECT)
772 assert report.items_by_source["x"]
773
774
775 # ---------------------------------------------------------------------------
776 # Single-serve and pipeline wiring (KTD5, KTD11, R12)
777 # ---------------------------------------------------------------------------
778
779
780 class TestPipelineWiring:
781 def test_second_planner_x_subquery_and_thin_retry_get_nothing(self, tmp_path):
782 envelope = _read(_write(tmp_path, _envelope([_call("topic", posts=[_row(0, "alice", "ai agents review")])])))
783 calls: list[str] = []
784 original = pipeline._retrieve_stream_impl
785
786 def spy(*args, **kwargs):
787 if kwargs.get("source") == "x":
788 calls.append(kwargs["subquery"].label)
789 return original(*args, **kwargs)
790
791 with mock.patch("lib.pipeline._retrieve_stream_impl", side_effect=spy):
792 report = _run(envelope, plan=_two_x_plan(), config={"_max_source_fetches": 5})
793 assert "primary" in calls and "angle" in calls and "retry" in calls
794 assert len(report.items_by_source["x"]) == 1
795 assert "x" not in report.errors_by_source
796 assert report.source_status["x"].state == health.OK
797
798 def test_available_sources_lists_x_for_envelope_without_backend(self):
799 with mock.patch("lib.env.x_backend_chain", return_value=[]), \
800 mock.patch("lib.env.x_pending_browser_auth", return_value=False):
801 assert "x" in pipeline.available_sources({}, None, x_envelope=True)
802 assert "x" not in pipeline.available_sources({}, None)
803
804 def test_envelope_without_lane_signal_and_no_backend_lands_under_x(self, tmp_path, monkeypatch):
805 monkeypatch.delenv("LAST30DAYS_X_HOST_LANE", raising=False)
806 envelope = _read(_basic(tmp_path))
807 report = _run(envelope, x_handle=SUBJECT, requested=None)
808 assert len(report.items_by_source["x"]) == 3
809 assert "x" in report.query_plan.source_weights or report.items_by_source["x"]
810
811 def test_lane_signal_without_envelope_records_not_passed(self):
812 config = {"LAST30DAYS_X_HOST_LANE": "1"}
813 with mock.patch("lib.env.x_backend_chain", return_value=[]), \
814 mock.patch("lib.env.x_pending_browser_auth", return_value=False):
815 assert "x" in pipeline.available_sources(config, None)
816 with _no_backend() as fetch:
817 report = pipeline.run(
818 topic=TOPIC, config=config, depth="default", requested_sources=["x"],
819 mock=False, external_plan=_plan(), web_backend="none", save_dir="",
820 )
821 fetch.assert_not_called()
822 assert report.source_status["x"].state == health.ERROR
823 assert report.source_status["x"].detail == x_envelope.DETAIL_NOT_PASSED
824
825 def test_lane_signal_with_bearer_still_records_not_passed(self):
826 config = {"LAST30DAYS_X_HOST_LANE": "1", "X_BEARER_TOKEN": "dummy-bearer"}
827 with mock.patch("lib.env.x_backend_chain", return_value=["xapi"]), \
828 mock.patch("lib.pipeline._fetch_x_backend", side_effect=AssertionError("must not fetch")), \
829 mock.patch("lib.x_api.search_handles", side_effect=AssertionError("no lanes")):
830 report = pipeline.run(
831 topic=TOPIC, config=config, depth="default", requested_sources=["x"],
832 mock=False, x_handle=SUBJECT, external_plan=_plan(), web_backend="none", save_dir="",
833 )
834 assert report.source_status["x"].state == health.ERROR
835 assert report.source_status["x"].detail == x_envelope.DETAIL_NOT_PASSED
836
837 def test_discovery_enrichment_pass_with_signal_records_nothing_for_x(self):
838 config = {"LAST30DAYS_X_HOST_LANE": "1"}
839 with mock.patch("lib.env.x_backend_chain", return_value=[]), \
840 mock.patch("lib.env.x_pending_browser_auth", return_value=False):
841 assert "x" not in pipeline.available_sources(config, None, suppress_x_host_lane=True)
842 with _no_backend(), mock.patch("lib.pipeline._retrieve_stream", return_value=([], {})):
843 report = pipeline.run(
844 topic=TOPIC, config=config, depth="default", requested_sources=None,
845 mock=False, external_plan=_plan(("reddit", "x")), web_backend="none", save_dir="",
846 internal_subrun=True, suppress_x_host_lane=True,
847 )
848 assert "x" not in report.errors_by_source
849 assert "x" not in report.source_status
850
851 def test_comparison_entity_pass_with_envelope_still_serves_x(self, tmp_path):
852 envelope = _read(_basic(tmp_path, topic="acme"), topic="acme")
853 report = _run(envelope, topic="acme", x_handle=SUBJECT, internal_subrun=True,
854 config={"LAST30DAYS_X_HOST_LANE": "1"})
855 assert len(report.items_by_source["x"]) == 3
856
857 def test_exclude_sources_x_ignores_the_envelope_with_a_receipt(self, tmp_path):
858 envelope = _read(_basic(tmp_path))
859 report, stderr = _capture(lambda: _run(
860 envelope, config={"EXCLUDE_SOURCES": "x"}, requested=None, plan=_plan(("reddit",)),
861 ))
862 assert "x" not in report.items_by_source
863 assert "envelope ignored" in stderr
864 assert envelope.topic_items, "an ignored envelope is not consumed"
865
866 def test_search_list_without_x_ignores_the_envelope(self, tmp_path):
867 envelope = _read(_basic(tmp_path))
868 with mock.patch("lib.pipeline._retrieve_stream", return_value=([], {})):
869 report, stderr = _capture(lambda: _run(envelope, requested=("reddit",), plan=_plan(("reddit",))))
870 assert "x" not in report.items_by_source
871 assert "envelope ignored" in stderr
872
873 def test_mixed_fixture_fuses_duplicate_url_into_one_candidate(self, tmp_path):
874 row = _row(0, "alice", "ai agents review")
875 envelope = _read(_write(tmp_path, _envelope([_call("topic", posts=[row])])))
876 url = f"https://x.com/alice/status/{row['id']}"
877 # Another source (HN) hands the engine the same post URL: fusion keys
878 # on the normalized URL, so the two copies become one candidate.
879 original = pipeline._retrieve_stream
880
881 def stream(*args, **kwargs):
882 if kwargs.get("source") == "hackernews":
883 return [{
884 "id": "hn1", "title": "ai agents review", "url": url,
885 "points": 40, "num_comments": 3, "date": _day(_OFFSETS[0]),
886 "author": "hnuser", "text": "ai agents review thread",
887 }], {}
888 return original(*args, **kwargs)
889
890 with mock.patch("lib.pipeline._retrieve_stream", side_effect=stream):
891 report = _run(envelope, plan=_plan(("x", "hackernews")), requested=("x", "hackernews"))
892 assert report.items_by_source["x"] and report.items_by_source["hackernews"]
893 candidates = [c for c in report.ranked_candidates if c.url == url]
894 assert len(candidates) == 1
895
896 def test_footer_provenance_reads_via_x_connector(self, tmp_path):
897 envelope = _read(_basic(tmp_path))
898 report = _run(envelope, x_handle=SUBJECT)
899 md = render.render_compact(report)
900 assert "via X connector" in md
901 assert "3 items" in md
902
903 def test_env_file_lane_line_without_process_env_leaves_x_absent(self, tmp_path, monkeypatch):
904 monkeypatch.delenv("LAST30DAYS_X_HOST_LANE", raising=False)
905 config_file = tmp_path / ".env"
906 config_file.write_text("LAST30DAYS_X_HOST_LANE=1\n", encoding="utf-8")
907 config_file.chmod(0o600)
908 monkeypatch.setenv("LAST30DAYS_CONFIG_DIR", str(tmp_path))
909 monkeypatch.setattr(env, "CONFIG_DIR", tmp_path)
910 monkeypatch.setattr(env, "CONFIG_FILE", config_file)
911 for key in ("LAST30DAYS_HOST", "X_BEARER_TOKEN", "LAST30DAYS_X_BACKEND", "XAI_API_KEY",
912 "AUTH_TOKEN", "CT0", "XQUIK_API_KEY", "FROM_BROWSER", "AGENTCOOKIE", "BROWSER_CDP_URL"):
913 monkeypatch.delenv(key, raising=False)
914 with mock.patch.object(env, "_load_keychain", return_value={}), \
915 mock.patch.object(env, "_load_pass", return_value={}), \
916 mock.patch.object(env, "_find_project_env", return_value=None):
917 config = env.get_config()
918 with mock.patch("lib.env.x_backend_chain", return_value=[]), \
919 mock.patch("lib.env.x_pending_browser_auth", return_value=False):
920 assert "x" not in pipeline.available_sources(config, None)
921
922 def test_diagnose_top_keys_unchanged(self):
923 from tests.test_diagnose_compat import DIAGNOSE_TOP_KEYS
924 with mock.patch("lib.env.x_backend_chain", return_value=[]):
925 payload = pipeline.diagnose({}, None, safe=True, x_envelope=True)
926 assert set(payload.keys()) == DIAGNOSE_TOP_KEYS
927 assert "x" in payload["available_sources"]
928
929
930 # ---------------------------------------------------------------------------
931 # CLI (R9, KTD5)
932 # ---------------------------------------------------------------------------
933
934
935 _DIAG = {
936 "available_sources": ["reddit", "x"],
937 "x_pending_browser_auth": False,
938 "native_search": False,
939 "bird_installed": False,
940 "bird_authenticated": False,
941 "bird_username": None,
942 "native_web_backend": None,
943 "safe": False,
944 }
945
946
947 def _cli(argv, tmp_path, *, config=None, run=None, environ=None, real_run=False):
948 out, err = io.StringIO(), io.StringIO()
949 cfg_dir = tmp_path / "cfg"
950 cfg_dir.mkdir(exist_ok=True)
951 stack = contextlib.ExitStack()
952 with stack:
953 stack.enter_context(mock.patch.object(cli.env, "get_config", return_value=dict(config or {})))
954 stack.enter_context(mock.patch.object(cli.env, "CONFIG_DIR", cfg_dir))
955 stack.enter_context(mock.patch.object(cli.env, "CONFIG_FILE", cfg_dir / ".env"))
956 stack.enter_context(mock.patch.object(cli.ui, "ProgressDisplay", return_value=mock.Mock()))
957 stack.enter_context(mock.patch.dict(
958 os.environ, {"LAST30DAYS_SKIP_PREFLIGHT": "1", **(environ or {})}, clear=False,
959 ))
960 stack.enter_context(mock.patch.object(sys, "argv", ["last30days.py", *argv]))
961 if real_run:
962 stack.enter_context(_no_backend())
963 else:
964 stack.enter_context(mock.patch.object(cli.pipeline, "diagnose", return_value=dict(_DIAG)))
965 stack.enter_context(mock.patch.object(
966 cli.pipeline, "run", side_effect=run or AssertionError("pipeline.run must not run"),
967 ))
968 stack.enter_context(redirect_stdout(out))
969 stack.enter_context(redirect_stderr(err))
970 rc = cli.main()
971 return rc, out.getvalue(), err.getvalue()
972
973
974 def _fake_run_capturing(store: dict):
975 def fake_run(**kwargs):
976 store.update(kwargs)
977 return _fake_report(kwargs["topic"])
978 return fake_run
979
980
981 def _fake_report(topic: str) -> schema.Report:
982 return schema.Report(
983 topic=topic, range_from=FROM, range_to=TO, generated_at="2026-09-08T00:00:00+00:00",
984 provider_runtime=schema.ProviderRuntime(reasoning_provider="local", planner_model="d", rerank_model="l"),
985 query_plan=schema.QueryPlan(intent="general", freshness_mode="balanced_recent", cluster_mode="story",
986 raw_topic=topic, subqueries=[], source_weights={}),
987 clusters=[], ranked_candidates=[], items_by_source={}, errors_by_source={},
988 )
989
990
991 class TestCli:
992 def test_flag_is_documented_in_argparse_help(self):
993 flags = {opt for action in cli.build_parser()._actions for opt in action.option_strings}
994 assert "--x-posts" in flags
995
996 def test_inline_json_exits_2(self, tmp_path):
997 rc, _, err = _cli([TOPIC, "--x-posts", '{"schema": "SECRETVALUE"}'], tmp_path)
998 assert rc == 2
999 assert "path" in err and "SECRETVALUE" not in err
1000
1001 def test_contract_error_exits_2_naming_the_path(self, tmp_path):
1002 path = _write(tmp_path, _envelope([_call("topic", posts=[_row(0)])], status="great"))
1003 rc, _, err = _cli([TOPIC, "--x-posts", path], tmp_path)
1004 assert rc == 2
1005 assert path in err and "great" not in err and "--x-posts" in err
1006
1007 def test_hosted_mode_with_flag_exits_2(self, tmp_path):
1008 path = _basic(tmp_path)
1009 with mock.patch.object(cli.env, "read_secret_env", return_value="hosted-test-key"), \
1010 mock.patch("lib.hosted.run_hosted", side_effect=AssertionError("hosted must not run")):
1011 rc, _, err = _cli(
1012 [TOPIC, "--x-posts", path], tmp_path,
1013 environ={"LAST30DAYS_API_BASE": "https://hosted.example.test"},
1014 )
1015 assert rc == 2
1016 assert "--x-posts" in err
1017
1018 def test_valid_flag_threads_the_envelope_into_run(self, tmp_path):
1019 path = _basic(tmp_path)
1020 store: dict = {}
1021 rc, _, _ = _cli([TOPIC, "--x-posts", path, "--x-handle", SUBJECT], tmp_path, run=_fake_run_capturing(store))
1022 assert rc == 0
1023 envelope = store["x_posts"]
1024 assert isinstance(envelope, x_envelope.Envelope)
1025 assert envelope.accepted == 3
1026
1027 def test_bare_flag_on_comparison_run_exits_2_naming_the_field(self, tmp_path):
1028 path = _basic(tmp_path)
1029 rc, _, err = _cli(["acme vs globex", "--x-posts", path], tmp_path)
1030 assert rc == 2
1031 assert "x_posts" in err and "--competitors-plan" in err
1032
1033 def test_per_entity_x_posts_in_competitors_plan(self, tmp_path):
1034 acme = _write(tmp_path, _envelope([_call("topic", posts=[_row(0, "a", "acme news")])], topic="acme"), "acme.json")
1035 globex = _write(tmp_path, _envelope([_call("topic", posts=[_row(1, "b", "globex news")])], topic="globex"), "globex.json")
1036 plan = json.dumps({"acme": {"x_posts": acme}, "globex": {"x_posts": globex}})
1037 seen: dict[str, dict] = {}
1038
1039 def fake_run(**kwargs):
1040 seen[kwargs["topic"]] = kwargs
1041 return _fake_report(kwargs["topic"])
1042
1043 with mock.patch.object(cli, "emit_comparison_output", return_value="# rendered"):
1044 rc, _, err = _cli(["acme vs globex", "--competitors-plan", plan], tmp_path, run=fake_run)
1045 assert rc == 0, err
1046 assert seen["acme"]["x_posts"].sha256 == hashlib.sha256(Path(acme).read_bytes()).hexdigest()
1047 assert seen["globex"]["x_posts"].sha256 == hashlib.sha256(Path(globex).read_bytes()).hexdigest()
1048 parsed = cli.parse_competitors_plan(plan)
1049 assert parsed["acme"]["x_posts"] == acme
1050
1051 def test_comparison_pass_rejects_the_other_entitys_envelope(self, tmp_path):
1052 acme = _write(tmp_path, _envelope([_call("topic", posts=[_row(0, "a", "acme news")])], topic="acme"), "acme.json")
1053 plan = json.dumps({"globex": {"x_posts": acme}})
1054 rc, _, err = _cli(["acme vs globex", "--competitors-plan", plan], tmp_path)
1055 assert rc == 2
1056 assert acme in err and "topic" in err
1057
1058 def test_comparison_cache_is_reused_only_with_validated_entity_envelopes(self, tmp_path):
1059 """The lookup digest is built from the validated per-entity envelopes
1060 (same fold as the write side), and a stale entity envelope fails the
1061 run closed (exit 2) before any cached output is served."""
1062 acme = _write(tmp_path, _envelope([_call("topic", posts=[_row(0, "a", "acme news")])], topic="acme"), "acme.json")
1063 globex = _write(tmp_path, _envelope([_call("topic", posts=[_row(1, "b", "globex news")])], topic="globex"), "globex.json")
1064 plan = json.dumps({"acme": {"x_posts": acme}, "globex": {"x_posts": globex}})
1065 comp_plan = cli.parse_competitors_plan(plan)
1066 cli._attach_entity_envelopes(comp_plan, mock.Mock(lookback_days=30, as_of_date=None))
1067 digest = cli._x_envelope_digest(None, comp_plan)
1068 assert digest and digest != comp_plan["acme"]["_x_envelope"].sha256
1069 synth = tmp_path / "synth.md"
1070 synth.write_text("# synthesis\n")
1071 cfg_dir = tmp_path / "cfg"
1072 cfg_dir.mkdir(exist_ok=True)
1073 entity_reports = [("acme", _fake_report("acme")), ("globex", _fake_report("globex"))]
1074 with mock.patch.object(cli.env, "CONFIG_DIR", cfg_dir):
1075 assert cli._write_last_run("acme vs globex", entity_reports[0][1], entity_reports, x_envelope_sha256=digest)
1076 argv = ["acme vs globex", "--competitors-plan", plan, "--emit", "html", "--synthesis-file", str(synth)]
1077 with mock.patch.object(cli, "_render_save_and_print", return_value=0) as render:
1078 rc, _, err = _cli(argv, tmp_path)
1079 assert rc == 0, err
1080 assert "Reusing cached report data" in err
1081 render.assert_called_once()
1082 # Same plan, but acme's envelope went stale: fail closed before the cache.
1083 stale = (datetime.now(timezone.utc) - timedelta(hours=7)).isoformat()
1084 _write(tmp_path, _envelope([_call("topic", posts=[_row(0, "a", "acme news")])], topic="acme", generated_at=stale), "acme.json")
1085 with mock.patch.object(cli, "_render_save_and_print", side_effect=AssertionError("must not render")):
1086 rc, _, err = _cli(argv, tmp_path)
1087 assert rc == 2
1088 assert "generated_at" in err and "Reusing cached" not in err
1089 main = _read(_basic(tmp_path))
1090 assert cli._x_envelope_digest(main, None) == main.sha256
1091
1092 def test_last_report_cache_misses_on_digest_mismatch(self, tmp_path):
1093 path = _basic(tmp_path)
1094 envelope = _read(path)
1095 report = _fake_report(TOPIC)
1096 with mock.patch.object(cli.env, "CONFIG_DIR", tmp_path / "cfg"):
1097 (tmp_path / "cfg").mkdir(exist_ok=True)
1098 assert cli._write_last_run(TOPIC, report, x_envelope_sha256=envelope.sha256)
1099 payload = json.loads((tmp_path / "cfg" / "last-report.json").read_text())
1100 assert payload["x_envelope_sha256"] == envelope.sha256
1101 assert cli._load_last_report_cache(TOPIC, x_envelope_sha256=envelope.sha256) is not None
1102 assert cli._load_last_report_cache(TOPIC, x_envelope_sha256="0" * 64) is None
1103 assert cli._load_last_report_cache(TOPIC) is None
1104 assert cli._write_last_run(TOPIC, report)
1105 assert cli._load_last_report_cache(TOPIC) is not None
1106 assert cli._load_last_report_cache(TOPIC, x_envelope_sha256=envelope.sha256) is None
1107
1108 def test_cli_run_end_to_end_emits_x_citations_only(self, tmp_path):
1109 path = _basic(tmp_path)
1110 argv = [TOPIC, "--x-posts", path, "--x-handle", SUBJECT, "--search", "x", "--web-backend", "none"]
1111 rc, out, err = _cli([*argv, "--emit", "md"], tmp_path, real_run=True)
1112 assert rc == 0, err
1113 assert "host-fetched X: accepted 3 of 3" in err
1114 assert "via X connector" in out
1115 assert "3 items" in out
1116 citations = re.findall(r"https?://[^\s)\]]+/status/\d+", out)
1117 assert len(citations) >= 3 and all(c.startswith("https://x.com/") for c in citations)
1118 assert "Optional source omitted" not in out + err
1119 rc, html, err = _cli([*argv, "--emit", "html"], tmp_path, real_run=True)
1120 assert rc == 0, err
1121 assert all(h.startswith("https://x.com/") for h in _hrefs(html) if "/status/" in h)
1122 assert "Optional source omitted" not in html + err
1123
1123 lines PYTHON