返回 last30days-skill
test_watchlist_delivery.py
根目录 / tests / test_watchlist_delivery.py
1 """Tests for watchlist.py delivery functions (PR #86 feature)."""
2
3 from unittest.mock import patch
4
5 import pytest
6
7 import watchlist
8 from lib.http import HTTPError
9
10 # === Tests for _format_delivery_message() ===
11
12
13 def test_format_message_announce_mode():
14 """Test announce mode formatting (default mode with emoji)."""
15 message = watchlist._format_delivery_message(
16 "Test Topic", {"new": 5, "updated": 2}, "announce"
17 )
18
19 assert "📰" in message
20 assert "Test Topic" in message
21 assert "5 new" in message
22 assert "2 updated" in message
23
24
25 def test_format_message_silent_mode():
26 """Test silent mode formatting (no emoji)."""
27 message = watchlist._format_delivery_message(
28 "Test Topic", {"new": 5, "updated": 2}, "silent"
29 )
30
31 assert "📰" not in message
32 assert "Test Topic" in message
33 assert "5 new" in message
34
35
36 def test_format_message_default_mode():
37 """Test default mode formatting."""
38 message = watchlist._format_delivery_message(
39 "Test Topic", {"new": 5, "updated": 2}, "default"
40 )
41
42 assert "complete" in message.lower()
43 assert "Test Topic" in message
44
45
46 def test_format_message_handles_zero_counts():
47 """Test formatting with zero counts."""
48 message = watchlist._format_delivery_message(
49 "Test Topic", {"new": 0, "updated": 0}, "announce"
50 )
51
52 assert "0 new" in message
53 assert "0 updated" in message
54
55 # === Tests for _send_slack_webhook() ===
56
57 @patch('watchlist.http.post')
58
59
60 def test_send_slack_webhook_format(mock_post):
61 """Test that Slack webhook uses correct format."""
62 watchlist._send_slack_webhook(
63 "https://hooks.slack.com/services/TEST",
64 "Test message"
65 )
66
67 assert mock_post.called
68 call_args = mock_post.call_args
69
70 assert call_args[0][0] == "https://hooks.slack.com/services/TEST"
71 assert call_args[1]["json_data"] == {"text": "Test message"}
72 assert call_args[1]["timeout"] == 10
73
74 @patch('watchlist.http.post')
75
76
77 def test_send_slack_webhook_raises_on_error(mock_post):
78 """Test that Slack webhook raises on HTTP error."""
79 mock_post.side_effect = HTTPError("HTTP 400", 400)
80
81 with pytest.raises(HTTPError, match="HTTP 400"):
82 watchlist._send_slack_webhook(
83 "https://hooks.slack.com/services/TEST",
84 "Test message"
85 )
86
87 # === Tests for _send_generic_webhook() ===
88
89 @patch('watchlist.http.post')
90
91
92 def test_send_generic_webhook_format(mock_post):
93 """Test that generic webhook uses correct format."""
94 watchlist._send_generic_webhook(
95 "https://webhook.example.com/hook",
96 "Test message"
97 )
98
99 assert mock_post.called
100 call_args = mock_post.call_args
101
102 assert call_args[0][0] == "https://webhook.example.com/hook"
103
104 json_data = call_args[1]["json_data"]
105 assert json_data["message"] == "Test message"
106 assert json_data["source"] == "last30days"
107 assert "timestamp" in json_data
108 assert isinstance(json_data["timestamp"], float)
109
110 @patch('watchlist.http.post')
111
112
113 def test_send_generic_webhook_raises_on_error(mock_post):
114 """Test that generic webhook raises on HTTP error."""
115 mock_post.side_effect = HTTPError("HTTP 500", 500)
116
117 with pytest.raises(HTTPError, match="HTTP 500"):
118 watchlist._send_generic_webhook(
119 "https://webhook.example.com/hook",
120 "Test message"
121 )
122
123 # === Tests for _deliver_findings() ===
124
125 @patch('watchlist.store.get_setting')
126 @patch('watchlist.http.post')
127
128
129 def test_deliver_findings_sends_when_new_greater_than_zero(mock_post, mock_get_setting):
130 """Test that delivery fires when new > 0."""
131 mock_get_setting.side_effect = lambda key, default="": {
132 "delivery_channel": "https://webhook.example.com/test",
133 "delivery_mode": "announce",
134 }.get(key, default)
135
136 watchlist._deliver_findings("Test Topic", {"new": 5, "updated": 2})
137
138 assert mock_post.called
139
140 @patch('watchlist.store.get_setting')
141 @patch('watchlist.http.post')
142
143
144 def test_deliver_findings_skips_when_new_is_zero(mock_post, mock_get_setting):
145 """Test that delivery is skipped when new=0."""
146 mock_get_setting.side_effect = lambda key, default="": {
147 "delivery_channel": "https://webhook.example.com/test",
148 "delivery_mode": "announce",
149 }.get(key, default)
150
151 watchlist._deliver_findings("Test Topic", {"new": 0, "updated": 5})
152
153 assert not mock_post.called
154
155 @patch('watchlist.store.get_setting')
156 @patch('watchlist.http.post')
157
158
159 def test_deliver_findings_skips_when_channel_empty(mock_post, mock_get_setting):
160 """Test that delivery is skipped when delivery_channel is empty."""
161 mock_get_setting.side_effect = lambda key, default="": {
162 "delivery_channel": "",
163 "delivery_mode": "announce",
164 }.get(key, default)
165
166 watchlist._deliver_findings("Test Topic", {"new": 5, "updated": 2})
167
168 assert not mock_post.called
169
170 @patch('watchlist.store.get_setting')
171 @patch('watchlist.http.post')
172
173
174 def test_deliver_findings_uses_slack_format_for_slack_urls(mock_post, mock_get_setting):
175 """Test that Slack URLs trigger Slack-specific format."""
176 mock_get_setting.side_effect = lambda key, default="": {
177 "delivery_channel": "https://hooks.slack.com/services/TEST",
178 "delivery_mode": "announce",
179 }.get(key, default)
180
181 watchlist._deliver_findings("Test Topic", {"new": 5, "updated": 2})
182
183 json_data = mock_post.call_args[1]["json_data"]
184 assert "text" in json_data
185 assert "Test Topic" in json_data["text"]
186
187 @patch('watchlist.store.get_setting')
188 @patch('watchlist.http.post')
189
190
191 def test_deliver_findings_uses_generic_format_for_other_urls(mock_post, mock_get_setting):
192 """Test that non-Slack URLs trigger generic format."""
193 mock_get_setting.side_effect = lambda key, default="": {
194 "delivery_channel": "https://webhook.example.com/test",
195 "delivery_mode": "announce",
196 }.get(key, default)
197
198 watchlist._deliver_findings("Test Topic", {"new": 5, "updated": 2})
199
200 json_data = mock_post.call_args[1]["json_data"]
201 assert "message" in json_data
202 assert "source" in json_data
203 assert "timestamp" in json_data
204
205 @patch('watchlist.store.get_setting')
206 @patch('watchlist.http.post')
207
208
209 def test_deliver_findings_handles_failure_gracefully(mock_post, mock_get_setting, capsys):
210 """Test that delivery failures don't crash the process."""
211 mock_get_setting.side_effect = lambda key, default="": {
212 "delivery_channel": "https://webhook.example.com/test",
213 "delivery_mode": "announce",
214 }.get(key, default)
215
216 mock_post.side_effect = HTTPError("HTTP 500", 500)
217
218 # Should not raise, just log to stderr
219 watchlist._deliver_findings("Test Topic", {"new": 5, "updated": 2})
220
221 captured = capsys.readouterr()
222 assert "Delivery failed" in captured.err
223
224 @patch('watchlist.store.get_setting')
225 @patch('watchlist.http.post')
226
227
228 def test_deliver_findings_rejects_non_https_slack_substring(mock_post, mock_get_setting, capsys):
229 """A non-https channel that merely contains 'hooks.slack.com' must not be
230 sent. The old substring match POSTed it in cleartext to whatever host the
231 URL actually named."""
232 mock_get_setting.side_effect = lambda key, default="": {
233 "delivery_channel": "http://evil.example/hooks.slack.com",
234 "delivery_mode": "announce",
235 }.get(key, default)
236
237 watchlist._deliver_findings("Test Topic", {"new": 5, "updated": 2})
238
239 assert not mock_post.called
240 assert "https://" in capsys.readouterr().err
241
242 @patch('watchlist.store.get_setting')
243 @patch('watchlist.http.post')
244
245
246 def test_deliver_findings_slack_match_is_exact_host(mock_post, mock_get_setting):
247 """An https URL with 'hooks.slack.com' only in the path routes as generic,
248 not Slack — the match is on the exact hostname, not a substring."""
249 mock_get_setting.side_effect = lambda key, default="": {
250 "delivery_channel": "https://webhook.example.com/hooks.slack.com/x",
251 "delivery_mode": "announce",
252 }.get(key, default)
253
254 watchlist._deliver_findings("Test Topic", {"new": 5, "updated": 2})
255
256 json_data = mock_post.call_args[1]["json_data"]
257 assert "message" in json_data # generic payload shape
258 assert "text" not in json_data # not the Slack {"text": ...} shape
259
260 @patch('watchlist.store.get_setting')
261 @patch('watchlist.http.post')
262
263
264 def test_deliver_findings_respects_delivery_mode(mock_post, mock_get_setting):
265 """Test that different delivery modes produce different messages."""
266 mock_get_setting.side_effect = lambda key, default="": {
267 "delivery_channel": "https://webhook.example.com/test",
268 "delivery_mode": "announce",
269 }.get(key, default)
270
271 watchlist._deliver_findings("Test Topic", {"new": 5, "updated": 2})
272
273 announce_message = mock_post.call_args[1]["json_data"]["message"]
274 assert "📰" in announce_message
275
276 mock_get_setting.side_effect = lambda key, default="": {
277 "delivery_channel": "https://webhook.example.com/test",
278 "delivery_mode": "silent",
279 }.get(key, default)
280
281 watchlist._deliver_findings("Test Topic", {"new": 5, "updated": 2})
282
283 silent_message = mock_post.call_args[1]["json_data"]["message"]
284 assert "📰" not in silent_message
285
286 if __name__ == "__main__":
287 pytest.main([__file__, "-v"])
288
288 lines PYTHON