6 Commits

Author SHA1 Message Date
e6ff234031 refactor(insta-lab): remove Playwright + slim Dockerfile (SP-4)
NAS에서 더 이상 카드 렌더 안 함 → Windows insta-render 워커로 완전 이전.
- card_renderer.py를 1줄 deprecation stub로 교체
- main.py의 import card_renderer 제거 + startup/shutdown hook 정리
- requirements.txt에서 playwright 삭제
- Dockerfile에서 Chromium 30+ dep 라인 + playwright install 제거 → image ~50% 감소
- test_card_renderer.py 폐기 (Windows 측 test_worker.py가 대체)
- test_main.py의 create_slate 테스트를 Redis-push 플로우에 맞게 업데이트

Plan-B-Insta Phase 3 완료.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 02:21:02 +09:00
912cd18e48 feat(insta-lab): cutover to Redis push, Playwright 렌더 호출 제거 (SP-4)
_bg_create_slate, _bg_render의 await card_renderer.render_slate(...)
호출을 Redis RPUSH queue:insta-render 로 전환.

NAS는 task_id 발급 + 큐 푸시 + 30~70% 진행률 보고만. Windows insta-render
워커가 BLPOP → 렌더 → webhook으로 succeeded 보고 시 NAS가
update_slate_status('rendered') 트리거.

Plan-B-Insta Phase 3 (cutover).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 02:18:12 +09:00
a06cc424ca chore(compose): insta-lab REDIS_URL + INTERNAL_API_KEY env + depends_on redis
박재오: NAS .env에 INTERNAL_API_KEY=$(openssl rand -hex 32) 추가 필요.
같은 값을 Windows insta-render .env에 보관 (대칭).

Plan-B-Insta Phase 1 완료.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 02:01:23 +09:00
e87c43a7a4 feat(insta-lab): wire internal_router + Redis client (SP-4 prep)
main.py에 internal_router include + 모듈 레벨 redis client.
requirements.txt에 redis>=5.0 추가 (playwright 제거는 Task 12에서).

Plan-B-Insta Phase 1 마무리. Task 11에서 _bg_render를 Redis push로 전환.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 01:59:55 +09:00
0c12c3527f feat(insta-lab): internal webhook /api/internal/insta/update (SP-4)
Windows insta-render worker가 작업 진행률·완료·실패를 보고할 수신부.
X-Internal-Key 인증 필수. 4건의 단위 테스트로 status·error·result_path 검증.

Plan-B-Insta Phase 1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 01:57:17 +09:00
5ed9d265f6 feat(insta-lab): verify_internal_key auth for Windows webhook (SP-4)
X-Internal-Key 헤더 검증 dependency. .env의 INTERNAL_API_KEY와 비교.
미설정 시 401 (fail-safe). Plan-B-Insta Phase 1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 01:51:38 +09:00
11 changed files with 252 additions and 257 deletions

View File

@@ -105,8 +105,12 @@ services:
- CARD_TEMPLATE_DIR=/app/app/templates
- INSTA_DEFAULT_THEME=${INSTA_DEFAULT_THEME:-default}
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
- INTERNAL_API_KEY=${INTERNAL_API_KEY:-}
volumes:
- ${RUNTIME_PATH}/data/insta:/app/data
depends_on:
- redis
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 60s

View File

@@ -3,22 +3,13 @@ ENV PYTHONUNBUFFERED=1
WORKDIR /app
# Korean fonts + Chromium runtime deps (Debian 12 / bookworm)
# `playwright install --with-deps`를 쓰지 않는 이유: 그 명령은 Ubuntu 패키지명을
# 사용해 Debian에서 ttf-ubuntu-font-family / ttf-unifont 등 없는 패키지를 시도
# → apt 실패. 대신 Chromium이 실제 필요로 하는 라이브러리만 명시 설치.
# Korean fonts (insta-lab가 자체 텍스트 처리는 안 하지만 향후 thumbnail 생성 등 위해 유지)
RUN apt-get update && apt-get install -y --no-install-recommends \
fonts-noto-cjk fonts-noto-cjk-extra \
libnss3 libnspr4 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 \
libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 \
libxfixes3 libxrandr2 libgbm1 libxshmfence1 libpango-1.0-0 \
libcairo2 libasound2 libatspi2.0-0 \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
# --timeout 600 --retries 5: NAS 느린 네트워크/CPU에서 pip 다운로드 timeout 방지
RUN pip install --no-cache-dir --timeout 600 --retries 5 -r requirements.txt
RUN playwright install chromium
COPY . .

17
insta-lab/app/auth.py Normal file
View File

@@ -0,0 +1,17 @@
"""SP-4 — Windows worker → NAS internal webhook 인증.
X-Internal-Key 헤더를 .env의 INTERNAL_API_KEY와 비교.
서버 측 키 미설정 시 401 (안전한 기본값).
"""
from __future__ import annotations
import os
from fastapi import Header, HTTPException
def verify_internal_key(x_internal_key: str = Header(...)):
expected = os.getenv("INTERNAL_API_KEY")
if not expected:
raise HTTPException(401, "INTERNAL_API_KEY not configured on server")
if x_internal_key != expected:
raise HTTPException(401, "Invalid X-Internal-Key")

View File

@@ -1,165 +1,7 @@
"""Jinja → HTML → Playwright headless screenshot."""
"""DEPRECATED 2026-05-19 — NAS에서 카드 렌더 안 함. Windows insta-render 워커로 이전됨.
import asyncio
import hashlib
import json
import logging
import os
import tempfile
from pathlib import Path
from typing import List
기존 render_slate, init_browser, shutdown_browser는 모두 web-ai/services/insta-render/card_renderer.py로 이식.
NAS insta-lab은 Redis push (queue:insta-render)만 담당.
from jinja2 import Environment, FileSystemLoader, select_autoescape
from playwright.async_api import async_playwright
from .config import CARDS_DIR, CARD_TEMPLATE_DIR
from . import db
logger = logging.getLogger(__name__)
# NAS Celeron 2C 환경에서 Chromium을 동시에 여러 인스턴스로 띄우면 CPU/메모리 폭주.
# 슬레이트 렌더는 디스크 I/O와 Chromium launch가 직렬화되어도 충분히 빠르므로
# 단일 슬롯으로 직렬화한다. (CHECK_POINT FU-C)
_RENDER_SEMAPHORE: asyncio.Semaphore | None = None
def _render_semaphore() -> asyncio.Semaphore:
global _RENDER_SEMAPHORE
if _RENDER_SEMAPHORE is None:
_RENDER_SEMAPHORE = asyncio.Semaphore(1)
return _RENDER_SEMAPHORE
# Chromium 브라우저 풀 — 매 슬레이트마다 launch 하지 않고 1개를 살려둠.
# (CHECK_POINT 중기-6) 카드 10장 렌더 시간 ~30% 단축 기대.
_PLAYWRIGHT = None
_BROWSER = None
async def init_browser() -> None:
"""앱 startup hook에서 1회 호출. 이미 살아있으면 no-op."""
global _PLAYWRIGHT, _BROWSER
if _BROWSER is not None and _BROWSER.is_connected():
return
_PLAYWRIGHT = await async_playwright().start()
_BROWSER = await _PLAYWRIGHT.chromium.launch()
logger.info("Chromium browser pool 초기화 완료")
async def shutdown_browser() -> None:
"""앱 shutdown hook에서 1회 호출."""
global _PLAYWRIGHT, _BROWSER
if _BROWSER is not None:
try:
await _BROWSER.close()
except Exception:
logger.exception("browser close 중 예외 (무시)")
_BROWSER = None
if _PLAYWRIGHT is not None:
try:
await _PLAYWRIGHT.stop()
except Exception:
logger.exception("playwright stop 중 예외 (무시)")
_PLAYWRIGHT = None
async def _get_browser():
"""현재 브라우저 핸들 반환. crashed/None이면 재초기화 후 반환."""
global _BROWSER
if _BROWSER is None or not _BROWSER.is_connected():
await init_browser()
return _BROWSER
def _resolve_template_dir() -> str:
"""Prefer config CARD_TEMPLATE_DIR if it exists; else fall back to in-repo templates/."""
if os.path.isdir(CARD_TEMPLATE_DIR):
return CARD_TEMPLATE_DIR
return os.path.join(os.path.dirname(__file__), "templates")
def _env() -> Environment:
return Environment(
loader=FileSystemLoader(_resolve_template_dir()),
autoescape=select_autoescape(["html", "j2"]),
)
def _slate_dir(slate_id: int) -> str:
out = os.path.join(CARDS_DIR, str(slate_id))
os.makedirs(out, exist_ok=True)
return out
def _build_pages(slate: dict) -> List[dict]:
cover = json.loads(slate["cover_copy"] or "{}")
bodies = json.loads(slate["body_copies"] or "[]")
cta = json.loads(slate["cta_copy"] or "{}")
accent = cover.get("accent_color") or "#0F62FE"
pages: List[dict] = []
pages.append({
"page_type": "cover", "page_no": 1, "total_pages": 10,
"headline": cover.get("headline", ""), "body": cover.get("body", ""),
"accent_color": accent, "cta": "",
})
for i, b in enumerate(bodies[:8]):
pages.append({
"page_type": "body", "page_no": i + 2, "total_pages": 10,
"headline": b.get("headline", ""), "body": b.get("body", ""),
"accent_color": accent, "cta": "",
})
pages.append({
"page_type": "cta", "page_no": 10, "total_pages": 10,
"headline": cta.get("headline", ""), "body": cta.get("body", ""),
"accent_color": accent, "cta": cta.get("cta", ""),
})
return pages
async def render_slate(slate_id: int, template: str = "default/card.html.j2") -> List[str]:
async with _render_semaphore():
return await _render_slate_locked(slate_id, template)
async def _render_slate_locked(slate_id: int, template: str) -> List[str]:
slate = db.get_card_slate(slate_id)
if not slate:
raise ValueError(f"slate {slate_id} not found")
env = _env()
# template 파일이 없으면 default로 폴백 (INSTA_DEFAULT_THEME가 import 안 된 theme이면 안전)
template_full = Path(_resolve_template_dir()) / template
if not template_full.exists():
logger.warning("Template '%s' 없음 → 'default/card.html.j2'로 폴백", template)
template = "default/card.html.j2"
tmpl = env.get_template(template)
pages = _build_pages(slate)
out_dir = _slate_dir(slate_id)
paths: List[str] = []
browser = await _get_browser()
ctx = await browser.new_context(viewport={"width": 1080, "height": 1350})
try:
page = await ctx.new_page()
for spec in pages:
html_str = tmpl.render(**spec)
with tempfile.NamedTemporaryFile("w", suffix=".html", delete=False, encoding="utf-8") as f:
f.write(html_str)
html_path = f.name
try:
await page.goto(f"file://{html_path}", wait_until="networkidle")
out_path = os.path.join(out_dir, f"{spec['page_no']:02d}.png")
await page.screenshot(path=out_path, full_page=False, omit_background=False)
with open(out_path, "rb") as fp:
file_hash = hashlib.md5(fp.read()).hexdigest()
db.add_card_asset(slate_id, spec["page_no"], out_path, file_hash)
paths.append(out_path)
finally:
try:
os.unlink(html_path)
except OSError:
pass
finally:
await ctx.close()
return paths
이 파일은 임포트 호환성 위해서만 존재. 새 코드는 이 모듈을 import하지 말 것.
"""

View File

@@ -0,0 +1,69 @@
"""SP-4 — Windows insta-render → NAS internal webhook.
POST /api/internal/insta/update
- X-Internal-Key 인증 필수
- task DB row update (status, progress, result_path, error)
- result_path는 nginx 서빙 경로 (예: /media/insta/{slate_id}/01.png)
- succeeded 시 params에서 slate_id 추출 → result_id 세팅
"""
from __future__ import annotations
import json
import logging
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from . import db
from .auth import verify_internal_key
logger = logging.getLogger(__name__)
router = APIRouter()
class UpdatePayload(BaseModel):
task_id: str
status: str = Field(..., description="processing|succeeded|failed")
progress: int = Field(..., ge=0, le=100)
result_path: Optional[str] = None
error: Optional[str] = None
@router.post(
"/api/internal/insta/update",
dependencies=[Depends(verify_internal_key)],
)
def insta_update(payload: UpdatePayload):
task = db.get_task(payload.task_id)
if task is None:
raise HTTPException(404, f"task not found: {payload.task_id}")
result_id = None
if payload.status == "succeeded":
try:
# DB stores params (not input_data) from create_task
params_data = json.loads(task.get("params") or "{}")
result_id = params_data.get("slate_id")
except (ValueError, TypeError):
pass
db.update_task(
payload.task_id,
payload.status,
payload.progress,
message=payload.result_path or "",
result_id=result_id,
error=payload.error,
)
# succeeded 시 slate_status도 'rendered'로 갱신 (cutover 후 NAS가 처리)
if payload.status == "succeeded" and result_id is not None:
try:
db.update_slate_status(result_id, "rendered")
except Exception:
logger.exception("update_slate_status %s 실패 (무시)", result_id)
logger.info(
"internal/insta/update task=%s status=%s progress=%d",
payload.task_id, payload.status, payload.progress,
)
return {"ok": True}

View File

@@ -16,10 +16,18 @@ from .config import (
INSTA_DATA_PATH, DB_PATH, DEFAULT_CATEGORY_SEEDS, KEYWORDS_PER_CATEGORY,
INSTA_DEFAULT_THEME,
)
from . import db, news_collector, keyword_extractor, card_writer, card_renderer, trend_collector
import redis.asyncio as aioredis
from . import db, news_collector, keyword_extractor, card_writer, trend_collector
from .internal_router import router as internal_router
logger = logging.getLogger(__name__)
REDIS_URL = os.getenv("REDIS_URL", "redis://redis:6379")
redis_client = aioredis.from_url(REDIS_URL, decode_responses=False)
app = FastAPI()
app.include_router(internal_router)
app.add_middleware(
CORSMiddleware,
@@ -34,13 +42,11 @@ app.add_middleware(
async def on_startup():
os.makedirs(INSTA_DATA_PATH, exist_ok=True)
db.init_db()
# Chromium browser pool 초기화 (CHECK_POINT 중기-6)
await card_renderer.init_browser()
@app.on_event("shutdown")
async def on_shutdown():
await card_renderer.shutdown_browser()
pass
@app.get("/health")
@@ -153,12 +159,20 @@ async def _bg_create_slate(task_id: str, keyword: str, category: str, keyword_id
try:
db.update_task(task_id, "processing", 30, "카피 생성 중")
sid = card_writer.write_slate(keyword=keyword, category=category)
db.update_task(task_id, "processing", 70, "카드 렌더 중")
await card_renderer.render_slate(sid, template=f"{INSTA_DEFAULT_THEME}/card.html.j2")
db.update_slate_status(sid, "rendered")
if keyword_id:
db.mark_keyword_used(keyword_id)
db.update_task(task_id, "succeeded", 100, "완료", result_id=sid)
# Redis 큐에 push — Windows insta-render worker가 BLPOP 후 렌더
from datetime import datetime, timezone, timedelta
kst = timezone(timedelta(hours=9))
payload = {
"task_id": task_id,
"kind": "insta",
"params": {"slate_id": sid, "theme": INSTA_DEFAULT_THEME},
"submitted_at": datetime.now(kst).isoformat(),
}
await redis_client.rpush("queue:insta-render", json.dumps(payload))
# 사용자는 GET /api/insta/tasks/{task_id}로 폴링 — worker가 webhook으로 status update
db.update_task(task_id, "processing", 70, "Redis 큐 푸시 → Windows worker 대기 중", result_id=sid)
except Exception as e:
logger.exception("create slate failed")
db.update_task(task_id, "failed", 0, "", error=str(e))
@@ -192,13 +206,20 @@ def get_slate(slate_id: int):
async def _bg_render(task_id: str, slate_id: int):
"""Redis 큐에 push. 실 렌더는 Windows insta-render worker."""
try:
db.update_task(task_id, "processing", 30, "재렌더 중")
await card_renderer.render_slate(slate_id, template=f"{INSTA_DEFAULT_THEME}/card.html.j2")
db.update_slate_status(slate_id, "rendered")
db.update_task(task_id, "succeeded", 100, "완료", result_id=slate_id)
from datetime import datetime, timezone, timedelta
kst = timezone(timedelta(hours=9))
payload = {
"task_id": task_id,
"kind": "insta",
"params": {"slate_id": slate_id, "theme": INSTA_DEFAULT_THEME},
"submitted_at": datetime.now(kst).isoformat(),
}
await redis_client.rpush("queue:insta-render", json.dumps(payload))
db.update_task(task_id, "processing", 30, "Redis 큐 푸시 → Windows worker 대기 중")
except Exception as e:
logger.exception("render failed")
logger.exception("queue push failed")
db.update_task(task_id, "failed", 0, "", error=str(e))

View File

@@ -4,7 +4,7 @@ requests==2.32.3
httpx>=0.27
anthropic==0.52.0
jinja2>=3.1.4
playwright==1.48.0
Pillow>=10
pytest>=8.0
pytest-asyncio>=0.24
redis>=5.0

View File

@@ -0,0 +1,25 @@
"""verify_internal_key dependency — Windows webhook 인증."""
import os
import pytest
from fastapi import HTTPException
from app.auth import verify_internal_key
def test_valid_key_passes(monkeypatch):
monkeypatch.setenv("INTERNAL_API_KEY", "secret123")
# dependency가 raise 안 하면 통과
verify_internal_key(x_internal_key="secret123")
def test_invalid_key_raises_401(monkeypatch):
monkeypatch.setenv("INTERNAL_API_KEY", "secret123")
with pytest.raises(HTTPException) as exc:
verify_internal_key(x_internal_key="wrong")
assert exc.value.status_code == 401
def test_missing_env_key_raises_401(monkeypatch):
monkeypatch.delenv("INTERNAL_API_KEY", raising=False)
with pytest.raises(HTTPException) as exc:
verify_internal_key(x_internal_key="any")
assert exc.value.status_code == 401

View File

@@ -1,59 +0,0 @@
import os
import tempfile
import pytest
from app import db as db_module
from app import card_renderer
@pytest.fixture
def tmp_db_and_dirs(monkeypatch, tmp_path):
fd, path = tempfile.mkstemp(suffix=".db")
os.close(fd)
monkeypatch.setattr(db_module, "DB_PATH", path)
monkeypatch.setattr(card_renderer, "CARDS_DIR", str(tmp_path / "cards"))
db_module.init_db()
yield path
import gc
gc.collect()
for ext in ("", "-wal", "-shm"):
try:
os.remove(path + ext)
except OSError:
pass
def _seed_slate() -> int:
return db_module.add_card_slate({
"keyword": "테스트",
"category": "economy",
"status": "draft",
"cover_copy": {"headline": "커버 헤드라인", "body": "서브카피", "accent_color": "#0F62FE"},
"body_copies": [{"headline": f"본문 {i+1}", "body": f"내용 {i+1}"} for i in range(8)],
"cta_copy": {"headline": "마무리", "body": "감사합니다", "cta": "팔로우"},
})
@pytest.mark.asyncio
async def test_render_slate_produces_ten_pngs(tmp_db_and_dirs):
sid = _seed_slate()
paths = await card_renderer.render_slate(sid)
assert len(paths) == 10
for p in paths:
assert os.path.exists(p)
assert os.path.getsize(p) > 1000 # > 1 KB sanity
db_module.update_slate_status(sid, "rendered")
assets = db_module.list_card_assets(sid)
assert {a["page_index"] for a in assets} == set(range(1, 11))
@pytest.mark.asyncio
async def test_render_falls_back_to_default_when_theme_html_missing(tmp_db_and_dirs):
"""존재하지 않는 theme HTML 지정 시 default/card.html.j2로 폴백, 정상 PNG 생성."""
sid = _seed_slate()
paths = await card_renderer.render_slate(sid, template="ghost_theme/card.html.j2")
assert len(paths) == 10
for p in paths:
assert os.path.exists(p)
assert os.path.getsize(p) > 1000

View File

@@ -0,0 +1,80 @@
"""POST /api/internal/insta/update — Windows worker webhook."""
import os
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.internal_router import router
from app import db
@pytest.fixture(autouse=True)
def _set_key(monkeypatch):
monkeypatch.setenv("INTERNAL_API_KEY", "test-secret")
@pytest.fixture
def client(tmp_path, monkeypatch):
# SQLite in-memory test
monkeypatch.setenv("INSTA_DATA_PATH", str(tmp_path))
db.init_db()
app = FastAPI()
app.include_router(router)
return TestClient(app)
def _make_task():
return db.create_task("slate_render", {"slate_id": 42})
def test_update_with_valid_key_updates_db(client):
tid = _make_task()
r = client.post(
"/api/internal/insta/update",
headers={"X-Internal-Key": "test-secret"},
json={"task_id": tid, "status": "processing", "progress": 30},
)
assert r.status_code == 200
task = db.get_task(tid)
assert task["status"] == "processing"
assert task["progress"] == 30
def test_update_with_invalid_key_returns_401(client):
tid = _make_task()
r = client.post(
"/api/internal/insta/update",
headers={"X-Internal-Key": "wrong"},
json={"task_id": tid, "status": "processing", "progress": 30},
)
assert r.status_code == 401
def test_update_succeeded_sets_result_path(client):
tid = _make_task()
r = client.post(
"/api/internal/insta/update",
headers={"X-Internal-Key": "test-secret"},
json={
"task_id": tid,
"status": "succeeded",
"progress": 100,
"result_path": "/media/insta/42/01.png",
},
)
assert r.status_code == 200
task = db.get_task(tid)
assert task["status"] == "succeeded"
assert task["result_id"] is not None # slate_id from input_data
def test_update_failed_records_error(client):
tid = _make_task()
r = client.post(
"/api/internal/insta/update",
headers={"X-Internal-Key": "test-secret"},
json={"task_id": tid, "status": "failed", "progress": 0, "error": "Chromium crashed"},
)
assert r.status_code == 200
task = db.get_task(tid)
assert task["status"] == "failed"
assert "Chromium" in (task.get("error") or "")

View File

@@ -58,7 +58,11 @@ def test_keywords_listing(client):
def test_create_slate_kicks_background_task(client, monkeypatch):
from app import main, card_writer, card_renderer
"""Plan-B-Insta SP-4: 슬레이트 생성 후 Redis push → task status=processing (Windows worker 대기).
card_renderer는 NAS에서 제거됨. write_slate → Redis rpush 경로만 검증.
"""
from app import main, card_writer
def fake_write(keyword, category, articles=None):
return db_module.add_card_slate({
@@ -68,24 +72,25 @@ def test_create_slate_kicks_background_task(client, monkeypatch):
"cta_copy": {"headline": "C", "body": "B", "cta": "F"},
})
async def fake_render(slate_id, template="default/card.html.j2"):
for i in range(1, 11):
db_module.add_card_asset(slate_id, i, f"/tmp/{slate_id}_{i}.png", "h")
return [f"/tmp/{slate_id}_{i}.png" for i in range(1, 11)]
async def fake_rpush(queue, payload):
pass # Redis 없이도 테스트 통과
monkeypatch.setattr(card_writer, "write_slate", fake_write)
monkeypatch.setattr(card_renderer, "render_slate", fake_render)
monkeypatch.setattr(main.redis_client, "rpush", fake_rpush)
resp = client.post("/api/insta/slates", json={"keyword": "K", "category": "economy"})
assert resp.status_code == 200
task_id = resp.json()["task_id"]
# poll task
# 잠시 대기 후 폴링 — background task가 완료될 때까지
import time
for _ in range(20):
st = client.get(f"/api/insta/tasks/{task_id}").json()
if st["status"] in ("succeeded", "failed"):
if st["status"] != "pending":
break
assert st["status"] == "succeeded"
time.sleep(0.1)
# Redis push 후 task는 processing 상태 (Windows worker가 rendered로 전환)
assert st["status"] == "processing"
assert st["result_id"] is not None # slate_id가 result_id에 기록됨
slate_id = st["result_id"]
detail = client.get(f"/api/insta/slates/{slate_id}").json()
assert detail["status"] == "rendered"
assert len(detail["assets"]) == 10
assert detail["keyword"] == "K"