"""포트(=IP 세션) 중재자 테스트. 여기서 지키는 계약은 운영 사고와 직결된다: - 한 IP 를 두 소유자가 동시에 쓰면 그 IP 가 빨리 탄다 - 태운 IP 를 다른 소유자가 곧바로 집으면 차단이 전파된다 - 게이트웨이가 다르면 같은 포트 번호라도 다른 IP 다(실측) — 서로 간섭하면 안 된다 """ import time import pytest from services.search.port_registry import PortRegistry from services.search.proxy import DecodoProxy from config.config_models import DecodoConfig GATE, KR = "gate.decodo.com", "kr.decodo.com" def _reg(n: int = 5) -> PortRegistry: return PortRegistry(10001, 10000 + n) # ── 배타 할당 ──────────────────────────────────────────────────────────── def test_same_port_is_never_handed_to_two_owners(): reg = _reg() taken = {reg.acquire(GATE, f"w{i}") for i in range(5)} assert len(taken) == 5, "5개 포트를 5명이 나눠 가져야 한다(중복 배정 없음)" def test_released_port_returns_to_pool(): reg = _reg(2) a = reg.acquire(GATE, "coupang") b = reg.acquire(GATE, "naver") reg.release(GATE, a, "coupang") assert reg.acquire(GATE, "naver2") == a # 반납된 포트가 다시 나온다 assert b != a def test_release_by_wrong_owner_is_ignored(): """뒤늦은 반납이 남의 리스를 깨면 그 순간 두 소유자가 같은 IP 를 쓰게 된다.""" reg = _reg(1) p = reg.acquire(GATE, "coupang") reg.release(GATE, p, "naver") # 남이 반납 시도 → 무시 assert reg.available(GATE) == 0 # ── 전역 쿨다운 ────────────────────────────────────────────────────────── def test_burned_port_is_avoided_by_every_owner(): reg = _reg(2) p = reg.acquire(GATE, "coupang") reg.burn(GATE, p, 600, owner="coupang", reason="block") other = reg.acquire(GATE, "naver") assert other != p, "다른 소유자도 태운 포트를 피해야 한다" def test_burn_releases_the_lease(): reg = _reg(3) p = reg.acquire(GATE, "coupang") reg.burn(GATE, p, 600) assert p not in [v for v in reg._held] # 보유 해제 assert reg.available(GATE) == 2 # 3 - 쿨다운 1 def test_cooldown_expiry_returns_port(): reg = _reg(1) p = reg.acquire(GATE, "coupang") reg.burn(GATE, p, -1) # 이미 만료된 쿨다운 assert reg.available(GATE) == 1 assert reg.acquire(GATE, "naver") == p # ── 게이트웨이 격리 ────────────────────────────────────────────────────── def test_gateways_do_not_interfere(): """같은 포트 번호라도 gate/kr 은 다른 IP(실측) — 한쪽 점유·차단이 다른 쪽을 막으면 안 된다.""" reg = _reg(1) g = reg.acquire(GATE, "coupang") k = reg.acquire(KR, "naver") assert g == k == 10001 # 번호는 같아도 서로 다른 자원 reg.burn(GATE, g, 600) assert reg.available(GATE) == 0 and reg.available(KR) == 0 # kr 은 여전히 naver 가 보유 reg.release(KR, k, "naver") assert reg.available(KR) == 1 # ── 소진 시 동작 ───────────────────────────────────────────────────────── def test_exhausted_pool_reclaims_soonest_expiring(): """전부 쿨다운이면 멈추는 대신 가장 빨리 풀릴 포트를 회수한다(가용성 우선).""" reg = _reg(2) reg.burn(GATE, 10001, 600) reg.burn(GATE, 10002, 60) # 얘가 더 빨리 풀린다 assert reg.acquire(GATE, "naver") == 10002 def test_lease_expiry_is_reported(): reg = _reg(1) p = reg.acquire(GATE, "coupang") assert reg.lease_expired(GATE, p, "coupang", lease_sec=600) is False assert reg.lease_expired(GATE, p, "naver", lease_sec=600) is True # 소유자 아님 reg._held[(GATE, p)] = ("coupang", time.monotonic() - 700) assert reg.lease_expired(GATE, p, "coupang", lease_sec=600) is True def test_snapshot_reports_per_gateway_owners(): reg = _reg(4) reg.acquire(GATE, "coupang-w0") reg.acquire(GATE, "coupang-w1") reg.acquire(KR, "naver-w0") reg.burn(KR, 10004, 600) snap = reg.snapshot() assert snap[GATE]["held"] == 2 and snap[GATE]["owners"] == {"coupang-w0": 1, "coupang-w1": 1} assert snap[KR]["held"] == 1 and snap[KR]["cooling"] == 1 assert snap[KR]["available"] == 2 # ── DecodoProxy 통합 ───────────────────────────────────────────────────── def _cfg() -> DecodoConfig: return DecodoConfig(host=GATE, kr_host=KR, username="u", password="p", port_start=10001, port_end=10003, session_minutes=10) def test_two_sources_never_share_a_port_through_registry(): reg = _reg(3) cou = DecodoProxy(_cfg(), registry=reg, owner="coupang-w0") nav = DecodoProxy(_cfg(), host=KR, registry=reg, owner="naver-w0") # 게이트웨이가 다르면 번호가 같아도 무방하지만, 같은 게이트웨이를 공유하면 갈려야 한다 nav_gate = DecodoProxy(_cfg(), registry=reg, owner="naver-w0") assert cou._port() != nav_gate._port() assert nav._port() is not None def test_rotate_releases_and_takes_new_port(): reg = _reg(3) p = DecodoProxy(_cfg(), registry=reg, owner="coupang-w0") first = p._port() p.rotate() second = p._port() assert first != second assert reg.available(GATE) == 2 # 이전 포트는 반납돼 풀에 남는다(쿨다운 아님) def test_mark_burned_goes_global_through_registry(): reg = _reg(3) cou = DecodoProxy(_cfg(), registry=reg, owner="coupang-w0") nav = DecodoProxy(_cfg(), registry=reg, owner="naver-w0") port = cou._port() cou.mark_burned(port) assert cou.available_ports() == 2 # 3 - 쿨다운 1 (보유 없음) assert nav._port() != port, "쿠팡이 태운 포트를 네이버가 집으면 안 된다" assert cou.available_ports() == 1 # 3 - 쿨다운 1 - 네이버 보유 1 def test_without_registry_behaviour_is_unchanged(): """registry 미주입 경로(단독 사용·기존 테스트)는 그대로 동작해야 한다.""" p = DecodoProxy(_cfg()) port = p._port() assert 10001 <= port <= 10003 p.mark_burned(port) assert p.available_ports() == 2 assert p._port() != port # ── 휴식(선제 회전) ────────────────────────────────────────────────────── def test_rested_port_is_not_immediately_reused(): """예산 도달로 놓은 IP 를 곧바로 다시 집으면 요청률이 도로 올라가 선제 회전이 무의미해진다.""" reg = _reg(2) p = reg.acquire(GATE, "naver-w0") reg.release(GATE, p, "naver-w0", rest_sec=600) assert reg.acquire(GATE, "naver-w1") != p def test_rest_expiry_returns_port_to_pool(): reg = _reg(1) p = reg.acquire(GATE, "naver-w0") reg.release(GATE, p, "naver-w0", rest_sec=-1) # 이미 만료된 휴식 assert reg.available(GATE) == 1 assert reg.acquire(GATE, "naver-w1") == p def test_release_without_rest_is_immediately_reusable(): """차단·예산이 아닌 평범한 반납(시간창 만료 등)은 휴식 없이 바로 풀에 돌아온다.""" reg = _reg(1) p = reg.acquire(GATE, "naver-w0") reg.release(GATE, p, "naver-w0") assert reg.acquire(GATE, "naver-w1") == p def test_burn_overrides_rest(): reg = _reg(2) p = reg.acquire(GATE, "naver-w0") reg.release(GATE, p, "naver-w0", rest_sec=60) reg.burn(GATE, p, 1800) # 쉬는 중이던 포트가 차단당함 assert reg.snapshot()[GATE]["cooling"] == 1 assert reg.snapshot()[GATE]["resting"] == 0 def test_exhausted_pool_reclaims_resting_before_burned(): """회수 우선순위: 휴식(탄 게 아님) > 쿨다운(차단). 탄 IP 를 먼저 꺼내 쓰면 안 된다.""" reg = _reg(2) reg.burn(GATE, 10001, 60) r = reg.acquire(GATE, "naver-w0") # 10002 reg.release(GATE, r, "naver-w0", rest_sec=600) assert reg.acquire(GATE, "naver-w1") == 10002 # 쉬던 포트를 회수(태운 10001 아님) def test_budget_rotation_rests_but_block_rotation_burns(): """DecodoProxy 통합: 선제 회전은 휴식, 차단은 쿨다운 — 로그·풀 회복 속도가 달라진다.""" reg = _reg(3) p = DecodoProxy(_cfg(), registry=reg, owner="naver-w0") first = p._port() p.rotate(kind="budget") # 예산 도달 — 태우지 않는다 snap = reg.snapshot()[GATE] assert snap["resting"] == 1 and snap["cooling"] == 0 assert p._port() != first second = p._port() p.mark_burned(second) # 차단 — 쿨다운 assert reg.snapshot()[GATE]["cooling"] == 1