| 1 | """Fast Search transport contract; no provider calls or credentials required.""" |
| 2 | import sys |
| 3 | from unittest.mock import patch |
| 4 | |
| 5 | import pytest |
| 6 | |
| 7 | import last30days as cli |
| 8 | from lib import env, perplexity |
| 9 | |
| 10 | DATE_RANGE = ("2026-09-01", "2026-09-27") |
| 11 | KEY = "LAST30DAYS_PERPLEXITY_SEARCH_TYPE" |
| 12 | SEARCH_RESPONSE = { |
| 13 | "id": "search-fixture", |
| 14 | "results": [{"title": "Rust release", "url": "https://example.com/rust", |
| 15 | "snippet": "Release source", "date": "2026-09-25"}], |
| 16 | } |
| 17 | AGENT_RESPONSE = {"id": "agent-fixture", "status": "completed", "output_text": "Summary", "output": []} |
| 18 | |
| 19 | |
| 20 | @pytest.mark.parametrize("search_type", [None, "web", "fast"]) |
| 21 | @pytest.mark.parametrize("mode", ["search", "agent", "both"]) |
| 22 | def test_transport_type_and_safe_receipt_preserve_filters(search_type, mode): |
| 23 | config = {"PERPLEXITY_API_KEY": "dummy-test-key", |
| 24 | "LAST30DAYS_PERPLEXITY_MODE": mode, |
| 25 | "LAST30DAYS_PERPLEXITY_DOMAIN_FILTER": "example.com", |
| 26 | "LAST30DAYS_PERPLEXITY_MAX_RESULTS": "25"} |
| 27 | if search_type: |
| 28 | config[KEY] = search_type |
| 29 | responses = {perplexity.PERPLEXITY_SEARCH_URL: SEARCH_RESPONSE, |
| 30 | perplexity.PERPLEXITY_AGENT_URL: AGENT_RESPONSE} |
| 31 | with patch.object(perplexity.http, "post", side_effect=lambda url, *a, **kw: responses[url]) as post: |
| 32 | items, artifact = perplexity.search("Rust releases", DATE_RANGE, config) |
| 33 | assert post.call_count == (2 if mode == "both" else 1) |
| 34 | for call in post.call_args_list: |
| 35 | is_search = call.args[0] == perplexity.PERPLEXITY_SEARCH_URL |
| 36 | payload = call.args[1] |
| 37 | request = payload if is_search else payload["tools"][0] |
| 38 | receipt = artifact["search" if is_search else "agent"] if mode == "both" else artifact |
| 39 | recorded = receipt["request"] if is_search else receipt["request"]["tools"][0] |
| 40 | if search_type: |
| 41 | assert request["search_type"] == recorded["search_type"] == search_type |
| 42 | else: |
| 43 | assert "search_type" not in request |
| 44 | assert "search_type" not in recorded |
| 45 | filters = request if is_search else request["filters"] |
| 46 | assert filters["search_domain_filter"] == ["example.com"] |
| 47 | assert filters["search_after_date_filter"] == "09/01/2026" |
| 48 | assert filters["search_before_date_filter"] == "09/27/2026" |
| 49 | assert request["max_results"] == 20 |
| 50 | assert "dummy-test-key" not in str(receipt) |
| 51 | if not is_search: |
| 52 | assert "preset" not in payload |
| 53 | assert payload["tool_choice"] == {"type": "web_search"} |
| 54 | if mode == "search": |
| 55 | assert items[0]["snippet"] == "Release source" |
| 56 | assert items[0]["date"] == "2026-09-25" |
| 57 | |
| 58 | |
| 59 | @pytest.mark.parametrize("deep,preset", [(False, "fast"), (False, "high"), (True, None)]) |
| 60 | def test_explicit_type_is_independent_of_mutable_agent_preset(deep, preset): |
| 61 | config = {KEY: "web", "LAST30DAYS_PERPLEXITY_AGENT_PRESET": preset} |
| 62 | payload, receipt = perplexity._build_agent_payload("test prompt", DATE_RANGE, config, deep) |
| 63 | assert payload["preset"] == ("high" if deep else preset) |
| 64 | assert payload["tools"][0]["search_type"] == "web" |
| 65 | assert receipt["request"]["tools"][0]["search_type"] == "web" |
| 66 | |
| 67 | |
| 68 | @pytest.mark.parametrize("mode", ["search", "agent", "both"]) |
| 69 | def test_invalid_type_makes_no_paid_request(mode): |
| 70 | with patch.object(perplexity.http, "post") as post: |
| 71 | items, artifact = perplexity.search("topic", DATE_RANGE, { |
| 72 | "PERPLEXITY_API_KEY": "dummy-test-key", KEY: "pro", |
| 73 | "LAST30DAYS_PERPLEXITY_MODE": mode}) |
| 74 | post.assert_not_called() |
| 75 | assert items == [] |
| 76 | assert "must be web or fast" in str(artifact) |
| 77 | |
| 78 | |
| 79 | def test_openrouter_keeps_sonar_and_discloses_ignored_search_type(): |
| 80 | response = {"choices": [{"message": {"content": "Summary"}}]} |
| 81 | with patch.object(perplexity.http, "post", return_value=response) as post, patch.object(perplexity, "_log") as log: |
| 82 | _, artifact = perplexity.search("topic", DATE_RANGE, { |
| 83 | "OPENROUTER_API_KEY": "dummy-test-key", KEY: "fast"}) |
| 84 | assert post.call_count == 1 |
| 85 | assert post.call_args.args[0] == perplexity.OPENROUTER_URL |
| 86 | assert "search_type" not in str(post.call_args.args[1]) |
| 87 | assert artifact["provider"] == "openrouter" |
| 88 | assert any("does not change OpenRouter" in str(call) for call in log.call_args_list) |
| 89 | |
| 90 | |
| 91 | @pytest.mark.parametrize("value", ["pro", "FAST!", "garbage"]) |
| 92 | def test_invalid_type_never_blocks_openrouter_sonar(value): |
| 93 | response = {"choices": [{"message": {"content": "Summary"}}]} |
| 94 | with patch.object(perplexity.http, "post", return_value=response) as post: |
| 95 | items, artifact = perplexity.search("topic", DATE_RANGE, { |
| 96 | "OPENROUTER_API_KEY": "dummy-test-key", KEY: value}) |
| 97 | assert post.call_count == 1 |
| 98 | assert post.call_args.args[0] == perplexity.OPENROUTER_URL |
| 99 | assert artifact["provider"] == "openrouter" |
| 100 | assert "must be web or fast" not in str(artifact) |
| 101 | |
| 102 | |
| 103 | @pytest.mark.parametrize("cli_value,expected", [(None, "fast"), ("web", "web")]) |
| 104 | def test_cli_override_reaches_config_without_enabling_paid_source(cli_value, expected): |
| 105 | args = ["last30days.py", "Rust releases"] |
| 106 | if cli_value: |
| 107 | args += ["--perplexity-search-type", cli_value] |
| 108 | class ConfigCaptured(Exception): |
| 109 | pass |
| 110 | with patch.object(sys, "argv", args), patch.object(cli.env, "get_config", return_value={KEY: "fast"}), patch.object(cli, "_propagate_config_to_environ", side_effect=ConfigCaptured) as capture: |
| 111 | with pytest.raises(ConfigCaptured): |
| 112 | cli.main() |
| 113 | config = capture.call_args.args[0] |
| 114 | assert config[KEY] == expected |
| 115 | assert "INCLUDE_SOURCES" not in config |
| 116 | assert "LAST30DAYS_PERPLEXITY_MODE" not in config |
| 117 | assert "LAST30DAYS_PERPLEXITY_AGENT_PRESET" not in config |
| 118 | |
| 119 | |
| 120 | def test_cli_rejects_sonar_only_search_type(): |
| 121 | with pytest.raises(SystemExit) as exc: |
| 122 | cli.build_parser().parse_args(["topic", "--perplexity-search-type", "pro"]) |
| 123 | assert exc.value.code == 2 |
| 124 | |
| 125 | |
| 126 | def test_search_type_loads_from_config_file_and_environment(tmp_path, monkeypatch): |
| 127 | config_file = tmp_path / ".env" |
| 128 | config_file.write_text(f"{KEY}=fast\n") |
| 129 | config_file.chmod(0o600) |
| 130 | monkeypatch.setattr(env, "CONFIG_FILE", config_file) |
| 131 | monkeypatch.delenv(KEY, raising=False) |
| 132 | with patch.object(env, "_find_project_env", return_value=None), patch.object(env, "_load_keychain", return_value={}), patch.object(env, "_load_pass", return_value={}): |
| 133 | assert env.get_config()[KEY] == "fast" |
| 134 | monkeypatch.setenv(KEY, "web") |
| 135 | assert env.get_config()[KEY] == "web" |
| 136 | |
| 137 | |
| 138 | @pytest.mark.parametrize("mode", ["search", "agent", "both"]) |
| 139 | @pytest.mark.parametrize("failure", [ |
| 140 | perplexity.http.HTTPError("unavailable", status_code=503), |
| 141 | TimeoutError("timed out"), |
| 142 | ]) |
| 143 | def test_failed_legs_keep_requested_type_without_extra_requests(mode, failure): |
| 144 | with patch.object(perplexity.http, "post", side_effect=failure) as post: |
| 145 | items, artifact = perplexity.search("topic", DATE_RANGE, { |
| 146 | "PERPLEXITY_API_KEY": "dummy-test-key", KEY: "fast", |
| 147 | "LAST30DAYS_PERPLEXITY_MODE": mode}) |
| 148 | assert post.call_count == (2 if mode == "both" else 1) |
| 149 | assert items == [] |
| 150 | legs = [artifact["search"], artifact["agent"]] if mode == "both" else [artifact] |
| 151 | for leg in legs: |
| 152 | assert leg["error"] |
| 153 | assert leg["requested_search_type"] == "fast" |
| 154 | assert "effective_search_type" not in leg |
| 155 | assert "dummy-test-key" not in str(leg) |
| 156 |