Coverage for tests/test_data_manager.py: 100%

498 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-11-13 19:28 +0000

1"""Deterministic lifecycle tests: no network access or wall-clock sleeps.""" 

2 

3import asyncio 

4import hashlib 

5import socket 

6from dataclasses import replace 

7 

8import httpx 

9import pytest 

10from conftest import course_payload 

11from pydantic import TypeAdapter 

12 

13from data_api.core.exceptions import DataNotAvailableException 

14from data_api.data.nthudata import FetchFailure, Freshness, NTHUDataManager 

15from data_api.domain.buses.services import BusesService, PreparedBusData 

16from data_api.domain.courses.services import CoursesService 

17 

18 

19class Clock: 

20 def __init__(self): 

21 self.now = 0.0 

22 

23 def __call__(self): 

24 return self.now 

25 

26 def advance(self, seconds=301): 

27 self.now += seconds 

28 

29 

30class Publisher: 

31 def __init__(self, payload=None, endpoint="test.json"): 

32 self.endpoint = endpoint 

33 self.payload = [] if payload is None else payload 

34 self.version = "a" 

35 self.manifest_override = None 

36 self.manifest_error = None 

37 self.data_error = None 

38 self.calls = [] 

39 self.sha256 = None 

40 

41 def manifest(self): 

42 entry = {"name": self.endpoint, "version": self.version} 

43 if self.sha256: 

44 entry["sha256"] = self.sha256 

45 return {"file_details": {"/": [entry]}} 

46 

47 async def __call__(self, request): 

48 self.calls.append(request.url.path) 

49 manifest = request.url.path == "/file_details.json" 

50 failure = self.manifest_error if manifest else self.data_error 

51 if isinstance(failure, Exception): 

52 raise failure 

53 if isinstance(failure, int): 

54 return httpx.Response(failure) 

55 if manifest: 

56 payload = ( 

57 self.manifest_override if self.manifest_override is not None else self.manifest() 

58 ) 

59 else: 

60 payload = self.payload 

61 if isinstance(payload, bytes): 

62 return httpx.Response(200, content=payload) 

63 return httpx.Response(200, json=payload) 

64 

65 def client(self): 

66 return httpx.AsyncClient(transport=httpx.MockTransport(self)) 

67 

68 

69async def test_prefetch_starts_datasets_concurrently_and_reports_failures(monkeypatch): 

70 manager = NTHUDataManager() 

71 started = set() 

72 all_started = asyncio.Event() 

73 endpoints = ["first.json", "unavailable.json", "last.json"] 

74 

75 async def get(endpoint): 

76 started.add(endpoint) 

77 if started == set(endpoints): 

78 all_started.set() 

79 await all_started.wait() 

80 if endpoint == "unavailable.json": 

81 raise DataNotAvailableException("Unavailable") 

82 return "version", [] 

83 

84 monkeypatch.setattr(manager, "get", get) 

85 async with asyncio.timeout(5): 

86 result = await manager.prefetch(endpoints) 

87 assert result == {"first.json": True, "unavailable.json": False, "last.json": True} 

88 assert await manager.prefetch([]) == {} 

89 

90 

91@pytest.mark.parametrize("initial,new", [([1], [2]), ([1], []), ({"a": 1}, {})]) 

92async def test_successful_replacement_including_empty(initial, new): 

93 clock, publisher = Clock(), Publisher(initial) 

94 manager = NTHUDataManager(clock=clock) 

95 async with manager.lifespan(publisher.client()): 

96 assert await manager.get("test.json") == ("a", initial) 

97 old = manager.state_for("test.json").snapshot 

98 publisher.version, publisher.payload = "b", new 

99 clock.advance() 

100 assert await manager.get("/test.json") == ("b", new) 

101 state = manager.state_for("test.json") 

102 assert state.snapshot is not old 

103 assert state.freshness == Freshness.CURRENT 

104 assert state.last_refresh_success_at == state.snapshot.loaded_at 

105 

106 

107@pytest.mark.parametrize( 

108 "failure,category", 

109 [ 

110 (httpx.ReadTimeout("secret URL"), "timeout"), 

111 (httpx.ConnectError("secret URL"), "connection"), 

112 (httpx.RemoteProtocolError("protocol"), "request"), 

113 (404, "http_status"), 

114 (500, "http_status"), 

115 (503, "http_status"), 

116 (b"not json", "invalid_json"), 

117 (b"<!doctype html><html><body>Index</body></html>", "invalid_json"), 

118 (b"null", "payload_type"), 

119 (b"42", "payload_type"), 

120 ], 

121) 

122@pytest.mark.parametrize("initial_loaded", [True, False]) 

123async def test_fetch_failures_preserve_last_known_good(failure, category, initial_loaded): 

124 clock, publisher = Clock(), Publisher([1]) 

125 manager = NTHUDataManager(clock=clock) 

126 async with manager.lifespan(publisher.client()): 

127 state = manager.state_for("test.json") 

128 if initial_loaded: 

129 await manager.get("test.json") 

130 old = state.snapshot 

131 publisher.version = "b" 

132 if isinstance(failure, bytes): 

133 publisher.payload = failure 

134 else: 

135 publisher.data_error = failure 

136 clock.advance() 

137 if initial_loaded: 

138 assert await manager.get("test.json") == ("a", [1]) 

139 else: 

140 with pytest.raises(DataNotAvailableException, match="temporarily unavailable"): 

141 await manager.get("test.json") 

142 assert state.snapshot is old 

143 assert state.last_error.category == category 

144 assert state.freshness == (Freshness.STALE if initial_loaded else Freshness.UNAVAILABLE) 

145 calls = len(publisher.calls) 

146 if initial_loaded: 

147 await manager.get("test.json") 

148 else: 

149 with pytest.raises(DataNotAvailableException): 

150 await manager.get("test.json") 

151 assert len(publisher.calls) == calls 

152 assert "secret" not in str(state.last_error) 

153 

154 

155async def test_dns_category_preserves_cause(): 

156 publisher = Publisher() 

157 error = httpx.ConnectError("connection") 

158 error.__cause__ = socket.gaierror("DNS") 

159 publisher.data_error = error 

160 manager = NTHUDataManager() 

161 async with manager.lifespan(publisher.client()): 

162 with pytest.raises(DataNotAvailableException): 

163 await manager.get("test.json") 

164 assert manager.state_for("test.json").last_error.category == "dns" 

165 

166 

167async def test_ttl_and_version_matching(): 

168 clock, publisher = Clock(), Publisher([1]) 

169 manager = NTHUDataManager(clock=clock) 

170 async with manager.lifespan(publisher.client()): 

171 await manager.get("test.json") 

172 state = manager.state_for("test.json") 

173 old = state.snapshot 

174 clock.advance(299) 

175 await manager.get("test.json") 

176 assert len(publisher.calls) == 2 

177 clock.advance(1) 

178 await manager.get("test.json") 

179 assert publisher.calls == ["/file_details.json", "/test.json", "/file_details.json"] 

180 assert state.snapshot is old 

181 assert state.freshness == Freshness.CURRENT 

182 assert state.last_refresh_success_at == old.loaded_at 

183 

184 

185async def test_only_version_changes_trigger_refresh(): 

186 clock, publisher = Clock(), Publisher([1]) 

187 entry = {"name": "test.json", "version": "a", "last_commit": "commit-a"} 

188 publisher.manifest_override = {"file_details": {"/": [entry]}} 

189 manager = NTHUDataManager(clock=clock) 

190 async with manager.lifespan(publisher.client()): 

191 assert await manager.get("test.json") == ("a", [1]) 

192 state = manager.state_for("test.json") 

193 old = state.snapshot 

194 

195 entry["last_commit"] = "commit-b" 

196 entry["sha256"] = hashlib.sha256(b"[2]").hexdigest() 

197 publisher.payload = b"[2]" 

198 clock.advance() 

199 assert await manager.get("test.json") == ("a", [1]) 

200 assert state.snapshot is old 

201 assert state.freshness == Freshness.CURRENT 

202 assert publisher.calls.count("/test.json") == 1 

203 

204 entry["version"] = "b" 

205 clock.advance() 

206 assert await manager.get("test.json") == ("b", [2]) 

207 assert state.snapshot is not old 

208 assert state.freshness == Freshness.CURRENT 

209 assert publisher.calls.count("/test.json") == 2 

210 

211 

212@pytest.mark.parametrize("loaded", [False, True]) 

213@pytest.mark.parametrize("metadata", [{}, {"version": None}]) 

214async def test_missing_version_never_falls_back_to_last_commit(loaded, metadata): 

215 clock, publisher = Clock(), Publisher([1]) 

216 manager = NTHUDataManager(clock=clock) 

217 async with manager.lifespan(publisher.client()): 

218 if loaded: 

219 await manager.get("test.json") 

220 state = manager.state_for("test.json") 

221 old = state.snapshot 

222 publisher.manifest_override = { 

223 "file_details": {"/": [{"name": "test.json", "last_commit": "commit-b", **metadata}]} 

224 } 

225 publisher.payload = [2] 

226 clock.advance() 

227 assert await manager.get("test.json") == (("a", [1]) if loaded else (None, [2])) 

228 if loaded: 

229 assert state.snapshot is old 

230 assert state.freshness == Freshness.UNVERIFIED 

231 assert state.last_error.category == "version_unknown" 

232 assert publisher.calls.count("/test.json") == 1 

233 

234 

235@pytest.mark.parametrize("current,expected", [("a", None), (None, None), (None, "a"), ("a", "b")]) 

236async def test_unknown_versions_are_not_a_match(current, expected): 

237 clock, publisher = Clock(), Publisher([1]) 

238 manager = NTHUDataManager(clock=clock) 

239 async with manager.lifespan(publisher.client()): 

240 await manager.get("test.json") 

241 state = manager.state_for("test.json") 

242 state.snapshot = replace(state.snapshot, version=current) 

243 publisher.version, publisher.payload = expected, [2] 

244 clock.advance() 

245 version, data = await manager.get("test.json") 

246 if expected is None: 

247 assert (version, data) == (current, [1]) 

248 assert state.freshness == Freshness.UNVERIFIED 

249 assert publisher.calls.count("/test.json") == 1 

250 else: 

251 assert (version, data) == (expected, [2]) 

252 assert publisher.calls.count("/test.json") == 2 

253 

254 

255@pytest.mark.parametrize( 

256 "manifest,category", 

257 [ 

258 ([], "manifest_parsing"), 

259 ({}, "manifest_parsing"), 

260 ({"file_details": []}, "manifest_parsing"), 

261 ({"file_details": {"/": [42]}}, "manifest_parsing"), 

262 ({"file_details": {"/": [{"name": "test.json", "version": 42}]}}, "manifest_parsing"), 

263 ({"file_details": {"/": [{"name": "test.json", "version": ""}]}}, "manifest_parsing"), 

264 ({"file_details": {"/": [{"name": "test.json", "sha256": "bad"}]}}, "manifest_parsing"), 

265 ({"file_details": {"/": []}}, "version_unknown"), 

266 ({"file_details": {"/": [{"name": "test.json"}]}}, "version_unknown"), 

267 (b"bad json", "manifest_parsing"), 

268 ], 

269) 

270async def test_manifest_contract_failures(manifest, category): 

271 clock, publisher = Clock(), Publisher([1]) 

272 manager = NTHUDataManager(clock=clock) 

273 async with manager.lifespan(publisher.client()): 

274 await manager.get("test.json") 

275 state = manager.state_for("test.json") 

276 old = state.snapshot 

277 clock.advance() 

278 publisher.manifest_override = manifest 

279 assert await manager.get("test.json") == ("a", [1]) 

280 assert state.snapshot is old 

281 assert state.last_error.category == category 

282 assert state.freshness == Freshness.UNVERIFIED 

283 assert publisher.calls.count("/test.json") == 1 

284 

285 

286async def test_manifest_extra_fields_optional_metadata_and_nested_paths(): 

287 publisher = Publisher([1], endpoint="libraries/rss.json") 

288 publisher.manifest_override = { 

289 "extra": "ignored", 

290 "file_details": { 

291 "libraries": [ 

292 {"name": "rss.json", "version": "b", "last_commit": 42, "extra": "ignored"} 

293 ], 

294 }, 

295 } 

296 manager = NTHUDataManager(base_url="https://example.com/") 

297 # Generic payload for a nested endpoint, independent of the RSS schema. 

298 manager.register("/libraries/rss.json", lambda raw: raw) 

299 async with manager.lifespan(publisher.client()): 

300 assert await manager.get("libraries/rss.json") == ("b", [1]) 

301 assert publisher.calls == ["/file_details.json", "/libraries/rss.json"] 

302 assert manager.state_for("libraries/rss.json").snapshot.published_at is None 

303 

304 

305@pytest.mark.parametrize("loaded", [False, True]) 

306async def test_manifest_outage_never_claims_freshness(loaded): 

307 clock, publisher = Clock(), Publisher([1]) 

308 manager = NTHUDataManager(clock=clock) 

309 async with manager.lifespan(publisher.client()): 

310 if loaded: 

311 await manager.get("test.json") 

312 publisher.manifest_error = 503 

313 clock.advance() 

314 assert await manager.get("test.json") == ("a" if loaded else None, [1]) 

315 state = manager.state_for("test.json") 

316 assert state.freshness == Freshness.UNVERIFIED 

317 assert state.last_error.status_code == 503 

318 count = len(publisher.calls) 

319 await manager.get("test.json") 

320 assert len(publisher.calls) == count 

321 

322 

323@pytest.mark.parametrize("stage", ["validation", "transformation", "programming"]) 

324async def test_candidate_failure_is_atomic(stage): 

325 clock, publisher = Clock(), Publisher([1]) 

326 manager = NTHUDataManager(clock=clock) 

327 

328 def prepare(raw): 

329 if raw == [2]: 

330 if stage == "validation": 

331 TypeAdapter(list[str]).validate_python(raw) 

332 elif stage == "transformation": 

333 raise ValueError("Failed model conversion") 

334 else: 

335 raise RuntimeError("Programming error must not be swallowed") 

336 return {"index": {str(value): value for value in raw}} 

337 

338 state = manager.register("test.json", prepare) 

339 async with manager.lifespan(publisher.client()): 

340 await manager.get("test.json") 

341 old = state.snapshot 

342 publisher.version, publisher.payload = "b", [2] 

343 clock.advance() 

344 if stage == "programming": 

345 with pytest.raises(RuntimeError, match="Programming"): 

346 await manager.get("test.json") 

347 else: 

348 assert await manager.get("test.json") == ("a", [1]) 

349 assert state.last_error.category == stage 

350 assert state.snapshot is old 

351 assert state.snapshot.data == {"index": {"1": 1}} 

352 

353 

354@pytest.mark.parametrize("fail", [False, True]) 

355async def test_twenty_concurrent_callers_single_refresh(fail): 

356 clock, publisher = Clock(), Publisher([1]) 

357 entered, release = asyncio.Event(), asyncio.Event() 

358 

359 async def handler(request): 

360 if request.url.path == "/test.json" and publisher.version == "b": 

361 entered.set() 

362 await release.wait() 

363 return await publisher(request) 

364 

365 manager = NTHUDataManager(clock=clock) 

366 async with manager.lifespan(httpx.AsyncClient(transport=httpx.MockTransport(handler))): 

367 await manager.get("test.json") 

368 old = manager.state_for("test.json").snapshot 

369 publisher.version, publisher.payload = "b", [2] 

370 if fail: 

371 publisher.data_error = 503 

372 clock.advance() 

373 tasks = [asyncio.create_task(manager.get("test.json")) for _ in range(20)] 

374 await asyncio.wait_for(entered.wait(), 2) 

375 assert manager.state_for("test.json").snapshot is old 

376 release.set() 

377 results = await asyncio.gather(*tasks) 

378 assert all(result == (("a", [1]) if fail else ("b", [2])) for result in results) 

379 assert publisher.calls.count("/test.json") == 2 

380 assert publisher.calls.count("/file_details.json") == 2 

381 

382 

383async def test_unrelated_dataset_downloads_do_not_block(): 

384 entered, release = asyncio.Event(), asyncio.Event() 

385 publisher = Publisher([1]) 

386 publisher.manifest_override = { 

387 "file_details": { 

388 "/": [ 

389 {"name": "slow.json", "version": "a"}, 

390 {"name": "fast.json", "version": "a"}, 

391 ] 

392 } 

393 } 

394 

395 async def handler(request): 

396 if request.url.path == "/slow.json": 

397 entered.set() 

398 await release.wait() 

399 return await publisher(request) 

400 

401 manager = NTHUDataManager() 

402 async with manager.lifespan(httpx.AsyncClient(transport=httpx.MockTransport(handler))): 

403 slow = asyncio.create_task(manager.get("slow.json")) 

404 try: 

405 await asyncio.wait_for(entered.wait(), 2) 

406 assert await asyncio.wait_for(manager.get("fast.json"), 2) == ("a", [1]) 

407 finally: 

408 release.set() 

409 await slow 

410 assert publisher.calls.count("/file_details.json") == 1 

411 

412 

413async def test_checksum_prevents_mislabeled_snapshot_during_publication(): 

414 clock, publisher = Clock(), Publisher([1]) 

415 manager = NTHUDataManager(clock=clock) 

416 async with manager.lifespan(publisher.client()): 

417 await manager.get("test.json") 

418 publisher.version = "b" 

419 publisher.sha256 = hashlib.sha256(b"[2]").hexdigest() 

420 clock.advance() 

421 assert await manager.get("test.json") == ("a", [1]) 

422 assert manager.state_for("test.json").last_error.category == "checksum" 

423 publisher.payload = b"[2]" 

424 clock.advance() 

425 assert await manager.get("test.json") == ("b", [2]) 

426 

427 

428async def test_course_validation_and_conversion_before_swap(monkeypatch): 

429 clock, publisher = Clock(), Publisher([course_payload(id="11510A")], "courses/latest.json") 

430 manager = NTHUDataManager(clock=clock) 

431 service = CoursesService(manager) 

432 async with manager.lifespan(publisher.client()): 

433 await service.get_snapshot() 

434 old = service.state.snapshot 

435 for payload in [[], [course_payload(id="11510B")]]: 

436 publisher.version += "b" 

437 publisher.payload = payload 

438 clock.advance() 

439 snapshot = await service.get_snapshot() 

440 assert [c.id for c in snapshot.data] == [r["id"] for r in payload] 

441 old = service.state.snapshot 

442 publisher.version, publisher.payload = "invalid", [{"id": None}] 

443 clock.advance() 

444 await service.get_snapshot() 

445 assert service.state.snapshot is old 

446 assert service.state.last_error.category == "validation" 

447 

448 def fail(raw): 

449 raise ValueError("conversion failed") 

450 

451 monkeypatch.setattr(service.state, "prepare", fail) 

452 publisher.payload = [course_payload(id="11510C")] 

453 clock.advance() 

454 await service.get_snapshot() 

455 assert service.state.snapshot is old 

456 assert service.state.last_error.category == "transformation" 

457 

458 

459async def test_bus_indexes_are_built_before_swap(monkeypatch): 

460 clock = Clock() 

461 payload = { 

462 "weekdayBusScheduleTowardTSMCBuilding": [ 

463 {"time": "8:00", "description": "", "line": "red", "dep_stop": "gate"} 

464 ] 

465 } 

466 publisher = Publisher(payload, "buses.json") 

467 manager = NTHUDataManager(clock=clock) 

468 service = BusesService(manager) 

469 async with manager.lifespan(publisher.client()): 

470 await service.update_data() 

471 old = service.state.snapshot 

472 

473 def fail(self): 

474 raise ValueError("index construction failed") 

475 

476 with monkeypatch.context() as patch: 

477 patch.setattr(PreparedBusData, "_derive_combined_views", fail) 

478 publisher.version = "b" 

479 clock.advance() 

480 await service.update_data() 

481 assert service.state.snapshot is old 

482 publisher.payload = {} 

483 clock.advance() 

484 await service.update_data() 

485 assert service.state.snapshot.version == "b" 

486 assert not any(service.raw_schedule_data.values()) 

487 

488 

489async def test_bus_candidate_must_also_satisfy_response_schema(): 

490 publisher = Publisher( 

491 {"weekdayBusScheduleTowardTSMCBuilding": [{"time": "8:00", "description": ""}]}, 

492 "buses.json", 

493 ) 

494 manager = NTHUDataManager() 

495 service = BusesService(manager) 

496 async with manager.lifespan(publisher.client()): 

497 with pytest.raises(DataNotAvailableException): 

498 await service.update_data() 

499 assert service.state.last_error.category == "validation" 

500 assert not service.state.usable 

501 

502 

503@pytest.mark.parametrize("fail_startup", [False, True]) 

504async def test_client_lifecycle_and_disposable_state(fail_startup): 

505 publisher = Publisher([1]) 

506 manager = NTHUDataManager() 

507 client = publisher.client() 

508 assert manager.fetcher.client is None 

509 try: 

510 async with manager.lifespan(client): 

511 await manager.get("test.json") 

512 assert manager.fetcher.client is client 

513 if fail_startup: 

514 raise RuntimeError("startup") 

515 except RuntimeError: 

516 assert fail_startup 

517 assert client.is_closed 

518 assert manager.fetcher.client is None 

519 assert not manager.state_for("test.json").usable 

520 publisher.data_error = 503 

521 async with manager.lifespan(publisher.client()): 

522 with pytest.raises(DataNotAvailableException): 

523 await manager.get("test.json") 

524 

525 

526async def test_cancelled_refresh_can_retry(): 

527 publisher = Publisher([1]) 

528 entered = asyncio.Event() 

529 block = True 

530 

531 async def handler(request): 

532 if request.url.path == "/test.json" and block: 

533 entered.set() 

534 await asyncio.Event().wait() 

535 return await publisher(request) 

536 

537 manager = NTHUDataManager() 

538 async with manager.lifespan(httpx.AsyncClient(transport=httpx.MockTransport(handler))): 

539 task = asyncio.create_task(manager.get("test.json")) 

540 await asyncio.wait_for(entered.wait(), 2) 

541 task.cancel() 

542 with pytest.raises(asyncio.CancelledError): 

543 await task 

544 block = False 

545 assert await manager.get("test.json") == ("a", [1]) 

546 

547 

548async def test_shared_manifest_does_not_extend_freshness_for_later_readers(): 

549 clock, publisher = Clock(), Publisher([1]) 

550 publisher.manifest_override = { 

551 "file_details": { 

552 "/": [ 

553 {"name": "first.json", "version": "a"}, 

554 {"name": "later.json", "version": "a"}, 

555 ] 

556 } 

557 } 

558 manager = NTHUDataManager(clock=clock) 

559 async with manager.lifespan(publisher.client()): 

560 await manager.get("first.json") 

561 clock.advance(299) 

562 await manager.get("later.json") 

563 assert publisher.calls.count("/file_details.json") == 1 

564 clock.advance(1) 

565 await manager.get("later.json") 

566 assert publisher.calls.count("/file_details.json") == 2 

567 assert publisher.calls.count("/later.json") == 1 

568 

569 

570@pytest.mark.parametrize("payload", [[], [{"id": "B", "language": "invalid"}], [42], {"id": "B"}]) 

571async def test_initial_course_schema_validation(payload): 

572 publisher = Publisher(payload, "courses/latest.json") 

573 manager = NTHUDataManager() 

574 service = CoursesService(manager) 

575 async with manager.lifespan(publisher.client()): 

576 if payload == []: 

577 assert (await service.get_snapshot()).data == [] 

578 assert service.state.usable 

579 else: 

580 with pytest.raises(DataNotAvailableException): 

581 await service.get_snapshot() 

582 assert not service.state.usable 

583 assert service.state.last_error.category in {"validation", "payload_type"} 

584 

585 

586@pytest.mark.parametrize( 

587 "endpoint,valid,invalid", 

588 [ 

589 ( 

590 "announcements.json", 

591 [ 

592 { 

593 "title": "News", 

594 "department": "Office", 

595 "language": "en", 

596 "link": "https://example.com", 

597 "articles": [], 

598 } 

599 ], 

600 [{"title": "News", "articles": [42]}], 

601 ), 

602 ( 

603 "announcements_list.json", 

604 [ 

605 { 

606 "title": "News", 

607 "department": "Office", 

608 "language": "en", 

609 "link": "https://example.com", 

610 } 

611 ], 

612 [{"title": "News"}], 

613 ), 

614 ( 

615 "announcements_list.json", 

616 [ 

617 { 

618 "title": "News", 

619 "department": "Office", 

620 "language": "en", 

621 "link": "https://example.com", 

622 } 

623 ], 

624 [ 

625 { 

626 "title": "News", 

627 "department": "Office", 

628 "language": "en", 

629 "link": 42, 

630 } 

631 ], 

632 ), 

633 ( 

634 "dining.json", 

635 [ 

636 { 

637 "building": "Hall", 

638 "restaurants": [ 

639 { 

640 "name": "Shop", 

641 "area": "Hall", 

642 "image": None, 

643 "note": "", 

644 "phone": "", 

645 "schedule": {}, 

646 } 

647 ], 

648 } 

649 ], 

650 [{"building": "Hall", "restaurants": [42]}], 

651 ), 

652 ( 

653 "directory.json", 

654 [{"index": "01", "name": "Office", "details": {"people": []}}], 

655 [{"index": "01", "name": "Office", "details": {"people": [42]}}], 

656 ), 

657 ( 

658 "maps.json", 

659 {"main": {"gate": {"latitude": "24", "longitude": "120"}}}, 

660 {"main": {"gate": {"latitude": 24}}}, 

661 ), 

662 ( 

663 "newsletters.json", 

664 [{"name": "News", "link": "https://example.com", "details": {}, "articles": []}], 

665 [{"name": "News", "link": "https://example.com", "details": {}, "articles": [42]}], 

666 ), 

667 ("libraries/rss.json", {"news": []}, {"news": [42]}), 

668 ( 

669 "libraries/calendars.json", 

670 [ 

671 { 

672 "id": "main", 

673 "name": None, 

674 "description": None, 

675 "timezone": "Asia/Taipei", 

676 "url": "https://example.com", 

677 "events": [], 

678 } 

679 ], 

680 [ 

681 { 

682 "id": "main", 

683 "name": None, 

684 "description": None, 

685 "timezone": "Asia/Taipei", 

686 "url": "https://example.com", 

687 "events": [ 

688 { 

689 "id": "x", 

690 "title": "Hours", 

691 "description": None, 

692 "all_day": True, 

693 "start": "invalid-date", 

694 "end": "2026-09-29", 

695 } 

696 ], 

697 } 

698 ], 

699 ), 

700 ], 

701) 

702async def test_dataset_schemas_validate_nested_records_before_install(endpoint, valid, invalid): 

703 clock, publisher = Clock(), Publisher(valid, endpoint) 

704 manager = NTHUDataManager(clock=clock) 

705 async with manager.lifespan(publisher.client()): 

706 await manager.get(endpoint) 

707 state = manager.state_for(endpoint) 

708 old = state.snapshot 

709 publisher.version, publisher.payload = "b", invalid 

710 clock.advance() 

711 assert await manager.get(endpoint) == ("a", valid) 

712 assert state.snapshot is old 

713 assert state.last_error.category == "validation" 

714 publisher.payload = {} if isinstance(valid, dict) else [] 

715 clock.advance() 

716 assert await manager.get(endpoint) == ("b", publisher.payload) 

717 

718 

719async def test_concurrent_cold_failure_is_one_attempt_then_recover(): 

720 clock, publisher = Clock(), Publisher([1]) 

721 publisher.data_error = 503 

722 manager = NTHUDataManager(clock=clock) 

723 async with manager.lifespan(publisher.client()): 

724 results = await asyncio.gather( 

725 *(manager.get("test.json") for _ in range(20)), return_exceptions=True 

726 ) 

727 assert all(isinstance(result, DataNotAvailableException) for result in results) 

728 assert publisher.calls == ["/file_details.json", "/test.json"] 

729 publisher.data_error = None 

730 clock.advance() 

731 assert await manager.get("test.json") == ("a", [1]) 

732 

733 

734async def test_instances_are_independent(): 

735 clock_a, clock_b, publisher = Clock(), Clock(), Publisher([1]) 

736 first, second = NTHUDataManager(clock=clock_a), NTHUDataManager(clock=clock_b) 

737 async with first.lifespan(publisher.client()), second.lifespan(publisher.client()): 

738 await first.get("test.json") 

739 await second.get("test.json") 

740 publisher.version, publisher.payload = "b", [2] 

741 clock_a.advance() 

742 assert await first.get("test.json") == ("b", [2]) 

743 assert await second.get("test.json") == ("a", [1]) 

744 clock_b.advance() 

745 assert await second.get("test.json") == ("b", [2]) 

746 assert first.state_for("test.json").lock is not second.state_for("test.json").lock 

747 

748 

749async def test_duplicate_manifest_paths_are_malformed(): 

750 publisher = Publisher() 

751 publisher.manifest_override = { 

752 "file_details": { 

753 "/": [ 

754 {"name": "test.json", "version": "a"}, 

755 {"name": "test.json", "version": "b"}, 

756 ] 

757 } 

758 } 

759 manager = NTHUDataManager() 

760 async with manager.lifespan(publisher.client()): 

761 assert await manager.get("test.json") == (None, []) 

762 assert manager.state_for("test.json").last_error.category == "manifest_parsing" 

763 

764 

765def test_runtime_settings_are_configurable_and_positive(monkeypatch): 

766 from pydantic import ValidationError 

767 

768 from data_api.core.settings import Settings 

769 

770 monkeypatch.setenv("FILE_DETAILS_CACHE_EXPIRY", "120") 

771 monkeypatch.setenv("DATA_HTTP_TIMEOUT", "5") 

772 settings = Settings(_env_file=None) 

773 assert settings.file_details_cache_expiry == 120 

774 assert settings.data_http_timeout == 5 

775 monkeypatch.setenv("FILE_DETAILS_CACHE_EXPIRY", "0") 

776 with pytest.raises(ValidationError): 

777 Settings(_env_file=None)