返回 last30days-skill
test_perplexity_fast_search.py
根目录 / tests / test_perplexity_fast_search.py
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
156 lines PYTHON