| 1 | """Tests for the remote API path (LAST30DAYS_API_KEY + LAST30DAYS_API_BASE). |
| 2 | |
| 3 | Fixtures mirror the remote API contract exactly: |
| 4 | POST {base}/search {"query","depth"} -> {"search_id","status"} | clarify payload |
| 5 | GET {base}/search?id=<uuid> -> pending|running|complete|error rows |
| 6 | 401 {"error"} / 402 {"error","requires_credits","balance","needed"} / 429 {"error"} |
| 7 | The endpoint is driven entirely through LAST30DAYS_API_BASE; there is no |
| 8 | built-in default. All keys/hosts in tests are obvious dummy values (see |
| 9 | AGENTS.md security hygiene). |
| 10 | """ |
| 11 | |
| 12 | import io |
| 13 | import json |
| 14 | import sys |
| 15 | from contextlib import redirect_stderr, redirect_stdout |
| 16 | from datetime import datetime |
| 17 | from unittest import mock |
| 18 | |
| 19 | import pytest |
| 20 | |
| 21 | import last30days as cli |
| 22 | from lib import hosted, http, schema |
| 23 | |
| 24 | TEST_KEY = "sk_live_DUMMY_TEST_KEY_00000" |
| 25 | # Neutral placeholder endpoint - no product host. Ends in /api/v1 to mirror the |
| 26 | # API-version-root convention the billing-link derivation relies on. |
| 27 | TEST_BASE = "https://api.example.test/api/v1" |
| 28 | SEARCH_ID = "3f6c1c2e-9f6a-4a55-8f8a-2d1a9b8c7d6e" |
| 29 | |
| 30 | SUBMIT_OK = {"search_id": SEARCH_ID, "status": "running"} |
| 31 | POLL_RUNNING = { |
| 32 | "id": SEARCH_ID, |
| 33 | "status": "running", |
| 34 | "stderr": ( |
| 35 | "[narrate] step=planning queries\n" |
| 36 | "[Reddit] fetched 12 threads\n" |
| 37 | "[narrate] step=searching sources\n" |
| 38 | ), |
| 39 | "eta_ms": 45000, |
| 40 | } |
| 41 | POLL_COMPLETE = { |
| 42 | "id": SEARCH_ID, |
| 43 | "status": "complete", |
| 44 | "synthesis_text": "## What happened\nSynthesized report body.", |
| 45 | "raw_markdown": "# Raw markdown\nFull dump.", |
| 46 | } |
| 47 | CLARIFY_RESPONSE = { |
| 48 | "needs_clarification": True, |
| 49 | "clarify_class": "ambiguous_entity", |
| 50 | "question": "Which 'mercury' do you mean?", |
| 51 | "options": ["Mercury the planet", "Mercury the element", "Mercury the band"], |
| 52 | "original_query": "mercury", |
| 53 | } |
| 54 | |
| 55 | DIAG = { |
| 56 | "available_sources": ["grounding"], |
| 57 | "providers": {"google": True, "openai": False, "xai": False}, |
| 58 | "x_backend": None, |
| 59 | "bird_installed": False, |
| 60 | "bird_authenticated": False, |
| 61 | "bird_username": None, |
| 62 | "native_web_backend": "brave", |
| 63 | } |
| 64 | |
| 65 | |
| 66 | def make_report(topic: str = "test topic") -> schema.Report: |
| 67 | return schema.Report( |
| 68 | topic=topic, |
| 69 | range_from="2026-06-03", |
| 70 | range_to="2026-07-03", |
| 71 | generated_at="2026-07-03T00:00:00+00:00", |
| 72 | provider_runtime=schema.ProviderRuntime( |
| 73 | reasoning_provider="gemini", |
| 74 | planner_model="gemini-3.1-flash-lite", |
| 75 | rerank_model="gemini-3.1-flash-lite", |
| 76 | ), |
| 77 | query_plan=schema.QueryPlan( |
| 78 | intent="overview", |
| 79 | freshness_mode="balanced_recent", |
| 80 | cluster_mode="themes", |
| 81 | raw_topic=topic, |
| 82 | subqueries=[ |
| 83 | schema.SubQuery( |
| 84 | label="primary", |
| 85 | search_query=topic.lower(), |
| 86 | ranking_query=f"What happened with {topic}?", |
| 87 | sources=["grounding"], |
| 88 | ) |
| 89 | ], |
| 90 | source_weights={"grounding": 1.0}, |
| 91 | ), |
| 92 | clusters=[], |
| 93 | ranked_candidates=[], |
| 94 | items_by_source={"grounding": []}, |
| 95 | errors_by_source={}, |
| 96 | ) |
| 97 | |
| 98 | |
| 99 | def run_main(argv): |
| 100 | stdout, stderr = io.StringIO(), io.StringIO() |
| 101 | with mock.patch.object(sys, "argv", ["last30days.py", *argv]): |
| 102 | with redirect_stdout(stdout), redirect_stderr(stderr): |
| 103 | rc = cli.main() |
| 104 | return rc, stdout.getvalue(), stderr.getvalue() |
| 105 | |
| 106 | |
| 107 | @pytest.fixture(autouse=True) |
| 108 | def _clean_env(monkeypatch): |
| 109 | monkeypatch.delenv("LAST30DAYS_API_KEY", raising=False) |
| 110 | monkeypatch.delenv("LAST30DAYS_API_BASE", raising=False) |
| 111 | monkeypatch.delenv("LAST30DAYS_MEMORY_DIR", raising=False) |
| 112 | |
| 113 | |
| 114 | # --------------------------------------------------------------------------- |
| 115 | # Mode selection in the CLI entrypoint |
| 116 | # --------------------------------------------------------------------------- |
| 117 | |
| 118 | |
| 119 | def test_env_unset_runs_local_path_with_no_gateway_http(monkeypatch): |
| 120 | """Without LAST30DAYS_API_KEY the local engine runs; the remote client and |
| 121 | the API are never touched.""" |
| 122 | |
| 123 | def no_hosted(*args, **kwargs): # pragma: no cover - failure path |
| 124 | raise AssertionError("remote path must not run when env is unset") |
| 125 | |
| 126 | def no_http(*args, **kwargs): # pragma: no cover - failure path |
| 127 | raise AssertionError(f"unexpected HTTP call in local test: {args}") |
| 128 | |
| 129 | monkeypatch.setattr(hosted, "run_hosted", no_hosted) |
| 130 | monkeypatch.setattr(http, "request", no_http) |
| 131 | |
| 132 | fake_progress = mock.Mock() |
| 133 | with mock.patch.object(cli.env, "get_config", return_value={}), \ |
| 134 | mock.patch.object(cli.pipeline, "diagnose", return_value=DIAG), \ |
| 135 | mock.patch.object(cli.pipeline, "run", return_value=make_report()) as pipeline_run, \ |
| 136 | mock.patch.object(cli.ui, "ProgressDisplay", return_value=fake_progress), \ |
| 137 | mock.patch.object(cli, "emit_output", return_value="# local rendered"): |
| 138 | rc, out, _err = run_main(["test", "topic"]) |
| 139 | |
| 140 | assert rc == 0 |
| 141 | pipeline_run.assert_called_once() |
| 142 | assert "# local rendered" in out |
| 143 | |
| 144 | |
| 145 | def test_env_set_routes_to_remote_path(monkeypatch): |
| 146 | # Both vars set -> remote path (KTD-2: key alone no longer activates it). |
| 147 | monkeypatch.setenv("LAST30DAYS_API_KEY", TEST_KEY) |
| 148 | monkeypatch.setenv("LAST30DAYS_API_BASE", TEST_BASE) |
| 149 | calls = [] |
| 150 | |
| 151 | def fake_run_hosted(topic, depth, *, emit, save_dir, save_suffix): |
| 152 | calls.append({"topic": topic, "depth": depth, "emit": emit, |
| 153 | "save_dir": save_dir, "save_suffix": save_suffix}) |
| 154 | return 0 |
| 155 | |
| 156 | monkeypatch.setattr(hosted, "run_hosted", fake_run_hosted) |
| 157 | with mock.patch.object(cli.env, "get_config", return_value={}), \ |
| 158 | mock.patch.object(cli.pipeline, "run", |
| 159 | side_effect=AssertionError("local pipeline must not run")): |
| 160 | rc, _out, _err = run_main(["test", "topic"]) |
| 161 | |
| 162 | assert rc == 0 |
| 163 | assert calls == [{ |
| 164 | "topic": "test topic", |
| 165 | "depth": "default", |
| 166 | "emit": "compact", |
| 167 | "save_dir": None, |
| 168 | "save_suffix": "", |
| 169 | }] |
| 170 | |
| 171 | |
| 172 | def test_register_is_forwarded_to_remote_backend(monkeypatch): |
| 173 | monkeypatch.setenv("LAST30DAYS_API_KEY", TEST_KEY) |
| 174 | monkeypatch.setenv("LAST30DAYS_API_BASE", TEST_BASE) |
| 175 | calls = [] |
| 176 | |
| 177 | monkeypatch.setattr( |
| 178 | hosted, |
| 179 | "run_hosted", |
| 180 | lambda topic, depth, **kwargs: calls.append((topic, depth, kwargs)) or 0, |
| 181 | ) |
| 182 | with mock.patch.object(cli.env, "get_config", return_value={}): |
| 183 | rc, _out, _err = run_main( |
| 184 | ["test", "topic", "--register=creator"] |
| 185 | ) |
| 186 | |
| 187 | assert rc == 0 |
| 188 | assert calls == [ |
| 189 | ( |
| 190 | "test topic", |
| 191 | "default", |
| 192 | { |
| 193 | "emit": "compact", |
| 194 | "save_dir": None, |
| 195 | "save_suffix": "", |
| 196 | "register": "creator", |
| 197 | }, |
| 198 | ) |
| 199 | ] |
| 200 | |
| 201 | |
| 202 | def test_hosted_submit_adds_only_nondefault_register(remote_env, monkeypatch): |
| 203 | payloads = [] |
| 204 | monkeypatch.setattr( |
| 205 | hosted.http, |
| 206 | "post", |
| 207 | lambda _url, *, json_data, **_kwargs: payloads.append(json_data) or SUBMIT_OK, |
| 208 | ) |
| 209 | |
| 210 | hosted.submit("test topic", "quick") |
| 211 | hosted.submit("test topic", "quick", register="dev") |
| 212 | |
| 213 | assert payloads == [ |
| 214 | {"query": "test topic", "depth": "quick"}, |
| 215 | {"query": "test topic", "depth": "quick", "register": "dev"}, |
| 216 | ] |
| 217 | |
| 218 | |
| 219 | def test_remote_json_requires_raw_profile(monkeypatch): |
| 220 | monkeypatch.setenv("LAST30DAYS_API_KEY", TEST_KEY) |
| 221 | monkeypatch.setenv("LAST30DAYS_API_BASE", TEST_BASE) |
| 222 | monkeypatch.setattr( |
| 223 | hosted, |
| 224 | "run_hosted", |
| 225 | lambda *args, **kwargs: pytest.fail("remote request should not start"), |
| 226 | ) |
| 227 | |
| 228 | with mock.patch.object(cli.env, "get_config", return_value={}): |
| 229 | rc, _out, err = run_main(["test", "topic", "--emit=json"]) |
| 230 | |
| 231 | assert rc == 2 |
| 232 | assert "remote API backend only supports --json-profile=raw" in err |
| 233 | |
| 234 | |
| 235 | def test_remote_raw_json_preserves_existing_server_contract(monkeypatch): |
| 236 | monkeypatch.setenv("LAST30DAYS_API_KEY", TEST_KEY) |
| 237 | monkeypatch.setenv("LAST30DAYS_API_BASE", TEST_BASE) |
| 238 | calls = [] |
| 239 | monkeypatch.setattr( |
| 240 | hosted, |
| 241 | "run_hosted", |
| 242 | lambda topic, depth, **kwargs: calls.append((topic, depth, kwargs)) or 0, |
| 243 | ) |
| 244 | |
| 245 | with mock.patch.object(cli.env, "get_config", return_value={}): |
| 246 | rc, _out, _err = run_main( |
| 247 | ["test", "topic", "--emit=json", "--json-profile=raw"] |
| 248 | ) |
| 249 | |
| 250 | assert rc == 0 |
| 251 | assert calls == [ |
| 252 | ( |
| 253 | "test topic", |
| 254 | "default", |
| 255 | {"emit": "json", "save_dir": None, "save_suffix": ""}, |
| 256 | ) |
| 257 | ] |
| 258 | |
| 259 | |
| 260 | @pytest.mark.parametrize( |
| 261 | ("flag", "expected_depth"), |
| 262 | [(["--quick"], "quick"), ([], "default"), (["--deep"], "deep")], |
| 263 | ) |
| 264 | def test_depth_mapping(monkeypatch, flag, expected_depth): |
| 265 | monkeypatch.setenv("LAST30DAYS_API_KEY", TEST_KEY) |
| 266 | monkeypatch.setenv("LAST30DAYS_API_BASE", TEST_BASE) |
| 267 | depths = [] |
| 268 | monkeypatch.setattr( |
| 269 | hosted, "run_hosted", |
| 270 | lambda topic, depth, **kwargs: depths.append(depth) or 0, |
| 271 | ) |
| 272 | with mock.patch.object(cli.env, "get_config", return_value={}): |
| 273 | rc, _out, _err = run_main(["test", "topic", *flag]) |
| 274 | assert rc == 0 |
| 275 | assert depths == [expected_depth] |
| 276 | |
| 277 | |
| 278 | def test_mock_flag_stays_local_even_with_key(monkeypatch): |
| 279 | monkeypatch.setenv("LAST30DAYS_API_KEY", TEST_KEY) |
| 280 | monkeypatch.setenv("LAST30DAYS_API_BASE", TEST_BASE) |
| 281 | |
| 282 | def no_hosted(*args, **kwargs): # pragma: no cover - failure path |
| 283 | raise AssertionError("remote path must not run with --mock") |
| 284 | |
| 285 | monkeypatch.setattr(hosted, "run_hosted", no_hosted) |
| 286 | fake_progress = mock.Mock() |
| 287 | with mock.patch.object(cli.env, "get_config", return_value={}), \ |
| 288 | mock.patch.object(cli.pipeline, "diagnose", return_value=DIAG), \ |
| 289 | mock.patch.object(cli.pipeline, "run", return_value=make_report()) as pipeline_run, \ |
| 290 | mock.patch.object(cli.ui, "ProgressDisplay", return_value=fake_progress), \ |
| 291 | mock.patch.object(cli, "emit_output", return_value="# local rendered"): |
| 292 | rc, _out, _err = run_main(["test", "topic", "--mock"]) |
| 293 | assert rc == 0 |
| 294 | pipeline_run.assert_called_once() |
| 295 | |
| 296 | |
| 297 | def test_key_set_but_base_unset_stays_local(monkeypatch): |
| 298 | """KTD-2 inertness: with only the key set (no LAST30DAYS_API_BASE), hosted |
| 299 | mode does not activate - the local engine runs and no HTTP is attempted. |
| 300 | This is the leak-proofing guarantee: a key alone can never phone anywhere.""" |
| 301 | monkeypatch.setenv("LAST30DAYS_API_KEY", TEST_KEY) |
| 302 | # LAST30DAYS_API_BASE deliberately left unset by the _clean_env fixture. |
| 303 | |
| 304 | def no_hosted(*args, **kwargs): # pragma: no cover - failure path |
| 305 | raise AssertionError("remote path must not run without LAST30DAYS_API_BASE") |
| 306 | |
| 307 | def no_http(*args, **kwargs): # pragma: no cover - failure path |
| 308 | raise AssertionError(f"unexpected HTTP call when base is unset: {args}") |
| 309 | |
| 310 | monkeypatch.setattr(hosted, "run_hosted", no_hosted) |
| 311 | monkeypatch.setattr(http, "request", no_http) |
| 312 | |
| 313 | fake_progress = mock.Mock() |
| 314 | with mock.patch.object(cli.env, "get_config", return_value={}), \ |
| 315 | mock.patch.object(cli.pipeline, "diagnose", return_value=DIAG), \ |
| 316 | mock.patch.object(cli.pipeline, "run", return_value=make_report()) as pipeline_run, \ |
| 317 | mock.patch.object(cli.ui, "ProgressDisplay", return_value=fake_progress), \ |
| 318 | mock.patch.object(cli, "emit_output", return_value="# local rendered"): |
| 319 | rc, out, _err = run_main(["test", "topic"]) |
| 320 | |
| 321 | assert rc == 0 |
| 322 | pipeline_run.assert_called_once() |
| 323 | assert "# local rendered" in out |
| 324 | |
| 325 | |
| 326 | # --------------------------------------------------------------------------- |
| 327 | # Remote client: submit -> poll -> complete |
| 328 | # --------------------------------------------------------------------------- |
| 329 | |
| 330 | |
| 331 | @pytest.fixture() |
| 332 | def remote_env(monkeypatch): |
| 333 | monkeypatch.setenv("LAST30DAYS_API_KEY", TEST_KEY) |
| 334 | monkeypatch.setenv("LAST30DAYS_API_BASE", TEST_BASE) |
| 335 | monkeypatch.setattr(hosted.time, "sleep", lambda _s: None) |
| 336 | |
| 337 | |
| 338 | def test_happy_path_submit_poll_complete(remote_env, monkeypatch, capsys): |
| 339 | posts, gets = [], [] |
| 340 | |
| 341 | def fake_post(url, json_data, headers=None, **kwargs): |
| 342 | posts.append({"url": url, "json": json_data, "headers": headers}) |
| 343 | return dict(SUBMIT_OK) |
| 344 | |
| 345 | poll_rows = [dict(POLL_RUNNING), dict(POLL_RUNNING), dict(POLL_COMPLETE)] |
| 346 | |
| 347 | def fake_get(url, headers=None, params=None, **kwargs): |
| 348 | gets.append({"url": url, "headers": headers, "params": params}) |
| 349 | return poll_rows.pop(0) |
| 350 | |
| 351 | monkeypatch.setattr(hosted.http, "post", fake_post) |
| 352 | monkeypatch.setattr(hosted.http, "get", fake_get) |
| 353 | |
| 354 | rc = hosted.run_hosted("test topic", "default", emit="compact", |
| 355 | save_dir=None, save_suffix="") |
| 356 | out = capsys.readouterr().out |
| 357 | |
| 358 | assert rc == 0 |
| 359 | # Contract: submit |
| 360 | assert posts == [{ |
| 361 | "url": f"{TEST_BASE}/search", |
| 362 | "json": {"query": "test topic", "depth": "default"}, |
| 363 | "headers": {"Authorization": f"Bearer {TEST_KEY}"}, |
| 364 | }] |
| 365 | # Contract: poll same auth, id param |
| 366 | assert all(g["url"] == f"{TEST_BASE}/search" for g in gets) |
| 367 | assert all(g["params"] == {"id": SEARCH_ID} for g in gets) |
| 368 | assert all(g["headers"] == {"Authorization": f"Bearer {TEST_KEY}"} for g in gets) |
| 369 | assert len(gets) == 3 |
| 370 | # Synthesis rendered on stdout |
| 371 | assert "Synthesized report body." in out |
| 372 | |
| 373 | |
| 374 | def test_happy_path_narration_printed_once_and_no_key_echo(remote_env, monkeypatch, capsys): |
| 375 | monkeypatch.setattr(hosted.http, "post", lambda *a, **k: dict(SUBMIT_OK)) |
| 376 | poll_rows = [dict(POLL_RUNNING), dict(POLL_RUNNING), dict(POLL_COMPLETE)] |
| 377 | monkeypatch.setattr(hosted.http, "get", lambda *a, **k: poll_rows.pop(0)) |
| 378 | |
| 379 | rc = hosted.run_hosted("test topic", "default", emit="compact", |
| 380 | save_dir=None, save_suffix="") |
| 381 | captured = capsys.readouterr() |
| 382 | |
| 383 | assert rc == 0 |
| 384 | # Each narration step printed exactly once even though the stderr blob |
| 385 | # was returned twice by consecutive polls. |
| 386 | assert captured.err.count("[narrate] step=planning queries") == 1 |
| 387 | assert captured.err.count("[narrate] step=searching sources") == 1 |
| 388 | # Progress line with elapsed/eta shape |
| 389 | assert "eta" in captured.err |
| 390 | # The API key never appears anywhere in output. |
| 391 | assert TEST_KEY not in captured.out |
| 392 | assert TEST_KEY not in captured.err |
| 393 | |
| 394 | |
| 395 | def test_save_dir_writes_raw_markdown(remote_env, monkeypatch, capsys, tmp_path): |
| 396 | monkeypatch.setattr(hosted.http, "post", lambda *a, **k: dict(SUBMIT_OK)) |
| 397 | poll_rows = [dict(POLL_COMPLETE)] |
| 398 | monkeypatch.setattr(hosted.http, "get", lambda *a, **k: poll_rows.pop(0)) |
| 399 | |
| 400 | rc = hosted.run_hosted("Test Topic!", "default", emit="compact", |
| 401 | save_dir=str(tmp_path), save_suffix="") |
| 402 | captured = capsys.readouterr() |
| 403 | |
| 404 | assert rc == 0 |
| 405 | saved = tmp_path / "test-topic-raw.md" |
| 406 | assert saved.exists() |
| 407 | assert "# Raw markdown" in saved.read_text(encoding="utf-8") |
| 408 | assert "Saved output to" in captured.err |
| 409 | assert TEST_KEY not in captured.err |
| 410 | |
| 411 | |
| 412 | def test_save_dir_uses_unique_dated_fallback(remote_env, monkeypatch, capsys, tmp_path): |
| 413 | monkeypatch.setattr(hosted.http, "post", lambda *a, **k: dict(SUBMIT_OK)) |
| 414 | poll_rows = [dict(POLL_COMPLETE)] |
| 415 | monkeypatch.setattr(hosted.http, "get", lambda *a, **k: poll_rows.pop(0)) |
| 416 | today = datetime.now().strftime("%Y-%m-%d") |
| 417 | base = tmp_path / "test-topic-raw.md" |
| 418 | dated = tmp_path / f"test-topic-raw-{today}.md" |
| 419 | base.write_text("base content", encoding="utf-8") |
| 420 | dated.write_text("dated content", encoding="utf-8") |
| 421 | |
| 422 | rc = hosted.run_hosted("Test Topic!", "default", emit="compact", |
| 423 | save_dir=str(tmp_path), save_suffix="") |
| 424 | captured = capsys.readouterr() |
| 425 | |
| 426 | saved = tmp_path / f"test-topic-raw-{today}-1.md" |
| 427 | assert rc == 0 |
| 428 | assert saved.exists() |
| 429 | assert "# Raw markdown" in saved.read_text(encoding="utf-8") |
| 430 | assert base.read_text(encoding="utf-8") == "base content" |
| 431 | assert dated.read_text(encoding="utf-8") == "dated content" |
| 432 | assert f"Saved output to {saved.resolve()}" in captured.err |
| 433 | assert TEST_KEY not in captured.err |
| 434 | |
| 435 | |
| 436 | def test_api_base_override(remote_env, monkeypatch, capsys): |
| 437 | monkeypatch.setenv("LAST30DAYS_API_BASE", "https://staging.example.dev/api/v1/") |
| 438 | urls = [] |
| 439 | |
| 440 | def fake_post(url, json_data, headers=None, **kwargs): |
| 441 | urls.append(url) |
| 442 | return dict(SUBMIT_OK) |
| 443 | |
| 444 | monkeypatch.setattr(hosted.http, "post", fake_post) |
| 445 | monkeypatch.setattr(hosted.http, "get", lambda *a, **k: dict(POLL_COMPLETE)) |
| 446 | |
| 447 | rc = hosted.run_hosted("test topic", "quick", emit="compact", |
| 448 | save_dir=None, save_suffix="") |
| 449 | capsys.readouterr() |
| 450 | assert rc == 0 |
| 451 | assert urls == ["https://staging.example.dev/api/v1/search"] |
| 452 | |
| 453 | |
| 454 | # --------------------------------------------------------------------------- |
| 455 | # Error handling |
| 456 | # --------------------------------------------------------------------------- |
| 457 | |
| 458 | |
| 459 | def test_401_invalid_or_revoked_key(remote_env, monkeypatch, capsys): |
| 460 | def fake_post(*a, **k): |
| 461 | raise http.HTTPError("HTTP 401: Unauthorized", 401, |
| 462 | json.dumps({"error": "Invalid API key"})) |
| 463 | |
| 464 | monkeypatch.setattr(hosted.http, "post", fake_post) |
| 465 | rc = hosted.run_hosted("test topic", "default", emit="compact", |
| 466 | save_dir=None, save_suffix="") |
| 467 | captured = capsys.readouterr() |
| 468 | assert rc == 1 |
| 469 | assert "invalid or revoked" in captured.err.lower() |
| 470 | assert TEST_KEY not in captured.err |
| 471 | |
| 472 | |
| 473 | def test_402_shows_balance_needed_and_billing_url(remote_env, monkeypatch, capsys): |
| 474 | body = {"error": "Insufficient credits", "requires_credits": True, |
| 475 | "balance": 40, "needed": 200} |
| 476 | |
| 477 | def fake_post(*a, **k): |
| 478 | raise http.HTTPError("HTTP 402: Payment Required", 402, json.dumps(body)) |
| 479 | |
| 480 | monkeypatch.setattr(hosted.http, "post", fake_post) |
| 481 | rc = hosted.run_hosted("test topic", "deep", emit="compact", |
| 482 | save_dir=None, save_suffix="") |
| 483 | captured = capsys.readouterr() |
| 484 | assert rc == 1 |
| 485 | # Balance and needed shown verbatim from the API response. |
| 486 | assert "40" in captured.err |
| 487 | assert "200" in captured.err |
| 488 | # Billing link is derived from the configured base (base minus /api/v1, |
| 489 | # plus /dashboard/billing) - never hardcoded. |
| 490 | assert "https://api.example.test/dashboard/billing" in captured.err |
| 491 | assert TEST_KEY not in captured.err |
| 492 | |
| 493 | |
| 494 | def test_429_rate_limited(remote_env, monkeypatch, capsys): |
| 495 | def fake_post(*a, **k): |
| 496 | raise http.HTTPError("HTTP 429: Too Many Requests", 429, |
| 497 | json.dumps({"error": "Rate limit exceeded"})) |
| 498 | |
| 499 | monkeypatch.setattr(hosted.http, "post", fake_post) |
| 500 | rc = hosted.run_hosted("test topic", "default", emit="compact", |
| 501 | save_dir=None, save_suffix="") |
| 502 | captured = capsys.readouterr() |
| 503 | assert rc == 1 |
| 504 | assert "rate limit" in captured.err.lower() |
| 505 | |
| 506 | |
| 507 | def test_clarify_response_prints_question_options_distinct_exit(remote_env, monkeypatch, capsys): |
| 508 | monkeypatch.setattr(hosted.http, "post", lambda *a, **k: dict(CLARIFY_RESPONSE)) |
| 509 | |
| 510 | def no_get(*a, **k): # pragma: no cover - failure path |
| 511 | raise AssertionError("must not poll on clarify") |
| 512 | |
| 513 | monkeypatch.setattr(hosted.http, "get", no_get) |
| 514 | rc = hosted.run_hosted("mercury", "default", emit="compact", |
| 515 | save_dir=None, save_suffix="") |
| 516 | captured = capsys.readouterr() |
| 517 | assert rc == hosted.EXIT_CLARIFY |
| 518 | assert rc not in (0, 1) |
| 519 | assert "Which 'mercury' do you mean?" in captured.err |
| 520 | assert "Mercury the planet" in captured.err |
| 521 | assert "Mercury the band" in captured.err |
| 522 | assert "re-run" in captured.err.lower() |
| 523 | |
| 524 | |
| 525 | def test_error_status_run_prints_server_message(remote_env, monkeypatch, capsys): |
| 526 | monkeypatch.setattr(hosted.http, "post", lambda *a, **k: dict(SUBMIT_OK)) |
| 527 | error_row = {"id": SEARCH_ID, "status": "error", |
| 528 | "error": "Synthesis failed upstream"} |
| 529 | monkeypatch.setattr(hosted.http, "get", lambda *a, **k: dict(error_row)) |
| 530 | rc = hosted.run_hosted("test topic", "default", emit="compact", |
| 531 | save_dir=None, save_suffix="") |
| 532 | captured = capsys.readouterr() |
| 533 | assert rc == 1 |
| 534 | assert "Synthesis failed upstream" in captured.err |
| 535 | |
| 536 | |
| 537 | def test_network_timeout_mid_poll_retries_get(remote_env, monkeypatch, capsys): |
| 538 | monkeypatch.setattr(hosted.http, "post", lambda *a, **k: dict(SUBMIT_OK)) |
| 539 | attempts = [] |
| 540 | |
| 541 | def flaky_get(*a, **k): |
| 542 | attempts.append(1) |
| 543 | if len(attempts) < 3: |
| 544 | raise http.HTTPError("Connection error: TimeoutError: timed out") |
| 545 | return dict(POLL_COMPLETE) |
| 546 | |
| 547 | monkeypatch.setattr(hosted.http, "get", flaky_get) |
| 548 | rc = hosted.run_hosted("test topic", "default", emit="compact", |
| 549 | save_dir=None, save_suffix="") |
| 550 | captured = capsys.readouterr() |
| 551 | assert rc == 0 |
| 552 | assert len(attempts) == 3 |
| 553 | assert "Synthesized report body." in captured.out |
| 554 | |
| 555 | |
| 556 | def test_persistent_network_failure_gives_up_with_message(remote_env, monkeypatch, capsys): |
| 557 | monkeypatch.setattr(hosted.http, "post", lambda *a, **k: dict(SUBMIT_OK)) |
| 558 | |
| 559 | def dead_get(*a, **k): |
| 560 | raise http.HTTPError("Connection error: TimeoutError: timed out") |
| 561 | |
| 562 | monkeypatch.setattr(hosted.http, "get", dead_get) |
| 563 | rc = hosted.run_hosted("test topic", "default", emit="compact", |
| 564 | save_dir=None, save_suffix="") |
| 565 | captured = capsys.readouterr() |
| 566 | assert rc == 1 |
| 567 | assert "poll" in captured.err.lower() |
| 568 | assert TEST_KEY not in captured.err |
| 569 | |
| 570 | |
| 571 | def test_emit_json_prints_terminal_row_without_stderr(remote_env, monkeypatch, capsys): |
| 572 | monkeypatch.setattr(hosted.http, "post", lambda *a, **k: dict(SUBMIT_OK)) |
| 573 | monkeypatch.setattr(hosted.http, "get", lambda *a, **k: dict(POLL_COMPLETE)) |
| 574 | rc = hosted.run_hosted("test topic", "default", emit="json", |
| 575 | save_dir=None, save_suffix="") |
| 576 | captured = capsys.readouterr() |
| 577 | assert rc == 0 |
| 578 | payload = json.loads(captured.out) |
| 579 | assert payload["status"] == "complete" |
| 580 | assert payload["synthesis_text"].startswith("## What happened") |
| 581 | assert payload["raw_markdown"].startswith("# Raw markdown") |
| 582 | assert "stderr" not in payload |
| 583 | assert TEST_KEY not in captured.out |
| 584 |