o2o-site-AEO/solution/backend/tests/test_alert_service.py
Mina Choi 11d30bb3d1 [chore] solution,admin,ontology: 코드 주석을 한 줄로 — 히스토리 주석 삭제
여러 줄 주석이 설명보다 경위(예전·실측·지적)를 적고 있어 읽는 사람이 결론을 찾기 어려웠다.

- ts·tsx·js·mjs·css·py 478개: 여러 줄 주석은 첫 문장 한 줄로, 과거형·날짜 문장은 삭제
- 주석 위치는 TypeScript 파서·파이썬 tokenize/ast 로 찾는다 — 문자열 안의 # · /* 는 건드리지 않는다
- eslint·ts·noqa·type: ignore 같은 지시 주석은 그대로 둔다

파이썬 275개 정리 전후 AST 동일, TS 298개 주석 뺀 토큰 동일(빈 JSX 주석 10곳만 차이).
site·frontend·admin tsc, site vitest 105 passed

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:05:19 +09:00

194 lines
8.3 KiB
Python

"""알림 발송함 — 적재(dedupe) · 발송(재시도/소진) · 복구 · 비밀 마스킹."""
import json
import httpx
from sqlalchemy import text
from services import alert_service, teams_webhook
def _mock_webhook(monkeypatch, handler):
"""teams_webhook 의 실제 HTTP 호출을 대역으로 바꾼다(네트워크를 타지 않는다)."""
real_client = httpx.AsyncClient
def make_client(**kw):
return real_client(transport=httpx.MockTransport(handler), **kw)
monkeypatch.setattr(teams_webhook.httpx, "AsyncClient", make_client)
async def _rows(db_engine, kind: str | None = None):
async with db_engine.begin() as c:
sql = "SELECT kind, dedupe_key, title, detail, status, attempts, resolved_at FROM alert_outbox"
params = {}
if kind:
sql += " WHERE kind = :k"
params["k"] = kind
sql += " ORDER BY created_at"
result = await c.execute(text(sql), params)
return [dict(r._mapping) for r in result]
# ── 적재 · 중복 억제 ─────────────────────────────────────────────────────────
async def test_send_alert_creates_pending_row(db_engine):
await alert_service.send_alert("job_dead", "잡 실패", "사유", dedupe_key="k1")
rows = await _rows(db_engine, "job_dead")
assert len(rows) == 1
assert rows[0]["status"] == 1 # AlertStatus.PENDING
assert rows[0]["dedupe_key"] == "k1"
async def test_send_alert_dedupes_within_window(db_engine):
"""검증: 같은 dedupe_key 로 두 번 연속 보낸다."""
await alert_service.send_alert("build_failed", "발행 실패", "1차", dedupe_key="k2")
await alert_service.send_alert("build_failed", "발행 실패", "2차", dedupe_key="k2")
rows = await _rows(db_engine, "build_failed")
assert len(rows) == 1
assert rows[0]["detail"] == "1차"
async def test_send_alert_without_dedupe_key_always_creates(db_engine):
"""dedupe_key 가 없으면(예: 복구 알림) 매번 새로 쌓인다."""
await alert_service.send_alert("recovery", "복구", "a")
await alert_service.send_alert("recovery", "복구", "b")
rows = await _rows(db_engine, "recovery")
assert len(rows) == 2
# ── 복구 ────────────────────────────────────────────────────────────────────
async def test_resolve_alert_marks_resolved_and_sends_recovery_notice(db_engine):
await alert_service.send_alert("queue_stuck", "정체", "사유", dedupe_key="k3")
await alert_service.resolve_alert("k3", "정상으로 돌아옴")
original = await _rows(db_engine, "queue_stuck")
assert original[0]["resolved_at"] is not None
recovered = await _rows(db_engine, "recovery")
assert len(recovered) == 1
assert recovered[0]["title"] == "정상으로 돌아옴"
async def test_resolve_alert_is_noop_when_nothing_unresolved(db_engine):
"""검증: 알린 적 없는 dedupe_key 를 resolve."""
await alert_service.resolve_alert("never-alerted", "정상")
rows = await _rows(db_engine, "recovery")
assert rows == []
async def test_send_alert_after_resolve_creates_new_row(db_engine):
"""검증: 한 번 풀린(resolved) dedupe_key 로 다시 보낸다."""
await alert_service.send_alert("build_failed", "실패1", "x", dedupe_key="k4")
await alert_service.resolve_alert("k4", "복구1")
await alert_service.send_alert("build_failed", "실패2", "y", dedupe_key="k4")
rows = await _rows(db_engine, "build_failed")
assert len(rows) == 2
assert rows[1]["detail"] == "y"
assert rows[1]["resolved_at"] is None
# ── 비밀·개인정보 마스킹 ──────────────────────────────────────────────────────
def test_scrub_redacts_query_string_secrets():
out = alert_service._scrub("https://api.example.com/x?api_key=SECRET123&q=hi")
assert "SECRET123" not in out
assert "api_key=***" in out
def test_scrub_redacts_bearer_token():
out = alert_service._scrub("Authorization: Bearer abcdef1234567890")
assert "abcdef1234567890" not in out
assert "Bearer ***" in out
def test_scrub_redacts_password_kv():
out = alert_service._scrub("login failed password=hunter2hunter2")
assert "hunter2hunter2" not in out
def test_scrub_masks_email():
out = alert_service._scrub("owner email: someone@example.com failed")
assert "someone@example.com" not in out
assert "@***" in out
def test_scrub_truncates_long_detail():
out = alert_service._scrub("x" * 5000)
assert len(out) <= 2000
# ── 발송 · 재시도 · 소진 ──────────────────────────────────────────────────────
async def test_process_outbox_sends_and_marks_sent(db_engine, monkeypatch):
monkeypatch.setenv("TEAMS_WEBHOOK_URL", "https://example.test/webhook")
received = []
def handler(request: httpx.Request) -> httpx.Response:
received.append(json.loads(request.content))
return httpx.Response(202)
_mock_webhook(monkeypatch, handler)
await alert_service.send_alert("job_dead", "잡 실패", "사유", dedupe_key="k5")
result = await alert_service.process_outbox()
assert result == {"sent": 1, "failed": 0}
assert len(received) == 1
rows = await _rows(db_engine, "job_dead")
assert rows[0]["status"] == 2 # AlertStatus.SENT
async def test_process_outbox_noop_when_webhook_unconfigured(db_engine, monkeypatch):
"""검증: TEAMS_WEBHOOK_URL 이 비어 있을 때 스윕을 돌린다."""
monkeypatch.delenv("TEAMS_WEBHOOK_URL", raising=False)
await alert_service.send_alert("job_dead", "잡 실패", "사유", dedupe_key="k6")
result = await alert_service.process_outbox()
assert result["sent"] == 0
rows = await _rows(db_engine, "job_dead")
assert rows[0]["status"] == 1 # 여전히 PENDING(백오프 대기)
assert rows[0]["attempts"] == 1
async def test_process_outbox_exhausts_after_max_attempts(db_engine, monkeypatch):
"""검증: 계속 실패하는 webhook 으로 MAX_ATTEMPTS 만큼 스윕한다."""
monkeypatch.setenv("TEAMS_WEBHOOK_URL", "https://example.test/webhook")
_mock_webhook(monkeypatch, lambda req: httpx.Response(500))
await alert_service.send_alert("job_dead", "잡 실패", "사유", dedupe_key="k7")
for _ in range(alert_service.MAX_ATTEMPTS):
# next_attempt_at 이 미래로 밀려도 여기선 process_outbox 가 직접 대상을 스윕하므로 시간 경과를 흉내 낼 필요 없이 next_attempt_at 을 매번 과거로 되돌린다.
async with db_engine.begin() as c:
await c.execute(text("UPDATE alert_outbox SET next_attempt_at = now() - interval '1 second'"))
await alert_service.process_outbox()
rows = await _rows(db_engine, "job_dead")
assert rows[0]["status"] == 3 # AlertStatus.FAILED(소진)
assert rows[0]["attempts"] == alert_service.MAX_ATTEMPTS
# ── Teams 카드 모양 ───────────────────────────────────────────────────────────
async def test_teams_webhook_sends_adaptive_card(monkeypatch):
monkeypatch.setenv("TEAMS_WEBHOOK_URL", "https://example.test/webhook")
received = []
def handler(request: httpx.Request) -> httpx.Response:
received.append(json.loads(request.content))
return httpx.Response(202)
_mock_webhook(monkeypatch, handler)
ok = await teams_webhook.send("제목", "내용")
assert ok is True
card = received[0]["attachments"][0]
assert card["contentType"] == "application/vnd.microsoft.card.adaptive"
async def test_teams_webhook_refuses_non_https(monkeypatch):
monkeypatch.setenv("TEAMS_WEBHOOK_URL", "http://not-secure.test/webhook")
ok = await teams_webhook.send("제목", "내용")
assert ok is False
async def test_teams_webhook_noop_when_unset(monkeypatch):
monkeypatch.delenv("TEAMS_WEBHOOK_URL", raising=False)
ok = await teams_webhook.send("제목", "내용")
assert ok is False