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 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:
|
||||
|
|
@ -81,6 +81,7 @@ app.include_router(health.router)
|
|||
app.include_router(creators.router, prefix="/api/v1")
|
||||
app.include_router(ingest.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")
|
||||
|
||||
|
||||
|
|
|
|||
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
|
||||
offset: int = 0
|
||||
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