Coverage for cli / tests / test_main.py: 100%
125 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-06-19 09:10 +0000
« prev ^ index » next coverage.py v7.14.0, created at 2026-06-19 09:10 +0000
1"""
2Tests for `_main` - orchestration of session creation, upload, status update, and cache
3purge.
4"""
6from pathlib import Path
7from typing import Any, TypedDict
8from unittest.mock import AsyncMock, MagicMock
10import httpx
11import pytest
12import typer
14from covered.cli import _main
16API_URL = "https://api.example.com"
17API_KEY = "test_api_key"
18REPO_OWNER = "owner"
19REPO_NAME = "repo"
20COMMIT_SHA = "abc123"
21GH_TOKEN = "gh_xyz"
22SITE_ID = "site-xyz"
24SESSION_RESP = {
25 "site_id": SITE_ID,
26 "bucket": "test-bucket",
27 "region": "us-east-1",
28 "access_key_id": "id",
29 "secret_access_key": "secret",
30 "session_token": "tok",
31}
33CAMO_URL = "https://camo.githubusercontent.com/abc123def/xyz789ABC"
34BADGE_HTML = (
35 f'<img alt="cov" src="{CAMO_URL}" '
36 f'data-canonical-src="{API_URL}/coverage/badge.svg">'
37)
40class PatchedMain(TypedDict):
41 """
42 Handles to the three mocks installed by the `patched_main` fixture.
43 """
45 request: AsyncMock
46 upload: AsyncMock
47 coverage: MagicMock
50def _main_kwargs(directory: Path, **overrides: Any) -> dict:
51 """
52 Build a kwargs dict for `_main`; override only what each test cares about.
53 """
54 return {
55 "directory": directory,
56 "api_url": API_URL,
57 "api_key": API_KEY,
58 "concurrency": 50,
59 "repo_owner": REPO_OWNER,
60 "repo_name": REPO_NAME,
61 "commit_sha": COMMIT_SHA,
62 "coverage_threshold": 90.0,
63 "gh_token": GH_TOKEN,
64 "purge_cache": False,
65 **overrides,
66 }
69@pytest.fixture
70def patched_main(monkeypatch: pytest.MonkeyPatch) -> PatchedMain:
71 """
72 Patch `_main`'s three collaborators; defaults: 95% coverage, 5 files uploaded.
73 """
74 request = AsyncMock()
75 upload = AsyncMock(return_value=5)
76 coverage = MagicMock(return_value=95.0)
78 monkeypatch.setattr("covered.cli._request", request)
79 monkeypatch.setattr("covered.cli._upload_files", upload)
80 monkeypatch.setattr("covered.cli._get_coverage_info", coverage)
82 return {"request": request, "upload": upload, "coverage": coverage}
85async def test_main_creates_session_then_uploads_then_sets_status(
86 patched_main: PatchedMain, tmp_path: Path
87):
88 """
89 Happy path: session is created, files uploaded, GitHub status posted.
90 """
91 patched_main["request"].side_effect = [
92 httpx.Response(200, json=SESSION_RESP), # create-site
93 httpx.Response(201), # github status
94 ]
96 await _main(**_main_kwargs(tmp_path))
98 calls = patched_main["request"].call_args_list
99 assert len(calls) == 2
100 assert calls[0].args == ("POST", f"{API_URL}/coverage/create-site/")
101 patched_main["upload"].assert_awaited_once_with(tmp_path, SESSION_RESP, 50)
102 assert calls[1].args == (
103 "POST",
104 f"https://api.github.com/repos/{REPO_OWNER}/{REPO_NAME}/statuses/{COMMIT_SHA}",
105 )
108async def test_main_sets_success_status_when_coverage_meets_threshold(
109 patched_main: PatchedMain, tmp_path: Path
110):
111 """
112 When coverage > threshold, the posted status `state` is `success`.
113 """
114 patched_main["coverage"].return_value = 95.0
115 patched_main["request"].side_effect = [
116 httpx.Response(200, json=SESSION_RESP), # create-site
117 httpx.Response(201), # github status
118 ]
120 await _main(**_main_kwargs(tmp_path, coverage_threshold=90.0))
122 assert (
123 patched_main["request"].call_args_list[1].kwargs["json"]["state"] == "success"
124 )
127async def test_main_sets_failure_status_when_coverage_below_threshold(
128 patched_main: PatchedMain, tmp_path: Path
129):
130 """
131 When coverage < threshold, the posted status `state` is `failure`.
132 """
133 patched_main["coverage"].return_value = 80.0
134 patched_main["request"].side_effect = [
135 httpx.Response(200, json=SESSION_RESP), # create-site
136 httpx.Response(201), # github status
137 ]
139 await _main(**_main_kwargs(tmp_path, coverage_threshold=90.0))
141 assert (
142 patched_main["request"].call_args_list[1].kwargs["json"]["state"] == "failure"
143 )
146async def test_main_status_at_threshold_is_success(
147 patched_main: PatchedMain, tmp_path: Path
148):
149 """
150 Boundary: coverage exactly equal to threshold yields `success`, not `failure`.
151 """
152 patched_main["coverage"].return_value = 90.0
153 patched_main["request"].side_effect = [
154 httpx.Response(200, json=SESSION_RESP), # create-site
155 httpx.Response(201), # github status
156 ]
158 await _main(**_main_kwargs(tmp_path, coverage_threshold=90.0))
160 assert (
161 patched_main["request"].call_args_list[1].kwargs["json"]["state"] == "success"
162 )
165async def test_main_skips_status_update_when_coverage_missing(
166 patched_main: PatchedMain, tmp_path: Path
167):
168 """
169 If `_get_coverage_info` returns None, no GitHub status call is made and `_main`
170 returns early.
171 """
172 patched_main["coverage"].return_value = None
173 patched_main["request"].side_effect = [
174 httpx.Response(200, json=SESSION_RESP), # create-site
175 ]
177 await _main(**_main_kwargs(tmp_path))
179 assert patched_main["request"].call_count == 1
182async def test_main_exits_with_error_when_status_post_fails(
183 patched_main: PatchedMain, tmp_path: Path
184):
185 """
186 Non-201 response from the GitHub status endpoint raises `typer.Exit(1)`.
187 """
188 patched_main["request"].side_effect = [
189 httpx.Response(200, json=SESSION_RESP), # create-site
190 httpx.Response(500, text="oops"), # github status failed
191 ]
193 with pytest.raises(typer.Exit) as exc_info:
194 await _main(**_main_kwargs(tmp_path))
196 assert exc_info.value.exit_code == 1
199async def test_main_status_target_url_points_at_site(
200 patched_main: PatchedMain, tmp_path: Path
201):
202 """
203 The status payload's `target_url` is `{api_url}/coverage/{site_id}/`.
204 """
205 patched_main["request"].side_effect = [
206 httpx.Response(200, json=SESSION_RESP), # create-site
207 httpx.Response(201), # github status
208 ]
210 await _main(**_main_kwargs(tmp_path))
212 payload = patched_main["request"].call_args_list[1].kwargs["json"]
213 assert payload["target_url"] == f"{API_URL}/coverage/{SITE_ID}/"
216async def test_main_status_request_headers(patched_main: PatchedMain, tmp_path: Path):
217 """
218 The status request uses `Authorization: Bearer <gh_token>` and
219 `Accept: application/vnd.github+json`.
220 """
221 patched_main["request"].side_effect = [
222 httpx.Response(200, json=SESSION_RESP), # create-site
223 httpx.Response(201), # github status
224 ]
226 await _main(**_main_kwargs(tmp_path))
228 headers = patched_main["request"].call_args_list[1].kwargs["headers"]
229 assert headers["Authorization"] == f"Bearer {GH_TOKEN}"
230 assert headers["Accept"] == "application/vnd.github+json"
233async def test_main_skips_cache_purge_when_purge_cache_false(
234 patched_main: PatchedMain, tmp_path: Path
235):
236 """
237 When `purge_cache=False`, no invalidate-cache or README calls are made.
238 """
239 patched_main["request"].side_effect = [
240 httpx.Response(200, json=SESSION_RESP), # create-site
241 httpx.Response(201), # github status
242 ]
244 await _main(**_main_kwargs(tmp_path, purge_cache=False))
246 assert patched_main["request"].call_count == 2
249async def test_main_invalidates_cache_when_purge_cache_true(
250 patched_main: PatchedMain, tmp_path: Path
251):
252 """
253 When `purge_cache=True`, POST to /invalidate-cache/{owner}/{repo}/ with the token
254 header.
255 """
256 patched_main["request"].side_effect = [
257 httpx.Response(200, json=SESSION_RESP), # create-site
258 httpx.Response(201), # github status
259 httpx.Response(200), # invalidate-cache
260 httpx.Response(200, text=""), # README (no badge match -> early return)
261 ]
263 await _main(**_main_kwargs(tmp_path, purge_cache=True))
265 invalidate = patched_main["request"].call_args_list[2]
266 assert invalidate.args == (
267 "POST",
268 f"{API_URL}/coverage/invalidate-cache/{REPO_OWNER}/{REPO_NAME}/",
269 )
270 assert invalidate.kwargs["headers"] == {"token": API_KEY}
273async def test_main_logs_failure_but_continues_when_invalidate_cache_fails(
274 patched_main: PatchedMain, tmp_path: Path
275):
276 """
277 A non-200 invalidate-cache response is logged but the flow continues to Camo purge.
278 """
279 patched_main["request"].side_effect = [
280 httpx.Response(200, json=SESSION_RESP), # create-site
281 httpx.Response(201), # github status
282 httpx.Response(500, text="oops"), # invalidate-cache failed
283 httpx.Response(200, text=""), # README still attempted
284 ]
286 await _main(**_main_kwargs(tmp_path, purge_cache=True))
288 # 4 calls: create-site, status, invalidate (failed), README
289 assert patched_main["request"].call_count == 4
292async def test_main_fetches_readme_with_html_accept_header(
293 patched_main: PatchedMain, tmp_path: Path
294):
295 """
296 The README request uses `Accept: application/vnd.github.html+json`.
297 """
298 patched_main["request"].side_effect = [
299 httpx.Response(200, json=SESSION_RESP), # create-site
300 httpx.Response(201), # github status
301 httpx.Response(200), # invalidate-cache
302 httpx.Response(200, text=""), # README (no badge match -> early return)
303 ]
305 await _main(**_main_kwargs(tmp_path, purge_cache=True))
307 readme = patched_main["request"].call_args_list[3]
308 assert readme.args == (
309 "GET",
310 f"https://api.github.com/repos/{REPO_OWNER}/{REPO_NAME}/readme",
311 )
312 assert readme.kwargs["headers"]["Accept"] == "application/vnd.github.html+json"
313 assert readme.kwargs["headers"]["Authorization"] == f"Bearer {GH_TOKEN}"
316async def test_main_purge_aborts_when_readme_fetch_fails(
317 patched_main: PatchedMain, tmp_path: Path
318):
319 """
320 A non-200 README response is logged and `_main` returns without sending PURGE.
321 """
322 patched_main["request"].side_effect = [
323 httpx.Response(200, json=SESSION_RESP), # create-site
324 httpx.Response(201), # github status
325 httpx.Response(200), # invalidate-cache
326 httpx.Response(404), # README fetch failed
327 ]
329 await _main(**_main_kwargs(tmp_path, purge_cache=True))
331 assert patched_main["request"].call_count == 4
334async def test_main_purge_aborts_when_badge_not_found_in_readme(
335 patched_main: PatchedMain, tmp_path: Path
336):
337 """
338 If the README has no matching `<img>`, `_main` logs and returns - no PURGE call.
339 """
340 patched_main["request"].side_effect = [
341 httpx.Response(200, json=SESSION_RESP), # create-site
342 httpx.Response(201), # github status
343 httpx.Response(200), # invalidate-cache
344 httpx.Response(200, text="<html>no badge here</html>"), # README (no badge)
345 ]
347 await _main(**_main_kwargs(tmp_path, purge_cache=True))
349 assert patched_main["request"].call_count == 4
352async def test_main_purges_camo_url_extracted_from_readme(
353 patched_main: PatchedMain, tmp_path: Path
354):
355 """
356 PURGE is sent to the Camo URL captured by the badge regex from the README.
357 """
358 patched_main["request"].side_effect = [
359 httpx.Response(200, json=SESSION_RESP), # create-site
360 httpx.Response(201), # github status
361 httpx.Response(200), # invalidate-cache
362 httpx.Response(200, text=BADGE_HTML), # README with matching badge
363 httpx.Response(200), # PURGE
364 ]
366 await _main(**_main_kwargs(tmp_path, purge_cache=True))
368 purge = patched_main["request"].call_args_list[4]
369 assert purge.args == ("PURGE", CAMO_URL)
372async def test_main_badge_regex_only_matches_camo_with_matching_canonical_src(
373 patched_main: PatchedMain, tmp_path: Path
374):
375 """
376 A Camo `<img>` whose `data-canonical-src` does not match `api_url` is ignored.
377 """
378 other_html = (
379 f'<img src="{CAMO_URL}" data-canonical-src="https://other.example.com/badge">'
380 )
381 patched_main["request"].side_effect = [
382 httpx.Response(200, json=SESSION_RESP), # create-site
383 httpx.Response(201), # github status
384 httpx.Response(200), # invalidate-cache
385 httpx.Response(200, text=other_html), # README - badge points elsewhere
386 ]
388 await _main(**_main_kwargs(tmp_path, purge_cache=True))
390 assert patched_main["request"].call_count == 4
393async def test_main_logs_failure_when_camo_purge_returns_non_200(
394 patched_main: PatchedMain, tmp_path: Path
395):
396 """
397 A non-200 PURGE response is logged; `_main` does not raise or exit non-zero.
398 """
399 patched_main["request"].side_effect = [
400 httpx.Response(200, json=SESSION_RESP), # create-site
401 httpx.Response(201), # github status
402 httpx.Response(200), # invalidate-cache
403 httpx.Response(200, text=BADGE_HTML), # README with matching badge
404 httpx.Response(500), # PURGE failed
405 ]
407 await _main(**_main_kwargs(tmp_path, purge_cache=True))
409 assert patched_main["request"].call_count == 5
412async def test_main_create_site_uses_api_key_header_and_120s_timeout(
413 patched_main: PatchedMain, tmp_path: Path
414):
415 """
416 The create-site request sends the `token` header and uses the longer 120s timeout.
417 """
418 patched_main["request"].side_effect = [
419 httpx.Response(200, json=SESSION_RESP), # create-site
420 httpx.Response(201), # github status
421 ]
423 await _main(**_main_kwargs(tmp_path))
425 create_call = patched_main["request"].call_args_list[0]
426 assert create_call.kwargs["headers"] == {"token": API_KEY}
427 assert create_call.kwargs["timeout"] == 120