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