| """ |
| Group 6 β Long-running / background-dispatch endpoints. |
| |
| Tier A only checks that these endpoints ACCEPT the request, enforce their |
| guards, persist the job row, and register the background task with the right |
| arguments. It does NOT run the background work (regenerating voiceovers, calling |
| ElevenLabs/R2) β that is the depth tier. |
| |
| To keep the work from actually executing under TestClient (which runs background |
| tasks synchronously after the response), ``BackgroundTasks.add_task`` is patched |
| to a spy that records calls without invoking them. |
| |
| POST /api/projects/{id}/change-voice -> dispatches _run_voice_change |
| POST /api/projects/{id}/delete-voiceover -> dispatches _run_delete_voiceover |
| """ |
| import pytest |
| from starlette.background import BackgroundTasks |
|
|
| from app.models.project import Project, ProjectStatus |
| from app.models.project_voice_change_job import ProjectVoiceChangeJob |
| from app.models.scene import Scene |
| from app.services import voice_change_progress |
|
|
| pytestmark = pytest.mark.smoke |
|
|
|
|
| @pytest.fixture() |
| def captured_tasks(monkeypatch): |
| """Record background tasks instead of running them. Returns the list of |
| (function_name, args) tuples registered during the test.""" |
| recorded: list[tuple[str, tuple]] = [] |
|
|
| def _spy(self, func, *args, **kwargs): |
| recorded.append((getattr(func, "__name__", str(func)), args)) |
|
|
| monkeypatch.setattr(BackgroundTasks, "add_task", _spy, raising=True) |
| return recorded |
|
|
|
|
| @pytest.fixture(autouse=True) |
| def _reset_progress(): |
| """voice_change_progress keeps in-memory per-project state; rolled-back test |
| projects reuse ids, so clear it around each test to avoid a stale |
| 'already in progress' 409 leaking across tests.""" |
| voice_change_progress._progress.clear() |
| yield |
| voice_change_progress._progress.clear() |
|
|
|
|
| def _project_with_scenes(db, user, n=2) -> Project: |
| project = Project( |
| user_id=user.id, name="Has Scenes", blog_url="https://s.test", |
| status=ProjectStatus.GENERATED, voice_gender="female", |
| ) |
| db.add(project) |
| db.commit() |
| db.refresh(project) |
| for i in range(n): |
| db.add(Scene( |
| project_id=project.id, order=i, title=f"Scene {i}", |
| narration_text="hello", visual_description="a visual", |
| )) |
| db.commit() |
| return project |
|
|
|
|
| |
|
|
| def test_change_voice__valid__dispatches_and_creates_job( |
| client, db_session, paid_user, auth, captured_tasks |
| ): |
| project = _project_with_scenes(db_session, paid_user) |
| resp = client.post( |
| f"/api/projects/{project.id}/change-voice", |
| headers=auth(paid_user), |
| json={"voice_gender": "male"}, |
| ) |
| assert resp.status_code == 200 |
| assert resp.json() == {"started": True, "total": 2} |
| |
| job = db_session.query(ProjectVoiceChangeJob).filter_by(project_id=project.id).first() |
| assert job is not None |
| |
| names = [name for name, _ in captured_tasks] |
| assert "_run_voice_change" in names |
| args = next(a for n, a in captured_tasks if n == "_run_voice_change") |
| assert args[0] == project.id |
|
|
|
|
| def test_change_voice__other_user__returns_404( |
| client, db_session, paid_user, other_user, auth, captured_tasks |
| ): |
| project = _project_with_scenes(db_session, paid_user) |
| resp = client.post( |
| f"/api/projects/{project.id}/change-voice", |
| headers=auth(other_user), json={"voice_gender": "male"}, |
| ) |
| assert resp.status_code == 404 |
|
|
|
|
| def test_change_voice__no_scenes__returns_400( |
| client, db_session, paid_user, auth, captured_tasks |
| ): |
| project = Project(user_id=paid_user.id, name="Empty", blog_url="https://e.test", |
| status=ProjectStatus.GENERATED, voice_gender="female") |
| db_session.add(project) |
| db_session.commit() |
| db_session.refresh(project) |
| resp = client.post( |
| f"/api/projects/{project.id}/change-voice", |
| headers=auth(paid_user), json={"voice_gender": "male"}, |
| ) |
| assert resp.status_code == 400 |
|
|
|
|
| def test_change_voice__without_token__returns_401(client, db_session, paid_user): |
| project = _project_with_scenes(db_session, paid_user) |
| resp = client.post(f"/api/projects/{project.id}/change-voice", json={}) |
| assert resp.status_code == 401 |
|
|
|
|
| |
|
|
| def test_delete_voiceover__valid__dispatches_and_creates_job( |
| client, db_session, paid_user, auth, captured_tasks |
| ): |
| project = _project_with_scenes(db_session, paid_user) |
| resp = client.post( |
| f"/api/projects/{project.id}/delete-voiceover", headers=auth(paid_user), |
| ) |
| assert resp.status_code == 200 |
| assert resp.json() == {"started": True, "total": 2} |
| names = [name for name, _ in captured_tasks] |
| assert "_run_delete_voiceover" in names |
|
|
|
|
| def test_delete_voiceover__other_user__returns_404( |
| client, db_session, paid_user, other_user, auth, captured_tasks |
| ): |
| project = _project_with_scenes(db_session, paid_user) |
| resp = client.post( |
| f"/api/projects/{project.id}/delete-voiceover", headers=auth(other_user), |
| ) |
| assert resp.status_code == 404 |
|
|