test: Built 9 review queue API endpoints (queue, stats, approve, reject…
- "backend/routers/review.py" - "backend/schemas.py" - "backend/redis_client.py" - "backend/main.py" - "backend/tests/test_review.py" GSD-Task: S04/T01
This commit is contained in:
parent
e27a86518d
commit
b43e4a079a
5 changed files with 920 additions and 1 deletions
|
|
@ -12,7 +12,7 @@ from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from config import get_settings
|
from config import get_settings
|
||||||
from routers import creators, health, ingest, pipeline, videos
|
from routers import creators, health, ingest, pipeline, review, videos
|
||||||
|
|
||||||
|
|
||||||
def _setup_logging() -> None:
|
def _setup_logging() -> None:
|
||||||
|
|
@ -81,6 +81,7 @@ app.include_router(health.router)
|
||||||
app.include_router(creators.router, prefix="/api/v1")
|
app.include_router(creators.router, prefix="/api/v1")
|
||||||
app.include_router(ingest.router, prefix="/api/v1")
|
app.include_router(ingest.router, prefix="/api/v1")
|
||||||
app.include_router(pipeline.router, prefix="/api/v1")
|
app.include_router(pipeline.router, prefix="/api/v1")
|
||||||
|
app.include_router(review.router, prefix="/api/v1")
|
||||||
app.include_router(videos.router, prefix="/api/v1")
|
app.include_router(videos.router, prefix="/api/v1")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
15
backend/redis_client.py
Normal file
15
backend/redis_client.py
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
"""Async Redis client helper for Chrysopedia."""
|
||||||
|
|
||||||
|
import redis.asyncio as aioredis
|
||||||
|
|
||||||
|
from config import get_settings
|
||||||
|
|
||||||
|
|
||||||
|
async def get_redis() -> aioredis.Redis:
|
||||||
|
"""Return an async Redis client from the configured URL.
|
||||||
|
|
||||||
|
Callers should close the connection when done, or use it
|
||||||
|
as a short-lived client within a request handler.
|
||||||
|
"""
|
||||||
|
settings = get_settings()
|
||||||
|
return aioredis.from_url(settings.redis_url, decode_responses=True)
|
||||||
354
backend/routers/review.py
Normal file
354
backend/routers/review.py
Normal file
|
|
@ -0,0 +1,354 @@
|
||||||
|
"""Review queue endpoints for Chrysopedia API.
|
||||||
|
|
||||||
|
Provides admin review workflow: list queue, stats, approve, reject,
|
||||||
|
edit, split, merge key moments, and toggle review/auto mode via Redis.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy import case, func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from config import get_settings
|
||||||
|
from database import get_session
|
||||||
|
from models import Creator, KeyMoment, KeyMomentContentType, ReviewStatus, SourceVideo
|
||||||
|
from redis_client import get_redis
|
||||||
|
from schemas import (
|
||||||
|
KeyMomentRead,
|
||||||
|
MomentEditRequest,
|
||||||
|
MomentMergeRequest,
|
||||||
|
MomentSplitRequest,
|
||||||
|
ReviewModeResponse,
|
||||||
|
ReviewModeUpdate,
|
||||||
|
ReviewQueueItem,
|
||||||
|
ReviewQueueResponse,
|
||||||
|
ReviewStatsResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger("chrysopedia.review")
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/review", tags=["review"])
|
||||||
|
|
||||||
|
REDIS_MODE_KEY = "chrysopedia:review_mode"
|
||||||
|
|
||||||
|
VALID_STATUSES = {"pending", "approved", "edited", "rejected", "all"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _moment_to_queue_item(
|
||||||
|
moment: KeyMoment, video_filename: str, creator_name: str
|
||||||
|
) -> ReviewQueueItem:
|
||||||
|
"""Convert a KeyMoment ORM instance + joined fields to a ReviewQueueItem."""
|
||||||
|
data = KeyMomentRead.model_validate(moment).model_dump()
|
||||||
|
data["video_filename"] = video_filename
|
||||||
|
data["creator_name"] = creator_name
|
||||||
|
return ReviewQueueItem(**data)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Endpoints ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/queue", response_model=ReviewQueueResponse)
|
||||||
|
async def list_queue(
|
||||||
|
status: Annotated[str, Query()] = "pending",
|
||||||
|
offset: Annotated[int, Query(ge=0)] = 0,
|
||||||
|
limit: Annotated[int, Query(ge=1, le=100)] = 50,
|
||||||
|
db: AsyncSession = Depends(get_session),
|
||||||
|
) -> ReviewQueueResponse:
|
||||||
|
"""List key moments in the review queue, filtered by status."""
|
||||||
|
if status not in VALID_STATUSES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Invalid status filter '{status}'. Must be one of: {', '.join(sorted(VALID_STATUSES))}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Base query joining KeyMoment → SourceVideo → Creator
|
||||||
|
base = (
|
||||||
|
select(
|
||||||
|
KeyMoment,
|
||||||
|
SourceVideo.filename.label("video_filename"),
|
||||||
|
Creator.name.label("creator_name"),
|
||||||
|
)
|
||||||
|
.join(SourceVideo, KeyMoment.source_video_id == SourceVideo.id)
|
||||||
|
.join(Creator, SourceVideo.creator_id == Creator.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
if status != "all":
|
||||||
|
base = base.where(KeyMoment.review_status == ReviewStatus(status))
|
||||||
|
|
||||||
|
# Count total matching rows
|
||||||
|
count_stmt = select(func.count()).select_from(base.subquery())
|
||||||
|
total = (await db.execute(count_stmt)).scalar_one()
|
||||||
|
|
||||||
|
# Fetch paginated results
|
||||||
|
stmt = base.order_by(KeyMoment.created_at.desc()).offset(offset).limit(limit)
|
||||||
|
rows = (await db.execute(stmt)).all()
|
||||||
|
|
||||||
|
items = [
|
||||||
|
_moment_to_queue_item(row.KeyMoment, row.video_filename, row.creator_name)
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
return ReviewQueueResponse(items=items, total=total, offset=offset, limit=limit)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats", response_model=ReviewStatsResponse)
|
||||||
|
async def get_stats(
|
||||||
|
db: AsyncSession = Depends(get_session),
|
||||||
|
) -> ReviewStatsResponse:
|
||||||
|
"""Return counts of key moments grouped by review status."""
|
||||||
|
stmt = (
|
||||||
|
select(
|
||||||
|
KeyMoment.review_status,
|
||||||
|
func.count().label("cnt"),
|
||||||
|
)
|
||||||
|
.group_by(KeyMoment.review_status)
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
counts = {row.review_status.value: row.cnt for row in result.all()}
|
||||||
|
|
||||||
|
return ReviewStatsResponse(
|
||||||
|
pending=counts.get("pending", 0),
|
||||||
|
approved=counts.get("approved", 0),
|
||||||
|
edited=counts.get("edited", 0),
|
||||||
|
rejected=counts.get("rejected", 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/moments/{moment_id}/approve", response_model=KeyMomentRead)
|
||||||
|
async def approve_moment(
|
||||||
|
moment_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_session),
|
||||||
|
) -> KeyMomentRead:
|
||||||
|
"""Approve a key moment for publishing."""
|
||||||
|
moment = await db.get(KeyMoment, moment_id)
|
||||||
|
if moment is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"Key moment {moment_id} not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
moment.review_status = ReviewStatus.approved
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(moment)
|
||||||
|
|
||||||
|
logger.info("Approved key moment %s", moment_id)
|
||||||
|
return KeyMomentRead.model_validate(moment)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/moments/{moment_id}/reject", response_model=KeyMomentRead)
|
||||||
|
async def reject_moment(
|
||||||
|
moment_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_session),
|
||||||
|
) -> KeyMomentRead:
|
||||||
|
"""Reject a key moment."""
|
||||||
|
moment = await db.get(KeyMoment, moment_id)
|
||||||
|
if moment is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"Key moment {moment_id} not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
moment.review_status = ReviewStatus.rejected
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(moment)
|
||||||
|
|
||||||
|
logger.info("Rejected key moment %s", moment_id)
|
||||||
|
return KeyMomentRead.model_validate(moment)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/moments/{moment_id}", response_model=KeyMomentRead)
|
||||||
|
async def edit_moment(
|
||||||
|
moment_id: uuid.UUID,
|
||||||
|
body: MomentEditRequest,
|
||||||
|
db: AsyncSession = Depends(get_session),
|
||||||
|
) -> KeyMomentRead:
|
||||||
|
"""Update editable fields of a key moment and set status to edited."""
|
||||||
|
moment = await db.get(KeyMoment, moment_id)
|
||||||
|
if moment is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"Key moment {moment_id} not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
update_data = body.model_dump(exclude_unset=True)
|
||||||
|
# Convert content_type string to enum if provided
|
||||||
|
if "content_type" in update_data and update_data["content_type"] is not None:
|
||||||
|
try:
|
||||||
|
update_data["content_type"] = KeyMomentContentType(update_data["content_type"])
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Invalid content_type '{update_data['content_type']}'",
|
||||||
|
)
|
||||||
|
|
||||||
|
for field, value in update_data.items():
|
||||||
|
setattr(moment, field, value)
|
||||||
|
|
||||||
|
moment.review_status = ReviewStatus.edited
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(moment)
|
||||||
|
|
||||||
|
logger.info("Edited key moment %s (fields: %s)", moment_id, list(update_data.keys()))
|
||||||
|
return KeyMomentRead.model_validate(moment)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/moments/{moment_id}/split", response_model=list[KeyMomentRead])
|
||||||
|
async def split_moment(
|
||||||
|
moment_id: uuid.UUID,
|
||||||
|
body: MomentSplitRequest,
|
||||||
|
db: AsyncSession = Depends(get_session),
|
||||||
|
) -> list[KeyMomentRead]:
|
||||||
|
"""Split a key moment into two at the given timestamp."""
|
||||||
|
moment = await db.get(KeyMoment, moment_id)
|
||||||
|
if moment is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"Key moment {moment_id} not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate split_time is strictly between start_time and end_time
|
||||||
|
if body.split_time <= moment.start_time or body.split_time >= moment.end_time:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=(
|
||||||
|
f"split_time ({body.split_time}) must be strictly between "
|
||||||
|
f"start_time ({moment.start_time}) and end_time ({moment.end_time})"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update original moment to [start_time, split_time)
|
||||||
|
original_end = moment.end_time
|
||||||
|
moment.end_time = body.split_time
|
||||||
|
moment.review_status = ReviewStatus.pending
|
||||||
|
|
||||||
|
# Create new moment for [split_time, end_time]
|
||||||
|
new_moment = KeyMoment(
|
||||||
|
source_video_id=moment.source_video_id,
|
||||||
|
technique_page_id=moment.technique_page_id,
|
||||||
|
title=f"{moment.title} (split)",
|
||||||
|
summary=moment.summary,
|
||||||
|
start_time=body.split_time,
|
||||||
|
end_time=original_end,
|
||||||
|
content_type=moment.content_type,
|
||||||
|
plugins=moment.plugins,
|
||||||
|
review_status=ReviewStatus.pending,
|
||||||
|
raw_transcript=moment.raw_transcript,
|
||||||
|
)
|
||||||
|
db.add(new_moment)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(moment)
|
||||||
|
await db.refresh(new_moment)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Split key moment %s at %.2f → original [%.2f, %.2f), new [%.2f, %.2f]",
|
||||||
|
moment_id, body.split_time,
|
||||||
|
moment.start_time, moment.end_time,
|
||||||
|
new_moment.start_time, new_moment.end_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
return [
|
||||||
|
KeyMomentRead.model_validate(moment),
|
||||||
|
KeyMomentRead.model_validate(new_moment),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/moments/{moment_id}/merge", response_model=KeyMomentRead)
|
||||||
|
async def merge_moments(
|
||||||
|
moment_id: uuid.UUID,
|
||||||
|
body: MomentMergeRequest,
|
||||||
|
db: AsyncSession = Depends(get_session),
|
||||||
|
) -> KeyMomentRead:
|
||||||
|
"""Merge two key moments into one."""
|
||||||
|
if moment_id == body.target_moment_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Cannot merge a moment with itself",
|
||||||
|
)
|
||||||
|
|
||||||
|
source = await db.get(KeyMoment, moment_id)
|
||||||
|
if source is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"Key moment {moment_id} not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
target = await db.get(KeyMoment, body.target_moment_id)
|
||||||
|
if target is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"Target key moment {body.target_moment_id} not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Both must belong to the same source video
|
||||||
|
if source.source_video_id != target.source_video_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Cannot merge moments from different source videos",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Merge: combined summary, min start, max end
|
||||||
|
source.summary = f"{source.summary}\n\n{target.summary}"
|
||||||
|
source.start_time = min(source.start_time, target.start_time)
|
||||||
|
source.end_time = max(source.end_time, target.end_time)
|
||||||
|
source.review_status = ReviewStatus.pending
|
||||||
|
|
||||||
|
# Delete target
|
||||||
|
await db.delete(target)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(source)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Merged key moment %s with %s → [%.2f, %.2f]",
|
||||||
|
moment_id, body.target_moment_id,
|
||||||
|
source.start_time, source.end_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
return KeyMomentRead.model_validate(source)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/mode", response_model=ReviewModeResponse)
|
||||||
|
async def get_mode() -> ReviewModeResponse:
|
||||||
|
"""Get the current review mode (review vs auto)."""
|
||||||
|
settings = get_settings()
|
||||||
|
try:
|
||||||
|
redis = await get_redis()
|
||||||
|
try:
|
||||||
|
value = await redis.get(REDIS_MODE_KEY)
|
||||||
|
if value is not None:
|
||||||
|
return ReviewModeResponse(review_mode=value.lower() == "true")
|
||||||
|
finally:
|
||||||
|
await redis.aclose()
|
||||||
|
except Exception as exc:
|
||||||
|
# Redis unavailable — fall back to config default
|
||||||
|
logger.warning("Redis unavailable for mode read, using config default: %s", exc)
|
||||||
|
|
||||||
|
return ReviewModeResponse(review_mode=settings.review_mode)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/mode", response_model=ReviewModeResponse)
|
||||||
|
async def set_mode(
|
||||||
|
body: ReviewModeUpdate,
|
||||||
|
) -> ReviewModeResponse:
|
||||||
|
"""Set the review mode (review vs auto)."""
|
||||||
|
try:
|
||||||
|
redis = await get_redis()
|
||||||
|
try:
|
||||||
|
await redis.set(REDIS_MODE_KEY, str(body.review_mode))
|
||||||
|
finally:
|
||||||
|
await redis.aclose()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to set review mode in Redis: %s", exc)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail=f"Redis unavailable: {exc}",
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Review mode set to %s", body.review_mode)
|
||||||
|
return ReviewModeResponse(review_mode=body.review_mode)
|
||||||
|
|
@ -194,3 +194,57 @@ class PaginatedResponse(BaseModel):
|
||||||
total: int = 0
|
total: int = 0
|
||||||
offset: int = 0
|
offset: int = 0
|
||||||
limit: int = 50
|
limit: int = 50
|
||||||
|
|
||||||
|
|
||||||
|
# ── Review Queue ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class ReviewQueueItem(KeyMomentRead):
|
||||||
|
"""Key moment enriched with source video and creator info for review UI."""
|
||||||
|
video_filename: str
|
||||||
|
creator_name: str
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewQueueResponse(BaseModel):
|
||||||
|
"""Paginated response for the review queue."""
|
||||||
|
items: list[ReviewQueueItem] = Field(default_factory=list)
|
||||||
|
total: int = 0
|
||||||
|
offset: int = 0
|
||||||
|
limit: int = 50
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewStatsResponse(BaseModel):
|
||||||
|
"""Counts of key moments grouped by review status."""
|
||||||
|
pending: int = 0
|
||||||
|
approved: int = 0
|
||||||
|
edited: int = 0
|
||||||
|
rejected: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class MomentEditRequest(BaseModel):
|
||||||
|
"""Editable fields for a key moment."""
|
||||||
|
title: str | None = None
|
||||||
|
summary: str | None = None
|
||||||
|
start_time: float | None = None
|
||||||
|
end_time: float | None = None
|
||||||
|
content_type: str | None = None
|
||||||
|
plugins: list[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class MomentSplitRequest(BaseModel):
|
||||||
|
"""Request to split a moment at a given timestamp."""
|
||||||
|
split_time: float
|
||||||
|
|
||||||
|
|
||||||
|
class MomentMergeRequest(BaseModel):
|
||||||
|
"""Request to merge two moments."""
|
||||||
|
target_moment_id: uuid.UUID
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewModeResponse(BaseModel):
|
||||||
|
"""Current review mode state."""
|
||||||
|
review_mode: bool
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewModeUpdate(BaseModel):
|
||||||
|
"""Request to update the review mode."""
|
||||||
|
review_mode: bool
|
||||||
|
|
|
||||||
495
backend/tests/test_review.py
Normal file
495
backend/tests/test_review.py
Normal file
|
|
@ -0,0 +1,495 @@
|
||||||
|
"""Integration tests for the review queue endpoints.
|
||||||
|
|
||||||
|
Tests run against a real PostgreSQL test database via httpx.AsyncClient.
|
||||||
|
Redis is mocked for mode toggle tests.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
from httpx import AsyncClient
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||||
|
|
||||||
|
from models import (
|
||||||
|
ContentType,
|
||||||
|
Creator,
|
||||||
|
KeyMoment,
|
||||||
|
KeyMomentContentType,
|
||||||
|
ProcessingStatus,
|
||||||
|
ReviewStatus,
|
||||||
|
SourceVideo,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
QUEUE_URL = "/api/v1/review/queue"
|
||||||
|
STATS_URL = "/api/v1/review/stats"
|
||||||
|
MODE_URL = "/api/v1/review/mode"
|
||||||
|
|
||||||
|
|
||||||
|
def _moment_url(moment_id: str, action: str = "") -> str:
|
||||||
|
"""Build a moment action URL."""
|
||||||
|
base = f"/api/v1/review/moments/{moment_id}"
|
||||||
|
return f"{base}/{action}" if action else base
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_creator_and_video(db_engine) -> dict:
|
||||||
|
"""Seed a creator and source video, return their IDs."""
|
||||||
|
session_factory = async_sessionmaker(
|
||||||
|
db_engine, class_=AsyncSession, expire_on_commit=False
|
||||||
|
)
|
||||||
|
async with session_factory() as session:
|
||||||
|
creator = Creator(
|
||||||
|
name="TestCreator",
|
||||||
|
slug="test-creator",
|
||||||
|
folder_name="TestCreator",
|
||||||
|
)
|
||||||
|
session.add(creator)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
video = SourceVideo(
|
||||||
|
creator_id=creator.id,
|
||||||
|
filename="test-video.mp4",
|
||||||
|
file_path="TestCreator/test-video.mp4",
|
||||||
|
duration_seconds=600,
|
||||||
|
content_type=ContentType.tutorial,
|
||||||
|
processing_status=ProcessingStatus.extracted,
|
||||||
|
)
|
||||||
|
session.add(video)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"creator_id": creator.id,
|
||||||
|
"creator_name": creator.name,
|
||||||
|
"video_id": video.id,
|
||||||
|
"video_filename": video.filename,
|
||||||
|
}
|
||||||
|
await session.commit()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_moment(
|
||||||
|
db_engine,
|
||||||
|
video_id: uuid.UUID,
|
||||||
|
title: str = "Test Moment",
|
||||||
|
summary: str = "A test key moment",
|
||||||
|
start_time: float = 10.0,
|
||||||
|
end_time: float = 30.0,
|
||||||
|
review_status: ReviewStatus = ReviewStatus.pending,
|
||||||
|
) -> uuid.UUID:
|
||||||
|
"""Seed a single key moment and return its ID."""
|
||||||
|
session_factory = async_sessionmaker(
|
||||||
|
db_engine, class_=AsyncSession, expire_on_commit=False
|
||||||
|
)
|
||||||
|
async with session_factory() as session:
|
||||||
|
moment = KeyMoment(
|
||||||
|
source_video_id=video_id,
|
||||||
|
title=title,
|
||||||
|
summary=summary,
|
||||||
|
start_time=start_time,
|
||||||
|
end_time=end_time,
|
||||||
|
content_type=KeyMomentContentType.technique,
|
||||||
|
review_status=review_status,
|
||||||
|
)
|
||||||
|
session.add(moment)
|
||||||
|
await session.commit()
|
||||||
|
return moment.id
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_second_video(db_engine, creator_id: uuid.UUID) -> uuid.UUID:
|
||||||
|
"""Seed a second video for cross-video merge tests."""
|
||||||
|
session_factory = async_sessionmaker(
|
||||||
|
db_engine, class_=AsyncSession, expire_on_commit=False
|
||||||
|
)
|
||||||
|
async with session_factory() as session:
|
||||||
|
video = SourceVideo(
|
||||||
|
creator_id=creator_id,
|
||||||
|
filename="other-video.mp4",
|
||||||
|
file_path="TestCreator/other-video.mp4",
|
||||||
|
duration_seconds=300,
|
||||||
|
content_type=ContentType.tutorial,
|
||||||
|
processing_status=ProcessingStatus.extracted,
|
||||||
|
)
|
||||||
|
session.add(video)
|
||||||
|
await session.commit()
|
||||||
|
return video.id
|
||||||
|
|
||||||
|
|
||||||
|
# ── Queue listing tests ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_queue_empty(client: AsyncClient):
|
||||||
|
"""Queue returns empty list when no moments exist."""
|
||||||
|
resp = await client.get(QUEUE_URL)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["items"] == []
|
||||||
|
assert data["total"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_queue_with_moments(client: AsyncClient, db_engine):
|
||||||
|
"""Queue returns moments enriched with video filename and creator name."""
|
||||||
|
seed = await _seed_creator_and_video(db_engine)
|
||||||
|
await _seed_moment(db_engine, seed["video_id"], title="EQ Basics")
|
||||||
|
|
||||||
|
resp = await client.get(QUEUE_URL)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["total"] == 1
|
||||||
|
item = data["items"][0]
|
||||||
|
assert item["title"] == "EQ Basics"
|
||||||
|
assert item["video_filename"] == seed["video_filename"]
|
||||||
|
assert item["creator_name"] == seed["creator_name"]
|
||||||
|
assert item["review_status"] == "pending"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_queue_filter_by_status(client: AsyncClient, db_engine):
|
||||||
|
"""Queue filters correctly by status query parameter."""
|
||||||
|
seed = await _seed_creator_and_video(db_engine)
|
||||||
|
await _seed_moment(db_engine, seed["video_id"], title="Pending One")
|
||||||
|
await _seed_moment(
|
||||||
|
db_engine, seed["video_id"], title="Approved One",
|
||||||
|
review_status=ReviewStatus.approved,
|
||||||
|
)
|
||||||
|
await _seed_moment(
|
||||||
|
db_engine, seed["video_id"], title="Rejected One",
|
||||||
|
review_status=ReviewStatus.rejected,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Default filter: pending
|
||||||
|
resp = await client.get(QUEUE_URL)
|
||||||
|
assert resp.json()["total"] == 1
|
||||||
|
assert resp.json()["items"][0]["title"] == "Pending One"
|
||||||
|
|
||||||
|
# Approved
|
||||||
|
resp = await client.get(QUEUE_URL, params={"status": "approved"})
|
||||||
|
assert resp.json()["total"] == 1
|
||||||
|
assert resp.json()["items"][0]["title"] == "Approved One"
|
||||||
|
|
||||||
|
# All
|
||||||
|
resp = await client.get(QUEUE_URL, params={"status": "all"})
|
||||||
|
assert resp.json()["total"] == 3
|
||||||
|
|
||||||
|
|
||||||
|
# ── Stats tests ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stats_counts(client: AsyncClient, db_engine):
|
||||||
|
"""Stats returns correct counts per review status."""
|
||||||
|
seed = await _seed_creator_and_video(db_engine)
|
||||||
|
await _seed_moment(db_engine, seed["video_id"], review_status=ReviewStatus.pending)
|
||||||
|
await _seed_moment(db_engine, seed["video_id"], review_status=ReviewStatus.pending)
|
||||||
|
await _seed_moment(db_engine, seed["video_id"], review_status=ReviewStatus.approved)
|
||||||
|
await _seed_moment(db_engine, seed["video_id"], review_status=ReviewStatus.rejected)
|
||||||
|
|
||||||
|
resp = await client.get(STATS_URL)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["pending"] == 2
|
||||||
|
assert data["approved"] == 1
|
||||||
|
assert data["edited"] == 0
|
||||||
|
assert data["rejected"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ── Approve tests ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_approve_moment(client: AsyncClient, db_engine):
|
||||||
|
"""Approve sets review_status to approved."""
|
||||||
|
seed = await _seed_creator_and_video(db_engine)
|
||||||
|
moment_id = await _seed_moment(db_engine, seed["video_id"])
|
||||||
|
|
||||||
|
resp = await client.post(_moment_url(str(moment_id), "approve"))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["review_status"] == "approved"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_approve_nonexistent_moment(client: AsyncClient):
|
||||||
|
"""Approve returns 404 for nonexistent moment."""
|
||||||
|
fake_id = str(uuid.uuid4())
|
||||||
|
resp = await client.post(_moment_url(fake_id, "approve"))
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ── Reject tests ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reject_moment(client: AsyncClient, db_engine):
|
||||||
|
"""Reject sets review_status to rejected."""
|
||||||
|
seed = await _seed_creator_and_video(db_engine)
|
||||||
|
moment_id = await _seed_moment(db_engine, seed["video_id"])
|
||||||
|
|
||||||
|
resp = await client.post(_moment_url(str(moment_id), "reject"))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["review_status"] == "rejected"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reject_nonexistent_moment(client: AsyncClient):
|
||||||
|
"""Reject returns 404 for nonexistent moment."""
|
||||||
|
fake_id = str(uuid.uuid4())
|
||||||
|
resp = await client.post(_moment_url(fake_id, "reject"))
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ── Edit tests ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_edit_moment(client: AsyncClient, db_engine):
|
||||||
|
"""Edit updates fields and sets review_status to edited."""
|
||||||
|
seed = await _seed_creator_and_video(db_engine)
|
||||||
|
moment_id = await _seed_moment(db_engine, seed["video_id"], title="Original Title")
|
||||||
|
|
||||||
|
resp = await client.put(
|
||||||
|
_moment_url(str(moment_id)),
|
||||||
|
json={"title": "Updated Title", "summary": "New summary"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["title"] == "Updated Title"
|
||||||
|
assert data["summary"] == "New summary"
|
||||||
|
assert data["review_status"] == "edited"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_edit_nonexistent_moment(client: AsyncClient):
|
||||||
|
"""Edit returns 404 for nonexistent moment."""
|
||||||
|
fake_id = str(uuid.uuid4())
|
||||||
|
resp = await client.put(
|
||||||
|
_moment_url(fake_id),
|
||||||
|
json={"title": "Won't Work"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ── Split tests ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_split_moment(client: AsyncClient, db_engine):
|
||||||
|
"""Split creates two moments with correct timestamps."""
|
||||||
|
seed = await _seed_creator_and_video(db_engine)
|
||||||
|
moment_id = await _seed_moment(
|
||||||
|
db_engine, seed["video_id"],
|
||||||
|
title="Full Moment", start_time=10.0, end_time=30.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
_moment_url(str(moment_id), "split"),
|
||||||
|
json={"split_time": 20.0},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert len(data) == 2
|
||||||
|
|
||||||
|
# First (original): [10.0, 20.0)
|
||||||
|
assert data[0]["start_time"] == 10.0
|
||||||
|
assert data[0]["end_time"] == 20.0
|
||||||
|
|
||||||
|
# Second (new): [20.0, 30.0]
|
||||||
|
assert data[1]["start_time"] == 20.0
|
||||||
|
assert data[1]["end_time"] == 30.0
|
||||||
|
assert "(split)" in data[1]["title"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_split_invalid_time_below_start(client: AsyncClient, db_engine):
|
||||||
|
"""Split returns 400 when split_time is at or below start_time."""
|
||||||
|
seed = await _seed_creator_and_video(db_engine)
|
||||||
|
moment_id = await _seed_moment(
|
||||||
|
db_engine, seed["video_id"], start_time=10.0, end_time=30.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
_moment_url(str(moment_id), "split"),
|
||||||
|
json={"split_time": 10.0},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_split_invalid_time_above_end(client: AsyncClient, db_engine):
|
||||||
|
"""Split returns 400 when split_time is at or above end_time."""
|
||||||
|
seed = await _seed_creator_and_video(db_engine)
|
||||||
|
moment_id = await _seed_moment(
|
||||||
|
db_engine, seed["video_id"], start_time=10.0, end_time=30.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
_moment_url(str(moment_id), "split"),
|
||||||
|
json={"split_time": 30.0},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_split_nonexistent_moment(client: AsyncClient):
|
||||||
|
"""Split returns 404 for nonexistent moment."""
|
||||||
|
fake_id = str(uuid.uuid4())
|
||||||
|
resp = await client.post(
|
||||||
|
_moment_url(fake_id, "split"),
|
||||||
|
json={"split_time": 20.0},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ── Merge tests ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_merge_moments(client: AsyncClient, db_engine):
|
||||||
|
"""Merge combines two moments: combined summary, min start, max end, target deleted."""
|
||||||
|
seed = await _seed_creator_and_video(db_engine)
|
||||||
|
m1_id = await _seed_moment(
|
||||||
|
db_engine, seed["video_id"],
|
||||||
|
title="First", summary="Summary A",
|
||||||
|
start_time=10.0, end_time=20.0,
|
||||||
|
)
|
||||||
|
m2_id = await _seed_moment(
|
||||||
|
db_engine, seed["video_id"],
|
||||||
|
title="Second", summary="Summary B",
|
||||||
|
start_time=25.0, end_time=35.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
_moment_url(str(m1_id), "merge"),
|
||||||
|
json={"target_moment_id": str(m2_id)},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["start_time"] == 10.0
|
||||||
|
assert data["end_time"] == 35.0
|
||||||
|
assert "Summary A" in data["summary"]
|
||||||
|
assert "Summary B" in data["summary"]
|
||||||
|
|
||||||
|
# Target should be deleted — reject should 404
|
||||||
|
resp2 = await client.post(_moment_url(str(m2_id), "reject"))
|
||||||
|
assert resp2.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_merge_different_videos(client: AsyncClient, db_engine):
|
||||||
|
"""Merge returns 400 when moments are from different source videos."""
|
||||||
|
seed = await _seed_creator_and_video(db_engine)
|
||||||
|
m1_id = await _seed_moment(db_engine, seed["video_id"], title="Video 1 moment")
|
||||||
|
|
||||||
|
other_video_id = await _seed_second_video(db_engine, seed["creator_id"])
|
||||||
|
m2_id = await _seed_moment(db_engine, other_video_id, title="Video 2 moment")
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
_moment_url(str(m1_id), "merge"),
|
||||||
|
json={"target_moment_id": str(m2_id)},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "different source videos" in resp.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_merge_with_self(client: AsyncClient, db_engine):
|
||||||
|
"""Merge returns 400 when trying to merge a moment with itself."""
|
||||||
|
seed = await _seed_creator_and_video(db_engine)
|
||||||
|
m_id = await _seed_moment(db_engine, seed["video_id"])
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
_moment_url(str(m_id), "merge"),
|
||||||
|
json={"target_moment_id": str(m_id)},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "itself" in resp.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_merge_nonexistent_target(client: AsyncClient, db_engine):
|
||||||
|
"""Merge returns 404 when target moment does not exist."""
|
||||||
|
seed = await _seed_creator_and_video(db_engine)
|
||||||
|
m_id = await _seed_moment(db_engine, seed["video_id"])
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
_moment_url(str(m_id), "merge"),
|
||||||
|
json={"target_moment_id": str(uuid.uuid4())},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_merge_nonexistent_source(client: AsyncClient):
|
||||||
|
"""Merge returns 404 when source moment does not exist."""
|
||||||
|
fake_id = str(uuid.uuid4())
|
||||||
|
resp = await client.post(
|
||||||
|
_moment_url(fake_id, "merge"),
|
||||||
|
json={"target_moment_id": str(uuid.uuid4())},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ── Mode toggle tests ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_mode_default(client: AsyncClient):
|
||||||
|
"""Get mode returns config default when Redis has no value."""
|
||||||
|
mock_redis = AsyncMock()
|
||||||
|
mock_redis.get = AsyncMock(return_value=None)
|
||||||
|
mock_redis.aclose = AsyncMock()
|
||||||
|
|
||||||
|
with patch("routers.review.get_redis", return_value=mock_redis):
|
||||||
|
resp = await client.get(MODE_URL)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
# Default from config is True
|
||||||
|
assert resp.json()["review_mode"] is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_set_mode(client: AsyncClient):
|
||||||
|
"""Set mode writes to Redis and returns the new value."""
|
||||||
|
mock_redis = AsyncMock()
|
||||||
|
mock_redis.set = AsyncMock()
|
||||||
|
mock_redis.aclose = AsyncMock()
|
||||||
|
|
||||||
|
with patch("routers.review.get_redis", return_value=mock_redis):
|
||||||
|
resp = await client.put(MODE_URL, json={"review_mode": False})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["review_mode"] is False
|
||||||
|
mock_redis.set.assert_called_once_with("chrysopedia:review_mode", "False")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_mode_from_redis(client: AsyncClient):
|
||||||
|
"""Get mode reads the value stored in Redis."""
|
||||||
|
mock_redis = AsyncMock()
|
||||||
|
mock_redis.get = AsyncMock(return_value="False")
|
||||||
|
mock_redis.aclose = AsyncMock()
|
||||||
|
|
||||||
|
with patch("routers.review.get_redis", return_value=mock_redis):
|
||||||
|
resp = await client.get(MODE_URL)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["review_mode"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_mode_redis_error_fallback(client: AsyncClient):
|
||||||
|
"""Get mode falls back to config default when Redis is unavailable."""
|
||||||
|
with patch("routers.review.get_redis", side_effect=ConnectionError("Redis down")):
|
||||||
|
resp = await client.get(MODE_URL)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
# Falls back to config default (True)
|
||||||
|
assert resp.json()["review_mode"] is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_set_mode_redis_error(client: AsyncClient):
|
||||||
|
"""Set mode returns 503 when Redis is unavailable."""
|
||||||
|
with patch("routers.review.get_redis", side_effect=ConnectionError("Redis down")):
|
||||||
|
resp = await client.put(MODE_URL, json={"review_mode": False})
|
||||||
|
assert resp.status_code == 503
|
||||||
Loading…
Add table
Reference in a new issue