o2o-negosium-original/lps/tests/test_source_state.py
민헌 b278f58d9c feat(lps): 1단계 — 몰별 상태 보존, '없음'과 '못 봄'을 가른다
docs/result-states.md 의 1단계. 어댑터는 이미 구분을 알고 있는데 핸들러가 그 정보를 버리고
있었다(per_source[src] = {"error": 문자열}). 그래서 차단당해 못 본 몰이 화면에서 '그 몰엔 없음'
으로 둔갑했다. 새로 알아낼 정보는 없고, 흘리던 걸 잡아두기만 하면 된다.

- common/enums.py: SourceState 7상태 추가(MATCHED/NO_MATCH/EMPTY/BLOCKED/ENV_BLOCKED/
  UNAVAILABLE/SKIPPED). `.confirmed` 프로퍼티로 **'봤다 vs 못 봤다' 경계를 한곳에** 둔다 —
  이 경계가 무너지면 나머지 판단이 전부 틀어지므로 흩어놓지 않는다.
- AdapterError.state: 어댑터가 아는 구분을 실어 보낸다. blocked/fatal 은 '어떻게 대응할까'
  (회전·재시도)를 위한 값이고 state 는 '사용자에게 뭐라 말할까'를 위한 값이라 쓰임이 다르다.
  특히 blocked=False 하나에 결과0건(EMPTY)과 전송실패(UNAVAILABLE)가 섞여 있어 state 없이는
  갈라낼 수 없었다. **기본값은 UNAVAILABLE** — 모르면 '못 봤다'가 안전하다(EMPTY 로 두면
  확인도 안 한 몰을 '없음'으로 단정한다).
- browser_base: raise 지점 5곳에 상태 부여. 핵심 갈림은 0건 종착 한 곳 —
  blocked=False → EMPTY(정말 없다) / blocked=True → BLOCKED(못 봤다).
- _search_round: {"state": ..., "count"|"error": ...} 로 구조화. EMPTY 는 '정상 응답'으로 세어
  (confirmed) 쿠팡에 정말 없을 때 잡이 재시도로 낭비되지 않게 한다.
- _finalize_states: 수집만 된 소스를 AI 판정 뒤 MATCHED/NO_MATCH 로 확정한다. 실패 상태는
  이미 확정이라 덮지 않는다.

검증(9조합 실측): empty → partial=False(확정) / blocked·env_blocked·unavailable → partial=True.
테스트 12건 추가, 전체 286 passed. 진행 상황은 docs/result-states.md 4절에 기록.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 10:47:28 +09:00

108 lines
5.3 KiB
Python

"""몰(소스)별 상태 테스트 — '그 몰에 없었다'와 '그 몰을 못 봤다'를 가르는 계약.
정의는 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"})