o2o-negosium-original/lps/tests/test_alerts.py
민헌 5c9567919f feat(lps): 네이버 회수율 48→120건(적응형 스크롤) + 크롤 차단을 기동 즉시 알림
**회수율**: 베이스 _wait_ready 는 '고정 3회 스크롤 → 셀렉터 대기' 순서라, 프록시 지연이 있으면
**아직 아무것도 안 그려진 화면을 스크롤**하고 끝났다. 네이버용으로 순서를 뒤집고 종료 조건을
횟수가 아니라 '카드 수가 더 안 늘어남'으로 바꿨다 — 네트워크가 느리든 빠르든 같은 결과가 나온다.
A/B(같은 IP·같은 세션, 3개 쿼리): 14·14·20 = 48건 → **40·40·40 = 120건**(전부 상한 도달).

**크롤 프리플라이트**: 웜업 대상에 naver 를 추가하고, 3회 모두 실패하면 로그가 아니라 **알림**을
쏜다. 컨테이너 워커는 크롤이 막혀도 하트비트가 살아 있어 healthy 로 보이고, 잡이 DEAD 로
쌓일 때까지 아무도 모른다(실측). 성공하면 해소 알림으로 자동 정리된다.
AlertManager 를 main 에서 만들어 웜업·ops 모니터가 쿨다운 상태를 공유한다.

**문서**: operations 에 차단 마커별 대응표(비정상적인 접근=구조적/wtm_captcha=회전)와
'컨테이너 크롤 차단' 절 추가 — 배제한 원인, Rosetta 에뮬 주의(= '이 맥에서만'일 수 있음),
배포 시 확인 순서(warmup 로그 → 호스트 비교 → 워커만 호스트 실행).

테스트 3건 추가(웜업 실패 알림·성공 해소·비크롤 소스 스킵), 전체 223 passed·0 failed.
2026-08-05 13:33:19 +09:00

204 lines
7.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""AlertManager 테스트 — 발화·쿨다운(스팸 방지)·회복 알림 (sender/clock 주입, 네트워크·대기 없음)."""
from common.alerts import AlertManager
from common.database.db_session_manager import DB_SESSION_MNG
from services.search.contract import NormalizedProduct, SearchAdapter
class _StubAdapter(SearchAdapter):
"""윈도우 카운터(_note_result/recent_stats)는 베이스 계약이라 아무 어댑터로도 검증된다."""
source = "stub"
async def search(self, query, limit=40) -> list[NormalizedProduct]:
return []
class _Clock:
def __init__(self):
self.t = 1000.0
def __call__(self):
return self.t
def _mgr(cooldown=1800):
sent = []
async def sender(text):
sent.append(text)
clock = _Clock()
return AlertManager(origin="test", cooldown_sec=cooldown, sender=sender, clock=clock), sent, clock
async def test_fires_once_on_activation():
mgr, sent, _ = _mgr()
await mgr.check("dead", True, "DEAD 1h=25")
await mgr.check("dead", True, "DEAD 1h=26") # 조건 지속 — 쿨다운 안이라 재발송 없음
assert len(sent) == 1 and "DEAD 1h=25" in sent[0]
assert mgr.is_active("dead")
async def test_refires_after_cooldown():
mgr, sent, clock = _mgr(cooldown=1800)
await mgr.check("dead", True, "m1")
clock.t += 1801 # 쿨다운 경과 — 리마인드 1회
await mgr.check("dead", True, "m2")
assert len(sent) == 2 and "m2" in sent[1]
async def test_recovery_notice_once():
mgr, sent, _ = _mgr()
await mgr.check("blocks", True, "차단 1h=90")
await mgr.check("blocks", False, "차단 1h=3") # 해소 알림 1회
await mgr.check("blocks", False, "차단 1h=0") # 이미 비활성 — 무발송
assert len(sent) == 2
assert "해소" in sent[1]
assert not mgr.is_active("blocks")
async def test_inactive_rule_never_fires():
mgr, sent, _ = _mgr()
await mgr.check("queue_lag", False, "큐지연=3s")
assert sent == []
async def test_rules_are_independent():
mgr, sent, _ = _mgr()
await mgr.check("dead", True, "a")
await mgr.check("db_pool", True, "b") # 다른 키 — 각자 발화
assert len(sent) == 2
async def test_sender_failure_does_not_raise():
async def boom(text):
raise RuntimeError("webhook down")
mgr = AlertManager(origin="test", cooldown_sec=10, sender=boom)
try:
await mgr.check("dead", True, "m")
except RuntimeError:
# sender 주입 시 예외는 호출부(모니터 루프의 try)가 처리 — 여기서는 전파돼도 루프가 삼킨다
pass
# ---- 윈도우 성공/실패 카운터(소스별 장기 실패 룰의 데이터) ----------------
def test_recent_stats_counts_within_window():
ad = _StubAdapter()
ad._note_result(True)
ad._note_result(False)
ad._note_result(False)
assert ad.recent_stats(1800) == (3, 1)
assert ad.recent_stats(0) == (0, 0) # 창 밖이면 미집계
def test_recent_stats_empty_adapter():
ad = _StubAdapter()
assert ad.recent_stats(1800) == (0, 0) # lazy init — 기록 전에도 안전
# ---- DB 풀 사용 현황 ------------------------------------------------------
def test_pool_status_shape():
st = DB_SESSION_MNG.pool_status()
assert set(st) == {"checked_out", "capacity", "pct"}
assert st["capacity"] > 0 # R/W 2엔진 × (pool+overflow)
assert 0 <= st["pct"] <= 100
# ---- 가용 프록시 포트 현황(포트 고갈 룰의 데이터) --------------------------
async def test_port_pool_status_picks_worst_gateway():
"""게이트웨이가 둘이라 **가장 마른 쪽** 기준으로 알림을 걸어야 한다 —
한쪽만 고갈돼도 그 소스(네이버/쿠팡)는 검색을 못 하므로 평균으로 덮으면 안 된다."""
from worker_main import _port_pool_status
class _Store:
async def snapshot(self, host=None):
return {"gate.decodo.com": {"held": 2, "resting": 0, "cooling": 0, "available": 8, "total": 10},
"kr.decodo.com": {"held": 1, "resting": 2, "cooling": 4, "available": 3, "total": 10}}
class _Proxy:
_store = _Store()
class _Ad:
_proxy = _Proxy()
detail, ports = await _port_pool_status([object(), _Ad()]) # 프록시 없는 어댑터 혼재 OK
assert ports == (3, 10) # kr(=3) 기준
assert detail["kr.decodo.com"]["cooling"] == 4 # 원인 파악용 상세도 함께 실린다
async def test_port_pool_status_none_without_store():
from worker_main import _port_pool_status
assert await _port_pool_status([object()]) == ({}, None)
assert await _port_pool_status(None) == ({}, None)
async def test_port_pool_status_survives_db_error():
"""장부 조회가 실패해도 ops 모니터는 계속 돌아야 한다(알림만 건너뛴다)."""
from worker_main import _port_pool_status
class _Broken:
async def snapshot(self, host=None):
raise RuntimeError("db down")
class _Ad:
class _proxy:
_store = _Broken()
assert await _port_pool_status([_Ad()]) == ({}, None)
# ---- 크롤 프리플라이트(웜업) 알림 ------------------------------------------
class _WarmAdapter(SearchAdapter):
"""웜업 검증용 — 항상 실패하거나 항상 성공하는 어댑터."""
def __init__(self, source, fail):
self.source, self._fail = source, fail
self.calls = 0
async def search(self, query, limit=40):
self.calls += 1
if self._fail:
raise RuntimeError("blocked")
return [NormalizedProduct(source=self.source, name="p", price=1)]
def _rotate_ip(self, *a, **k):
pass
async def test_warmup_alerts_when_source_cannot_crawl():
"""기동 직후 크롤이 막혔으면 잡이 DEAD 로 쌓일 때까지 기다리지 않고 바로 알린다.
(컨테이너에서만 막히는 사례 — 프로세스는 healthy 라 조용히 0건이 된다)"""
from worker_main import _warmup_worker
mgr, sent, _ = _mgr()
ad = _WarmAdapter("naver", fail=True)
await _warmup_worker([ad], tries=2, attempt_timeout=5, alerts=mgr)
assert ad.calls == 2 # 재시도까지 소진
assert len(sent) == 1 and "naver" in sent[0]
assert mgr.is_active("warmup_naver")
async def test_warmup_success_clears_the_alert():
from worker_main import _warmup_worker
mgr, sent, _ = _mgr()
await _warmup_worker([_WarmAdapter("coupang", fail=True)], tries=1, attempt_timeout=5, alerts=mgr)
assert mgr.is_active("warmup_coupang")
await _warmup_worker([_WarmAdapter("coupang", fail=False)], tries=1, attempt_timeout=5, alerts=mgr)
assert not mgr.is_active("warmup_coupang") # 회복 알림 후 해소
assert "해소" in sent[-1]
async def test_warmup_skips_non_crawl_sources():
from worker_main import _warmup_worker
mgr, sent, _ = _mgr()
ad = _WarmAdapter("someapi", fail=True) # 브라우저 소스가 아님 → 웜업 대상 아님
await _warmup_worker([ad], tries=2, attempt_timeout=5, alerts=mgr)
assert ad.calls == 0 and sent == []