feat(worker-pool): Registry-1C recap context + /jobs/recap + 100KB guard

- app/services/worker_recap_context.py — fetch_recap_context(user_id, days)
  documents file_type='note' 7d (single-user invariant) + events 7d
  (user_id 매칭 + cancelled 제외) JOIN. timezone Asia/Seoul.
- /internal/worker/jobs/recap POST — 일반 user JWT 인증 + context 조립
  + worker_jobs INSERT. job_type='recap' + payload JSONB.
- payload 100KB guard — JSON 직렬화 100_000 bytes 초과 시 413.
- 회귀 위험 0: memos/events API select 절 touch 0, read-only 쿼리만.

worker-pool-policy §B.2 invariant 보존: ProcessingQueue 무변경, 운영 자동
분기 변경 0, canonical promote 0 (worker_jobs.payload JSONB only).

Notebook-Pilot-1 entry condition 4항목 모두 충족 가능:
manual recap E2E / payload <100KB guard / residue 0 / 권한 분리 403.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-05-19 12:44:07 +09:00
parent 0cbd97fcba
commit 0ea72c1aa6
3 changed files with 288 additions and 2 deletions
+127
View File
@@ -0,0 +1,127 @@
"""PR-Worker-Pool-Registry-1C — /internal/worker/jobs/recap + recap context.
3 항목 (단독 실행 기준 PASS; 같은 파일 sequential 한계 = test fixture isolation follow-up):
1. fetch_recap_context — empty user (memo/event 0) → memo_count=0 + event_count=0 + 타임존 표기
2. /jobs/recap endpoint — 일반 user JWT 로 호출 → 200 + worker_jobs INSERT + payload JSONB
3. payload size guard — fetch_recap_context monkeypatch 로 거대 context 반환 → 413
"""
from __future__ import annotations
import json
import os
import sys
import pytest
import pytest_asyncio
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "app"))
from httpx import ASGITransport, AsyncClient
from sqlalchemy import text
from sqlalchemy.ext.asyncio import async_sessionmaker
from _worker_pool_helpers import (
_make_engine,
cleanup_worker_jobs,
ensure_user,
fetch_worker_job,
get_database_url,
mint_access_token,
)
@pytest_asyncio.fixture
async def env_setup(monkeypatch):
monkeypatch.setenv("LAPTOP_WORKER_BOT_USERNAME", "laptop-worker-bot")
@pytest.mark.asyncio
async def test_fetch_recap_context_shape(env_setup):
"""fetch_recap_context — 시그니처 + 빈 user 시 0/0 + KST 타임존 표기."""
from services.worker_recap_context import fetch_recap_context
owner_id = await ensure_user("test-recap-empty-1c")
engine = _make_engine()
sm = async_sessionmaker(engine, expire_on_commit=False)
try:
async with sm() as session:
ctx = await fetch_recap_context(session, user_id=owner_id, days=7)
finally:
await engine.dispose()
assert ctx["user_id"] == owner_id
assert ctx["days"] == 7
assert ctx["timezone"] == "Asia/Seoul"
assert isinstance(ctx["memos"], list)
assert isinstance(ctx["events"], list)
assert ctx["memo_count"] == len(ctx["memos"])
assert ctx["event_count"] == len(ctx["events"])
# 신규 user 이므로 event 0
assert ctx["event_count"] == 0
@pytest.mark.asyncio
async def test_recap_endpoint_creates_worker_job(env_setup):
"""/jobs/recap 호출 → 200 + worker_jobs INSERT."""
from main import app
owner_id = await ensure_user("test-recap-endpoint-1c")
token = mint_access_token("test-recap-endpoint-1c")
try:
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as c:
r = await c.post(
"/internal/worker/jobs/recap",
json={"days": 7},
headers={"Authorization": f"Bearer {token}"},
)
assert r.status_code == 200, r.text
js = r.json()
assert "job_id" in js
assert js["memo_count"] >= 0
assert js["event_count"] >= 0
assert js["payload_bytes"] > 0
# DB verify
job = await fetch_worker_job(js["job_id"])
assert job is not None
# payload 는 JSONB dict 로 저장됨
finally:
await cleanup_worker_jobs("recap")
@pytest.mark.asyncio
async def test_recap_payload_413_when_oversize(env_setup, monkeypatch):
"""payload 100KB 초과 시 413."""
from api import internal_worker as iw_mod
from main import app
owner_id = await ensure_user("test-recap-413-1c")
token = mint_access_token("test-recap-413-1c")
async def fake_fetch(session, user_id, days=7):
return {
"user_id": user_id,
"days": days,
"period_start": "2026-05-12T00:00:00+09:00",
"period_end": "2026-05-19T00:00:00+09:00",
"timezone": "Asia/Seoul",
"memos": [{"id": i, "title": "x" * 1000} for i in range(120)], # ~120KB
"events": [],
"memo_count": 120,
"event_count": 0,
}
monkeypatch.setattr(iw_mod, "fetch_recap_context", fake_fetch)
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as c:
r = await c.post(
"/internal/worker/jobs/recap",
json={"days": 7},
headers={"Authorization": f"Bearer {token}"},
)
assert r.status_code == 413, r.text
assert "bytes" in r.json()["detail"]