diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index efbcc9a0..4a845162 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -57,7 +57,9 @@ It is nice to add information about the purpose of the pull request to help revi ### Don't forget to: - check your commits - check the linting: `make lint && make frontend-lint` -- check the tests: `make test` +- check the tests: + - backend: `make test` + - summary: `make test-summary` - add a changelog entry Once all the required tests have passed, you can request a review from the project maintainers. diff --git a/Makefile b/Makefile index b044a718..18be754d 100644 --- a/Makefile +++ b/Makefile @@ -203,6 +203,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 diff --git a/bin/pytest b/bin/pytest index 8ed0d30f..f064a583 100755 --- a/bin/pytest +++ b/bin/pytest @@ -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 "$@" diff --git a/bin/pytest-summary b/bin/pytest-summary new file mode 100755 index 00000000..29e32101 --- /dev/null +++ b/bin/pytest-summary @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +source "$(dirname "${BASH_SOURCE[0]}")/_config.sh" + +_dc_run \ + app-summary-dev \ + python -m pytest "$@" diff --git a/src/summary/pyproject.toml b/src/summary/pyproject.toml index bc300add..87278f72 100644 --- a/src/summary/pyproject.toml +++ b/src/summary/pyproject.toml @@ -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. diff --git a/src/summary/summary/core/celery_worker.py b/src/summary/summary/core/celery_worker.py index 8ba10c30..17426a4e 100644 --- a/src/summary/summary/core/celery_worker.py +++ b/src/summary/summary/core/celery_worker.py @@ -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") diff --git a/src/summary/tests/__init__.py b/src/summary/tests/__init__.py new file mode 100644 index 00000000..224e5ad5 --- /dev/null +++ b/src/summary/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for the summary service.""" diff --git a/src/summary/tests/conftest.py b/src/summary/tests/conftest.py new file mode 100644 index 00000000..e0185b9b --- /dev/null +++ b/src/summary/tests/conftest.py @@ -0,0 +1,26 @@ +"""Shared test fixtures and environment setup for the summary service tests.""" + +import os + +# Set required environment variables before any summary module imports. +# This is necessary because several modules call get_settings() at module level, +# which validates env vars via Pydantic Settings. +os.environ.setdefault("APP_API_TOKEN", "test-api-token") +os.environ.setdefault("AWS_STORAGE_BUCKET_NAME", "test-bucket") +os.environ.setdefault("AWS_S3_ENDPOINT_URL", "http://localhost:9000") +os.environ.setdefault("AWS_S3_ACCESS_KEY_ID", "test-access-key") +os.environ.setdefault("AWS_S3_SECRET_ACCESS_KEY", "test-secret-key") +os.environ.setdefault("AWS_S3_SECURE_ACCESS", "false") +os.environ.setdefault("WHISPERX_API_KEY", "test-whisperx-key") +os.environ.setdefault("WHISPERX_BASE_URL", "http://localhost:8000/v1") +os.environ.setdefault("LLM_BASE_URL", "http://localhost:8001/v1") +os.environ.setdefault("LLM_API_KEY", "test-llm-key") +os.environ.setdefault("LLM_MODEL", "test-model") +os.environ.setdefault("WEBHOOK_API_TOKEN", "test-webhook-token") +os.environ.setdefault("WEBHOOK_URL", "http://localhost:8002/webhook") +os.environ.setdefault("CELERY_BROKER_URL", "memory://") +os.environ.setdefault("CELERY_RESULT_BACKEND", "cache+memory://") +os.environ.setdefault("POSTHOG_ENABLED", "false") +os.environ.setdefault("SENTRY_IS_ENABLED", "false") +os.environ.setdefault("LANGFUSE_ENABLED", "false") +os.environ.setdefault("TASK_TRACKER_REDIS_URL", "redis://localhost:6379/0") diff --git a/src/summary/tests/integration/__init__.py b/src/summary/tests/integration/__init__.py new file mode 100644 index 00000000..a2fba98f --- /dev/null +++ b/src/summary/tests/integration/__init__.py @@ -0,0 +1 @@ +"""Integration tests for the summary service.""" diff --git a/src/summary/tests/integration/conftest.py b/src/summary/tests/integration/conftest.py new file mode 100644 index 00000000..a6f1464b --- /dev/null +++ b/src/summary/tests/integration/conftest.py @@ -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 diff --git a/src/summary/tests/integration/test_api_health.py b/src/summary/tests/integration/test_api_health.py new file mode 100644 index 00000000..0460b1d8 --- /dev/null +++ b/src/summary/tests/integration/test_api_health.py @@ -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 diff --git a/src/summary/tests/integration/test_api_tasks.py b/src/summary/tests/integration/test_api_tasks.py new file mode 100644 index 00000000..4a9e4816 --- /dev/null +++ b/src/summary/tests/integration/test_api_tasks.py @@ -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 + +settings = get_settings() + +API_PREFIX = "/api/v1" +AUTH_HEADER = {"Authorization": f"Bearer {settings.app_api_token.get_secret_value()}"} +WEBHOOK_URL = 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"] diff --git a/src/summary/tests/unit/__init__.py b/src/summary/tests/unit/__init__.py new file mode 100644 index 00000000..dabdf5e5 --- /dev/null +++ b/src/summary/tests/unit/__init__.py @@ -0,0 +1 @@ +"""Unit tests for the summary service.""" diff --git a/src/summary/tests/unit/test_celery_worker.py b/src/summary/tests/unit/test_celery_worker.py new file mode 100644 index 00000000..4d46041d --- /dev/null +++ b/src/summary/tests/unit/test_celery_worker.py @@ -0,0 +1,251 @@ +"""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 + +settings = get_settings() + +WEBHOOK_URL = 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 {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()