"""IP 선제 로테이션 테스트 — 요청 예산 판정·세션 종료 기록(브라우저 불필요) + CRUD(실 lps_db).""" import pytest_asyncio from sqlalchemy import text from config.config_models import DecodoConfig from crud.ip_session import IpSessionLog from services.search.coupang.adapter import CoupangAdapter from services.search.proxy import DecodoProxy def _proxy(): return DecodoProxy(DecodoConfig(host="gate.decodo.com", username="u", password="p", port_start=10001, port_end=10010, session_minutes=10)) def _adapter(**kw): # __init__ 은 브라우저를 띄우지 않는다 — 예산/세션 부기 로직만 검증 return CoupangAdapter(headless=True, user_data_dir="/tmp/lps_test_profile", **kw) # ---- 요청 예산(선제 회전) 판정 ------------------------------------------ def test_budget_reached_only_with_proxy(): ad = _adapter(ip_request_budget=3) ad._ip_requests = 3 assert ad._budget_reached() is False # 프록시 미사용 — 예산 개념 없음 ad._proxy = _proxy() assert ad._budget_reached() is True def test_budget_zero_disables_preemptive_rotation(): ad = _adapter(ip_request_budget=0, proxy=_proxy()) ad._ip_requests = 999 assert ad._budget_reached() is False def test_budget_not_reached_below_threshold(): ad = _adapter(ip_request_budget=3, proxy=_proxy()) ad._ip_requests = 2 assert ad._budget_reached() is False def test_default_budget_is_conservative(): ad = _adapter(proxy=_proxy()) assert ad._ip_budget == 3 # 실측상 5회 부근 차단 → 기본 3회 def test_rotate_ip_records_end_reason_and_recycles(): ad = _adapter(proxy=_proxy()) before = ad._proxy.current_port ad._rotate_ip("예산 도달", kind="budget", warn=False) assert ad._end_reason == "budget" assert ad._force_recycle is True assert ad._proxy.current_port != before # 즉시 다음 포트 # ---- 세션 종료 기록 ------------------------------------------------------- async def test_session_end_event_shape(): events = [] async def cb(e): events.append(e) ad = _adapter(proxy=_proxy(), on_session_end=cb) ad._ip_requests, ad._sess_ok, ad._sess_blocked = 3, 2, 1 ad._current_port, ad._end_reason = 10005, "budget" await ad._record_session_end() assert events == [{ "source": "coupang", "proxy_port": 10005, "requests": 3, "ok_count": 2, "blocked_count": 1, "elapsed_sec": events[0]["elapsed_sec"], "end_reason": "budget", }] assert ad._end_reason is None # 기록 후 리셋 async def test_session_end_skips_empty_session(): events = [] async def cb(e): events.append(e) ad = _adapter(on_session_end=cb) ad._ip_requests = 0 # 요청 없던 세션(유휴 정리 등)은 노이즈 — 미기록 await ad._record_session_end() assert events == [] async def test_session_end_defaults_to_window_reason(): events = [] async def cb(e): events.append(e) ad = _adapter(on_session_end=cb) ad._ip_requests = 2 # end_reason 미지정 = 시간창 만료 재기동 await ad._record_session_end() assert events[0]["end_reason"] == "window" async def test_session_end_callback_failure_is_swallowed(): async def boom(e): raise RuntimeError("db down") ad = _adapter(on_session_end=boom) ad._ip_requests = 1 await ad._record_session_end() # 예외가 검색을 막지 않는다 # ---- CRUD (실 lps_db) ---------------------------------------------------- @pytest_asyncio.fixture async def ip_log(db_engine): async with db_engine.begin() as conn: await conn.execute(text("TRUNCATE ip_session")) return IpSessionLog() async def test_record_persists_session(ip_log, db_engine): await ip_log.record({"source": "coupang", "proxy_port": 10003, "requests": 3, "ok_count": 3, "blocked_count": 0, "elapsed_sec": 120, "end_reason": "budget"}) async with db_engine.begin() as conn: row = (await conn.execute(text( "SELECT source, proxy_port, requests, ok_count, end_reason FROM ip_session" ))).first() assert row.source == "coupang" and row.proxy_port == 10003 assert row.requests == 3 and row.ok_count == 3 and row.end_reason == "budget" async def test_recent_stats_groups_by_reason(ip_log, db_engine): for reason in ("budget", "budget", "block"): await ip_log.record({"source": "coupang", "requests": 3, "ok_count": 3, "blocked_count": 0, "end_reason": reason}) stats = await ip_log.recent_stats(60) assert stats == {"budget": 2, "block": 1}