Coverage for tests / scripts / test_ghcr_prune_untagged.py: 99%

117 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2026-04-03 18:53 +0000

1"""Tests for the GHCR prune untagged utility script.""" 

2 

3from __future__ import annotations 

4 

5from collections.abc import Mapping 

6from typing import TYPE_CHECKING, Any, cast 

7 

8import pytest 

9from assertpy import assert_that 

10 

11from scripts.ci.maintenance.ghcr_prune_untagged import ( 

12 GhcrClient, 

13 GhcrVersion, 

14 delete_version, 

15 list_container_versions, 

16 main, 

17) 

18 

19if TYPE_CHECKING: 

20 from types import TracebackType 

21 

22 

23# ============================================================================= 

24# Shared Mock Classes 

25# ============================================================================= 

26 

27 

28class MockOwnerResponse: 

29 """Mock response for owner type lookup.""" 

30 

31 def __init__(self) -> None: 

32 """Initialize mock response with default status code 200.""" 

33 self.status_code = 200 

34 self.headers: dict[str, str] = {} 

35 

36 def raise_for_status(self) -> None: 

37 """No-op since this is a successful response.""" 

38 return 

39 

40 def json(self) -> dict[str, str]: 

41 """Return mock user type data.""" 

42 return {"type": "User"} 

43 

44 

45class MockDeleteResponse: 

46 """Mock response for delete operations.""" 

47 

48 def __init__(self, status_code: int = 204) -> None: 

49 """Initialize mock response with configurable status code.""" 

50 self.status_code = status_code 

51 

52 def raise_for_status(self) -> None: # pragma: no cover 

53 """Raise error for unexpected status codes.""" 

54 if self.status_code not in (204, 404): 

55 raise RuntimeError("boom") 

56 

57 

58def make_versions_response( 

59 versions_data: list[dict[str, Any]], 

60 status_code: int = 200, 

61) -> type: 

62 """Factory for mock GET response with version data. 

63 

64 Args: 

65 versions_data: List of version dictionaries to return. 

66 status_code: HTTP status code for the response. 

67 

68 Returns: 

69 Mock response class. 

70 """ 

71 import httpx 

72 

73 class MockVersionsResponse: 

74 def __init__(self) -> None: 

75 self.status_code = status_code 

76 self.headers: dict[str, str] = {} 

77 

78 def raise_for_status(self) -> None: 

79 if self.status_code == 404: 

80 raise httpx.HTTPStatusError( 

81 message="Not Found", 

82 request=httpx.Request("GET", "http://test"), 

83 response=httpx.Response(404), 

84 ) 

85 

86 def json(self) -> list[dict[str, Any]]: 

87 return versions_data 

88 

89 return MockVersionsResponse 

90 

91 

92def make_mock_client( 

93 versions_data: list[dict[str, Any]], 

94 deleted: list[int], 

95 missing_packages: list[str] | None = None, 

96) -> type: 

97 """Factory for mock httpx.Client with configurable behavior. 

98 

99 Args: 

100 versions_data: Version data to return from GET requests. 

101 deleted: List to append deleted version IDs to. 

102 missing_packages: Package names that should return 404. 

103 

104 Returns: 

105 Mock client class. 

106 """ 

107 missing = missing_packages or [] 

108 versions_resp_cls = make_versions_response(versions_data) 

109 not_found_resp_cls = make_versions_response([], status_code=404) 

110 

111 class _MockClient: # noqa: N801 - intentional class name for factory pattern 

112 def __init__( 

113 self, 

114 headers: dict[str, str], 

115 timeout: int, 

116 ) -> None: # noqa: ARG002 

117 pass 

118 

119 def __enter__(self) -> _MockClient: 

120 return self 

121 

122 def __exit__( 

123 self, 

124 exc_type: type[BaseException] | None, 

125 exc: BaseException | None, 

126 tb: TracebackType | None, 

127 ) -> None: 

128 return None 

129 

130 def get( 

131 self, 

132 url: str, 

133 headers: dict[str, str], 

134 ) -> MockOwnerResponse | Any: # noqa: ARG002 

135 # Owner type lookup returns a dict with "type" field 

136 if "/users/" in url and "/packages/" not in url: 

137 return MockOwnerResponse() 

138 # Check for missing packages 

139 for pkg in missing: 

140 if pkg in url: 

141 return not_found_resp_cls() 

142 return versions_resp_cls() 

143 

144 def delete( 

145 self, 

146 url: str, 

147 headers: dict[str, str], 

148 ) -> MockDeleteResponse: # noqa: ARG002 

149 deleted.append(int(url.rstrip("/").split("/")[-1])) 

150 return MockDeleteResponse() 

151 

152 return _MockClient 

153 

154 

155# ============================================================================= 

156# Tests 

157# ============================================================================= 

158 

159 

160def test_version_dataclass() -> None: 

161 """Construct ``GhcrVersion`` and validate fields are populated.""" 

162 v = GhcrVersion(id=123, tags=["latest"]) 

163 assert_that(v.id).is_equal_to(123) 

164 assert_that(v.tags).is_equal_to(["latest"]) 

165 

166 

167def test_list_container_versions_parses_minimal_structure( 

168 monkeypatch: pytest.MonkeyPatch, 

169) -> None: 

170 """Parse a minimal response structure into version objects. 

171 

172 Args: 

173 monkeypatch: Pytest monkeypatch fixture (not used). 

174 """ 

175 

176 class DummyResp: 

177 def __init__(self, data: list[dict[str, Any]]) -> None: 

178 self._data = data 

179 self.headers: dict[str, str] = {} 

180 

181 def raise_for_status(self) -> None: # pragma: no cover 

182 return 

183 

184 def json(self) -> list[dict[str, Any]]: 

185 return self._data 

186 

187 class DummyClient: 

188 def get( 

189 self, 

190 url: str, 

191 *, 

192 headers: Mapping[str, str] | None = None, 

193 ) -> DummyResp | MockOwnerResponse: # noqa: ARG002 

194 # Owner type lookup returns a dict 

195 if "/users/" in url and "/packages/" not in url: 

196 return MockOwnerResponse() 

197 # Package versions returns a list 

198 return DummyResp( 

199 data=[ 

200 {"id": 1, "metadata": {"container": {"tags": ["latest"]}}}, 

201 {"id": 2, "metadata": {"container": {"tags": []}}}, 

202 ], 

203 ) 

204 

205 # DummyClient is a test mock implementing only the methods needed for this test 

206 # Cast to GhcrClient - the mock only implements get() which is sufficient here 

207 versions = list_container_versions( 

208 client=cast(GhcrClient, DummyClient()), 

209 owner="owner", 

210 ) 

211 assert_that([v.id for v in versions]).is_equal_to([1, 2]) 

212 assert_that(versions[0].tags).is_equal_to(["latest"]) 

213 assert_that(versions[1].tags).is_equal_to([]) 

214 

215 

216def test_delete_version_calls_delete(monkeypatch: pytest.MonkeyPatch) -> None: 

217 """Call delete and ensure correct endpoint is used. 

218 

219 Args: 

220 monkeypatch: Pytest monkeypatch fixture (not used). 

221 """ 

222 calls: list[tuple[str, Mapping[str, str]]] = [] 

223 

224 class DummyClient: 

225 def delete( 

226 self, 

227 url: str, 

228 *, 

229 headers: Mapping[str, str] | None = None, 

230 ) -> MockDeleteResponse: # noqa: ARG002 

231 calls.append((url, headers or {})) 

232 return MockDeleteResponse() 

233 

234 # DummyClient is a test mock that only implements delete(). 

235 # Pass base_path to avoid owner type lookup (DummyClient doesn't implement get()). 

236 # Cast to GhcrClient - the mock only implements delete() which is sufficient here 

237 delete_version( 

238 client=cast(GhcrClient, DummyClient()), 

239 owner="owner", 

240 version_id=42, 

241 base_path="https://api.github.com/users/owner/packages/container", 

242 ) 

243 assert_that(calls).is_not_empty() 

244 assert_that(calls[0][0]).contains("versions/42") 

245 

246 

247def test_delete_version_raises_on_non_204_non_404() -> None: 

248 """Raise when the delete operation returns an unexpected status code. 

249 

250 Raises: 

251 AssertionError: If the expected RuntimeError is not raised. 

252 """ 

253 

254 class DummyClient: 

255 def delete( 

256 self, 

257 url: str, 

258 *, 

259 headers: Mapping[str, str] | None = None, 

260 ) -> MockDeleteResponse: # noqa: ARG002 

261 return MockDeleteResponse(status_code=500) 

262 

263 try: 

264 # DummyClient is a test mock that only implements delete(). 

265 # Pass base_path to avoid owner type lookup (DummyClient doesn't implement get()). 

266 # Cast to GhcrClient - the mock only implements delete() which is sufficient here 

267 delete_version( 

268 client=cast(GhcrClient, DummyClient()), 

269 owner="owner", 

270 version_id=1, 

271 base_path="https://api.github.com/users/owner/packages/container", 

272 ) 

273 except RuntimeError: 

274 return 

275 raise AssertionError("Expected RuntimeError on non-204/404 response") 

276 

277 

278def test_main_deletes_only_untagged(monkeypatch: pytest.MonkeyPatch) -> None: 

279 """Delete only untagged versions using the main entry point. 

280 

281 Args: 

282 monkeypatch: Pytest monkeypatch fixture for environment and client. 

283 """ 

284 import httpx 

285 

286 import scripts.ci.maintenance.ghcr_prune_untagged as mod 

287 

288 deleted: list[int] = [] 

289 versions_data = [ 

290 { 

291 "id": 11, 

292 "created_at": "2025-08-24T10:00:00Z", 

293 "metadata": {"container": {"tags": ["latest"]}}, 

294 }, 

295 { 

296 "id": 22, 

297 "created_at": "2025-08-24T09:00:00Z", 

298 "metadata": {"container": {"tags": []}}, 

299 }, 

300 { 

301 "id": 33, 

302 "created_at": "2025-08-24T08:00:00Z", 

303 "metadata": {"container": {"tags": ["0.4.1"]}}, 

304 }, 

305 { 

306 "id": 44, 

307 "created_at": "2025-08-24T07:00:00Z", 

308 "metadata": {"container": {"tags": []}}, 

309 }, 

310 ] 

311 

312 mock_client = make_mock_client( 

313 versions_data=versions_data, 

314 deleted=deleted, 

315 missing_packages=["lintro-tools"], 

316 ) 

317 

318 mock_httpx = type( 

319 "MockHttpx", 

320 (), 

321 {"Client": mock_client, "HTTPStatusError": httpx.HTTPStatusError}, 

322 ) 

323 

324 monkeypatch.setenv("GITHUB_TOKEN", "x") 

325 monkeypatch.setenv("GITHUB_REPOSITORY", "owner/name") 

326 monkeypatch.setattr(mod, "httpx", mock_httpx) 

327 

328 rc = main() 

329 assert_that(rc).is_equal_to(0) 

330 # Only untagged IDs 22 and 44 should be deleted (from py-lintro package only) 

331 assert_that(sorted(deleted)).is_equal_to([22, 44]) 

332 

333 

334def test_main_respects_keep_n_and_dry_run(monkeypatch: pytest.MonkeyPatch) -> None: 

335 """Respect keep-N and dry-run options when pruning. 

336 

337 Args: 

338 monkeypatch: Pytest monkeypatch fixture for environment and client. 

339 """ 

340 import httpx 

341 

342 import scripts.ci.maintenance.ghcr_prune_untagged as mod 

343 

344 deleted: list[int] = [] 

345 # 3 untagged versions 

346 versions_data = [ 

347 { 

348 "id": 100, 

349 "created_at": "2025-08-24T12:00:00Z", 

350 "metadata": {"container": {"tags": []}}, 

351 }, 

352 { 

353 "id": 200, 

354 "created_at": "2025-08-24T11:00:00Z", 

355 "metadata": {"container": {"tags": []}}, 

356 }, 

357 { 

358 "id": 300, 

359 "created_at": "2025-08-24T10:00:00Z", 

360 "metadata": {"container": {"tags": []}}, 

361 }, 

362 ] 

363 

364 mock_client = make_mock_client( 

365 versions_data=versions_data, 

366 deleted=deleted, 

367 missing_packages=["lintro-tools"], 

368 ) 

369 

370 mock_httpx = type( 

371 "MockHttpx", 

372 (), 

373 {"Client": mock_client, "HTTPStatusError": httpx.HTTPStatusError}, 

374 ) 

375 

376 # Dry-run with keep 2 -> no deletions performed 

377 monkeypatch.setenv("GITHUB_TOKEN", "x") 

378 monkeypatch.setenv("GITHUB_REPOSITORY", "owner/name") 

379 monkeypatch.setenv("GHCR_PRUNE_DRY_RUN", "1") 

380 monkeypatch.setenv("GHCR_PRUNE_KEEP_UNTAGGED_N", "2") 

381 monkeypatch.setattr(mod, "httpx", mock_httpx) 

382 

383 rc = main() 

384 assert_that(rc).is_equal_to(0) 

385 # Keep 2 newest untagged (100, 200). Would delete only 300; dry-run prevents it 

386 assert_that(deleted).is_equal_to([])