Compare commits

...

12 Commits

Author SHA1 Message Date
leo 2a00e3a72e fix linting 2026-03-17 18:00:34 +01:00
leo efa0d48598 revert readme change 2026-03-17 17:42:39 +01:00
leo 873cc083ab refactor env proposal 2026-03-17 17:42:39 +01:00
leo 8fc7ccf33e update make test 2026-03-17 17:42:39 +01:00
leo d650dbc988 👷(CI) add summary service testing to CI
Add summary service testing to CI.
2026-03-17 17:42:39 +01:00
leo c08411d133 (summary) add unit and API tests for summary service
Summary service currently has no tests. Add unit and API tests to
summary service.
2026-03-17 17:42:39 +01:00
leo cb4502354e 🔧(build) update openssl and libssl3t64 versions to fix build
Update OpenSSL and libssl3t64 package versions to resolve a build failure
caused by version regression.
2026-03-17 17:28:40 +01:00
lebaudantoine 7347fc7c86 🚨(doc) fix changelog linting
Merge an outside contribution, didn't notice it broke the changelog fix it.
2026-03-17 16:45:04 +01:00
Hadrien Blanc ada7d9a666 🐛(frontend) fix dimension mismatch in BackgroundCustomProcessor
The getImageData call was using PROCESSING_WIDTH for both dimensions
instead of PROCESSING_WIDTH and PROCESSING_HEIGHT. This caused the
source image data to be 256x256 instead of the expected 256x144, leading
to buffer overflow when writing to the segmentation mask and potential
visual artifacts in Firefox background effects.
2026-03-17 16:41:40 +01:00
lebaudantoine 0cb3fb8e3c 🔧(ci) explicitly set Docker Hub CI permissions to read-only
Define read-only permissions in the workflow to clarify the
expected access level and follow the principle of least
privilege.
2026-03-15 16:53:07 +01:00
lebaudantoine dcb788b57b 🔒️(backend) avoid information exposure through exception messages
Sanitize error handling to prevent leaking internal details when
invalid or malicious requests are sent to the API.

Return generic error responses to reduce the risk of information
disclosure during probing attempts.
2026-03-13 17:33:55 +01:00
lebaudantoine 73bcb9d598 🐛(backend) fix unescaped dot in regex pattern
The dot before (?P<extension>...) was not escaped and matched any
character instead of a literal period.

Escape it to align with MEDIA_STORAGE_URL_PATTERN, which correctly
uses \. for the file extension separator.
2026-03-13 16:19:36 +01:00
29 changed files with 585 additions and 69 deletions
+15
View File
@@ -12,6 +12,9 @@ on:
branches:
- 'main'
permissions:
contents: read
env:
DOCKER_USER: 1001:127
DOCKER_CONTAINER_REGISTRY_HOSTNAME: docker.io
@@ -20,6 +23,8 @@ env:
jobs:
build-and-push-backend:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
-
name: Checkout repository
@@ -63,6 +68,8 @@ jobs:
build-and-push-frontend-generic:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
-
name: Checkout repository
@@ -107,6 +114,8 @@ jobs:
build-and-push-frontend-dinum:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
-
name: Checkout repository
@@ -151,6 +160,8 @@ jobs:
build-and-push-summary:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
-
name: Checkout repository
@@ -197,6 +208,8 @@ jobs:
build-and-push-agents:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
-
name: Checkout repository
@@ -242,6 +255,8 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
notify-argocd:
permissions:
contents: read
needs:
- build-and-push-frontend-generic
- build-and-push-frontend-dinum
+26 -2
View File
@@ -180,7 +180,7 @@ jobs:
- name: Lint code with ruff
run: ~/.local/bin/ruff check .
test-back:
test-backend:
runs-on: ubuntu-latest
needs: build-mails
permissions:
@@ -294,9 +294,33 @@ jobs:
- name: Generate a MO file from strings extracted from the project
run: uv run python manage.py compilemessages
- name: Run tests
- name: Run backend tests
run: uv run pytest -n 2
test-summary:
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: src/summary
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Install Python
uses: actions/setup-python@v6
with:
python-version: "3.13"
cache: "pip"
- name: Install development dependencies
run: pip install --user .[dev]
- name: Run summary tests
run: ~/.local/bin/pytest
lint-front:
runs-on: ubuntu-latest
permissions:
+3
View File
@@ -28,6 +28,7 @@ and this project adheres to
- ♻️(backend) align Application model field with `is_active` convention #1133
- 🔐(backend) avoids revealing the inactive status of an application #1135
- ⚡️(helm) reduce initialDelaySeconds and add periods seconds #1139
- 🔒️(backend) avoid information exposure through exception messages #1144
### Fixed
@@ -35,6 +36,8 @@ and this project adheres to
- 🩹(backend) add page_size to pagination for room endpoints #1131
- 🐛(backend) refactor lobby throttling to use participant id #1129
- 🩹(backend) ignore non-recording uploads in storage webhook handler #1142
- 🐛(frontend) fix dimension mismatch in BackgroundCustomProcessor #1116
## [1.10.0] - 2026-03-05
+6
View File
@@ -191,6 +191,7 @@ lint-pylint: ## lint back-end python sources with pylint only on changed files f
test: ## run project tests
@$(MAKE) test-back-parallel
@$(MAKE) test-summary
.PHONY: test
test-back: ## run back-end tests
@@ -203,6 +204,11 @@ test-back-parallel: ## run all back-end tests in parallel
bin/pytest -n auto $${args:-${1}}
.PHONY: test-back-parallel
test-summary: ## run summary service tests
@args="$(filter-out $@,$(MAKECMDGOALS))" && \
bin/pytest-summary $${args:-${1}}
.PHONY: test-summary
makemigrations: ## run django makemigrations for the Meet project.
@echo "$(BOLD)Running makemigrations$(RESET)"
@$(COMPOSE) up -d postgresql
+2 -1
View File
@@ -1,8 +1,9 @@
#!/usr/bin/env bash
# NB: this file is used locally only. In CI, it is overwritten by pytest install
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
_dc_run \
-e DJANGO_CONFIGURATION=Test \
app-dev \
pytest "$@"
pytest "$@"
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
_dc_run \
app-summary-dev \
python -m pytest "$@"
+8 -7
View File
@@ -1,13 +1,14 @@
FROM python:3.13-slim AS base
# Install system dependencies required by LiveKit
RUN apt-get update && apt-get install -y \
libglib2.0-0 \
libgobject-2.0-0 \
"openssl=3.5.4-1~deb13u2" \
"libssl3t64=3.5.4-1~deb13u2" \
&& rm -rf /var/lib/apt/lists/*
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
libglib2.0-0 \
libgobject-2.0-0 \
&& apt-get upgrade -y openssl libssl3t64 \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
FROM base AS builder
WORKDIR /builder
+3 -5
View File
@@ -488,9 +488,7 @@ class RoomViewSet(
if status_code == drf_status.HTTP_500_INTERNAL_SERVER_ERROR:
raise e
return drf_response.Response(
{"status": "error", "message": str(e)}, status=status_code
)
return drf_response.Response({"status": "error"}, status=status_code)
@decorators.action(
detail=False,
@@ -757,10 +755,10 @@ class RecordingViewSet(
recording_id = parser.get_recording_id(request.data)
except ParsingEventDataError as e:
raise drf_exceptions.PermissionDenied(f"Invalid request data: {e}") from e
raise drf_exceptions.PermissionDenied("Invalid request data.") from e
except InvalidBucketError as e:
raise drf_exceptions.PermissionDenied("Invalid bucket specified") from e
raise drf_exceptions.PermissionDenied("Invalid bucket specified.") from e
except InvalidFilepathError:
return drf_response.Response(
+1 -1
View File
@@ -14,7 +14,7 @@ FILE_EXT_REGEX = r"[a-zA-Z0-9]{1,10}"
# pylint: disable=line-too-long
RECORDING_STORAGE_URL_PATTERN = re.compile(
f"{settings.MEDIA_URL:s}{settings.RECORDING_OUTPUT_FOLDER}/(?P<recording_id>{UUID_REGEX:s}).(?P<extension>{FILE_EXT_REGEX:s})"
rf"{settings.MEDIA_URL:s}{settings.RECORDING_OUTPUT_FOLDER}/(?P<recording_id>{UUID_REGEX:s})\.(?P<extension>{FILE_EXT_REGEX:s})"
)
MEDIA_STORAGE_URL_PATTERN = re.compile(
@@ -95,7 +95,7 @@ def test_save_recording_parsing_error(recording_settings, mock_get_parser, clien
)
assert response.status_code == 403
assert response.json() == {"detail": "Invalid request data: Error message"}
assert response.json() == {"detail": "Invalid request data."}
def test_save_recording_bucket_error(recording_settings, mock_get_parser, client):
@@ -112,7 +112,7 @@ def test_save_recording_bucket_error(recording_settings, mock_get_parser, client
)
assert response.status_code == 403
assert response.json() == {"detail": "Invalid bucket specified"}
assert response.json() == {"detail": "Invalid bucket specified."}
def test_save_recording_filetype_error(recording_settings, mock_get_parser):
@@ -77,7 +77,6 @@ def test_missing_auth_header(client, serialized_event_data, mock_livekit_config)
assert response.status_code == 401
assert response.json() == {
"status": "error",
"message": "Authorization header missing",
}
@@ -91,7 +90,7 @@ def test_invalid_payload(client, auth_token, mock_livekit_config):
)
assert response.status_code == 400
assert response.json() == {"status": "error", "message": "Invalid webhook payload"}
assert response.json() == {"status": "error"}
def test_unknown_event_type(client, mock_livekit_config):
@@ -116,7 +115,6 @@ def test_unknown_event_type(client, mock_livekit_config):
assert response.status_code == 422
assert response.json() == {
"status": "error",
"message": "Unknown webhook type: unknown_event_type",
}
@@ -187,7 +187,7 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
0,
0,
PROCESSING_WIDTH,
PROCESSING_WIDTH
PROCESSING_HEIGHT
)
}
+11 -3
View File
@@ -21,8 +21,17 @@ dependencies = [
[project.optional-dependencies]
dev = [
"ruff==0.14.4",
"pytest==9.0.2",
"responses>=0.25.8",
]
[tool.pytest.ini_options]
markers = [
"unit: Test individual components",
"integration: Test the API",
]
testpaths = ["tests"]
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
@@ -49,11 +58,10 @@ select = [
"T20", # flake8-print
"W", # pycodestyle warning
]
ignore= ["DJ001", "PLR2004"]
[tool.ruff.lint.per-file-ignores]
"tests/*" = [
"S101", # use of assert
]
"tests/*" = ["S", "SLF"]
[tool.ruff.lint.pydocstyle]
# Use Google-style docstrings.
+4 -5
View File
@@ -12,8 +12,6 @@ from summary.core.celery_worker import (
)
from summary.core.config import get_settings
settings = get_settings()
class TranscribeSummarizeTaskCreation(BaseModel):
"""Transcription and summarization parameters."""
@@ -34,10 +32,11 @@ class TranscribeSummarizeTaskCreation(BaseModel):
@classmethod
def validate_language(cls, v):
"""Validate 'language' parameter."""
if v is not None and v not in settings.whisperx_allowed_languages:
allowed = get_settings().whisperx_allowed_languages
if v is not None and v not in allowed:
raise ValueError(
f"Language '{v}' is not allowed. "
f"Allowed languages: {', '.join(settings.whisperx_allowed_languages)}"
f"Allowed languages: {', '.join(allowed)}"
)
return v
@@ -62,7 +61,7 @@ async def create_transcribe_summarize_task(request: TranscribeSummarizeTaskCreat
request.download_link,
request.context_language,
],
queue=settings.transcribe_queue,
queue=get_settings().transcribe_queue,
)
return {"id": task.id, "message": "Task created"}
+3 -2
View File
@@ -12,7 +12,6 @@ from posthog import Posthog
from summary.core.config import get_settings
logger = get_task_logger(__name__)
settings = get_settings()
class AnalyticsException(Exception):
@@ -26,6 +25,7 @@ class Analytics:
def __init__(self):
"""Initialize a client if settings are configure."""
settings = get_settings()
self._client = None
if settings.posthog_api_key and settings.posthog_enabled:
logger.info("Initialize analytics client")
@@ -71,6 +71,7 @@ class MetadataManager:
def __init__(self):
"""Initialize the task tracker with analytics client."""
settings = get_settings()
self._redis = redis.from_url(settings.task_tracker_redis_url)
self._key_prefix = settings.task_tracker_prefix
self._analytics = get_analytics()
@@ -117,7 +118,7 @@ class MetadataManager:
start_time = time.time()
initial_metadata = {
"start_time": start_time,
"asr_model": settings.whisperx_asr_model,
"asr_model": get_settings().whisperx_asr_model,
"retries": 0,
"filename": filename,
"email": email,
+2 -2
View File
@@ -140,7 +140,7 @@ def format_transcript(
)
def format_actions(llm_output: dict) -> str:
def _format_actions(llm_output: dict) -> str:
"""Format the actions from the LLM output into a markdown list.
fomat:
@@ -328,7 +328,7 @@ def summarize_transcription(
response_format=FORMAT_NEXT_STEPS,
)
next_steps = format_actions(json.loads(next_steps))
next_steps = _format_actions(json.loads(next_steps))
logger.info("Next steps generated")
+29
View File
@@ -1,5 +1,6 @@
"""Application configuration and settings."""
import os
from functools import lru_cache
from typing import Annotated, List, Literal, Optional, Set
@@ -91,9 +92,37 @@ class Settings(BaseSettings):
task_tracker_prefix: str = "task_metadata:"
class TestSettings(Settings):
"""Settings with safe defaults for testing."""
model_config = SettingsConfigDict(env_file=None)
app_api_token: SecretStr = SecretStr("test-api-token")
aws_storage_bucket_name: str = "test-bucket"
aws_s3_endpoint_url: str = "http://localhost:9000"
aws_s3_access_key_id: str = "test-access-key"
aws_s3_secret_access_key: SecretStr = SecretStr("test-secret-key")
aws_s3_secure_access: bool = False
whisperx_api_key: SecretStr = SecretStr("test-whisperx-key")
whisperx_base_url: str = "http://localhost:8000/v1"
llm_base_url: str = "http://localhost:8001/v1"
llm_api_key: SecretStr = SecretStr("test-llm-key")
llm_model: str = "test-model"
webhook_api_token: SecretStr = SecretStr("test-webhook-token")
webhook_url: str = "http://localhost:8002/webhook"
celery_broker_url: str = "memory://"
celery_result_backend: str = "cache+memory://"
posthog_enabled: bool = False
sentry_is_enabled: bool = False
langfuse_enabled: bool = False
task_tracker_redis_url: str = "redis://localhost:6379/0"
@lru_cache
def get_settings():
"""Load and cache application settings."""
if os.environ.get("SUMMARY_ENV") == "test":
return TestSettings()
return Settings()
+9 -11
View File
@@ -13,9 +13,6 @@ from minio.error import MinioException, S3Error
from summary.core.config import get_settings
settings = get_settings()
logger = logging.getLogger(__name__)
@@ -31,23 +28,24 @@ class FileService:
def __init__(self):
"""Initialize FileService with MinIO client and configuration."""
endpoint = (
settings.aws_s3_endpoint_url.removeprefix("https://")
get_settings()
.aws_s3_endpoint_url.removeprefix("https://")
.removeprefix("http://")
.rstrip("/")
)
self._minio_client = Minio(
endpoint,
access_key=settings.aws_s3_access_key_id,
secret_key=settings.aws_s3_secret_access_key.get_secret_value(),
secure=settings.aws_s3_secure_access,
access_key=get_settings().aws_s3_access_key_id,
secret_key=get_settings().aws_s3_secret_access_key.get_secret_value(),
secure=get_settings().aws_s3_secure_access,
)
self._bucket_name = settings.aws_storage_bucket_name
self._bucket_name = get_settings().aws_storage_bucket_name
self._stream_chunk_size = 32 * 1024
self._allowed_extensions = settings.recording_allowed_extensions
self._max_duration = settings.recording_max_duration
self._allowed_extensions = get_settings().recording_allowed_extensions
self._max_duration = get_settings().recording_max_duration
def _download_from_minio(self, remote_object_key) -> Path:
"""Download file from MinIO to local temporary file.
@@ -174,7 +172,7 @@ class FileService:
extension = downloaded_path.suffix.lower()
if extension in settings.recording_video_extensions:
if extension in get_settings().recording_video_extensions:
logger.info("Video file detected, extracting audio...")
extracted_audio_path = self._extract_audio_from_video(downloaded_path)
processed_path = extracted_audio_path
+10 -13
View File
@@ -8,9 +8,6 @@ from langfuse import Langfuse
from summary.core.config import get_settings
settings = get_settings()
logger = logging.getLogger(__name__)
@@ -34,28 +31,28 @@ class LLMObservability:
self.session_id = session_id
self.user_id = user_id
if settings.langfuse_enabled:
if get_settings().langfuse_enabled:
def masking_function(data, **kwargs):
if (
user_has_tracing_consent
or settings.langfuse_environment != "production"
or get_settings().langfuse_environment != "production"
):
return data
return "[REDACTED]"
if not settings.langfuse_secret_key:
if not get_settings().langfuse_secret_key:
raise ValueError(
"langfuse_secret_key is not configured. "
"Please set the secret key or disable Langfuse."
)
self._observability_client = Langfuse(
secret_key=settings.langfuse_secret_key.get_secret_value(),
public_key=settings.langfuse_public_key,
host=settings.langfuse_host,
environment=settings.langfuse_environment,
secret_key=get_settings().langfuse_secret_key.get_secret_value(),
public_key=get_settings().langfuse_public_key,
host=get_settings().langfuse_host,
environment=get_settings().langfuse_environment,
mask=masking_function,
)
@@ -72,8 +69,8 @@ class LLMObservability:
to Langfuse for observability when enabled.
"""
base_args = {
"base_url": settings.llm_base_url,
"api_key": settings.llm_api_key.get_secret_value(),
"base_url": get_settings().llm_base_url,
"api_key": get_settings().llm_api_key.get_secret_value(),
}
if not self.is_enabled:
@@ -120,7 +117,7 @@ class LLMService:
"""
try:
params: dict[str, Any] = {
"model": settings.llm_model,
"model": get_settings().llm_model,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
@@ -6,8 +6,6 @@ from typing import Optional, Tuple
from summary.core.config import get_settings
from summary.core.locales import LocaleStrings
settings = get_settings()
logger = logging.getLogger(__name__)
@@ -23,7 +21,7 @@ class TranscriptFormatter:
def __init__(self, locale: LocaleStrings):
"""Initialize formatter with settings and locale."""
self.hallucination_patterns = settings.hallucination_patterns
self.hallucination_patterns = get_settings().hallucination_patterns
self._locale = locale
def _get_segments(self, transcription):
+8 -8
View File
@@ -9,8 +9,6 @@ from urllib3.util import Retry
from summary.core.config import get_settings
settings = get_settings()
logger = logging.getLogger(__name__)
@@ -18,9 +16,9 @@ def _create_retry_session():
"""Create an HTTP session configured with retry logic."""
session = Session()
retries = Retry(
total=settings.webhook_max_retries,
backoff_factor=settings.webhook_backoff_factor,
status_forcelist=settings.webhook_status_forcelist,
total=get_settings().webhook_max_retries,
backoff_factor=get_settings().webhook_backoff_factor,
status_forcelist=get_settings().webhook_status_forcelist,
allowed_methods={"POST"},
)
session.mount("https://", HTTPAdapter(max_retries=retries))
@@ -31,7 +29,9 @@ def _post_with_retries(url, data):
"""Send POST request with automatic retries."""
session = _create_retry_session()
session.headers.update(
{"Authorization": f"Bearer {settings.webhook_api_token.get_secret_value()}"}
{
"Authorization": f"Bearer {get_settings().webhook_api_token.get_secret_value()}" # noqa: E501
}
)
try:
response = session.post(url, json=data)
@@ -53,10 +53,10 @@ def submit_content(content, title, email, sub):
"sub": sub,
}
logger.debug("Submitting to %s", settings.webhook_url)
logger.debug("Submitting to %s", get_settings().webhook_url)
logger.debug("Request payload: %s", json.dumps(data, indent=2))
response = _post_with_retries(settings.webhook_url, data)
response = _post_with_retries(get_settings().webhook_url, data)
try:
response_data = response.json()
+1
View File
@@ -0,0 +1 @@
"""Tests for the summary service."""
+7
View File
@@ -0,0 +1,7 @@
"""Shared test fixtures and environment setup for the summary service tests."""
import os
# Activate TestSettings (safe defaults for all required env vars)
# before any summary module is imported.
os.environ["SUMMARY_ENV"] = "test"
@@ -0,0 +1 @@
"""Integration tests for the summary service."""
+23
View File
@@ -0,0 +1,23 @@
"""Integration test configuration. Provides shared fixtures."""
import pytest
from fastapi.testclient import TestClient
from summary.core.celery_worker import celery
from summary.main import app
@pytest.fixture()
def client():
"""Provide a FastAPI TestClient for integration tests."""
return TestClient(app)
@pytest.fixture()
def eager_celery():
"""Run Celery tasks synchronously in the same process."""
celery.conf.task_always_eager = True
celery.conf.task_eager_propagates = True
yield
celery.conf.task_always_eager = False
celery.conf.task_eager_propagates = False
@@ -0,0 +1,21 @@
"""Integration tests for the health check endpoints."""
class TestHeartbeat:
"""Tests for the /__heartbeat__ endpoint."""
def test_returns_200(self, client):
"""The heartbeat endpoint responds with 200 OK without a token."""
response = client.get("/__heartbeat__")
assert response.status_code == 200
class TestLBHeartbeat:
"""Tests for the /__lbheartbeat__ endpoint."""
def test_returns_200(self, client):
"""The load-balancer heartbeat endpoint responds with 200 OK without a token."""
response = client.get("/__lbheartbeat__")
assert response.status_code == 200
@@ -0,0 +1,130 @@
"""Integration test for the transcribe-and-summarize task flow via the API."""
import json
from contextlib import contextmanager
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import responses
from summary.core.config import get_settings
API_PREFIX = "/api/v1"
AUTH_HEADER = {
"Authorization": f"Bearer {get_settings().app_api_token.get_secret_value()}"
}
WEBHOOK_URL = get_settings().webhook_url
class TestTranscribeSummarizeFlow:
"""End-to-end test: POST /tasks/ triggers transcription and summary via webhook."""
@responses.activate
@patch("summary.core.celery_worker.analytics")
@patch("summary.core.celery_worker.LLMObservability")
@patch("summary.core.celery_worker.LLMService")
@patch("summary.core.celery_worker.metadata_manager")
@patch("summary.core.celery_worker.openai")
@patch("summary.core.celery_worker.file_service")
def test_transcription_and_summary_are_submitted( # noqa: PLR0913
self,
mock_file_service,
mock_openai,
mock_metadata,
mock_llm_cls,
mock_observability_cls,
mock_analytics,
client,
eager_celery,
):
"""Creating a task produces a transcription and summary sent to the webhook."""
# Stub file service
fake_audio = MagicMock()
@contextmanager
def fake_prepare(filename):
yield fake_audio, {"duration": 60.0}
mock_file_service.prepare_audio_file = fake_prepare
# Stub WhisperX transcription
fake_transcription = SimpleNamespace(
segments=[
{"speaker": "SPEAKER_00", "text": "Hello everyone."},
{"speaker": "SPEAKER_01", "text": "Let's discuss the roadmap."},
],
)
mock_client = MagicMock()
mock_client.audio.transcriptions.create.return_value = fake_transcription
mock_openai.OpenAI.return_value = mock_client
# Stub analytics to enable summary
mock_analytics.is_feature_enabled.return_value = True
# Stub LLM for summarization
mock_llm = MagicMock()
mock_llm_cls.return_value = mock_llm
plan_json = json.dumps({"titles": ["Roadmap"]})
next_steps_json = json.dumps(
{
"actions": [
{
"title": "Draft roadmap",
"assignees": ["Aleb"],
"due_date": "2026-03-04",
}
]
}
)
mock_llm.call.side_effect = [
"### TL;DR\nQuick overview.", # tldr
plan_json, # parts plan
"### Roadmap\nDetails.", # part content
next_steps_json, # next steps
"Cleaned summary.", # cleaning
]
mock_observability_cls.return_value = MagicMock()
# Stub webhook (called twice: transcription + summary)
responses.post(WEBHOOK_URL, json={"id": "doc-1"}, status=200)
responses.post(WEBHOOK_URL, json={"id": "doc-2"}, status=200)
payload = {
"owner_id": "owner-1",
"filename": "recording.webm",
"email": "user@example.com",
"sub": "user-sub-id",
"room": "Visio room",
"recording_date": "2026-03-04",
"recording_time": "09:00",
"language": "en",
"download_link": "https://example.com/rec.webm",
"context_language": "en",
}
response = client.post(
f"{API_PREFIX}/tasks/", json=payload, headers=AUTH_HEADER
)
assert response.status_code == 200
body = response.json()
assert "id" in body
assert body["message"] == "Task created"
# Verify the webhook received the transcription
assert len(responses.calls) >= 1
transcript_payload = json.loads(responses.calls[0].request.body)
assert "SPEAKER_00" in transcript_payload["content"]
assert "Hello everyone." in transcript_payload["content"]
assert "Visio room" in transcript_payload["title"]
assert transcript_payload["email"] == "user@example.com"
assert transcript_payload["sub"] == "user-sub-id"
# Verify the webhook received the summary
assert len(responses.calls) == 2
summary_payload = json.loads(responses.calls[1].request.body)
assert "TL;DR" in summary_payload["content"]
assert "Cleaned summary." in summary_payload["content"]
assert "Draft roadmap" in summary_payload["content"]
+1
View File
@@ -0,0 +1 @@
"""Unit tests for the summary service."""
@@ -0,0 +1,249 @@
"""Tests for the celery_worker module."""
import json
from contextlib import contextmanager
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
import responses
from summary.core.celery_worker import (
format_transcript,
summarize_transcription,
transcribe_audio,
)
from summary.core.config import get_settings
from summary.core.file_service import FileServiceException
WEBHOOK_URL = get_settings().webhook_url
# ---------------------------------------------------------------------------
# transcribe_audio
# ---------------------------------------------------------------------------
class TestTranscribeAudio:
"""Tests for the transcribe_audio function."""
@patch("summary.core.celery_worker.metadata_manager")
@patch("summary.core.celery_worker.openai")
@patch("summary.core.celery_worker.file_service")
def test_success(self, mock_file_service, mock_openai, mock_metadata):
"""Transcription succeeds and returns the transcription object."""
fake_audio = MagicMock()
fake_metadata = {"duration": 120.5}
@contextmanager
def fake_prepare(filename):
yield fake_audio, fake_metadata
mock_file_service.prepare_audio_file = fake_prepare
fake_transcription = SimpleNamespace(
segments=[{"speaker": "SPEAKER_00", "text": "Hello"}],
)
mock_client = MagicMock()
mock_client.audio.transcriptions.create.return_value = fake_transcription
mock_openai.OpenAI.return_value = mock_client
result = transcribe_audio("task-1", "recording.ogg", "en")
assert result is fake_transcription
mock_client.audio.transcriptions.create.assert_called_once()
call_kwargs = mock_client.audio.transcriptions.create.call_args
assert call_kwargs.kwargs["language"] == "en"
assert call_kwargs.kwargs["file"] is fake_audio
@patch("summary.core.celery_worker.metadata_manager")
@patch("summary.core.celery_worker.openai")
@patch("summary.core.celery_worker.file_service")
def test_file_service_error_returns_none(
self, mock_file_service, mock_openai, mock_metadata
):
"""Returns None when the file cannot be retrieved."""
@contextmanager
def failing_prepare(filename):
raise FileServiceException("download failed")
yield # NOSONAR - yield required for contextmanager
mock_file_service.prepare_audio_file = failing_prepare
result = transcribe_audio("task-1", "recording.ogg", "en")
assert result is None
mock_openai.OpenAI.return_value.audio.transcriptions.create.assert_not_called()
# ---------------------------------------------------------------------------
# format_transcript
# ---------------------------------------------------------------------------
class TestFormatTranscript:
"""Tests for the format_transcript function."""
def test_with_segments(self):
"""Formats a transcription with segments into content and title."""
transcription = {
"segments": [
{"speaker": "SPEAKER_00", "text": "Hello everyone."},
{"speaker": "SPEAKER_01", "text": "Good morning."},
],
}
content, title = format_transcript(
transcription,
context_language="en",
language="en",
room="Daily standup",
recording_date="2026-03-04",
recording_time="09:00",
download_link="https://example.com/rec.ogg",
)
assert "SPEAKER_00" in content
assert "Hello everyone." in content
assert "SPEAKER_01" in content
assert "Good morning." in content
assert "Daily standup" in title
assert "2026-03-04" in title
assert "09:00" in title
@pytest.mark.parametrize(
"context_language, expected_string",
[
("en", "Download your recording"),
("fr", "Télécharger votre enregistrement"),
("de", "diesem Link folgen"),
("nl", "Download uw opname door"),
],
)
def test_context_language(self, context_language, expected_string):
"""Context language parameter modifies output."""
transcription = {
"segments": [
{"speaker": "SPEAKER_00", "text": "Hello everyone."},
],
}
content, _ = format_transcript(
transcription,
context_language=context_language,
language="en",
room="Daily standup",
recording_date="2026-03-04",
recording_time="09:00",
download_link="https://example.com/rec.ogg",
)
assert expected_string in content
def test_empty_segments(self):
"""Returns empty-transcription message when there are no segments."""
transcription = {"segments": []}
content, title = format_transcript(
transcription,
context_language="en",
language="en",
room=None,
recording_date=None,
recording_time=None,
download_link=None,
)
assert "No audio content" in content or "Transcription" in title
# ---------------------------------------------------------------------------
# summarize_transcription
# ---------------------------------------------------------------------------
class TestSummarizeTranscription:
"""Tests for the summarize_transcription Celery task."""
@responses.activate
@patch("summary.core.celery_worker.LLMService")
@patch("summary.core.celery_worker.LLMObservability")
@patch("summary.core.celery_worker.analytics")
def test_generates_and_submits_summary(
self, mock_analytics, mock_observability_cls, mock_llm_cls
):
"""Assembles TLDR + parts + next steps + cleaning, then submits."""
mock_analytics.is_feature_enabled.return_value = False
# Mock the webhook HTTP endpoint
responses.post(
WEBHOOK_URL,
json={"id": "doc-42"},
status=200,
)
mock_llm = MagicMock()
mock_llm_cls.return_value = mock_llm
plan_json = json.dumps({"titles": ["Topic A", "Topic B"]})
next_steps_json = json.dumps(
{
"actions": [
{
"title": "What's nice about Visio",
"assignees": ["Aleb"],
"due_date": "2026-03-04",
}
]
}
)
# LLM calls in order: tldr, parts (plan), part A, part B, next-steps, cleaning
mock_llm.call.side_effect = [
"### TL;DR\nShort summary.", # tldr
plan_json, # parts plan
"### Topic A\nDetails about A.", # part A
"### Topic B\nDetails about B.", # part B
next_steps_json, # next steps
"Cleaned summary content.", # cleaning
]
mock_observability = MagicMock()
mock_observability_cls.return_value = mock_observability
# Push a fake request context so self.request.id is available
summarize_transcription.push_request(id="summary-task-1")
try:
summarize_transcription.run(
"owner-1",
"Full transcript text",
"user@example.com",
"oidc-sub-123",
"99.999% uptime. Is it reasonable ?",
)
finally:
summarize_transcription.pop_request()
# Verify the webhook was called with the assembled summary
assert len(responses.calls) == 1
webhook_request = responses.calls[0]
submitted_payload = json.loads(webhook_request.request.body)
assert "TL;DR" in submitted_payload["content"]
assert "Cleaned summary content." in submitted_payload["content"]
assert "What's nice about Visio" in submitted_payload["content"]
assert "99.999% uptime. Is it reasonable ?" in submitted_payload["title"]
assert submitted_payload["email"] == "user@example.com"
assert submitted_payload["sub"] == "oidc-sub-123"
# Verify auth header was sent
assert (
webhook_request.request.headers["Authorization"]
== f"Bearer {get_settings().webhook_api_token.get_secret_value()}"
)
# LLM was called for: tldr, plan, part A, part B, next-steps, cleaning
expected_llm_calls = 6
assert mock_llm.call.call_count == expected_llm_calls
mock_observability.flush.assert_called_once()