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>
140 lines
5.9 KiB
Python
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
|