eae1f48d62
사용자 결정 2026-05-19: 100KB cap 이 운영 7d 데이터 1.36MB 대비 부족 → cap 상향만으로 raw 비대화 위험. cap 1MB + payload compaction 병행. fetch_recap_context() 변경: - memo payload item field 축소 = id/title/ai_tldr/ai_event_kind/created_at (5 필드) (ai_bullets/file_type/source_channel/category/extracted_text 등 제외) - memo top-N = RECAP_MEMO_TOP_N env (default 200) — 초과분은 aggregate 로 - aggregate = memos_by_day + memos_by_kind + omitted_memos - payload_compacted flag = aggregate fallback 발현 여부 - events 는 raw (운영 7d 데이터에서 통상 0~소량) internal_worker.py: - PAYLOAD_MAX_BYTES → _payload_max_bytes() env override (WORKER_RECAP_PAYLOAD_MAX_BYTES default 1_000_000) - JobsRecapResponse 에 payload_compacted / omitted_memos 노출 - 413 detail 에 "after compaction" 명시 + RECAP_MEMO_TOP_N 조정 안내 테스트 3 항목 신규 + 기존 endpoint 413 test 업데이트: - 700 memo → 200 kept + 500 omitted + compacted=true + < 1MB - 10 memo → compacted=false + omitted=0 - 비정상 큰 title (compaction 후에도 cap 초과) → 413 유지 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
141 lines
4.6 KiB
Python
141 lines
4.6 KiB
Python
"""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
|
|
assert "payload_compacted" in js
|
|
assert "omitted_memos" in js
|
|
# 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 1MB 초과 시 413 (사용자 결정 2026-05-19 cap 1MB)."""
|
|
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",
|
|
# ~1.2MB raw payload (compaction 후에도 cap 초과 가정)
|
|
"memos": [{"id": i, "title": "x" * 6000} for i in range(200)],
|
|
"events": [],
|
|
"memo_count": 200,
|
|
"event_count": 0,
|
|
"summary_stats": {
|
|
"total_memos": 200,
|
|
"memos_kept": 200,
|
|
"omitted_memos": 0,
|
|
"top_n": 200,
|
|
"memos_by_day": {},
|
|
"memos_by_kind": {},
|
|
},
|
|
"payload_compacted": False,
|
|
}
|
|
|
|
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"]
|
|
assert "after compaction" in r.json()["detail"]
|