Skip to content

Commit afa2c21

Browse files
authored
Add E2E tests for critical user flows (#31)
* #22 Add implementation plan for E2E tests * #22 Add E2E tests for critical user flows Cover upload, transcription completion, retry, and delete flows with multi-step assertions verifying disk state and API responses. * #22 Remove unused imports in E2E tests * #22 Fix line length violations in E2E tests * #22 Fix ruff format violation in E2E tests
1 parent 00d73ad commit afa2c21

File tree

2 files changed

+244
-0
lines changed

2 files changed

+244
-0
lines changed

docs/plans/22-e2e-tests.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# Plan: E2E Tests for Critical Flows
2+
3+
**Story**: #22
4+
**Spec**: docs/specs/test-framework-setup.md (US4)
5+
**Branch**: feature/22-e2e-tests
6+
**Date**: 2026-03-14
7+
**Mode**: Standard — testing infrastructure, TDD not applicable
8+
9+
## Technical Decisions
10+
11+
### TD-1: E2E vs Integration distinction
12+
- **Context**: Integration tests already cover individual endpoint contracts; E2E tests need to verify multi-step user flows
13+
- **Decision**: E2E tests chain multiple API calls and verify side effects across steps (upload → transcribe → retrieve)
14+
- **Alternatives considered**: Adding to existing integration tests — would blur the distinction and make test intent less clear
15+
16+
### TD-2: Simulating transcription completion
17+
- **Context**: Cannot run actual WhisperX/PyAnnote in tests (BR1 from spec)
18+
- **Decision**: Mock `start_transcription`, then simulate completion by writing transcript.json and updating metadata/job status directly
19+
- **Alternatives considered**: Running a lightweight mock transcriber thread — adds complexity with no benefit
20+
21+
## Files to Create or Modify
22+
23+
- `tests/e2e/test_flows.py` — Four E2E test classes covering upload, transcription completion, retry, and delete flows
24+
25+
## Approach per AC
26+
27+
### Upload flow
28+
POST upload with mocked transcription → verify meeting dir created with metadata.json + audio file → verify job exists in queue
29+
30+
### Transcription completion flow
31+
Upload → simulate transcription by writing transcript.json + updating metadata to READY + marking job COMPLETED → GET meeting → verify transcript returned with segments
32+
33+
### Retry flow
34+
Create ERROR meeting → POST retry → verify new job created → verify status back to PROCESSING
35+
36+
### Delete flow
37+
Upload meeting → DELETE → verify meeting directory removed from disk
38+
39+
## Commit Sequence
40+
41+
1. Add implementation plan for E2E tests
42+
2. Add E2E tests for critical flows
43+
44+
## Risks and Trade-offs
45+
46+
- Some overlap with integration tests is intentional — E2E tests chain multiple steps to verify full workflows
47+
48+
## Deviations from Spec
49+
50+
- Spec US4 mentions polling job status; we verify job state directly since polling is a frontend concern tested by integration tests for the jobs endpoint
51+
52+
## Deviations from Plan
53+
54+
_Populated after implementation._

tests/e2e/test_flows.py

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
from __future__ import annotations
2+
3+
import json
4+
import shutil
5+
from pathlib import Path
6+
from unittest.mock import patch
7+
8+
from backend.schemas import JobStatus
9+
from backend.services.job_queue import job_queue
10+
11+
12+
class TestUploadFlow:
13+
"""Upload a file and verify all artifacts are created on disk."""
14+
15+
@patch("backend.routers.meetings.start_transcription")
16+
async def test_upload_creates_meeting_with_job(self, mock_start, client, sample_audio: Path, meetings_dir: Path):
17+
with open(sample_audio, "rb") as f:
18+
res = await client.post(
19+
"/api/meetings",
20+
files={"file": ("recording.wav", f, "audio/wav")},
21+
data={"title": "Sprint Review", "meeting_type": "interview"},
22+
)
23+
24+
assert res.status_code == 200
25+
data = res.json()
26+
meeting_id = data["meeting_id"]
27+
job_id = data["job_id"]
28+
29+
# Verify meeting directory and files exist on disk
30+
meeting_dir = meetings_dir / meeting_id
31+
assert meeting_dir.exists()
32+
assert (meeting_dir / "metadata.json").exists()
33+
assert (meeting_dir / "audio.wav").exists()
34+
35+
# Verify metadata content
36+
meta = json.loads((meeting_dir / "metadata.json").read_text())
37+
assert meta["title"] == "Sprint Review"
38+
assert meta["type"] == "interview"
39+
assert meta["status"] == "processing"
40+
assert meta["audio_filename"] == "audio.wav"
41+
assert meta["job_id"] == job_id
42+
43+
# Verify job was created in the queue
44+
job = job_queue.get_job(job_id)
45+
assert job is not None
46+
assert job.meeting_id == meeting_id
47+
48+
# Verify transcription was started
49+
mock_start.assert_called_once_with(meeting_id, job_id)
50+
51+
52+
class TestTranscriptionCompletionFlow:
53+
"""Upload → simulate transcription completing → verify transcript is retrievable."""
54+
55+
@patch("backend.routers.meetings.start_transcription")
56+
async def test_full_transcription_lifecycle(self, mock_start, client, sample_audio: Path, meetings_dir: Path):
57+
# Step 1: Upload
58+
with open(sample_audio, "rb") as f:
59+
res = await client.post(
60+
"/api/meetings",
61+
files={"file": ("call.wav", f, "audio/wav")},
62+
data={"title": "Client Call", "meeting_type": "sales"},
63+
)
64+
65+
meeting_id = res.json()["meeting_id"]
66+
job_id = res.json()["job_id"]
67+
meeting_dir = meetings_dir / meeting_id
68+
69+
# Verify initial state is PROCESSING
70+
detail = await client.get(f"/api/meetings/{meeting_id}")
71+
assert detail.json()["metadata"]["status"] == "processing"
72+
assert detail.json()["transcript"] is None
73+
74+
# Step 2: Simulate transcription completion (what transcriber.py does)
75+
transcript_data = {
76+
"segments": [
77+
{"id": "seg_0000", "start": 0.0, "end": 3.5, "speaker": "SPEAKER_00", "text": "Hello."},
78+
{"id": "seg_0001", "start": 4.0, "end": 8.2, "speaker": "SPEAKER_01", "text": "Hi there."},
79+
{"id": "seg_0002", "start": 8.5, "end": 15.0, "speaker": "SPEAKER_00", "text": "Let's begin."},
80+
],
81+
"language": "en",
82+
}
83+
(meeting_dir / "transcript.json").write_text(json.dumps(transcript_data))
84+
85+
meta = json.loads((meeting_dir / "metadata.json").read_text())
86+
meta["status"] = "ready"
87+
meta["duration_seconds"] = 15.0
88+
meta["speakers"] = {"SPEAKER_00": "SPEAKER_00", "SPEAKER_01": "SPEAKER_01"}
89+
(meeting_dir / "metadata.json").write_text(json.dumps(meta))
90+
91+
job_queue.update_job(job_id, status=JobStatus.COMPLETED, progress=100, stage="done")
92+
93+
# Step 3: Verify meeting is now READY with transcript
94+
detail = await client.get(f"/api/meetings/{meeting_id}")
95+
assert detail.status_code == 200
96+
data = detail.json()
97+
assert data["metadata"]["status"] == "ready"
98+
assert data["metadata"]["duration_seconds"] == 15.0
99+
assert data["transcript"] is not None
100+
assert len(data["transcript"]["segments"]) == 3
101+
assert data["transcript"]["segments"][0]["text"] == "Hello."
102+
103+
# Verify job shows as completed
104+
job = job_queue.get_job(job_id)
105+
assert job.status == JobStatus.COMPLETED
106+
107+
# Step 4: Verify it appears in the meeting list
108+
list_res = await client.get("/api/meetings")
109+
meetings = list_res.json()
110+
assert any(m["id"] == meeting_id and m["status"] == "ready" for m in meetings)
111+
112+
113+
class TestRetryFlow:
114+
"""Failed transcription → retry → verify new job is created."""
115+
116+
@patch("backend.routers.meetings.start_transcription")
117+
async def test_retry_failed_transcription(
118+
self,
119+
mock_start,
120+
client,
121+
meetings_dir: Path,
122+
sample_audio: Path,
123+
sample_metadata_error: dict,
124+
):
125+
# Step 1: Set up a failed meeting on disk
126+
meeting_id = sample_metadata_error["id"]
127+
meeting_dir = meetings_dir / meeting_id
128+
meeting_dir.mkdir()
129+
(meeting_dir / "metadata.json").write_text(json.dumps(sample_metadata_error))
130+
shutil.copy(sample_audio, meeting_dir / sample_metadata_error["audio_filename"])
131+
132+
# Verify it shows as error
133+
detail = await client.get(f"/api/meetings/{meeting_id}")
134+
assert detail.json()["metadata"]["status"] == "error"
135+
136+
# Step 2: Retry
137+
res = await client.post(f"/api/meetings/{meeting_id}/retry")
138+
assert res.status_code == 200
139+
new_job_id = res.json()["job_id"]
140+
141+
# Step 3: Verify status changed to processing with a new job
142+
detail = await client.get(f"/api/meetings/{meeting_id}")
143+
meta = detail.json()["metadata"]
144+
assert meta["status"] == "processing"
145+
assert meta["job_id"] == new_job_id
146+
147+
# Verify new job exists in the queue
148+
job = job_queue.get_job(new_job_id)
149+
assert job is not None
150+
assert job.meeting_id == meeting_id
151+
152+
mock_start.assert_called_once_with(meeting_id, new_job_id)
153+
154+
155+
class TestDeleteFlow:
156+
"""Upload → delete → verify all files removed."""
157+
158+
@patch("backend.routers.meetings.start_transcription")
159+
async def test_upload_then_delete(self, mock_start, client, sample_audio: Path, meetings_dir: Path):
160+
# Step 1: Upload a meeting
161+
with open(sample_audio, "rb") as f:
162+
res = await client.post(
163+
"/api/meetings",
164+
files={"file": ("meeting.wav", f, "audio/wav")},
165+
data={"title": "To Be Deleted"},
166+
)
167+
168+
meeting_id = res.json()["meeting_id"]
169+
meeting_dir = meetings_dir / meeting_id
170+
assert meeting_dir.exists()
171+
172+
# Step 2: Verify it appears in the list
173+
list_res = await client.get("/api/meetings")
174+
assert any(m["id"] == meeting_id for m in list_res.json())
175+
176+
# Step 3: Delete
177+
del_res = await client.delete(f"/api/meetings/{meeting_id}")
178+
assert del_res.status_code == 200
179+
assert del_res.json()["ok"] is True
180+
181+
# Step 4: Verify directory is gone
182+
assert not meeting_dir.exists()
183+
184+
# Step 5: Verify it no longer appears in the list
185+
list_res = await client.get("/api/meetings")
186+
assert not any(m["id"] == meeting_id for m in list_res.json())
187+
188+
# Step 6: Verify GET returns 404
189+
detail = await client.get(f"/api/meetings/{meeting_id}")
190+
assert detail.status_code == 404

0 commit comments

Comments
 (0)