"""fetch_worker — 주간창/조립/격리/heartbeat.""" import datetime as dt import json from zoneinfo import ZoneInfo from fetch_worker import FetchState, in_daytime, run_cycle, build_heartbeat from config import load_settings KST = ZoneInfo("Asia/Seoul") def _kst(hh, mm): return dt.datetime(2026, 7, 10, hh, mm, tzinfo=KST) def test_in_daytime_boundaries(): assert in_daytime(_kst(8, 0), "08:00", "20:00") is True assert in_daytime(_kst(19, 59), "08:00", "20:00") is True assert in_daytime(_kst(20, 0), "08:00", "20:00") is False assert in_daytime(_kst(7, 59), "08:00", "20:00") is False class _FakeNAS: def __init__(self, targets): self._targets = targets self.ingested = None async def get_targets(self): return self._targets async def post_ingest(self, fetched_at, batches): self.ingested = {"fetched_at": fetched_at, "batches": batches} return {"received": sum(len(b["articles"]) for b in batches), "new": 1, "matched": 1} class _FakeNaver: def __init__(self, fail_dongs=None): self._fail = fail_dongs or set() self.token_acquired = False async def acquire_token(self): self.token_acquired = True async def fetch_articles(self, cortar_no, page=1): if cortar_no in self._fail: raise RuntimeError("naver 429") return {"isMoreData": False, "articleList": [{"articleNo": f"{cortar_no}-{page}"}]} async def test_run_cycle_assembles_batches_and_ingests(): nas = _FakeNAS({"dongs": [{"dong": "신대방동", "cortar_no": "1159010100"}, {"dong": "봉천동", "cortar_no": "1162010100"}], "deal_types": ["매매"], "page_limit": 1}) naver = _FakeNaver() state = FetchState() await run_cycle(nas, naver, state, load_settings()) assert naver.token_acquired is True assert nas.ingested is not None dongs = [b["dong"] for b in nas.ingested["batches"]] assert dongs == ["신대방동", "봉천동"] assert state.listings_pushed == 2 assert state.last_fetch_at is not None assert state.state == "idle" assert nas.ingested["fetched_at"].endswith("Z") async def test_run_cycle_dong_failure_isolated(): nas = _FakeNAS({"dongs": [{"dong": "A", "cortar_no": "1159010100"}, {"dong": "B", "cortar_no": "1162010100"}], "page_limit": 1}) naver = _FakeNaver(fail_dongs={"1159010100"}) state = FetchState() await run_cycle(nas, naver, state, load_settings()) assert nas.ingested is not None # 실패해도 ingest 진행 assert state.errors >= 1 batches = {b["dong"]: b["articles"] for b in nas.ingested["batches"]} assert batches["A"] == [] # 실패 동 빈 배열 assert len(batches["B"]) == 1 # 성공 동 async def test_run_cycle_token_failure_skips_ingest(): class _BadNaver(_FakeNaver): async def acquire_token(self): raise RuntimeError("token 실패") nas = _FakeNAS({"dongs": [], "page_limit": 1}) state = FetchState() await run_cycle(nas, _BadNaver(), state, load_settings()) assert nas.ingested is None assert state.errors == 1 assert state.state == "idle" def test_build_heartbeat_schema(): state = FetchState() state.state = "fetching" state.listings_pushed = 7 d = json.loads(build_heartbeat(state)) assert d["name"] == "naver-fetch" assert d["kind"] == "fetcher" assert d["state"] == "fetching" assert isinstance(d["ts"], int) assert d["last_fetch_at"] is None assert d["listings_pushed"] == 7 assert d["errors"] == 0