mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2026-03-22 07:06:47 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72dca7cacc | ||
|
|
4ae56b730a | ||
|
|
cf6e867cf1 | ||
|
|
183601a121 | ||
|
|
b53c8da398 | ||
|
|
22a50bb94d | ||
|
|
504ca74184 |
15
CHANGELOG.md
15
CHANGELOG.md
@@ -1,5 +1,20 @@
|
||||
# Changelog
|
||||
|
||||
## [0.38.2](https://github.com/GreyhavenHQ/reflector/compare/v0.38.1...v0.38.2) (2026-03-12)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add auth guards to prevent anonymous access to write endpoints in non-public mode ([#907](https://github.com/GreyhavenHQ/reflector/issues/907)) ([cf6e867](https://github.com/GreyhavenHQ/reflector/commit/cf6e867cf12c42411e5a7412f6ec44eee8351665))
|
||||
* add tests that check some of the issues are already fixed ([#905](https://github.com/GreyhavenHQ/reflector/issues/905)) ([b53c8da](https://github.com/GreyhavenHQ/reflector/commit/b53c8da3981c394bdab08504b45d25f62c35495a))
|
||||
|
||||
## [0.38.1](https://github.com/GreyhavenHQ/reflector/compare/v0.38.0...v0.38.1) (2026-03-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* pin hatchet sdk version ([#903](https://github.com/GreyhavenHQ/reflector/issues/903)) ([504ca74](https://github.com/GreyhavenHQ/reflector/commit/504ca74184211eda9020d0b38ba7bd2b55d09991))
|
||||
|
||||
## [0.38.0](https://github.com/GreyhavenHQ/reflector/compare/v0.37.0...v0.38.0) (2026-03-06)
|
||||
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ dependencies = [
|
||||
"pytest-env>=1.1.5",
|
||||
"webvtt-py>=0.5.0",
|
||||
"icalendar>=6.0.0",
|
||||
"hatchet-sdk>=0.47.0",
|
||||
"hatchet-sdk==1.22.16",
|
||||
"pydantic>=2.12.5",
|
||||
]
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ AccessTokenInfo = auth_module.AccessTokenInfo
|
||||
authenticated = auth_module.authenticated
|
||||
current_user = auth_module.current_user
|
||||
current_user_optional = auth_module.current_user_optional
|
||||
current_user_optional_if_public_mode = auth_module.current_user_optional_if_public_mode
|
||||
parse_ws_bearer_token = auth_module.parse_ws_bearer_token
|
||||
current_user_ws_optional = auth_module.current_user_ws_optional
|
||||
verify_raw_token = auth_module.verify_raw_token
|
||||
|
||||
@@ -129,6 +129,17 @@ async def current_user_optional(
|
||||
return await _authenticate_user(jwt_token, api_key, jwtauth)
|
||||
|
||||
|
||||
async def current_user_optional_if_public_mode(
|
||||
jwt_token: Annotated[Optional[str], Depends(oauth2_scheme)],
|
||||
api_key: Annotated[Optional[str], Depends(api_key_header)],
|
||||
jwtauth: JWTAuth = Depends(),
|
||||
) -> Optional[UserInfo]:
|
||||
user = await _authenticate_user(jwt_token, api_key, jwtauth)
|
||||
if user is None and not settings.PUBLIC_MODE:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
return user
|
||||
|
||||
|
||||
def parse_ws_bearer_token(
|
||||
websocket: "WebSocket",
|
||||
) -> tuple[Optional[str], Optional[str]]:
|
||||
|
||||
@@ -21,6 +21,11 @@ def current_user_optional():
|
||||
return None
|
||||
|
||||
|
||||
def current_user_optional_if_public_mode():
|
||||
# auth_none means no authentication at all — always public
|
||||
return None
|
||||
|
||||
|
||||
def parse_ws_bearer_token(websocket):
|
||||
return None, None
|
||||
|
||||
|
||||
@@ -150,6 +150,16 @@ async def current_user_optional(
|
||||
return await _authenticate_user(jwt_token, api_key)
|
||||
|
||||
|
||||
async def current_user_optional_if_public_mode(
|
||||
jwt_token: Annotated[Optional[str], Depends(oauth2_scheme)],
|
||||
api_key: Annotated[Optional[str], Depends(api_key_header)],
|
||||
) -> Optional[UserInfo]:
|
||||
user = await _authenticate_user(jwt_token, api_key)
|
||||
if user is None and not settings.PUBLIC_MODE:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
return user
|
||||
|
||||
|
||||
# --- WebSocket auth (same pattern as auth_jwt.py) ---
|
||||
def parse_ws_bearer_token(
|
||||
websocket: "WebSocket",
|
||||
|
||||
@@ -697,6 +697,18 @@ class TranscriptController:
|
||||
return False
|
||||
return user_id and transcript.user_id == user_id
|
||||
|
||||
@staticmethod
|
||||
def check_can_mutate(transcript: Transcript, user_id: str | None) -> None:
|
||||
"""
|
||||
Raises HTTP 403 if the user cannot mutate the transcript.
|
||||
|
||||
Policy:
|
||||
- Anonymous transcripts (user_id is None) are editable by anyone
|
||||
- Owned transcripts can only be mutated by their owner
|
||||
"""
|
||||
if transcript.user_id is not None and transcript.user_id != user_id:
|
||||
raise HTTPException(status_code=403, detail="Not authorized")
|
||||
|
||||
@asynccontextmanager
|
||||
async def transaction(self):
|
||||
"""
|
||||
|
||||
@@ -89,14 +89,16 @@ class StartRecordingRequest(BaseModel):
|
||||
|
||||
@router.post("/meetings/{meeting_id}/recordings/start")
|
||||
async def start_recording(
|
||||
meeting_id: NonEmptyString, body: StartRecordingRequest
|
||||
meeting_id: NonEmptyString,
|
||||
body: StartRecordingRequest,
|
||||
user: Annotated[
|
||||
Optional[auth.UserInfo], Depends(auth.current_user_optional_if_public_mode)
|
||||
],
|
||||
) -> dict[str, Any]:
|
||||
"""Start cloud or raw-tracks recording via Daily.co REST API.
|
||||
|
||||
Both cloud and raw-tracks are started via REST API to bypass enable_recording limitation of allowing only 1 recording at a time.
|
||||
Uses different instanceIds for cloud vs raw-tracks (same won't work)
|
||||
|
||||
Note: No authentication required - anonymous users supported. TODO this is a DOS vector
|
||||
"""
|
||||
meeting = await meetings_controller.get_by_id(meeting_id)
|
||||
if not meeting:
|
||||
|
||||
@@ -17,7 +17,6 @@ from reflector.db.rooms import rooms_controller
|
||||
from reflector.redis_cache import RedisAsyncLock
|
||||
from reflector.schemas.platform import Platform
|
||||
from reflector.services.ics_sync import ics_sync_service
|
||||
from reflector.settings import settings
|
||||
from reflector.utils.url import add_query_param
|
||||
from reflector.video_platforms.factory import create_platform_client
|
||||
from reflector.worker.webhook import test_webhook
|
||||
@@ -178,11 +177,10 @@ router = APIRouter()
|
||||
|
||||
@router.get("/rooms", response_model=Page[RoomDetails])
|
||||
async def rooms_list(
|
||||
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
|
||||
user: Annotated[
|
||||
Optional[auth.UserInfo], Depends(auth.current_user_optional_if_public_mode)
|
||||
],
|
||||
) -> list[RoomDetails]:
|
||||
if not user and not settings.PUBLIC_MODE:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
user_id = user["sub"] if user else None
|
||||
|
||||
paginated = await apaginate(
|
||||
|
||||
@@ -263,16 +263,15 @@ class SearchResponse(BaseModel):
|
||||
|
||||
@router.get("/transcripts", response_model=Page[GetTranscriptMinimal])
|
||||
async def transcripts_list(
|
||||
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
|
||||
user: Annotated[
|
||||
Optional[auth.UserInfo], Depends(auth.current_user_optional_if_public_mode)
|
||||
],
|
||||
source_kind: SourceKind | None = None,
|
||||
room_id: str | None = None,
|
||||
search_term: str | None = None,
|
||||
change_seq_from: int | None = None,
|
||||
sort_by: Literal["created_at", "change_seq"] | None = None,
|
||||
):
|
||||
if not user and not settings.PUBLIC_MODE:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
user_id = user["sub"] if user else None
|
||||
|
||||
# Default behavior preserved: sort_by=None → "-created_at"
|
||||
@@ -307,13 +306,10 @@ async def transcripts_search(
|
||||
from_datetime: SearchFromDatetimeParam = None,
|
||||
to_datetime: SearchToDatetimeParam = None,
|
||||
user: Annotated[
|
||||
Optional[auth.UserInfo], Depends(auth.current_user_optional)
|
||||
Optional[auth.UserInfo], Depends(auth.current_user_optional_if_public_mode)
|
||||
] = None,
|
||||
):
|
||||
"""Full-text search across transcript titles and content."""
|
||||
if not user and not settings.PUBLIC_MODE:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
user_id = user["sub"] if user else None
|
||||
|
||||
if from_datetime and to_datetime and from_datetime > to_datetime:
|
||||
@@ -346,7 +342,9 @@ async def transcripts_search(
|
||||
@router.post("/transcripts", response_model=GetTranscriptWithParticipants)
|
||||
async def transcripts_create(
|
||||
info: CreateTranscript,
|
||||
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
|
||||
user: Annotated[
|
||||
Optional[auth.UserInfo], Depends(auth.current_user_optional_if_public_mode)
|
||||
],
|
||||
):
|
||||
user_id = user["sub"] if user else None
|
||||
transcript = await transcripts_controller.add(
|
||||
|
||||
@@ -62,8 +62,7 @@ async def transcript_add_participant(
|
||||
transcript = await transcripts_controller.get_by_id_for_http(
|
||||
transcript_id, user_id=user_id
|
||||
)
|
||||
if transcript.user_id is not None and transcript.user_id != user_id:
|
||||
raise HTTPException(status_code=403, detail="Not authorized")
|
||||
transcripts_controller.check_can_mutate(transcript, user_id)
|
||||
|
||||
# ensure the speaker is unique
|
||||
if participant.speaker is not None and transcript.participants is not None:
|
||||
@@ -109,8 +108,7 @@ async def transcript_update_participant(
|
||||
transcript = await transcripts_controller.get_by_id_for_http(
|
||||
transcript_id, user_id=user_id
|
||||
)
|
||||
if transcript.user_id is not None and transcript.user_id != user_id:
|
||||
raise HTTPException(status_code=403, detail="Not authorized")
|
||||
transcripts_controller.check_can_mutate(transcript, user_id)
|
||||
|
||||
# ensure the speaker is unique
|
||||
for p in transcript.participants:
|
||||
@@ -148,7 +146,6 @@ async def transcript_delete_participant(
|
||||
transcript = await transcripts_controller.get_by_id_for_http(
|
||||
transcript_id, user_id=user_id
|
||||
)
|
||||
if transcript.user_id is not None and transcript.user_id != user_id:
|
||||
raise HTTPException(status_code=403, detail="Not authorized")
|
||||
transcripts_controller.check_can_mutate(transcript, user_id)
|
||||
await transcripts_controller.delete_participant(transcript, participant_id)
|
||||
return DeletionStatus(status="ok")
|
||||
|
||||
@@ -26,7 +26,9 @@ class ProcessStatus(BaseModel):
|
||||
@router.post("/transcripts/{transcript_id}/process")
|
||||
async def transcript_process(
|
||||
transcript_id: str,
|
||||
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
|
||||
user: Annotated[
|
||||
Optional[auth.UserInfo], Depends(auth.current_user_optional_if_public_mode)
|
||||
],
|
||||
) -> ProcessStatus:
|
||||
user_id = user["sub"] if user else None
|
||||
transcript = await transcripts_controller.get_by_id_for_http(
|
||||
|
||||
@@ -41,8 +41,7 @@ async def transcript_assign_speaker(
|
||||
transcript = await transcripts_controller.get_by_id_for_http(
|
||||
transcript_id, user_id=user_id
|
||||
)
|
||||
if transcript.user_id is not None and transcript.user_id != user_id:
|
||||
raise HTTPException(status_code=403, detail="Not authorized")
|
||||
transcripts_controller.check_can_mutate(transcript, user_id)
|
||||
|
||||
if not transcript:
|
||||
raise HTTPException(status_code=404, detail="Transcript not found")
|
||||
@@ -121,8 +120,7 @@ async def transcript_merge_speaker(
|
||||
transcript = await transcripts_controller.get_by_id_for_http(
|
||||
transcript_id, user_id=user_id
|
||||
)
|
||||
if transcript.user_id is not None and transcript.user_id != user_id:
|
||||
raise HTTPException(status_code=403, detail="Not authorized")
|
||||
transcripts_controller.check_can_mutate(transcript, user_id)
|
||||
|
||||
if not transcript:
|
||||
raise HTTPException(status_code=404, detail="Transcript not found")
|
||||
|
||||
@@ -21,7 +21,9 @@ async def transcript_record_upload(
|
||||
chunk_number: int,
|
||||
total_chunks: int,
|
||||
chunk: UploadFile,
|
||||
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
|
||||
user: Annotated[
|
||||
Optional[auth.UserInfo], Depends(auth.current_user_optional_if_public_mode)
|
||||
],
|
||||
):
|
||||
user_id = user["sub"] if user else None
|
||||
transcript = await transcripts_controller.get_by_id_for_http(
|
||||
|
||||
@@ -15,7 +15,9 @@ async def transcript_record_webrtc(
|
||||
transcript_id: str,
|
||||
params: RtcOffer,
|
||||
request: Request,
|
||||
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
|
||||
user: Annotated[
|
||||
Optional[auth.UserInfo], Depends(auth.current_user_optional_if_public_mode)
|
||||
],
|
||||
):
|
||||
user_id = user["sub"] if user else None
|
||||
transcript = await transcripts_controller.get_by_id_for_http(
|
||||
|
||||
@@ -132,7 +132,7 @@ async def process_recording(bucket_name: str, object_key: str):
|
||||
target_language="en",
|
||||
user_id=room.user_id,
|
||||
recording_id=recording.id,
|
||||
share_mode="public",
|
||||
share_mode="semi-private",
|
||||
meeting_id=meeting.id,
|
||||
room_id=room.id,
|
||||
)
|
||||
@@ -343,7 +343,7 @@ async def _process_multitrack_recording_inner(
|
||||
target_language="en",
|
||||
user_id=room.user_id,
|
||||
recording_id=recording.id,
|
||||
share_mode="public",
|
||||
share_mode="semi-private",
|
||||
meeting_id=meeting.id,
|
||||
room_id=room.id,
|
||||
)
|
||||
|
||||
@@ -437,6 +437,8 @@ async def ws_manager_in_memory(monkeypatch):
|
||||
|
||||
try:
|
||||
fastapi_app.dependency_overrides[auth.current_user_optional] = lambda: None
|
||||
# current_user_optional_if_public_mode is NOT overridden here so the real
|
||||
# implementation runs and enforces the PUBLIC_MODE check during tests.
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -491,37 +493,39 @@ async def authenticated_client2():
|
||||
@asynccontextmanager
|
||||
async def authenticated_client_ctx():
|
||||
from reflector.app import app
|
||||
from reflector.auth import current_user, current_user_optional
|
||||
from reflector.auth import (
|
||||
current_user,
|
||||
current_user_optional,
|
||||
current_user_optional_if_public_mode,
|
||||
)
|
||||
|
||||
app.dependency_overrides[current_user] = lambda: {
|
||||
"sub": "randomuserid",
|
||||
"email": "test@mail.com",
|
||||
}
|
||||
app.dependency_overrides[current_user_optional] = lambda: {
|
||||
"sub": "randomuserid",
|
||||
"email": "test@mail.com",
|
||||
}
|
||||
_user = lambda: {"sub": "randomuserid", "email": "test@mail.com"}
|
||||
app.dependency_overrides[current_user] = _user
|
||||
app.dependency_overrides[current_user_optional] = _user
|
||||
app.dependency_overrides[current_user_optional_if_public_mode] = _user
|
||||
yield
|
||||
del app.dependency_overrides[current_user]
|
||||
del app.dependency_overrides[current_user_optional]
|
||||
del app.dependency_overrides[current_user_optional_if_public_mode]
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def authenticated_client2_ctx():
|
||||
from reflector.app import app
|
||||
from reflector.auth import current_user, current_user_optional
|
||||
from reflector.auth import (
|
||||
current_user,
|
||||
current_user_optional,
|
||||
current_user_optional_if_public_mode,
|
||||
)
|
||||
|
||||
app.dependency_overrides[current_user] = lambda: {
|
||||
"sub": "randomuserid2",
|
||||
"email": "test@mail.com",
|
||||
}
|
||||
app.dependency_overrides[current_user_optional] = lambda: {
|
||||
"sub": "randomuserid2",
|
||||
"email": "test@mail.com",
|
||||
}
|
||||
_user = lambda: {"sub": "randomuserid2", "email": "test@mail.com"}
|
||||
app.dependency_overrides[current_user] = _user
|
||||
app.dependency_overrides[current_user_optional] = _user
|
||||
app.dependency_overrides[current_user_optional_if_public_mode] = _user
|
||||
yield
|
||||
del app.dependency_overrides[current_user]
|
||||
del app.dependency_overrides[current_user_optional]
|
||||
del app.dependency_overrides[current_user_optional_if_public_mode]
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
@@ -550,7 +554,7 @@ def reset_hatchet_client():
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def fake_transcript_with_topics(tmpdir, client):
|
||||
async def fake_transcript_with_topics(tmpdir, client, monkeypatch):
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
@@ -559,6 +563,9 @@ async def fake_transcript_with_topics(tmpdir, client):
|
||||
from reflector.settings import settings
|
||||
from reflector.views.transcripts import transcripts_controller
|
||||
|
||||
monkeypatch.setattr(
|
||||
settings, "PUBLIC_MODE", True
|
||||
) # public mode: allow anonymous transcript creation for this test
|
||||
settings.DATA_DIR = Path(tmpdir)
|
||||
|
||||
# create a transcript
|
||||
|
||||
17
server/tests/test_app.py
Normal file
17
server/tests/test_app.py
Normal file
@@ -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"}
|
||||
@@ -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()
|
||||
|
||||
@@ -5,6 +5,7 @@ import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from conftest import authenticated_client_ctx
|
||||
from httpx_ws import aconnect_ws
|
||||
from uvicorn import Config, Server
|
||||
|
||||
@@ -382,3 +383,607 @@ async def test_audio_mp3_requires_token_for_owned_transcript(
|
||||
)
|
||||
resp2 = await client.get(f"/transcripts/{t.id}/audio/mp3", params={"token": token})
|
||||
assert resp2.status_code == 200
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# Auth guards: anonymous blocked when PUBLIC_MODE=False
|
||||
# ======================================================================
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_anonymous_cannot_create_transcript_when_not_public(client, monkeypatch):
|
||||
monkeypatch.setattr(settings, "PUBLIC_MODE", False)
|
||||
|
||||
resp = await client.post("/transcripts", json={"name": "anon-test"})
|
||||
assert resp.status_code == 401, resp.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_anonymous_cannot_process_transcript_when_not_public(client, monkeypatch):
|
||||
monkeypatch.setattr(settings, "PUBLIC_MODE", False)
|
||||
|
||||
t = await transcripts_controller.add(
|
||||
name="process-test",
|
||||
source_kind=SourceKind.LIVE,
|
||||
user_id=None,
|
||||
share_mode="public",
|
||||
)
|
||||
|
||||
resp = await client.post(f"/transcripts/{t.id}/process")
|
||||
assert resp.status_code == 401, resp.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_anonymous_cannot_upload_when_not_public(client, monkeypatch):
|
||||
monkeypatch.setattr(settings, "PUBLIC_MODE", False)
|
||||
|
||||
t = await transcripts_controller.add(
|
||||
name="upload-test",
|
||||
source_kind=SourceKind.LIVE,
|
||||
user_id=None,
|
||||
share_mode="public",
|
||||
)
|
||||
|
||||
# Minimal multipart upload
|
||||
resp = await client.post(
|
||||
f"/transcripts/{t.id}/record/upload",
|
||||
params={"chunk_number": 0, "total_chunks": 1},
|
||||
files={"chunk": ("test.mp3", b"fake-audio", "audio/mpeg")},
|
||||
)
|
||||
assert resp.status_code == 401, resp.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_anonymous_cannot_webrtc_record_when_not_public(client, monkeypatch):
|
||||
monkeypatch.setattr(settings, "PUBLIC_MODE", False)
|
||||
|
||||
t = await transcripts_controller.add(
|
||||
name="webrtc-test",
|
||||
source_kind=SourceKind.LIVE,
|
||||
user_id=None,
|
||||
share_mode="public",
|
||||
)
|
||||
|
||||
resp = await client.post(
|
||||
f"/transcripts/{t.id}/record/webrtc",
|
||||
json={"sdp": "v=0\r\n", "type": "offer"},
|
||||
)
|
||||
assert resp.status_code == 401, resp.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_anonymous_cannot_start_meeting_recording_when_not_public(
|
||||
client, monkeypatch
|
||||
):
|
||||
monkeypatch.setattr(settings, "PUBLIC_MODE", False)
|
||||
|
||||
room = await rooms_controller.add(
|
||||
name="recording-auth-test",
|
||||
user_id="owner-rec",
|
||||
zulip_auto_post=False,
|
||||
zulip_stream="",
|
||||
zulip_topic="",
|
||||
is_locked=False,
|
||||
room_mode="normal",
|
||||
recording_type="cloud",
|
||||
recording_trigger="automatic-2nd-participant",
|
||||
is_shared=True,
|
||||
webhook_url="",
|
||||
webhook_secret="",
|
||||
)
|
||||
|
||||
meeting = await meetings_controller.create(
|
||||
id="meeting-rec-test",
|
||||
room_name="recording-auth-test",
|
||||
room_url="room-url",
|
||||
host_room_url="host-url",
|
||||
start_date=Room.model_fields["created_at"].default_factory(),
|
||||
end_date=Room.model_fields["created_at"].default_factory(),
|
||||
room=room,
|
||||
)
|
||||
|
||||
resp = await client.post(
|
||||
f"/meetings/{meeting.id}/recordings/start",
|
||||
json={"type": "cloud", "instanceId": "00000000-0000-0000-0000-000000000001"},
|
||||
)
|
||||
assert resp.status_code == 401, resp.text
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# Public mode: anonymous IS allowed when PUBLIC_MODE=True
|
||||
# ======================================================================
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_anonymous_can_create_transcript_when_public(client, monkeypatch):
|
||||
monkeypatch.setattr(settings, "PUBLIC_MODE", True)
|
||||
|
||||
resp = await client.post("/transcripts", json={"name": "anon-public-test"})
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_anonymous_can_list_transcripts_when_public(client, monkeypatch):
|
||||
monkeypatch.setattr(settings, "PUBLIC_MODE", True)
|
||||
|
||||
resp = await client.get("/transcripts")
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_anonymous_can_read_public_transcript(client, monkeypatch):
|
||||
monkeypatch.setattr(settings, "PUBLIC_MODE", True)
|
||||
|
||||
t = await transcripts_controller.add(
|
||||
name="readable-test",
|
||||
source_kind=SourceKind.LIVE,
|
||||
user_id=None,
|
||||
share_mode="public",
|
||||
)
|
||||
|
||||
resp = await client.get(f"/transcripts/{t.id}")
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_anonymous_can_upload_when_public(client, monkeypatch):
|
||||
monkeypatch.setattr(settings, "PUBLIC_MODE", True)
|
||||
|
||||
t = await transcripts_controller.add(
|
||||
name="upload-public-test",
|
||||
source_kind=SourceKind.LIVE,
|
||||
user_id=None,
|
||||
share_mode="public",
|
||||
)
|
||||
|
||||
resp = await client.post(
|
||||
f"/transcripts/{t.id}/record/upload",
|
||||
params={"chunk_number": 0, "total_chunks": 2},
|
||||
files={"chunk": ("test.mp3", b"fake-audio", "audio/mpeg")},
|
||||
)
|
||||
# Chunk 0 of 2 won't trigger av.open validation, so should succeed with "ok"
|
||||
# The key assertion: auth did NOT block us (no 401)
|
||||
assert resp.status_code != 401, f"Should not get 401 in public mode: {resp.text}"
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_anonymous_can_start_meeting_recording_when_public(client, monkeypatch):
|
||||
monkeypatch.setattr(settings, "PUBLIC_MODE", True)
|
||||
|
||||
room = await rooms_controller.add(
|
||||
name="recording-public-test",
|
||||
user_id="owner-pub",
|
||||
zulip_auto_post=False,
|
||||
zulip_stream="",
|
||||
zulip_topic="",
|
||||
is_locked=False,
|
||||
room_mode="normal",
|
||||
recording_type="cloud",
|
||||
recording_trigger="automatic-2nd-participant",
|
||||
is_shared=True,
|
||||
webhook_url="",
|
||||
webhook_secret="",
|
||||
)
|
||||
|
||||
meeting = await meetings_controller.create(
|
||||
id="meeting-pub-test",
|
||||
room_name="recording-public-test",
|
||||
room_url="room-url",
|
||||
host_room_url="host-url",
|
||||
start_date=Room.model_fields["created_at"].default_factory(),
|
||||
end_date=Room.model_fields["created_at"].default_factory(),
|
||||
room=room,
|
||||
)
|
||||
|
||||
resp = await client.post(
|
||||
f"/meetings/{meeting.id}/recordings/start",
|
||||
json={"type": "cloud", "instanceId": "00000000-0000-0000-0000-000000000002"},
|
||||
)
|
||||
# Should not be 401 (may fail for other reasons like no Daily API, but auth passes)
|
||||
assert resp.status_code != 401, f"Should not get 401 in public mode: {resp.text}"
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# Authenticated user vs private data (own transcripts)
|
||||
# Authenticated owner should be able to create, read, and process
|
||||
# their own private transcripts even when PUBLIC_MODE=False
|
||||
# ======================================================================
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticated_can_create_transcript_private_mode(client, monkeypatch):
|
||||
"""Authenticated user can create transcripts even when PUBLIC_MODE=False."""
|
||||
monkeypatch.setattr(settings, "PUBLIC_MODE", False)
|
||||
|
||||
async with authenticated_client_ctx():
|
||||
resp = await client.post("/transcripts", json={"name": "auth-private-create"})
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["user_id"] == "randomuserid"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticated_can_read_own_private_transcript(client, monkeypatch):
|
||||
"""Authenticated owner can read their own private transcript."""
|
||||
monkeypatch.setattr(settings, "PUBLIC_MODE", False)
|
||||
|
||||
# Create transcript owned by "randomuserid"
|
||||
t = await transcripts_controller.add(
|
||||
name="auth-private-read",
|
||||
source_kind=SourceKind.LIVE,
|
||||
user_id="randomuserid",
|
||||
share_mode="private",
|
||||
)
|
||||
|
||||
async with authenticated_client_ctx():
|
||||
resp = await client.get(f"/transcripts/{t.id}")
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticated_cannot_read_others_private_transcript(client, monkeypatch):
|
||||
"""Authenticated user cannot read another user's private transcript."""
|
||||
monkeypatch.setattr(settings, "PUBLIC_MODE", False)
|
||||
|
||||
# Create transcript owned by someone else
|
||||
t = await transcripts_controller.add(
|
||||
name="other-private",
|
||||
source_kind=SourceKind.LIVE,
|
||||
user_id="other-owner",
|
||||
share_mode="private",
|
||||
)
|
||||
|
||||
async with authenticated_client_ctx():
|
||||
resp = await client.get(f"/transcripts/{t.id}")
|
||||
assert resp.status_code == 403, resp.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticated_can_process_own_private_transcript(client, monkeypatch):
|
||||
"""Authenticated owner can trigger processing on their own private transcript."""
|
||||
monkeypatch.setattr(settings, "PUBLIC_MODE", False)
|
||||
|
||||
t = await transcripts_controller.add(
|
||||
name="auth-private-process",
|
||||
source_kind=SourceKind.LIVE,
|
||||
user_id="randomuserid",
|
||||
share_mode="private",
|
||||
)
|
||||
|
||||
async with authenticated_client_ctx():
|
||||
resp = await client.post(f"/transcripts/{t.id}/process")
|
||||
# Should pass auth (may fail for other reasons like validation, but not 401/403)
|
||||
assert resp.status_code not in (401, 403), resp.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticated_can_upload_to_own_private_transcript(client, monkeypatch):
|
||||
"""Authenticated owner can upload audio to their own private transcript."""
|
||||
monkeypatch.setattr(settings, "PUBLIC_MODE", False)
|
||||
|
||||
t = await transcripts_controller.add(
|
||||
name="auth-private-upload",
|
||||
source_kind=SourceKind.LIVE,
|
||||
user_id="randomuserid",
|
||||
share_mode="private",
|
||||
)
|
||||
|
||||
async with authenticated_client_ctx():
|
||||
resp = await client.post(
|
||||
f"/transcripts/{t.id}/record/upload",
|
||||
params={"chunk_number": 0, "total_chunks": 2},
|
||||
files={"chunk": ("test.mp3", b"fake-audio", "audio/mpeg")},
|
||||
)
|
||||
# Auth passes, chunk accepted (not final chunk so no av validation)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticated_can_webrtc_own_private_transcript(client, monkeypatch):
|
||||
"""Authenticated owner can start WebRTC recording on their own private transcript."""
|
||||
monkeypatch.setattr(settings, "PUBLIC_MODE", False)
|
||||
|
||||
t = await transcripts_controller.add(
|
||||
name="auth-private-webrtc",
|
||||
source_kind=SourceKind.LIVE,
|
||||
user_id="randomuserid",
|
||||
share_mode="private",
|
||||
)
|
||||
|
||||
async with authenticated_client_ctx():
|
||||
resp = await client.post(
|
||||
f"/transcripts/{t.id}/record/webrtc",
|
||||
json={"sdp": "v=0\r\n", "type": "offer"},
|
||||
)
|
||||
# Auth passes (may fail for other reasons like RTC setup, but not 401/403)
|
||||
assert resp.status_code not in (401, 403), resp.text
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# Authenticated user vs semi-private data (other user's transcripts)
|
||||
# Any authenticated user should be able to READ semi-private transcripts
|
||||
# but NOT write to them (upload, process) since they don't own them
|
||||
# ======================================================================
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticated_can_read_others_semi_private_transcript(
|
||||
client, monkeypatch
|
||||
):
|
||||
"""Any authenticated user can read a semi-private transcript (link sharing)."""
|
||||
monkeypatch.setattr(settings, "PUBLIC_MODE", False)
|
||||
|
||||
# Create transcript owned by someone else with semi-private share mode
|
||||
t = await transcripts_controller.add(
|
||||
name="semi-private-readable",
|
||||
source_kind=SourceKind.LIVE,
|
||||
user_id="other-owner",
|
||||
share_mode="semi-private",
|
||||
)
|
||||
|
||||
async with authenticated_client_ctx():
|
||||
resp = await client.get(f"/transcripts/{t.id}")
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_anonymous_cannot_read_semi_private_transcript(client, monkeypatch):
|
||||
"""Anonymous user cannot read a semi-private transcript."""
|
||||
monkeypatch.setattr(settings, "PUBLIC_MODE", False)
|
||||
|
||||
t = await transcripts_controller.add(
|
||||
name="semi-private-blocked",
|
||||
source_kind=SourceKind.LIVE,
|
||||
user_id="some-owner",
|
||||
share_mode="semi-private",
|
||||
)
|
||||
|
||||
resp = await client.get(f"/transcripts/{t.id}")
|
||||
assert resp.status_code == 403, resp.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticated_can_list_own_transcripts_private_mode(client, monkeypatch):
|
||||
"""Authenticated user can list their own transcripts when PUBLIC_MODE=False."""
|
||||
monkeypatch.setattr(settings, "PUBLIC_MODE", False)
|
||||
|
||||
await transcripts_controller.add(
|
||||
name="my-transcript",
|
||||
source_kind=SourceKind.LIVE,
|
||||
user_id="randomuserid",
|
||||
share_mode="private",
|
||||
)
|
||||
|
||||
async with authenticated_client_ctx():
|
||||
resp = await client.get("/transcripts")
|
||||
assert resp.status_code == 200, resp.text
|
||||
items = resp.json()["items"]
|
||||
assert len(items) >= 1
|
||||
# All returned transcripts should belong to the user or be in shared rooms
|
||||
for item in items:
|
||||
assert item["user_id"] == "randomuserid" or item.get("room_id") is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticated_cannot_list_others_private_transcripts(
|
||||
client, monkeypatch
|
||||
):
|
||||
"""Authenticated user should NOT see another user's private transcripts in the list."""
|
||||
monkeypatch.setattr(settings, "PUBLIC_MODE", False)
|
||||
|
||||
await transcripts_controller.add(
|
||||
name="hidden-from-others",
|
||||
source_kind=SourceKind.LIVE,
|
||||
user_id="secret-owner",
|
||||
share_mode="private",
|
||||
)
|
||||
|
||||
async with authenticated_client_ctx():
|
||||
resp = await client.get("/transcripts")
|
||||
assert resp.status_code == 200, resp.text
|
||||
items = resp.json()["items"]
|
||||
# Should not contain transcripts owned by "secret-owner"
|
||||
for item in items:
|
||||
assert (
|
||||
item.get("user_id") != "secret-owner"
|
||||
), f"Leaked private transcript: {item['id']}"
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# Anonymous-created transcripts (user_id=None)
|
||||
# These transcripts bypass share_mode checks entirely in get_by_id_for_http.
|
||||
# They should always be accessible to everyone regardless of PUBLIC_MODE
|
||||
# or share_mode setting, because there is no owner to restrict access.
|
||||
# ======================================================================
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_anonymous_transcript_accessible_when_public_mode_true(
|
||||
client, monkeypatch
|
||||
):
|
||||
"""Anonymous transcript (user_id=None) is accessible even with default private share_mode
|
||||
when PUBLIC_MODE=True."""
|
||||
monkeypatch.setattr(settings, "PUBLIC_MODE", True)
|
||||
|
||||
t = await transcripts_controller.add(
|
||||
name="anon-transcript-public-mode",
|
||||
source_kind=SourceKind.LIVE,
|
||||
user_id=None,
|
||||
share_mode="private", # share_mode is irrelevant for user_id=None
|
||||
)
|
||||
|
||||
resp = await client.get(f"/transcripts/{t.id}")
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_anonymous_transcript_accessible_when_public_mode_false(
|
||||
client, monkeypatch
|
||||
):
|
||||
"""Anonymous transcript (user_id=None) is accessible by authenticated users
|
||||
even when PUBLIC_MODE=False. The transcript has no owner, so share_mode is bypassed."""
|
||||
monkeypatch.setattr(settings, "PUBLIC_MODE", False)
|
||||
|
||||
t = await transcripts_controller.add(
|
||||
name="anon-transcript-private-mode",
|
||||
source_kind=SourceKind.LIVE,
|
||||
user_id=None,
|
||||
share_mode="private",
|
||||
)
|
||||
|
||||
async with authenticated_client_ctx():
|
||||
resp = await client.get(f"/transcripts/{t.id}")
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_anonymous_transcript_accessible_regardless_of_share_mode(
|
||||
client, monkeypatch
|
||||
):
|
||||
"""Anonymous transcripts (user_id=None) are accessible regardless of share_mode value.
|
||||
Tests all three share modes to confirm the user_id=None bypass works consistently."""
|
||||
monkeypatch.setattr(settings, "PUBLIC_MODE", True)
|
||||
|
||||
for mode in ("private", "semi-private", "public"):
|
||||
t = await transcripts_controller.add(
|
||||
name=f"anon-share-{mode}",
|
||||
source_kind=SourceKind.LIVE,
|
||||
user_id=None,
|
||||
share_mode=mode,
|
||||
)
|
||||
|
||||
resp = await client.get(f"/transcripts/{t.id}")
|
||||
assert resp.status_code == 200, f"Failed for share_mode={mode}: {resp.text}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_anonymous_transcript_readable_by_different_authenticated_user(
|
||||
client, monkeypatch
|
||||
):
|
||||
"""An authenticated user can read anonymous transcripts (user_id=None) even with
|
||||
private share_mode, because the no-owner bypass applies."""
|
||||
monkeypatch.setattr(settings, "PUBLIC_MODE", False)
|
||||
|
||||
t = await transcripts_controller.add(
|
||||
name="anon-read-by-auth-user",
|
||||
source_kind=SourceKind.LIVE,
|
||||
user_id=None,
|
||||
share_mode="private",
|
||||
)
|
||||
|
||||
async with authenticated_client_ctx():
|
||||
resp = await client.get(f"/transcripts/{t.id}")
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["user_id"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_anonymous_transcript_in_list_when_public_mode(client, monkeypatch):
|
||||
"""Anonymous transcripts appear in the transcript list when PUBLIC_MODE=True."""
|
||||
monkeypatch.setattr(settings, "PUBLIC_MODE", True)
|
||||
|
||||
t = await transcripts_controller.add(
|
||||
name="anon-in-list",
|
||||
source_kind=SourceKind.LIVE,
|
||||
user_id=None,
|
||||
share_mode="private",
|
||||
)
|
||||
|
||||
resp = await client.get("/transcripts")
|
||||
assert resp.status_code == 200, resp.text
|
||||
ids = [item["id"] for item in resp.json()["items"]]
|
||||
assert t.id in ids, "Anonymous transcript should appear in the public list"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_anonymous_transcript_audio_accessible(client, monkeypatch, tmpdir):
|
||||
"""Anonymous transcript audio (mp3) is accessible without authentication
|
||||
because user_id=None bypasses share_mode checks."""
|
||||
monkeypatch.setattr(settings, "PUBLIC_MODE", True)
|
||||
monkeypatch.setattr(settings, "DATA_DIR", Path(tmpdir).as_posix())
|
||||
|
||||
t = await transcripts_controller.add(
|
||||
name="anon-audio-access",
|
||||
source_kind=SourceKind.LIVE,
|
||||
user_id=None,
|
||||
share_mode="private",
|
||||
)
|
||||
|
||||
tr = await transcripts_controller.get_by_id(t.id)
|
||||
await transcripts_controller.update(tr, {"status": "ended"})
|
||||
|
||||
# Copy fixture audio to transcript path
|
||||
audio_path = Path(__file__).parent / "records" / "test_mathieu_hello.mp3"
|
||||
tr.audio_mp3_filename.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy(audio_path, tr.audio_mp3_filename)
|
||||
|
||||
resp = await client.get(f"/transcripts/{t.id}/audio/mp3")
|
||||
assert (
|
||||
resp.status_code == 200
|
||||
), f"Anonymous transcript audio should be accessible: {resp.text}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_owned_transcript_not_accessible_by_anon_when_not_public(
|
||||
client, monkeypatch
|
||||
):
|
||||
"""Contrast test: owned transcript with private share_mode is NOT accessible
|
||||
to anonymous users when PUBLIC_MODE=False. This confirms that the user_id=None
|
||||
bypass only applies to anonymous transcripts, not to all transcripts."""
|
||||
monkeypatch.setattr(settings, "PUBLIC_MODE", False)
|
||||
|
||||
t = await transcripts_controller.add(
|
||||
name="owned-private-contrast",
|
||||
source_kind=SourceKind.LIVE,
|
||||
user_id="some-owner",
|
||||
share_mode="private",
|
||||
)
|
||||
|
||||
resp = await client.get(f"/transcripts/{t.id}")
|
||||
assert (
|
||||
resp.status_code == 403
|
||||
), f"Owned private transcript should be denied to anonymous: {resp.text}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticated_can_start_meeting_recording_private_mode(
|
||||
client, monkeypatch
|
||||
):
|
||||
"""Authenticated user can start recording in non-public mode."""
|
||||
monkeypatch.setattr(settings, "PUBLIC_MODE", False)
|
||||
|
||||
room = await rooms_controller.add(
|
||||
name="auth-recording-test",
|
||||
user_id="randomuserid",
|
||||
zulip_auto_post=False,
|
||||
zulip_stream="",
|
||||
zulip_topic="",
|
||||
is_locked=False,
|
||||
room_mode="normal",
|
||||
recording_type="cloud",
|
||||
recording_trigger="automatic-2nd-participant",
|
||||
is_shared=True,
|
||||
webhook_url="",
|
||||
webhook_secret="",
|
||||
)
|
||||
|
||||
meeting = await meetings_controller.create(
|
||||
id="meeting-auth-rec",
|
||||
room_name="auth-recording-test",
|
||||
room_url="room-url",
|
||||
host_room_url="host-url",
|
||||
start_date=Room.model_fields["created_at"].default_factory(),
|
||||
end_date=Room.model_fields["created_at"].default_factory(),
|
||||
room=room,
|
||||
)
|
||||
|
||||
async with authenticated_client_ctx():
|
||||
resp = await client.post(
|
||||
f"/meetings/{meeting.id}/recordings/start",
|
||||
json={
|
||||
"type": "cloud",
|
||||
"instanceId": "00000000-0000-0000-0000-000000000003",
|
||||
},
|
||||
)
|
||||
# Auth passes (may fail for Daily API reasons, but not 401)
|
||||
assert resp.status_code != 401, resp.text
|
||||
|
||||
@@ -340,8 +340,13 @@ async def test_transcript_formats_with_overlapping_speakers_multitrack():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_transcript_format_text(client):
|
||||
async def test_api_transcript_format_text(monkeypatch, client):
|
||||
"""Test GET /transcripts/{id} with transcript_format=text."""
|
||||
from reflector.settings import settings
|
||||
|
||||
monkeypatch.setattr(
|
||||
settings, "PUBLIC_MODE", True
|
||||
) # public mode: allow anonymous transcript creation for this test
|
||||
response = await client.post("/transcripts", json={"name": "Test transcript"})
|
||||
assert response.status_code == 200
|
||||
tid = response.json()["id"]
|
||||
@@ -390,8 +395,13 @@ async def test_api_transcript_format_text(client):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_transcript_format_text_timestamped(client):
|
||||
async def test_api_transcript_format_text_timestamped(monkeypatch, client):
|
||||
"""Test GET /transcripts/{id} with transcript_format=text-timestamped."""
|
||||
from reflector.settings import settings
|
||||
|
||||
monkeypatch.setattr(
|
||||
settings, "PUBLIC_MODE", True
|
||||
) # public mode: allow anonymous transcript creation for this test
|
||||
response = await client.post("/transcripts", json={"name": "Test transcript"})
|
||||
assert response.status_code == 200
|
||||
tid = response.json()["id"]
|
||||
@@ -441,8 +451,13 @@ async def test_api_transcript_format_text_timestamped(client):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_transcript_format_webvtt_named(client):
|
||||
async def test_api_transcript_format_webvtt_named(monkeypatch, client):
|
||||
"""Test GET /transcripts/{id} with transcript_format=webvtt-named."""
|
||||
from reflector.settings import settings
|
||||
|
||||
monkeypatch.setattr(
|
||||
settings, "PUBLIC_MODE", True
|
||||
) # public mode: allow anonymous transcript creation for this test
|
||||
response = await client.post("/transcripts", json={"name": "Test transcript"})
|
||||
assert response.status_code == 200
|
||||
tid = response.json()["id"]
|
||||
@@ -491,8 +506,13 @@ async def test_api_transcript_format_webvtt_named(client):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_transcript_format_json(client):
|
||||
async def test_api_transcript_format_json(monkeypatch, client):
|
||||
"""Test GET /transcripts/{id} with transcript_format=json."""
|
||||
from reflector.settings import settings
|
||||
|
||||
monkeypatch.setattr(
|
||||
settings, "PUBLIC_MODE", True
|
||||
) # public mode: allow anonymous transcript creation for this test
|
||||
response = await client.post("/transcripts", json={"name": "Test transcript"})
|
||||
assert response.status_code == 200
|
||||
tid = response.json()["id"]
|
||||
@@ -544,8 +564,13 @@ async def test_api_transcript_format_json(client):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_transcript_format_default_is_text(client):
|
||||
async def test_api_transcript_format_default_is_text(monkeypatch, client):
|
||||
"""Test GET /transcripts/{id} defaults to text format."""
|
||||
from reflector.settings import settings
|
||||
|
||||
monkeypatch.setattr(
|
||||
settings, "PUBLIC_MODE", True
|
||||
) # public mode: allow anonymous transcript creation for this test
|
||||
response = await client.post("/transcripts", json={"name": "Test transcript"})
|
||||
assert response.status_code == 200
|
||||
tid = response.json()["id"]
|
||||
@@ -654,12 +679,18 @@ async def test_api_topics_endpoint_multitrack_segmentation(client):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_topics_endpoint_non_multitrack_segmentation(client):
|
||||
async def test_api_topics_endpoint_non_multitrack_segmentation(monkeypatch, client):
|
||||
"""Test GET /transcripts/{id}/topics uses default segmentation for non-multitrack.
|
||||
|
||||
Ensures backward compatibility - transcripts without multitrack recordings
|
||||
should continue using the default speaker-change-based segmentation.
|
||||
"""
|
||||
from reflector.settings import settings
|
||||
|
||||
monkeypatch.setattr(
|
||||
settings, "PUBLIC_MODE", True
|
||||
) # public mode: allow anonymous transcript creation for this test
|
||||
|
||||
from reflector.db.transcripts import (
|
||||
TranscriptParticipant,
|
||||
TranscriptTopic,
|
||||
|
||||
@@ -5,7 +5,12 @@ from reflector.db.transcripts import transcripts_controller
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transcript_create(client):
|
||||
async def test_transcript_create(monkeypatch, client):
|
||||
from reflector.settings import settings
|
||||
|
||||
monkeypatch.setattr(
|
||||
settings, "PUBLIC_MODE", True
|
||||
) # public mode: allow anonymous transcript creation for this test
|
||||
response = await client.post("/transcripts", json={"name": "test"})
|
||||
assert response.status_code == 200
|
||||
assert response.json()["name"] == "test"
|
||||
@@ -110,6 +115,33 @@ 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(
|
||||
monkeypatch, client
|
||||
):
|
||||
"""set_status adds a STATUS event and updates the transcript status (broadcast for WebSocket)."""
|
||||
from reflector.settings import settings
|
||||
|
||||
monkeypatch.setattr(
|
||||
settings, "PUBLIC_MODE", True
|
||||
) # public mode: allow anonymous transcript creation for this test
|
||||
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 +265,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
|
||||
|
||||
@@ -5,10 +5,13 @@ import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def fake_transcript(tmpdir, client):
|
||||
async def fake_transcript(tmpdir, client, monkeypatch):
|
||||
from reflector.settings import settings
|
||||
from reflector.views.transcripts import transcripts_controller
|
||||
|
||||
monkeypatch.setattr(
|
||||
settings, "PUBLIC_MODE", True
|
||||
) # public mode: allow anonymous transcript creation for this test
|
||||
settings.DATA_DIR = Path(tmpdir)
|
||||
|
||||
# create a transcript
|
||||
|
||||
@@ -5,6 +5,8 @@ from unittest.mock import AsyncMock, patch
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from reflector.settings import settings
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def app_lifespan():
|
||||
@@ -36,7 +38,11 @@ async def test_transcript_process(
|
||||
dummy_file_diarization,
|
||||
dummy_storage,
|
||||
client,
|
||||
monkeypatch,
|
||||
):
|
||||
# public mode: this test uses an anonymous client; allow anonymous transcript creation
|
||||
monkeypatch.setattr(settings, "PUBLIC_MODE", True)
|
||||
|
||||
# create a transcript
|
||||
response = await client.post("/transcripts", json={"name": "test"})
|
||||
assert response.status_code == 200
|
||||
@@ -106,12 +112,17 @@ async def test_transcript_process(
|
||||
|
||||
@pytest.mark.usefixtures("setup_database")
|
||||
@pytest.mark.asyncio
|
||||
async def test_whereby_recording_uses_file_pipeline(client):
|
||||
async def test_whereby_recording_uses_file_pipeline(monkeypatch, client):
|
||||
"""Test that Whereby recordings (bucket_name but no track_keys) use file pipeline"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from reflector.db.recordings import Recording, recordings_controller
|
||||
from reflector.db.transcripts import transcripts_controller
|
||||
from reflector.settings import settings
|
||||
|
||||
monkeypatch.setattr(
|
||||
settings, "PUBLIC_MODE", True
|
||||
) # public mode: allow anonymous transcript creation for this test
|
||||
|
||||
# Create transcript with Whereby recording (has bucket_name, no track_keys)
|
||||
transcript = await transcripts_controller.add(
|
||||
@@ -157,13 +168,18 @@ async def test_whereby_recording_uses_file_pipeline(client):
|
||||
|
||||
@pytest.mark.usefixtures("setup_database")
|
||||
@pytest.mark.asyncio
|
||||
async def test_dailyco_recording_uses_multitrack_pipeline(client):
|
||||
async def test_dailyco_recording_uses_multitrack_pipeline(monkeypatch, client):
|
||||
"""Test that Daily.co recordings (bucket_name + track_keys) use multitrack pipeline"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from reflector.db.recordings import Recording, recordings_controller
|
||||
from reflector.db.rooms import rooms_controller
|
||||
from reflector.db.transcripts import transcripts_controller
|
||||
from reflector.settings import settings
|
||||
|
||||
monkeypatch.setattr(
|
||||
settings, "PUBLIC_MODE", True
|
||||
) # public mode: allow anonymous transcript creation for this test
|
||||
|
||||
room = await rooms_controller.add(
|
||||
name="test-room",
|
||||
@@ -235,13 +251,18 @@ async def test_dailyco_recording_uses_multitrack_pipeline(client):
|
||||
|
||||
@pytest.mark.usefixtures("setup_database")
|
||||
@pytest.mark.asyncio
|
||||
async def test_reprocess_error_transcript_passes_force(client):
|
||||
async def test_reprocess_error_transcript_passes_force(monkeypatch, client):
|
||||
"""When transcript status is 'error', reprocess passes force=True to start fresh workflow."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from reflector.db.recordings import Recording, recordings_controller
|
||||
from reflector.db.rooms import rooms_controller
|
||||
from reflector.db.transcripts import transcripts_controller
|
||||
from reflector.settings import settings
|
||||
|
||||
monkeypatch.setattr(
|
||||
settings, "PUBLIC_MODE", True
|
||||
) # public mode: allow anonymous transcript creation for this test
|
||||
|
||||
room = await rooms_controller.add(
|
||||
name="test-room",
|
||||
|
||||
@@ -133,10 +133,17 @@ async def test_transcript_rtc_and_websocket(
|
||||
fake_mp3_upload,
|
||||
appserver,
|
||||
client,
|
||||
monkeypatch,
|
||||
):
|
||||
# goal: start the server, exchange RTC, receive websocket events
|
||||
# because of that, we need to start the server in a thread
|
||||
# to be able to connect with aiortc
|
||||
from reflector.settings import settings
|
||||
|
||||
monkeypatch.setattr(
|
||||
settings, "PUBLIC_MODE", True
|
||||
) # public mode: allow anonymous transcript creation for this test
|
||||
|
||||
server, host, port = appserver
|
||||
|
||||
# create a transcript
|
||||
@@ -298,11 +305,18 @@ async def test_transcript_rtc_and_websocket_and_fr(
|
||||
fake_mp3_upload,
|
||||
appserver,
|
||||
client,
|
||||
monkeypatch,
|
||||
):
|
||||
# goal: start the server, exchange RTC, receive websocket events
|
||||
# because of that, we need to start the server in a thread
|
||||
# to be able to connect with aiortc
|
||||
# with target french language
|
||||
from reflector.settings import settings
|
||||
|
||||
monkeypatch.setattr(
|
||||
settings, "PUBLIC_MODE", True
|
||||
) # public mode: allow anonymous transcript creation for this test
|
||||
|
||||
server, host, port = appserver
|
||||
|
||||
# create a transcript
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import pytest
|
||||
|
||||
from reflector.settings import settings
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transcript_create_default_translation(client):
|
||||
async def test_transcript_create_default_translation(monkeypatch, client):
|
||||
monkeypatch.setattr(
|
||||
settings, "PUBLIC_MODE", True
|
||||
) # public mode: allow anonymous transcript creation for this test
|
||||
response = await client.post("/transcripts", json={"name": "test en"})
|
||||
assert response.status_code == 200
|
||||
assert response.json()["name"] == "test en"
|
||||
@@ -18,7 +23,10 @@ async def test_transcript_create_default_translation(client):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transcript_create_en_fr_translation(client):
|
||||
async def test_transcript_create_en_fr_translation(monkeypatch, client):
|
||||
monkeypatch.setattr(
|
||||
settings, "PUBLIC_MODE", True
|
||||
) # public mode: allow anonymous transcript creation for this test
|
||||
response = await client.post(
|
||||
"/transcripts", json={"name": "test en/fr", "target_language": "fr"}
|
||||
)
|
||||
@@ -36,7 +44,10 @@ async def test_transcript_create_en_fr_translation(client):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transcript_create_fr_en_translation(client):
|
||||
async def test_transcript_create_fr_en_translation(monkeypatch, client):
|
||||
monkeypatch.setattr(
|
||||
settings, "PUBLIC_MODE", True
|
||||
) # public mode: allow anonymous transcript creation for this test
|
||||
response = await client.post(
|
||||
"/transcripts", json={"name": "test fr/en", "source_language": "fr"}
|
||||
)
|
||||
|
||||
@@ -16,7 +16,13 @@ async def test_transcript_upload_file(
|
||||
dummy_file_diarization,
|
||||
dummy_storage,
|
||||
client,
|
||||
monkeypatch,
|
||||
):
|
||||
from reflector.settings import settings
|
||||
|
||||
monkeypatch.setattr(
|
||||
settings, "PUBLIC_MODE", True
|
||||
) # public mode: allow anonymous transcript creation for this test
|
||||
# create a transcript
|
||||
response = await client.post("/transcripts", json={"name": "test"})
|
||||
assert response.status_code == 200
|
||||
|
||||
@@ -141,33 +141,19 @@ async def test_user_ws_accepts_valid_token_and_receives_events(appserver_ws_user
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
# Emit an event to the user's room via a standard HTTP action
|
||||
# Use a real HTTP request to the server with the JWT token so that
|
||||
# current_user_optional_if_public_mode is exercised without dependency overrides
|
||||
from httpx import AsyncClient
|
||||
|
||||
from reflector.app import app
|
||||
from reflector.auth import current_user, current_user_optional
|
||||
|
||||
# Override auth dependencies so HTTP request is performed as the same user
|
||||
# Use the internal user.id (not the Authentik UID)
|
||||
app.dependency_overrides[current_user] = lambda: {
|
||||
"sub": user.id,
|
||||
"email": "user-abc@example.com",
|
||||
}
|
||||
app.dependency_overrides[current_user_optional] = lambda: {
|
||||
"sub": user.id,
|
||||
"email": "user-abc@example.com",
|
||||
}
|
||||
|
||||
# Use in-memory client (global singleton makes it share ws_manager)
|
||||
async with AsyncClient(app=app, base_url=f"http://{host}:{port}/v1") as ac:
|
||||
# Create a transcript as this user so that the server publishes TRANSCRIPT_CREATED to user room
|
||||
resp = await ac.post("/transcripts", json={"name": "WS Test"})
|
||||
async with AsyncClient(base_url=f"http://{host}:{port}/v1") as ac:
|
||||
resp = await ac.post(
|
||||
"/transcripts",
|
||||
json={"name": "WS Test"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Receive the published event
|
||||
msg = await ws.receive_json()
|
||||
assert msg["event"] == "TRANSCRIPT_CREATED"
|
||||
assert "id" in msg["data"]
|
||||
|
||||
# Clean overrides
|
||||
del app.dependency_overrides[current_user]
|
||||
del app.dependency_overrides[current_user_optional]
|
||||
|
||||
15
server/uv.lock
generated
15
server/uv.lock
generated
@@ -1326,7 +1326,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "hatchet-sdk"
|
||||
version = "1.27.0"
|
||||
version = "1.22.16"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
@@ -1338,12 +1338,11 @@ dependencies = [
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "tenacity" },
|
||||
{ name = "typing-inspection" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5b/02/e8bcc42654f03af3a39f9319d21fc42ab36abca9514cee275c04b2810186/hatchet_sdk-1.27.0.tar.gz", hash = "sha256:c312a83c8e6c13040cc2512a6ed7e60085af2496587a2dbd5c18a62d84217cb8", size = 246838, upload-time = "2026-02-27T18:21:40.236Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/aa/6c/c35395e5f77fb0fa20eaab615d2982fc0c995d1f310ee837ef5581a0aca8/hatchet_sdk-1.22.16.tar.gz", hash = "sha256:60d0065a533cabb7635e23ede954b53d4742184e7970171518ceb1d9a7d3872f", size = 232395, upload-time = "2026-02-05T14:52:14.536Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/5b/3c2a8b6908a68d42489d903c41fa460cd6d61e07a27252737fcec8d97b31/hatchet_sdk-1.27.0-py3-none-any.whl", hash = "sha256:3cea10e68d3551881588ec941b50f0e383855b191eb79905ee57ee806b08430b", size = 574642, upload-time = "2026-02-27T18:21:37.611Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/93/e84b468bb8ac13dbb6f7f29f886763ede48dd9895094077b394d310ff77c/hatchet_sdk-1.22.16-py3-none-any.whl", hash = "sha256:7f5daae20791675a7425bf2ea0571a2febb76af21d466715248f6729393916cf", size = 554652, upload-time = "2026-02-05T14:52:12.464Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2974,11 +2973,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pypdf"
|
||||
version = "6.7.5"
|
||||
version = "6.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f6/52/37cc0aa9e9d1bf7729a737a0d83f8b3f851c8eb137373d9f71eafb0a3405/pypdf-6.7.5.tar.gz", hash = "sha256:40bb2e2e872078655f12b9b89e2f900888bb505e88a82150b64f9f34fa25651d", size = 5304278, upload-time = "2026-03-02T09:05:21.464Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b4/a3/e705b0805212b663a4c27b861c8a603dba0f8b4bb281f96f8e746576a50d/pypdf-6.8.0.tar.gz", hash = "sha256:cb7eaeaa4133ce76f762184069a854e03f4d9a08568f0e0623f7ea810407833b", size = 5307831, upload-time = "2026-03-09T13:37:40.591Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/05/89/336673efd0a88956562658aba4f0bbef7cb92a6fbcbcaf94926dbc82b408/pypdf-6.7.5-py3-none-any.whl", hash = "sha256:07ba7f1d6e6d9aa2a17f5452e320a84718d4ce863367f7ede2fd72280349ab13", size = 331421, upload-time = "2026-03-02T09:05:19.722Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/ec/4ccf3bb86b1afe5d7176e1c8abcdbf22b53dd682ec2eda50e1caadcf6846/pypdf-6.8.0-py3-none-any.whl", hash = "sha256:2a025080a8dd73f48123c89c57174a5ff3806c71763ee4e49572dc90454943c7", size = 332177, upload-time = "2026-03-09T13:37:38.774Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3407,7 +3406,7 @@ requires-dist = [
|
||||
{ name = "databases", extras = ["aiosqlite", "asyncpg"], specifier = ">=0.7.0" },
|
||||
{ name = "fastapi", extras = ["standard"], specifier = ">=0.100.1" },
|
||||
{ name = "fastapi-pagination", specifier = ">=0.14.2" },
|
||||
{ name = "hatchet-sdk", specifier = ">=0.47.0" },
|
||||
{ name = "hatchet-sdk", specifier = "==1.22.16" },
|
||||
{ name = "httpx", specifier = ">=0.24.1" },
|
||||
{ name = "icalendar", specifier = ">=6.0.0" },
|
||||
{ name = "jsonschema", specifier = ">=4.23.0" },
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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})`,
|
||||
);
|
||||
|
||||
10
www/app/(app)/transcripts/webSocketReconnect.ts
Normal file
10
www/app/(app)/transcripts/webSocketReconnect.ts
Normal file
@@ -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);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user