Files
ai-trade/services/insta-render/tests/test_worker.py
gahusb 1e6638a64b fix(insta-render): F6 ReliableQueue 적용 — BLMOVE + ack/fail (F6 part 2)
- worker.py: BLPOP → ReliableQueue.dequeue / ack / fail / startup recovery
- _process_one: 예외 시 webhook(failed) 후 raise — poll_once가 fail(raw, payload)
  로 retry/dead-letter 처리
- poll_once 함수 추가 (테스트 단위)
- Dockerfile: build context=services/ 로 올리고 _shared 포함, PYTHONPATH=/app
- docker-compose.yml: insta-render build context 갱신

기존 webhook 호출 동작은 그대로 (멱등) — retry 시 매번 NAS에 failed 통보되어도
마지막 상태만 보임. dead-letter는 운영 모니터링으로 별도 처리.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 20:13:24 +09:00

200 lines
6.6 KiB
Python

"""worker.py — Redis BLPOP + webhook 단위 테스트."""
import json
import pytest
import httpx
from unittest.mock import AsyncMock, patch
import worker
@pytest.fixture
def fake_slate():
return {
"id": 42,
"cover_copy": json.dumps({"headline": "테스트 H", "body": "테스트 B", "accent_color": "#FF0000"}),
"body_copies": json.dumps([{"headline": "본문1", "body": "..."} for _ in range(8)]),
"cta_copy": json.dumps({"headline": "CTA", "body": "...", "cta": "Click"}),
}
@pytest.mark.asyncio
async def test_post_update_sends_correct_payload(monkeypatch):
monkeypatch.setenv("INTERNAL_API_KEY", "test-secret")
monkeypatch.setenv("NAS_BASE_URL", "http://nas.test")
# worker 모듈 환경변수 재로딩
worker.NAS_BASE_URL = "http://nas.test"
worker.INTERNAL_API_KEY = "test-secret"
captured = {}
async def fake_post(self, url, headers=None, json=None, **kw):
captured["url"] = url
captured["headers"] = headers
captured["json"] = json
class R:
status_code = 200
text = "ok"
return R()
monkeypatch.setattr(httpx.AsyncClient, "post", fake_post)
async with httpx.AsyncClient() as client:
await worker._post_update(client, "t-1", "processing", 30)
assert captured["url"] == "http://nas.test/api/internal/insta/update"
assert captured["headers"]["X-Internal-Key"] == "test-secret"
assert captured["json"]["status"] == "processing"
assert captured["json"]["progress"] == 30
@pytest.mark.asyncio
async def test_process_one_success_calls_webhook_twice(monkeypatch, fake_slate):
"""processing(50) → succeeded(100) 두 번 호출 + render 한 번."""
calls: list = []
async def fake_post(self, url, headers=None, json=None, **kw):
calls.append({"status": json["status"], "progress": json["progress"]})
class R:
status_code = 200
text = "ok"
return R()
async def fake_get(self, url, **kw):
class R:
status_code = 200
def json(self_inner): return fake_slate
def raise_for_status(self_inner): pass
return R()
async def fake_render(slate, slate_id, template="default/card.html.j2"):
return [f"/tmp/{slate_id}/{i:02d}.png" for i in range(1, 11)]
monkeypatch.setattr(httpx.AsyncClient, "post", fake_post)
monkeypatch.setattr(httpx.AsyncClient, "get", fake_get)
monkeypatch.setattr(worker, "render_slate", fake_render)
worker.INTERNAL_API_KEY = "test"
worker.NAS_BASE_URL = "http://nas.test"
async with httpx.AsyncClient() as client:
await worker._process_one(client, {
"task_id": "t-2",
"params": {"slate_id": 42, "theme": "default"},
})
statuses = [c["status"] for c in calls]
assert "processing" in statuses
assert "succeeded" in statuses
assert calls[-1]["progress"] == 100
@pytest.mark.asyncio
async def test_process_one_render_failure_reports_failed(monkeypatch, fake_slate):
"""render 예외 시 failed webhook 호출."""
calls: list = []
async def fake_post(self, url, headers=None, json=None, **kw):
calls.append(json)
class R: status_code = 200; text = "ok"
return R()
async def fake_get(self, url, **kw):
class R:
status_code = 200
def json(self_inner): return fake_slate
def raise_for_status(self_inner): pass
return R()
async def fake_render(*a, **k):
raise RuntimeError("Chromium crashed")
monkeypatch.setattr(httpx.AsyncClient, "post", fake_post)
monkeypatch.setattr(httpx.AsyncClient, "get", fake_get)
monkeypatch.setattr(worker, "render_slate", fake_render)
worker.INTERNAL_API_KEY = "test"
worker.NAS_BASE_URL = "http://nas.test"
async with httpx.AsyncClient() as client:
# F6: _process_one은 webhook(failed) 호출 후 raise — poll_once가 fail(raw)로 retry/dead-letter.
with pytest.raises(RuntimeError, match="Chromium"):
await worker._process_one(client, {
"task_id": "t-3",
"params": {"slate_id": 99},
})
last = calls[-1]
assert last["status"] == "failed"
assert "Chromium" in last["error"]
# ----- F6: ReliableQueue (ack on success, fail on exception) -----
@pytest.mark.asyncio
async def test_poll_once_acks_on_success(monkeypatch):
"""F6 — 성공 시 queue.ack(raw) 호출 + fail 안 부름."""
fake_payload = {
"task_id": "t-ok",
"params": {"slate_id": 7, "theme": "default"},
}
fake_raw = json.dumps(fake_payload).encode()
fake_queue = AsyncMock()
fake_queue.dequeue = AsyncMock(return_value=(fake_payload, fake_raw))
fake_queue.ack = AsyncMock()
fake_queue.fail = AsyncMock()
process_mock = AsyncMock()
monkeypatch.setattr(worker, "_process_one", process_mock)
async with httpx.AsyncClient() as client:
handled = await worker.poll_once(fake_queue, client)
assert handled is True
process_mock.assert_awaited_once()
fake_queue.ack.assert_awaited_once_with(fake_raw)
fake_queue.fail.assert_not_awaited()
@pytest.mark.asyncio
async def test_poll_once_calls_fail_on_exception(monkeypatch):
"""F6 — _process_one 예외 시 queue.fail(raw, payload) 호출."""
fake_payload = {
"task_id": "t-err",
"params": {"slate_id": 9, "theme": "default"},
}
fake_raw = json.dumps(fake_payload).encode()
fake_queue = AsyncMock()
fake_queue.dequeue = AsyncMock(return_value=(fake_payload, fake_raw))
fake_queue.ack = AsyncMock()
fake_queue.fail = AsyncMock()
async def boom(client, payload):
raise RuntimeError("simulated dispatch failure")
monkeypatch.setattr(worker, "_process_one", boom)
async with httpx.AsyncClient() as client:
handled = await worker.poll_once(fake_queue, client)
assert handled is True
fake_queue.fail.assert_awaited_once_with(fake_raw, fake_payload)
fake_queue.ack.assert_not_awaited()
@pytest.mark.asyncio
async def test_poll_once_returns_false_on_timeout(monkeypatch):
"""F6 — dequeue가 None 반환(타임아웃)이면 False 리턴, ack/fail 안 부름."""
fake_queue = AsyncMock()
fake_queue.dequeue = AsyncMock(return_value=None)
fake_queue.ack = AsyncMock()
fake_queue.fail = AsyncMock()
process_mock = AsyncMock()
monkeypatch.setattr(worker, "_process_one", process_mock)
async with httpx.AsyncClient() as client:
handled = await worker.poll_once(fake_queue, client)
assert handled is False
process_mock.assert_not_awaited()
fake_queue.ack.assert_not_awaited()
fake_queue.fail.assert_not_awaited()