chrysopedia/backend/tests/test_review.py
jlightner 4b0914b12b fix: restore complete project tree from ub01 canonical state
Auto-mode commit 7aa33cd accidentally deleted 78 files (14,814 lines) during M005
execution. Subsequent commits rebuilt some frontend files but backend/, alembic/,
tests/, whisper/, docker configs, and prompts were never restored in this repo.

This commit restores the full project tree by syncing from ub01's working directory,
which has all M001-M007 features running in production containers.

Restored: backend/ (config, models, routers, database, redis, search_service, worker),
alembic/ (6 migrations), docker/ (Dockerfiles, nginx, compose), prompts/ (4 stages),
tests/, whisper/, README.md, .env.example, chrysopedia-spec.md
2026-03-31 02:10:41 +00:00

495 lines
17 KiB
Python

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