From b53c8da3981c394bdab08504b45d25f62c35495a Mon Sep 17 00:00:00 2001 From: Sergey Mankovsky Date: Tue, 10 Mar 2026 17:58:53 +0100 Subject: [PATCH] fix: add tests that check some of the issues are already fixed (#905) * Add tests that check some of the issues are already fixed * Fix test formatting --- server/tests/test_app.py | 17 ++++++ server/tests/test_pipeline_main_file.py | 41 +++++++++++++ server/tests/test_transcripts.py | 60 +++++++++++++++++++ .../__tests__/webSocketReconnect.test.ts | 26 ++++++++ www/app/(app)/transcripts/useWebSockets.ts | 4 +- .../(app)/transcripts/webSocketReconnect.ts | 10 ++++ www/package.json | 3 +- 7 files changed, 158 insertions(+), 3 deletions(-) create mode 100644 server/tests/test_app.py create mode 100644 www/app/(app)/transcripts/__tests__/webSocketReconnect.test.ts create mode 100644 www/app/(app)/transcripts/webSocketReconnect.ts diff --git a/server/tests/test_app.py b/server/tests/test_app.py new file mode 100644 index 00000000..c501fa1b --- /dev/null +++ b/server/tests/test_app.py @@ -0,0 +1,17 @@ +"""Tests for app-level endpoints (root, not under /v1).""" + +import pytest + + +@pytest.mark.asyncio +async def test_health_endpoint_returns_healthy(): + """GET /health returns 200 and {"status": "healthy"} for probes and CI.""" + from httpx import AsyncClient + + from reflector.app import app + + # Health is at app root, not under /v1 + async with AsyncClient(app=app, base_url="http://test") as root_client: + response = await root_client.get("/health") + assert response.status_code == 200 + assert response.json() == {"status": "healthy"} diff --git a/server/tests/test_pipeline_main_file.py b/server/tests/test_pipeline_main_file.py index 8a4d63dd..342e8288 100644 --- a/server/tests/test_pipeline_main_file.py +++ b/server/tests/test_pipeline_main_file.py @@ -5,6 +5,7 @@ This test verifies the complete file processing pipeline without mocking much, ensuring all processors are correctly invoked and the happy path works correctly. """ +from contextlib import asynccontextmanager from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch from uuid import uuid4 @@ -651,3 +652,43 @@ async def test_pipeline_file_process_no_audio_file( # This should fail when trying to open the file with av with pytest.raises(Exception): await pipeline.process(non_existent_path) + + +@pytest.mark.asyncio +async def test_on_title_does_not_overwrite_user_set_title(): + """When transcript already has a title, on_title does not call update.""" + from reflector.db.transcripts import Transcript, TranscriptFinalTitle + from reflector.pipelines.main_file_pipeline import PipelineMainFile + + transcript_id = str(uuid4()) + transcript_with_title = Transcript( + id=transcript_id, + name="test", + source_kind="file", + title="User set title", + ) + + controller = "reflector.pipelines.main_live_pipeline.transcripts_controller" + with patch(f"{controller}.get_by_id", new_callable=AsyncMock) as mock_get: + with patch(f"{controller}.update", new_callable=AsyncMock) as mock_update: + with patch( + f"{controller}.append_event", new_callable=AsyncMock + ) as mock_append: + with patch(f"{controller}.transaction") as mock_txn: + mock_get.return_value = transcript_with_title + mock_append.return_value = None + + @asynccontextmanager + async def noop_txn(): + yield + + mock_txn.return_value = noop_txn() + + pipeline = PipelineMainFile(transcript_id=transcript_id) + await pipeline.on_title( + TranscriptFinalTitle(title="Generated title") + ) + + mock_get.assert_called_once() + mock_update.assert_not_called() + mock_append.assert_called_once() diff --git a/server/tests/test_transcripts.py b/server/tests/test_transcripts.py index 9a3ff576..6d04c06c 100644 --- a/server/tests/test_transcripts.py +++ b/server/tests/test_transcripts.py @@ -110,6 +110,26 @@ async def test_transcript_get_update_title(authenticated_client, client): assert response.json()["title"] == "test_title" +@pytest.mark.asyncio +async def test_set_status_emits_status_event_and_updates_transcript(client): + """set_status adds a STATUS event and updates the transcript status (broadcast for WebSocket).""" + response = await client.post("/transcripts", json={"name": "Status test"}) + assert response.status_code == 200 + transcript_id = response.json()["id"] + + transcript = await transcripts_controller.get_by_id(transcript_id) + assert transcript is not None + assert transcript.status == "idle" + + event = await transcripts_controller.set_status(transcript_id, "processing") + assert event is not None + assert event.event == "STATUS" + assert event.data.get("value") == "processing" + + updated = await transcripts_controller.get_by_id(transcript_id) + assert updated.status == "processing" + + @pytest.mark.asyncio async def test_transcripts_list_anonymous(client): # XXX this test is a bit fragile, as it depends on the storage which @@ -233,3 +253,43 @@ async def test_transcript_get_returns_null_room_name_when_no_room( assert response.status_code == 200 assert response.json()["room_id"] is None assert response.json()["room_name"] is None + + +@pytest.mark.asyncio +async def test_transcripts_list_filtered_by_room_id(authenticated_client, client): + """GET /transcripts?room_id=X returns only transcripts for that room.""" + # Use same user as authenticated_client (conftest uses "randomuserid") + user_id = "randomuserid" + room = await rooms_controller.add( + name="room-for-list-filter", + user_id=user_id, + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + webhook_url="", + webhook_secret="", + ) + in_room = await transcripts_controller.add( + name="in-room", + source_kind="file", + room_id=room.id, + user_id=user_id, + ) + other = await transcripts_controller.add( + name="no-room", + source_kind="file", + room_id=None, + user_id=user_id, + ) + + response = await client.get("/transcripts", params={"room_id": room.id}) + assert response.status_code == 200 + items = response.json()["items"] + ids = [t["id"] for t in items] + assert in_room.id in ids + assert other.id not in ids diff --git a/www/app/(app)/transcripts/__tests__/webSocketReconnect.test.ts b/www/app/(app)/transcripts/__tests__/webSocketReconnect.test.ts new file mode 100644 index 00000000..24abaac1 --- /dev/null +++ b/www/app/(app)/transcripts/__tests__/webSocketReconnect.test.ts @@ -0,0 +1,26 @@ +/** + * Reconnection policy for WebSocket. + * Ensures exponential backoff is applied and capped at 30s. + */ +import { getReconnectDelayMs, MAX_RETRIES } from "../webSocketReconnect"; + +describe("webSocketReconnect", () => { + describe("getReconnectDelayMs", () => { + it("returns exponential backoff: 1s, 2s, 4s, 8s, 16s, then cap 30s", () => { + expect(getReconnectDelayMs(0)).toBe(1000); + expect(getReconnectDelayMs(1)).toBe(2000); + expect(getReconnectDelayMs(2)).toBe(4000); + expect(getReconnectDelayMs(3)).toBe(8000); + expect(getReconnectDelayMs(4)).toBe(16000); + expect(getReconnectDelayMs(5)).toBe(30000); // 32s capped to 30s + expect(getReconnectDelayMs(6)).toBe(30000); + expect(getReconnectDelayMs(9)).toBe(30000); + }); + + it("never exceeds 30s for any retry index", () => { + for (let i = 0; i <= MAX_RETRIES; i++) { + expect(getReconnectDelayMs(i)).toBeLessThanOrEqual(30000); + } + }); + }); +}); diff --git a/www/app/(app)/transcripts/useWebSockets.ts b/www/app/(app)/transcripts/useWebSockets.ts index 01d7563b..d4a521fe 100644 --- a/www/app/(app)/transcripts/useWebSockets.ts +++ b/www/app/(app)/transcripts/useWebSockets.ts @@ -14,6 +14,7 @@ import { } from "../../lib/apiHooks"; import { useAuth } from "../../lib/AuthProvider"; import { parseNonEmptyString } from "../../lib/utils"; +import { getReconnectDelayMs, MAX_RETRIES } from "./webSocketReconnect"; type TranscriptWsEvent = operations["v1_transcript_get_websocket_events"]["responses"][200]["content"]["application/json"]; @@ -338,7 +339,6 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => { if (!transcriptId) return; const tsId = parseNonEmptyString(transcriptId); - const MAX_RETRIES = 10; const url = `${WEBSOCKET_URL}/v1/transcripts/${transcriptId}/events`; let ws: WebSocket | null = null; let retryCount = 0; @@ -472,7 +472,7 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => { if (normalCodes.includes(event.code)) return; if (retryCount < MAX_RETRIES) { - const delay = Math.min(1000 * Math.pow(2, retryCount), 30000); + const delay = getReconnectDelayMs(retryCount); console.log( `WebSocket reconnecting in ${delay}ms (attempt ${retryCount + 1}/${MAX_RETRIES})`, ); diff --git a/www/app/(app)/transcripts/webSocketReconnect.ts b/www/app/(app)/transcripts/webSocketReconnect.ts new file mode 100644 index 00000000..2e3d28b5 --- /dev/null +++ b/www/app/(app)/transcripts/webSocketReconnect.ts @@ -0,0 +1,10 @@ +/** Reconnection policy for WebSocket: exponential backoff, capped at 30s. */ +export const MAX_RETRIES = 10; + +/** + * Delay in ms before reconnecting. retryIndex is 0-based (0 = first retry). + * Returns 1000, 2000, 4000, ... up to 30000 max. + */ +export function getReconnectDelayMs(retryIndex: number): number { + return Math.min(1000 * Math.pow(2, retryIndex), 30000); +} diff --git a/www/package.json b/www/package.json index 30941dd9..77c26987 100644 --- a/www/package.json +++ b/www/package.json @@ -10,7 +10,8 @@ "lint": "next lint", "format": "prettier --write .", "openapi": "openapi-typescript http://127.0.0.1:1250/openapi.json -o ./app/reflector-api.d.ts", - "test": "jest" + "test": "jest", + "typecheck": "tsc --noEmit" }, "dependencies": { "@chakra-ui/react": "^3.33.0",