o2o-negosium-original/lps/tests/test_admin_api.py
민헌 61b438d8d0 feat(lps): 관리자 FE 용 API 6종 — 잡 목록/재큐·상품 목록·IP세션/차단/비용 통계
React 관리자 페이지(협의: 모니터링+필수 액션)의 데이터 소스.

- GET /v1/lps/jobs: 최신순 목록+총건수, status/q(상품코드·명) 필터.
  결과에 outcome·최저가·검색원가·오류를 평탄화해 목록에서 바로 보이게.
- POST /v1/lps/jobs/{id}/requeue: DEAD 재큐(attempts 리셋+pg_notify 워커
  깨움). 활성 중복(dedupe)이면 DB_ALREADY_SAME_KEY 로 거절.
- GET /v1/lps/products: 상품별 최신 스냅샷+누적 검색 수(최근 검색순).
- GET /v1/lps/stats/ip-sessions: 종료사유 분포·요청수 히스토그램·차단
  세션 최소 요청수(예산 튜닝 기준선)·최근 세션 50.
- GET /v1/lps/stats/bot: 시간대별 차단 + 최근 감지 목록.
- GET /v1/lps/stats/cost: 시간별 원가(AI/프록시 분해)+평균 소요.
- AdminService/admin_protocol/admin 라우터 신설, guard 일괄 적용.
  설정 변경 UI 는 두지 않음 — toml 단일 소스 원칙.
- 테스트 9건 추가, 전체 154 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 21:42:38 +09:00

140 lines
5.9 KiB
Python

"""관리자 FE API 테스트 — 잡 목록/재큐·상품 목록·IP세션/차단/비용 통계 (실 lps_db)."""
import pytest_asyncio
from sqlalchemy import text
from common.enums import JobType
from crud.job_crud import JobQueue
@pytest_asyncio.fixture
async def clean_all(db_engine):
async with db_engine.begin() as conn:
for t in ("job", "price_history", "ip_session", "bot_detection"):
await conn.execute(text(f"TRUNCATE {t}"))
return db_engine
@pytest_asyncio.fixture
async def q(clean_all):
return JobQueue()
# ---- 잡 목록 --------------------------------------------------------------
async def test_jobs_list_with_filters(client, q):
await q.enqueue(JobType.SEARCH.value, {"product_code": "A1", "product_name": "맥심 커피"})
await q.enqueue(JobType.SEARCH.value, {"product_code": "B2", "product_name": "생수"})
job = await q.claim("w1")
await q.complete(job["job_id"], "w1", {"outcome": "found", "lowest": {"price": 12000},
"metrics": {"cost": {"total_usd": 0.01}}})
r = await client.get("/v1/lps/jobs")
j = r.json()
assert r.status_code == 200 and j["total"] == 2
r = await client.get("/v1/lps/jobs", params={"status": "DONE"})
j = r.json()
assert j["total"] == 1
assert j["items"][0]["outcome"] == "found" and j["items"][0]["final_lowest"] == 12000
assert j["items"][0]["cost_usd"] == 0.01
r = await client.get("/v1/lps/jobs", params={"q": "맥심"})
assert r.json()["total"] == 1
async def test_jobs_list_unknown_status(client, q):
r = await client.get("/v1/lps/jobs", params={"status": "NOPE"})
assert r.json()["result"]["success"] is False
# ---- 재큐 ------------------------------------------------------------------
async def test_requeue_dead_job(client, q):
jid = await q.enqueue(JobType.SEARCH.value, {"product_code": "A1"}, max_attempts=1, dedupe_key="search-A1")
await q.claim("w1")
await q.fail(jid, "w1", "boom", backoff_sec=0) # 1/1 → DEAD
r = await client.post(f"/v1/lps/jobs/{jid}/requeue")
assert r.json()["requeued"] is True
assert (await q.counts())["PENDING"] == 1 # DEAD → PENDING
async def test_requeue_rejects_non_dead_and_missing(client, q):
jid = await q.enqueue(JobType.SEARCH.value, {"product_code": "A1"})
r = await client.post(f"/v1/lps/jobs/{jid}/requeue") # PENDING — 재큐 대상 아님
assert r.json()["result"]["success"] is False
r = await client.post("/v1/lps/jobs/not-a-uuid/requeue")
assert r.json()["result"]["success"] is False
async def test_requeue_blocked_by_active_duplicate(client, q):
dead = await q.enqueue(JobType.SEARCH.value, {"product_code": "A1"}, max_attempts=1, dedupe_key="search-A1")
await q.claim("w1")
await q.fail(dead, "w1", "boom", backoff_sec=0)
await q.enqueue(JobType.SEARCH.value, {"product_code": "A1"}, dedupe_key="search-A1") # 활성 중복 생성
r = await client.post(f"/v1/lps/jobs/{dead}/requeue")
assert r.json()["requeued"] is False
assert r.json()["result"]["success"] is False # DB_ALREADY_SAME_KEY
# ---- 상품 목록 --------------------------------------------------------------
async def test_products_list_latest_snapshot(client, clean_all):
async with clean_all.begin() as conn:
await conn.execute(text("""
INSERT INTO price_history (product_code, outcome, naver_lowest, final_lowest, naver_name, triggered_at)
VALUES ('P1', 'found', 1000, 900, '커피 320개입', now() - interval '2 hour'),
('P1', 'found', 1100, 950, '커피 320개입', now() - interval '1 hour'),
('P2', 'not_found', NULL, NULL, NULL, now())
"""))
r = await client.get("/v1/lps/products")
items = r.json()["items"]
assert [i["product_code"] for i in items] == ["P2", "P1"] # 최근 검색순
p1 = items[1]
assert p1["searches"] == 2 and p1["final_lowest"] == 950 # 최신 스냅샷 + 누적 횟수
r = await client.get("/v1/lps/products", params={"q": "P1"})
assert len(r.json()["items"]) == 1
# ---- 통계 3종 ---------------------------------------------------------------
async def test_ip_session_stats(client, clean_all):
async with clean_all.begin() as conn:
await conn.execute(text("""
INSERT INTO ip_session (source, proxy_port, requests, ok_count, blocked_count, end_reason)
VALUES ('coupang', 10001, 3, 3, 0, 'budget'),
('coupang', 10002, 3, 3, 0, 'budget'),
('coupang', 10003, 5, 4, 1, 'block')
"""))
r = await client.get("/v1/lps/stats/ip-sessions")
j = r.json()
assert j["by_reason"] == {"budget": 2, "block": 1}
assert j["block_min_requests"] == 5 # 예산 튜닝 기준선
assert {"requests": 3, "count": 2} in j["histogram"]
assert len(j["sessions"]) == 3
async def test_bot_stats(client, clean_all):
async with clean_all.begin() as conn:
await conn.execute(text("""
INSERT INTO bot_detection (source, query, ip_request_no, proxy_port, marker)
VALUES ('coupang', '생수', 4, 10001, '/akam/')
"""))
r = await client.get("/v1/lps/stats/bot")
j = r.json()
assert len(j["items"]) == 1 and j["items"][0]["marker"] == "/akam/"
assert sum(h["count"] for h in j["hourly"]) == 1
async def test_cost_stats(client, q):
for cost in (0.01, 0.02):
jid = await q.enqueue(JobType.SEARCH.value, {"product_code": f"C{cost}"})
job = await q.claim("w1")
await q.complete(job["job_id"], "w1",
{"metrics": {"cost": {"ai_usd": cost / 2, "proxy_usd": cost / 2, "total_usd": cost},
"duration_ms": 15000}})
r = await client.get("/v1/lps/stats/cost")
buckets = r.json()["buckets"]
assert sum(b["total_usd"] for b in buckets) == 0.03
assert sum(b["jobs"] for b in buckets) == 2
assert buckets[0]["avg_ms"] == 15000