"""몰(소스)별 상태 테스트 — '그 몰에 없었다'와 '그 몰을 못 봤다'를 가르는 계약. 정의는 docs/result-states.md 가 소스다. 여기서 지키는 것은 하나다: **확인한 것(matched/no_match/empty)과 못 본 것(blocked/env_blocked/unavailable)이 섞이지 않는다.** 섞이면 차단당해 못 본 몰이 화면에서 '그 몰엔 없음'으로 둔갑한다(실제로 그랬다). """ import pytest from common.enums import SourceState from services.search.contract import AdapterError, NormalizedProduct, SearchAdapter from worker.handlers import _finalize_states, build_search_handler # ── 상태 자체의 계약 ──────────────────────────────────────────────────── def test_confirmed_separates_seen_from_unseen(): """이 경계가 무너지면 나머지 로직이 전부 틀어진다.""" assert [s for s in SourceState if s.confirmed] == [ SourceState.MATCHED, SourceState.NO_MATCH, SourceState.EMPTY] for s in (SourceState.BLOCKED, SourceState.ENV_BLOCKED, SourceState.UNAVAILABLE, SourceState.SKIPPED): assert not s.confirmed, s # ── AdapterError 가 상태를 싣는가 ─────────────────────────────────────── @pytest.mark.parametrize("kw,expected", [ (dict(), SourceState.UNAVAILABLE), # 아무것도 모르면 '못 봤다'가 안전 (dict(blocked=True), SourceState.BLOCKED), (dict(blocked=True, fatal=True), SourceState.ENV_BLOCKED), (dict(state=SourceState.EMPTY), SourceState.EMPTY), ]) def test_error_state_defaults(kw, expected): """state 를 안 준 옛 호출부도 맞게 동작해야 한다 — 특히 기본값이 EMPTY 면 안 된다 (확인도 안 한 몰을 '없음'으로 단정하게 된다).""" assert AdapterError("x", source="s", **kw).state is expected # ── 수집 뒤 매칭 결과로 확정 ──────────────────────────────────────────── def test_collected_but_unmatched_becomes_no_match(): per_source = {"naver": {"state": "matched"}, "coupang": {"state": "matched"}} matched = [NormalizedProduct(source="naver", name="x", price=1)] _finalize_states(per_source, matched) assert per_source["naver"]["state"] == "matched" assert per_source["coupang"]["state"] == "no_match" # 가져왔지만 같은 상품이 아니었다 def test_finalize_never_overwrites_a_failure_state(): """실패 상태는 이미 확정이다 — 매칭 결과로 덮으면 '못 봤다'가 사라진다.""" per_source = {"coupang": {"state": "blocked"}, "naver": {"state": "empty"}} _finalize_states(per_source, []) assert per_source["coupang"]["state"] == "blocked" assert per_source["naver"]["state"] == "empty" # ── 핸들러 통합: 상태가 결과까지 실려 나가는가 ────────────────────────── class _A(SearchAdapter): def __init__(self, source, mode): self.source, self.mode = source, mode async def search(self, q, limit=40): if self.mode == "hit": return [NormalizedProduct(source=self.source, name="생수 2L", price=9000)] if self.mode == "empty": raise AdapterError("결과 없음", source=self.source, state=SourceState.EMPTY) if self.mode == "blocked": raise AdapterError("차단", source=self.source, blocked=True, state=SourceState.BLOCKED) raise RuntimeError("우리 코드 버그") def _job(): return {"job_type": 1, "job_id": "J", "attempts": 1, "max_attempts": 3, "payload": {"product_code": "P", "product_name": "생수"}} async def _run(modes): return await build_search_handler({s: _A(s, m) for s, m in modes.items()})(_job()) async def test_empty_is_a_confirmed_answer_not_a_gap(): """봤는데 없었으면 결과는 **확정**이다 — partial 로 흐리면 안 된다.""" r = await _run({"naver": "hit", "coupang": "empty"}) assert r["sources"]["coupang"]["state"] == "empty" assert r["partial"] is False and r["sources_failed"] == [] async def test_blocked_marks_the_result_incomplete(): """못 본 몰이 있으면 그 결과는 최종이 아니다.""" r = await _run({"naver": "hit", "coupang": "blocked"}) assert r["sources"]["coupang"]["state"] == "blocked" assert r["partial"] is True and r["sources_failed"] == ["coupang"] async def test_unknown_exception_counts_as_unseen(): """우리 코드 버그로 못 본 것도 '없음'이라 말하면 안 된다.""" r = await _run({"naver": "hit", "coupang": "boom"}) assert r["sources"]["coupang"]["state"] == "unavailable" assert r["partial"] is True async def test_all_empty_is_a_definitive_not_found(): """전부 확인했고 없었다 → 확정적 not_found(네거티브 캐시에 넣어도 되는 상태).""" r = await _run({"naver": "empty", "coupang": "empty"}) assert r["outcome"] == "not_found" and r["partial"] is False async def test_all_blocked_is_not_an_answer(): """전부 못 봤으면 '없음'이 아니라 '모름'이다 — 재시도해야 한다.""" with pytest.raises(RuntimeError): await _run({"naver": "blocked", "coupang": "blocked"})