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
495 lines
17 KiB
Python
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
|