o2o-negosium-original/lps/tests/test_lps_api.py
민헌 d6dd47e652 feat(lps): P4 관측·알림·워커 헬스 — readyz/ops + 하트비트/HEALTHCHECK + 임계 알림
프로덕션 운영 가시성. 행/좀비 워커 감지 + 큐/차단 지표 노출 + 임계 알림.

- API: /readyz(DB 도달성=readiness, 실패 503; /healthz=liveness와 구분).
  /v1/lps/ops(플랫 JSON): 큐 카운트 + oldest_pending_sec(큐지연) + dead_1h + stuck_running + blocks_1h.
- crud: JobQueue.ops()/ping(), BotDetectionLog.recent_count().
- worker: run_ops_monitor — 하트비트 파일 주기 갱신(Docker HEALTHCHECK 가 신선도로 행 워커 감지)
  + 임계(DEAD/차단/큐지연/stuck) 초과 시 WARN 로그 + (LPS_ALERT_WEBHOOK 있으면) Slack 호환 웹훅.
- Dockerfile.worker: HEALTHCHECK(하트비트 <120s). 임계·웹훅은 env(LPS_ALERT_*).
- 테스트: readyz/ops 2종.

검증: 컨테이너 healthy 판정, 하트비트 갱신, ops 스냅샷 정상. 91 tests.

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

95 lines
3.8 KiB
Python

"""LPS API 라우터 테스트 — 검색 적재/중복/상태조회/큐 통계 (ASGI 클라이언트 + 실 lps_db)."""
import pytest_asyncio
from sqlalchemy import text
@pytest_asyncio.fixture
async def clean_jobs(db_engine):
async with db_engine.begin() as conn:
await conn.execute(text("TRUNCATE job"))
async def test_search_enqueues_jobs(client, clean_jobs):
body = {"data": [
{"product_code": "P1", "product_name": "커피", "job_type": "new_product"},
{"product_code": "P2", "product_name": "무선마우스", "specification": "M170"},
]}
r = await client.post("/v1/lps/search", json=body)
assert r.status_code == 200
j = r.json()
assert j["accepted"] == 2
assert {i["product_code"] for i in j["items"]} == {"P1", "P2"}
assert all(i.get("job_id") for i in j["items"])
async def test_search_dedupes_active_product(client, clean_jobs):
body = {"data": [{"product_code": "P1", "product_name": "커피", "job_type": "new_product"}]}
await client.post("/v1/lps/search", json=body)
r2 = await client.post("/v1/lps/search", json=body) # 같은 product_code 재요청
item = r2.json()["items"][0]
assert item["duplicated"] is True
assert "job_id" not in item # None → RemoveNoneResponse 로 제거됨
assert r2.json()["accepted"] == 0
async def test_job_status_flow(client, clean_jobs):
jid = (await client.post("/v1/lps/search", json={"data": [{"product_code": "P9", "product_name": "커피"}]})).json()["items"][0]["job_id"]
r = await client.get(f"/v1/lps/jobs/{jid}")
body = r.json()
assert body["status"] == "PENDING" and body["attempts"] == 0
assert body["result"]["success"] is True
async def test_job_status_not_found(client, clean_jobs):
# 존재하지 않는(유효 UUID) 잡
r = await client.get("/v1/lps/jobs/00000000-0000-0000-0000-000000000000")
assert r.json()["result"]["success"] is False
assert r.json()["result"]["desc"] == "LPS_JOB_NOT_FOUND"
# 잘못된 형식의 id 도 not-found 처리
r2 = await client.get("/v1/lps/jobs/not-a-uuid")
assert r2.json()["result"]["desc"] == "LPS_JOB_NOT_FOUND"
async def test_price_history_endpoint(client, db_engine):
from crud.price_history import PriceHistory
async with db_engine.begin() as conn:
await conn.execute(text("TRUNCATE price_history"))
ph = PriceHistory()
await ph.record({"product_code": "GRAPH1", "outcome": "found", "final_lowest": 2500,
"final_source": "naver", "naver_lowest": 2500, "coupang_lowest": 2700, "matched_count": 2})
r = await client.get("/v1/lps/products/GRAPH1/history")
assert r.status_code == 200
body = r.json()
assert body["product_code"] == "GRAPH1"
assert len(body["points"]) == 1
pt = body["points"][0]
assert pt["final"] == 2500 and pt["naver"] == 2500 and pt["coupang"] == 2700 and pt["final_source"] == "naver"
assert "triggered_at" in pt
async def test_queue_stats(client, clean_jobs):
await client.post("/v1/lps/search", json={"data": [
{"product_code": "A", "product_name": "x"},
{"product_code": "B", "product_name": "y"},
]})
r = await client.get("/v1/lps/queue/stats")
counts = r.json()["counts"]
assert counts["PENDING"] == 2 and counts["DONE"] == 0 and counts["DEAD"] == 0
async def test_readyz(client):
r = await client.get("/readyz") # DB 도달 → ready
assert r.status_code == 200 and r.json()["ready"] is True
async def test_ops_snapshot(client, clean_jobs):
await client.post("/v1/lps/search", json={"data": [{"product_code": "A", "product_name": "x"}]})
r = await client.get("/v1/lps/ops")
assert r.status_code == 200
j = r.json()
for k in ("pending", "running", "done", "dead", "dead_1h", "stuck_running", "oldest_pending_sec", "blocks_1h"):
assert k in j and isinstance(j[k], int)
assert j["pending"] == 1