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
« 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."""
3import asyncio
4import hashlib
5import socket
6from dataclasses import replace
8import httpx
9import pytest
10from conftest import course_payload
11from pydantic import TypeAdapter
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
19class Clock:
20 def __init__(self):
21 self.now = 0.0
23 def __call__(self):
24 return self.now
26 def advance(self, seconds=301):
27 self.now += seconds
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
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]}}
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)
65 def client(self):
66 return httpx.AsyncClient(transport=httpx.MockTransport(self))
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"]
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", []
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([]) == {}
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
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)
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"
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
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
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
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
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
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
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
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
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
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)
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}}
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}}
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()
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)
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
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 }
395 async def handler(request):
396 if request.url.path == "/slow.json":
397 entered.set()
398 await release.wait()
399 return await publisher(request)
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
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])
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"
448 def fail(raw):
449 raise ValueError("conversion failed")
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"
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
473 def fail(self):
474 raise ValueError("index construction failed")
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())
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
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")
526async def test_cancelled_refresh_can_retry():
527 publisher = Publisher([1])
528 entered = asyncio.Event()
529 block = True
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)
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])
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
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"}
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)
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])
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
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"
765def test_runtime_settings_are_configurable_and_positive(monkeypatch):
766 from pydantic import ValidationError
768 from data_api.core.settings import Settings
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)