"""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.ingests = [] # 동별 post_ingest 호출 기록 async def get_targets(self): return self._targets async def post_ingest(self, fetched_at, batches): self.ingests.append({"fetched_at": fetched_at, "batches": batches}) n = sum(len(b["articles"]) for b in batches) return {"received": n, "new": n, "matched": 0} 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_ingests_per_dong(): 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 len(nas.ingests) == 2 # 동별 1 POST씩 dongs = [ing["batches"][0]["dong"] for ing in nas.ingests] assert dongs == ["신대방동", "봉천동"] assert all(len(ing["batches"]) == 1 for ing in nas.ingests) assert state.listings_pushed == 2 assert state.last_fetch_at is not None assert state.state == "idle" assert nas.ingests[0]["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()) # A는 fetch 실패 → ingest skip, B만 ingest (부분 실패 격리) assert len(nas.ingests) == 1 assert nas.ingests[0]["batches"][0]["dong"] == "B" assert state.errors >= 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.ingests == [] 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