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:
jlightner 2026-03-29 23:13:43 +00:00
parent e27a86518d
commit b43e4a079a
5 changed files with 920 additions and 1 deletions

View file

@ -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
View 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
View 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)

View file

@ -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

View 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