"""풀 파이프라인 happy-path 통합 테스트 — 모든 외부 호출 mock.""" import pytest import sqlite3 from unittest.mock import AsyncMock, patch from fastapi.testclient import TestClient from app.main import app from app import db @pytest.fixture def client(monkeypatch, tmp_path): monkeypatch.setattr(db, "DB_PATH", str(tmp_path / "music.db")) db.init_db() # 기본 트랙 1개 등록 (music_library 테이블) conn = sqlite3.connect(db.DB_PATH) cur = conn.cursor() try: cur.execute( """INSERT INTO music_library (id, title, genre, moods, instruments, duration_sec, bpm, key, scale, prompt, audio_url, file_path, task_id, tags) VALUES (1, 'Integration Test', 'lo-fi', '["chill"]', '["piano"]', 120, 85, 'C', 'maj', 'p', '/media/music/x.mp3', '/app/data/music/x.mp3', NULL, '[]')""", ) conn.commit() except sqlite3.OperationalError as e: # 스키마가 다르면 테스트 스킵 가이드 표시 pytest.skip(f"music_library schema mismatch: {e}") conn.close() return TestClient(app) # `new=...`로 patch한 항목은 mock 인자를 함수에 주입하지 않음. # return_value로 patch한 두 개(cover/video는 new=AsyncMock 사용 → 미주입, # thumb/video는 return_value → 주입, youtube.upload_video도 return_value → 주입). # 데코레이터는 아래에서 위 순서로 인자에 주입됨: # 1) cover (new=AsyncMock) → 미주입 # 2) video (return_value) → 주입 → mock_video # 3) thumb (return_value) → 주입 → mock_thumb # 4) metadata (new=AsyncMock) → 미주입 # 5) review (new=AsyncMock) → 미주입 # 6) youtube.upload_video (return_value) → 주입 → mock_yt @patch("app.pipeline.youtube.upload_video", return_value={"video_id": "VID999"}) @patch("app.pipeline.review.run_4_axis", new=AsyncMock(return_value={ "metadata_quality": {"score": 80, "notes": "ok"}, "policy_compliance": {"score": 90, "issues": []}, "viewer_experience": {"score": 80, "notes": "ok"}, "trend_alignment": {"score": 70, "matched_keywords": ["lofi"]}, "weighted_total": 80.0, "verdict": "pass", "summary": "good", "used_fallback": False, })) @patch("app.pipeline.metadata.generate", new=AsyncMock(return_value={ "title": "Integration Test", "description": "Test desc", "tags": ["lofi"], "category_id": 10, "used_fallback": False, "error": None, })) @patch("app.pipeline.thumb.generate", return_value={ "url": "/media/videos/1/thumbnail.jpg", "used_fallback": False, }) @patch("app.pipeline.video.generate", return_value={ "url": "/media/videos/1/video.mp4", "used_fallback": False, "duration_sec": 120, }) @patch("app.pipeline.cover.generate", new=AsyncMock(return_value={ "url": "/media/videos/1/cover.jpg", "used_fallback": False, "error": None, })) def test_full_pipeline_happy_path(mock_video, mock_thumb, mock_yt, client): # 1. 파이프라인 생성 pid = client.post("/api/music/pipeline", json={"track_id": 1}).json()["id"] assert db.get_pipeline(pid)["state"] == "created" # 2. 시작 → cover 자동 생성 → cover_pending r = client.post(f"/api/music/pipeline/{pid}/start") assert r.status_code == 202 assert db.get_pipeline(pid)["state"] == "cover_pending" # 3. 4단계 사용자 승인 (cover, video, thumb, meta) for step in ["cover", "video", "thumb", "meta"]: r = client.post(f"/api/music/pipeline/{pid}/feedback", json={"step": step, "intent": "approve"}) assert r.status_code == 202 # 4. ai_review 자동 진행 후 publish_pending p = db.get_pipeline(pid) assert p["state"] == "publish_pending" assert p["review"]["verdict"] == "pass" # 5. 발행 트리거 r = client.post(f"/api/music/pipeline/{pid}/publish") assert r.status_code == 202 # 6. 최종 published p = db.get_pipeline(pid) assert p["state"] == "published" assert p["youtube_video_id"] == "VID999" # cover.generate는 new=AsyncMock이므로 함수 인자에 주입되지 않음. @patch("app.pipeline.cover.generate", new=AsyncMock(return_value={ "url": "/media/videos/2/cover.jpg", "used_fallback": False, "error": None, })) def test_pipeline_reject_and_regenerate(client): pid = client.post("/api/music/pipeline", json={"track_id": 1}).json()["id"] client.post(f"/api/music/pipeline/{pid}/start") assert db.get_pipeline(pid)["state"] == "cover_pending" # 반려 + 피드백 → 같은 단계 재진입 r = client.post(f"/api/music/pipeline/{pid}/feedback", json={"step": "cover", "intent": "reject", "feedback_text": "더 어둡게"}) assert r.status_code == 202 p = db.get_pipeline(pid) assert p["state"] == "cover_pending" # 같은 단계 유지 assert p["feedback_count_per_step"]["cover"] == 1 history = db.get_feedback_history(pid) assert history[0]["feedback_text"] == "더 어둡게" @patch("app.pipeline.youtube.upload_video", return_value={"video_id": "MIX_VID"}) @patch("app.pipeline.review.run_4_axis", new=AsyncMock(return_value={ "metadata_quality": {"score": 90, "notes": ""}, "policy_compliance": {"score": 95, "issues": []}, "viewer_experience": {"score": 85, "notes": ""}, "trend_alignment": {"score": 70, "matched_keywords": []}, "weighted_total": 87.0, "verdict": "pass", "summary": "ok", "used_fallback": False, })) @patch("app.pipeline.metadata.generate", new=AsyncMock(return_value={ "title": "Mix", "description": "Track desc", "tags": ["lofi"], "category_id": 10, "used_fallback": False, "error": None, })) @patch("app.pipeline.thumb.generate", return_value={ "url": "/media/videos/X/thumbnail.jpg", "used_fallback": False, }) @patch("app.pipeline.video.generate", return_value={ "url": "/media/videos/X/video.mp4", "used_fallback": False, "duration_sec": 600, }) @patch("app.pipeline.cover.generate", new=AsyncMock(return_value={ "url": "/media/videos/X/cover.jpg", "used_fallback": False, "error": None, })) def test_full_pipeline_compile_job_happy_path(mock_video, mock_thumb, mock_yt, client): # compile_job 1개 추가 (succeeded) conn = sqlite3.connect(db.DB_PATH) cur = conn.cursor() try: cur.execute(""" INSERT INTO compile_jobs (title, track_ids, crossfade_sec, output_path, status, created_at) VALUES ('Test Mix', '[1]', 3, '/app/data/compiles/1.mp3', 'succeeded', datetime()) """) except sqlite3.OperationalError: pytest.skip("compile_jobs schema mismatch — skip integration test") conn.commit() cid = cur.lastrowid conn.close() pid = client.post("/api/music/pipeline", json={"compile_job_id": cid}).json()["id"] assert db.get_pipeline(pid)["state"] == "created" assert db.get_pipeline(pid)["compile_job_id"] == cid assert db.get_pipeline(pid)["track_id"] is None client.post(f"/api/music/pipeline/{pid}/start") p = db.get_pipeline(pid) assert p["state"] == "cover_pending" for step in ["cover", "video", "thumb", "meta"]: r = client.post(f"/api/music/pipeline/{pid}/feedback", json={"step": step, "intent": "approve"}) assert r.status_code == 202 p = db.get_pipeline(pid) assert p["state"] == "publish_pending" client.post(f"/api/music/pipeline/{pid}/publish") p = db.get_pipeline(pid) assert p["state"] == "published" assert p["youtube_video_id"] == "MIX_VID"