"""관리자 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