feat(video-lab): /api/internal/video/update endpoint + tests (SP-8)
UpdatePayload schema (task_id/status/progress/message/video_url/error). 404 if task not found. insta/music-lab과 동일 패턴 + video_url 필드. Plan-B-Video Phase 1. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
52
video-lab/app/internal_router.py
Normal file
52
video-lab/app/internal_router.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
"""SP-8 — Windows video-render → NAS video-lab internal webhook.
|
||||||
|
|
||||||
|
POST /api/internal/video/update
|
||||||
|
- X-Internal-Key 인증 필수
|
||||||
|
- video_tasks row update (status, progress, message, video_url, error)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
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)
|
||||||
|
message: str = ""
|
||||||
|
video_url: Optional[str] = None
|
||||||
|
error: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/api/internal/video/update",
|
||||||
|
dependencies=[Depends(verify_internal_key)],
|
||||||
|
)
|
||||||
|
def video_update(payload: UpdatePayload):
|
||||||
|
task = db.get_task(payload.task_id)
|
||||||
|
if task is None:
|
||||||
|
raise HTTPException(404, f"task not found: {payload.task_id}")
|
||||||
|
|
||||||
|
db.update_task(
|
||||||
|
payload.task_id,
|
||||||
|
payload.status,
|
||||||
|
payload.progress,
|
||||||
|
message=payload.message,
|
||||||
|
video_url=payload.video_url,
|
||||||
|
error=payload.error,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"internal/video/update task=%s status=%s progress=%d",
|
||||||
|
payload.task_id, payload.status, payload.progress,
|
||||||
|
)
|
||||||
|
return {"ok": True}
|
||||||
89
video-lab/tests/test_internal_router.py
Normal file
89
video-lab/tests/test_internal_router.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
"""POST /api/internal/video/update — Windows video-render webhook."""
|
||||||
|
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):
|
||||||
|
monkeypatch.setenv("VIDEO_DATA_DIR", str(tmp_path))
|
||||||
|
monkeypatch.setattr(db, "DB_PATH", str(tmp_path / "test_video.db"))
|
||||||
|
db.init_db()
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(router)
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_task():
|
||||||
|
tid = "video-task-1"
|
||||||
|
db.create_task(tid, "sora", {"prompt": "test"})
|
||||||
|
return tid
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_with_valid_key_updates_db(client):
|
||||||
|
tid = _make_task()
|
||||||
|
r = client.post(
|
||||||
|
"/api/internal/video/update",
|
||||||
|
headers={"X-Internal-Key": "test-secret"},
|
||||||
|
json={"task_id": tid, "status": "processing", "progress": 30, "message": "downloading"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
task = db.get_task(tid)
|
||||||
|
assert task["status"] == "processing"
|
||||||
|
assert task["progress"] == 30
|
||||||
|
assert task["message"] == "downloading"
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_with_invalid_key_returns_401(client):
|
||||||
|
tid = _make_task()
|
||||||
|
r = client.post(
|
||||||
|
"/api/internal/video/update",
|
||||||
|
headers={"X-Internal-Key": "wrong"},
|
||||||
|
json={"task_id": tid, "status": "processing", "progress": 30},
|
||||||
|
)
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_succeeded_with_video_url(client):
|
||||||
|
tid = _make_task()
|
||||||
|
r = client.post(
|
||||||
|
"/api/internal/video/update",
|
||||||
|
headers={"X-Internal-Key": "test-secret"},
|
||||||
|
json={
|
||||||
|
"task_id": tid, "status": "succeeded", "progress": 100,
|
||||||
|
"message": "완료", "video_url": "/media/video/video-task-1.mp4",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
task = db.get_task(tid)
|
||||||
|
assert task["status"] == "succeeded"
|
||||||
|
assert task["video_url"] == "/media/video/video-task-1.mp4"
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_failed_records_error(client):
|
||||||
|
tid = _make_task()
|
||||||
|
r = client.post(
|
||||||
|
"/api/internal/video/update",
|
||||||
|
headers={"X-Internal-Key": "test-secret"},
|
||||||
|
json={"task_id": tid, "status": "failed", "progress": 0, "error": "Sora API rate limit"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
task = db.get_task(tid)
|
||||||
|
assert task["status"] == "failed"
|
||||||
|
assert "Sora" in (task.get("error") or "")
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_unknown_task_returns_404(client):
|
||||||
|
r = client.post(
|
||||||
|
"/api/internal/video/update",
|
||||||
|
headers={"X-Internal-Key": "test-secret"},
|
||||||
|
json={"task_id": "nonexistent", "status": "processing", "progress": 10},
|
||||||
|
)
|
||||||
|
assert r.status_code == 404
|
||||||
Reference in New Issue
Block a user