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>
This commit is contained in:
63
insta-lab/app/internal_router.py
Normal file
63
insta-lab/app/internal_router.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""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,
|
||||
)
|
||||
logger.info(
|
||||
"internal/insta/update task=%s status=%s progress=%d",
|
||||
payload.task_id, payload.status, payload.progress,
|
||||
)
|
||||
return {"ok": True}
|
||||
80
insta-lab/tests/test_internal_router.py
Normal file
80
insta-lab/tests/test_internal_router.py
Normal 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 "")
|
||||
Reference in New Issue
Block a user