mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-27 04:09:26 +00:00
Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 724a4ef3df | |||
| 4f2c4bfaf9 | |||
| dacf705329 | |||
| 8296738347 | |||
| 04be495351 | |||
| 43185605eb | |||
| cf3fb208e2 | |||
| 4ca230eb12 | |||
| 4b5e0cb2a3 | |||
| 45f374610f | |||
| b419a2bfd2 | |||
| ee8d96bee7 | |||
| c65ff2d75d | |||
| ea1c90d8ca | |||
| 3e963e3e6d | |||
| 82769128a1 | |||
| 05f4ce6b2e | |||
| 508984ecfa | |||
| a05507f73d | |||
| a27def119c | |||
| fce94f38ce | |||
| 83bd9c5ce3 | |||
| bfbfade99a | |||
| 16daf7b8d3 | |||
| fcad79d662 | |||
| 2424ce17ec | |||
| 4715905334 | |||
| 7c05aedcfe |
@@ -180,7 +180,7 @@ jobs:
|
||||
- name: Lint code with ruff
|
||||
run: ~/.local/bin/ruff check .
|
||||
|
||||
test-backend:
|
||||
test-back:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-mails
|
||||
permissions:
|
||||
@@ -294,33 +294,9 @@ jobs:
|
||||
- name: Generate a MO file from strings extracted from the project
|
||||
run: uv run python manage.py compilemessages
|
||||
|
||||
- name: Run backend tests
|
||||
- name: Run 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:
|
||||
|
||||
@@ -36,8 +36,6 @@ 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
|
||||
|
||||
|
||||
@@ -191,7 +191,6 @@ 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
|
||||
@@ -204,11 +203,6 @@ 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
|
||||
|
||||
+1
-2
@@ -1,9 +1,8 @@
|
||||
#!/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 "$@"
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
|
||||
|
||||
_dc_run \
|
||||
app-summary-dev \
|
||||
python -m pytest "$@"
|
||||
@@ -1,14 +1,13 @@
|
||||
FROM python:3.13-slim AS base
|
||||
|
||||
# Install system dependencies required by LiveKit
|
||||
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/*
|
||||
|
||||
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/*
|
||||
|
||||
FROM base AS builder
|
||||
|
||||
WORKDIR /builder
|
||||
|
||||
@@ -73,3 +73,9 @@ class CreationCallbackAnonRateThrottle(MonitoredAnonRateThrottle):
|
||||
"""Throttle Anonymous user requesting room generation callback"""
|
||||
|
||||
scope = "creation_callback"
|
||||
|
||||
|
||||
class SessionExchangeAnonRateThrottle(MonitoredAnonRateThrottle):
|
||||
"""Throttle anonymous requests to the session exchange endpoint."""
|
||||
|
||||
scope = "session_exchange"
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""API endpoint for exchanging a one-time code for a session ID."""
|
||||
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
|
||||
from rest_framework import serializers, status
|
||||
from rest_framework.decorators import api_view, permission_classes, throttle_classes
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.response import Response
|
||||
|
||||
from core.api.throttling import SessionExchangeAnonRateThrottle
|
||||
|
||||
from .views import EXCHANGE_CODE_PREFIX
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SessionExchangeSerializer(serializers.Serializer):
|
||||
"""Validates the exchange code request."""
|
||||
|
||||
code = serializers.CharField(max_length=64, min_length=16)
|
||||
|
||||
|
||||
@api_view(["POST"])
|
||||
@permission_classes([AllowAny])
|
||||
@throttle_classes([SessionExchangeAnonRateThrottle])
|
||||
def session_exchange(
|
||||
request,
|
||||
): # NOSONAR (S3752) POST-only, AllowAny is intentional: single-use code with 30s TTL and rate limiting
|
||||
"""Exchange a one-time code for a session ID.
|
||||
|
||||
The code was generated during the OIDC callback and stored in cache
|
||||
with a short TTL. This endpoint retrieves the session ID, deletes the
|
||||
code (single-use), and returns the session ID to the native app.
|
||||
"""
|
||||
serializer = SessionExchangeSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
code = serializer.validated_data["code"]
|
||||
cache_key = f"{EXCHANGE_CODE_PREFIX}{code}"
|
||||
|
||||
session_key = cache.get(cache_key)
|
||||
if session_key is None:
|
||||
logger.warning(
|
||||
"Session exchange failed: invalid or expired code from %s",
|
||||
request.META.get("REMOTE_ADDR"),
|
||||
)
|
||||
return Response(
|
||||
{"detail": "Invalid or expired code."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Delete immediately — single use
|
||||
cache.delete(cache_key)
|
||||
|
||||
cookie_name = getattr(settings, "SESSION_COOKIE_NAME", "sessionid")
|
||||
logger.info("Session exchange successful from %s", request.META.get("REMOTE_ADDR"))
|
||||
|
||||
return Response({cookie_name: session_key})
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Custom OIDC authentication views for native app support.
|
||||
|
||||
When a native app (iOS, Android, Desktop) initiates OIDC login, it sets
|
||||
`returnTo` to a custom URL scheme (e.g. `visio://auth-callback`). After
|
||||
the OIDC flow completes, Django sets the session cookie and redirects to
|
||||
that URL. However, native apps cannot read browser cookies — they need
|
||||
the session ID passed explicitly.
|
||||
|
||||
Instead of exposing the session ID directly in the redirect URL, this
|
||||
module generates a short-lived, single-use exchange code. The native app
|
||||
then exchanges this code for the session ID via a dedicated API endpoint.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from urllib.parse import urlencode, urlparse
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.http import HttpResponseRedirect
|
||||
|
||||
from lasuite.oidc_login.views import (
|
||||
OIDCAuthenticationCallbackView as BaseCallbackView,
|
||||
OIDCAuthenticationRequestView as BaseRequestView,
|
||||
)
|
||||
|
||||
# Cache key prefix and TTL for exchange codes
|
||||
EXCHANGE_CODE_PREFIX = "auth_exchange:"
|
||||
EXCHANGE_CODE_TTL = 30 # seconds
|
||||
|
||||
|
||||
class NativeAppRedirect(HttpResponseRedirect):
|
||||
"""HttpResponseRedirect subclass that allows native app custom URL schemes.
|
||||
|
||||
Django's HttpResponseRedirect only allows http, https, and ftp schemes.
|
||||
Native apps use custom schemes (e.g. visio://) for deep links, which
|
||||
Django rejects with DisallowedRedirect. This subclass extends
|
||||
allowed_schemes with the configured native app schemes.
|
||||
"""
|
||||
|
||||
allowed_schemes = HttpResponseRedirect.allowed_schemes + list(
|
||||
getattr(settings, "NATIVE_APP_REDIRECT_SCHEMES", [])
|
||||
)
|
||||
|
||||
|
||||
class OIDCAuthenticationRequestView(BaseRequestView):
|
||||
"""Custom authenticate view that preserves native app returnTo in session.
|
||||
|
||||
mozilla-django-oidc's get_next_url() rejects custom URL schemes
|
||||
(e.g. visio://auth-callback) because url_has_allowed_host_and_scheme()
|
||||
only allows http/https. We intercept the returnTo parameter and store
|
||||
it directly in the session for whitelisted schemes, bypassing the
|
||||
safety check (which is not relevant for native app deep links).
|
||||
"""
|
||||
|
||||
def get(self, request):
|
||||
redirect_field = getattr(settings, "OIDC_REDIRECT_FIELD_NAME", "returnTo")
|
||||
return_to = request.GET.get(redirect_field, "")
|
||||
parsed = urlparse(return_to)
|
||||
allowed_schemes = getattr(settings, "NATIVE_APP_REDIRECT_SCHEMES", [])
|
||||
|
||||
response = super().get(request)
|
||||
|
||||
# Override oidc_login_next AFTER super() which set it to None
|
||||
# via get_next_url() rejecting the custom scheme.
|
||||
if parsed.scheme in allowed_schemes:
|
||||
request.session["oidc_login_next"] = return_to
|
||||
request.session.save()
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class OIDCAuthenticationCallbackView(BaseCallbackView):
|
||||
"""Callback view that generates an exchange code for native app deep links."""
|
||||
|
||||
def login_success(self):
|
||||
"""After successful login, append exchange code for whitelisted scheme redirects."""
|
||||
# Temporarily remove native redirect from session to prevent
|
||||
# super().login_success() from raising DisallowedRedirect when
|
||||
# it tries HttpResponseRedirect with a custom scheme.
|
||||
native_redirect = self.request.session.pop("oidc_login_next", None)
|
||||
allowed_schemes = getattr(settings, "NATIVE_APP_REDIRECT_SCHEMES", [])
|
||||
parsed = urlparse(native_redirect or "")
|
||||
|
||||
if native_redirect and parsed.scheme in allowed_schemes:
|
||||
# Let super() redirect to the default URL (homepage)
|
||||
super().login_success()
|
||||
|
||||
# Generate a short-lived, single-use exchange code
|
||||
exchange_code = uuid.uuid4().hex
|
||||
session_key = self.request.session.session_key
|
||||
cache.set(
|
||||
f"{EXCHANGE_CODE_PREFIX}{exchange_code}",
|
||||
session_key,
|
||||
EXCHANGE_CODE_TTL,
|
||||
)
|
||||
|
||||
separator = "&" if parsed.query else "?"
|
||||
new_url = (
|
||||
f"{native_redirect}{separator}{urlencode({'code': exchange_code})}"
|
||||
)
|
||||
return NativeAppRedirect(new_url)
|
||||
|
||||
return super().login_success()
|
||||
@@ -0,0 +1,143 @@
|
||||
"""
|
||||
Tests for the session exchange API endpoint and OIDC callback view.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from django.core.cache import cache
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core.authentication.views import EXCHANGE_CODE_PREFIX, EXCHANGE_CODE_TTL
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_throttle_cache():
|
||||
"""Clear cache before each test to reset throttle counters."""
|
||||
cache.clear()
|
||||
|
||||
|
||||
def test_session_exchange_valid_code():
|
||||
"""A valid exchange code should return the session ID and be consumed."""
|
||||
code = uuid.uuid4().hex
|
||||
cache_key = f"{EXCHANGE_CODE_PREFIX}{code}"
|
||||
cache.set(cache_key, "test-session-key-123", EXCHANGE_CODE_TTL)
|
||||
|
||||
client = APIClient()
|
||||
response = client.post(
|
||||
"/api/v1.0/auth/session-exchange/",
|
||||
{"code": code},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "test-session-key-123" in data.values()
|
||||
|
||||
# Code should be consumed (single-use)
|
||||
assert cache.get(cache_key) is None
|
||||
|
||||
|
||||
def test_session_exchange_invalid_code():
|
||||
"""An invalid/unknown code should return 400."""
|
||||
client = APIClient()
|
||||
response = client.post(
|
||||
"/api/v1.0/auth/session-exchange/",
|
||||
{"code": uuid.uuid4().hex},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"] == "Invalid or expired code."
|
||||
|
||||
|
||||
def test_session_exchange_expired_code():
|
||||
"""An expired code should return 400."""
|
||||
code = uuid.uuid4().hex
|
||||
cache_key = f"{EXCHANGE_CODE_PREFIX}{code}"
|
||||
# Set with 0 TTL to simulate expiration
|
||||
cache.set(cache_key, "expired-session", 0)
|
||||
|
||||
client = APIClient()
|
||||
response = client.post(
|
||||
"/api/v1.0/auth/session-exchange/",
|
||||
{"code": code},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_session_exchange_code_too_short():
|
||||
"""A code that's too short should be rejected by validation."""
|
||||
client = APIClient()
|
||||
response = client.post(
|
||||
"/api/v1.0/auth/session-exchange/",
|
||||
{"code": "short"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_session_exchange_missing_code():
|
||||
"""Missing code field should be rejected."""
|
||||
client = APIClient()
|
||||
response = client.post(
|
||||
"/api/v1.0/auth/session-exchange/",
|
||||
{},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_session_exchange_replay_attack():
|
||||
"""Using the same code twice should fail the second time."""
|
||||
code = uuid.uuid4().hex
|
||||
cache.set(f"{EXCHANGE_CODE_PREFIX}{code}", "session-123", EXCHANGE_CODE_TTL)
|
||||
|
||||
client = APIClient()
|
||||
|
||||
# First use succeeds
|
||||
response = client.post(
|
||||
"/api/v1.0/auth/session-exchange/",
|
||||
{"code": code},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Second use fails
|
||||
response = client.post(
|
||||
"/api/v1.0/auth/session-exchange/",
|
||||
{"code": code},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_session_exchange_returns_correct_cookie_name(settings):
|
||||
"""The response key should match SESSION_COOKIE_NAME from settings."""
|
||||
settings.SESSION_COOKIE_NAME = "meet_sessionid"
|
||||
code = uuid.uuid4().hex
|
||||
cache.set(f"{EXCHANGE_CODE_PREFIX}{code}", "my-session", EXCHANGE_CODE_TTL)
|
||||
|
||||
client = APIClient()
|
||||
response = client.post(
|
||||
"/api/v1.0/auth/session-exchange/",
|
||||
{"code": code},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"meet_sessionid": "my-session"}
|
||||
|
||||
|
||||
def test_session_exchange_get_not_allowed():
|
||||
"""GET method should not be allowed on the exchange endpoint."""
|
||||
client = APIClient()
|
||||
response = client.get("/api/v1.0/auth/session-exchange/")
|
||||
assert response.status_code == 405
|
||||
@@ -7,6 +7,7 @@ from lasuite.oidc_login.urls import urlpatterns as oidc_urls
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from core.api import get_frontend_configuration, viewsets
|
||||
from core.authentication.api import session_exchange
|
||||
from core.external_api import viewsets as external_viewsets
|
||||
|
||||
# - Main endpoints
|
||||
@@ -40,6 +41,9 @@ urlpatterns = [
|
||||
[
|
||||
*router.urls,
|
||||
*oidc_urls,
|
||||
path(
|
||||
"auth/session-exchange/", session_exchange, name="session_exchange"
|
||||
),
|
||||
path("config/", get_frontend_configuration, name="config"),
|
||||
]
|
||||
),
|
||||
|
||||
@@ -344,6 +344,11 @@ class Base(Configuration):
|
||||
environ_name="CREATION_CALLBACK_THROTTLE_RATES",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"session_exchange": values.Value(
|
||||
default="5/minute",
|
||||
environ_name="SESSION_EXCHANGE_THROTTLE_RATES",
|
||||
environ_prefix=None,
|
||||
),
|
||||
},
|
||||
}
|
||||
MONITORED_THROTTLE_FAILURE_CALLBACK = (
|
||||
@@ -459,8 +464,16 @@ class Base(Configuration):
|
||||
)
|
||||
|
||||
# OIDC - Authorization Code Flow
|
||||
OIDC_AUTHENTICATE_CLASS = "lasuite.oidc_login.views.OIDCAuthenticationRequestView"
|
||||
OIDC_CALLBACK_CLASS = "lasuite.oidc_login.views.OIDCAuthenticationCallbackView"
|
||||
OIDC_AUTHENTICATE_CLASS = "core.authentication.views.OIDCAuthenticationRequestView"
|
||||
OIDC_CALLBACK_CLASS = "core.authentication.views.OIDCAuthenticationCallbackView"
|
||||
|
||||
# Custom URL schemes allowed for native app OIDC redirects.
|
||||
# Only these schemes will receive an exchange code in the callback.
|
||||
NATIVE_APP_REDIRECT_SCHEMES = values.ListValue(
|
||||
default=["visio"],
|
||||
environ_name="NATIVE_APP_REDIRECT_SCHEMES",
|
||||
environ_prefix=None,
|
||||
)
|
||||
OIDC_CREATE_USER = values.BooleanValue(
|
||||
default=True, environ_name="OIDC_CREATE_USER", environ_prefix=None
|
||||
)
|
||||
|
||||
+1
-1
@@ -187,7 +187,7 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
||||
0,
|
||||
0,
|
||||
PROCESSING_WIDTH,
|
||||
PROCESSING_HEIGHT
|
||||
PROCESSING_WIDTH
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -21,17 +21,8 @@ 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"
|
||||
@@ -58,10 +49,11 @@ select = [
|
||||
"T20", # flake8-print
|
||||
"W", # pycodestyle warning
|
||||
]
|
||||
ignore= ["DJ001", "PLR2004"]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"tests/*" = ["S", "SLF"]
|
||||
"tests/*" = [
|
||||
"S101", # use of assert
|
||||
]
|
||||
|
||||
[tool.ruff.lint.pydocstyle]
|
||||
# Use Google-style docstrings.
|
||||
|
||||
@@ -12,6 +12,8 @@ from summary.core.celery_worker import (
|
||||
)
|
||||
from summary.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class TranscribeSummarizeTaskCreation(BaseModel):
|
||||
"""Transcription and summarization parameters."""
|
||||
@@ -32,11 +34,10 @@ class TranscribeSummarizeTaskCreation(BaseModel):
|
||||
@classmethod
|
||||
def validate_language(cls, v):
|
||||
"""Validate 'language' parameter."""
|
||||
allowed = get_settings().whisperx_allowed_languages
|
||||
if v is not None and v not in allowed:
|
||||
if v is not None and v not in settings.whisperx_allowed_languages:
|
||||
raise ValueError(
|
||||
f"Language '{v}' is not allowed. "
|
||||
f"Allowed languages: {', '.join(allowed)}"
|
||||
f"Allowed languages: {', '.join(settings.whisperx_allowed_languages)}"
|
||||
)
|
||||
return v
|
||||
|
||||
@@ -61,7 +62,7 @@ async def create_transcribe_summarize_task(request: TranscribeSummarizeTaskCreat
|
||||
request.download_link,
|
||||
request.context_language,
|
||||
],
|
||||
queue=get_settings().transcribe_queue,
|
||||
queue=settings.transcribe_queue,
|
||||
)
|
||||
|
||||
return {"id": task.id, "message": "Task created"}
|
||||
|
||||
@@ -12,6 +12,7 @@ from posthog import Posthog
|
||||
from summary.core.config import get_settings
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class AnalyticsException(Exception):
|
||||
@@ -25,7 +26,6 @@ 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,7 +71,6 @@ 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()
|
||||
@@ -118,7 +117,7 @@ class MetadataManager:
|
||||
start_time = time.time()
|
||||
initial_metadata = {
|
||||
"start_time": start_time,
|
||||
"asr_model": get_settings().whisperx_asr_model,
|
||||
"asr_model": settings.whisperx_asr_model,
|
||||
"retries": 0,
|
||||
"filename": filename,
|
||||
"email": email,
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Application configuration and settings."""
|
||||
|
||||
import os
|
||||
from functools import lru_cache
|
||||
from typing import Annotated, List, Literal, Optional, Set
|
||||
|
||||
@@ -92,37 +91,9 @@ 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()
|
||||
|
||||
|
||||
|
||||
@@ -13,6 +13,9 @@ from minio.error import MinioException, S3Error
|
||||
|
||||
from summary.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -28,24 +31,23 @@ class FileService:
|
||||
def __init__(self):
|
||||
"""Initialize FileService with MinIO client and configuration."""
|
||||
endpoint = (
|
||||
get_settings()
|
||||
.aws_s3_endpoint_url.removeprefix("https://")
|
||||
settings.aws_s3_endpoint_url.removeprefix("https://")
|
||||
.removeprefix("http://")
|
||||
.rstrip("/")
|
||||
)
|
||||
|
||||
self._minio_client = Minio(
|
||||
endpoint,
|
||||
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,
|
||||
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,
|
||||
)
|
||||
|
||||
self._bucket_name = get_settings().aws_storage_bucket_name
|
||||
self._bucket_name = settings.aws_storage_bucket_name
|
||||
self._stream_chunk_size = 32 * 1024
|
||||
|
||||
self._allowed_extensions = get_settings().recording_allowed_extensions
|
||||
self._max_duration = get_settings().recording_max_duration
|
||||
self._allowed_extensions = settings.recording_allowed_extensions
|
||||
self._max_duration = settings.recording_max_duration
|
||||
|
||||
def _download_from_minio(self, remote_object_key) -> Path:
|
||||
"""Download file from MinIO to local temporary file.
|
||||
@@ -172,7 +174,7 @@ class FileService:
|
||||
|
||||
extension = downloaded_path.suffix.lower()
|
||||
|
||||
if extension in get_settings().recording_video_extensions:
|
||||
if extension in 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
|
||||
|
||||
@@ -8,6 +8,9 @@ from langfuse import Langfuse
|
||||
|
||||
from summary.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -31,28 +34,28 @@ class LLMObservability:
|
||||
self.session_id = session_id
|
||||
self.user_id = user_id
|
||||
|
||||
if get_settings().langfuse_enabled:
|
||||
if settings.langfuse_enabled:
|
||||
|
||||
def masking_function(data, **kwargs):
|
||||
if (
|
||||
user_has_tracing_consent
|
||||
or get_settings().langfuse_environment != "production"
|
||||
or settings.langfuse_environment != "production"
|
||||
):
|
||||
return data
|
||||
|
||||
return "[REDACTED]"
|
||||
|
||||
if not get_settings().langfuse_secret_key:
|
||||
if not 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=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,
|
||||
secret_key=settings.langfuse_secret_key.get_secret_value(),
|
||||
public_key=settings.langfuse_public_key,
|
||||
host=settings.langfuse_host,
|
||||
environment=settings.langfuse_environment,
|
||||
mask=masking_function,
|
||||
)
|
||||
|
||||
@@ -69,8 +72,8 @@ class LLMObservability:
|
||||
to Langfuse for observability when enabled.
|
||||
"""
|
||||
base_args = {
|
||||
"base_url": get_settings().llm_base_url,
|
||||
"api_key": get_settings().llm_api_key.get_secret_value(),
|
||||
"base_url": settings.llm_base_url,
|
||||
"api_key": settings.llm_api_key.get_secret_value(),
|
||||
}
|
||||
|
||||
if not self.is_enabled:
|
||||
@@ -117,7 +120,7 @@ class LLMService:
|
||||
"""
|
||||
try:
|
||||
params: dict[str, Any] = {
|
||||
"model": get_settings().llm_model,
|
||||
"model": settings.llm_model,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt},
|
||||
|
||||
@@ -6,6 +6,8 @@ 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__)
|
||||
|
||||
|
||||
@@ -21,7 +23,7 @@ class TranscriptFormatter:
|
||||
|
||||
def __init__(self, locale: LocaleStrings):
|
||||
"""Initialize formatter with settings and locale."""
|
||||
self.hallucination_patterns = get_settings().hallucination_patterns
|
||||
self.hallucination_patterns = settings.hallucination_patterns
|
||||
self._locale = locale
|
||||
|
||||
def _get_segments(self, transcription):
|
||||
|
||||
@@ -9,6 +9,8 @@ from urllib3.util import Retry
|
||||
|
||||
from summary.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -16,9 +18,9 @@ def _create_retry_session():
|
||||
"""Create an HTTP session configured with retry logic."""
|
||||
session = Session()
|
||||
retries = Retry(
|
||||
total=get_settings().webhook_max_retries,
|
||||
backoff_factor=get_settings().webhook_backoff_factor,
|
||||
status_forcelist=get_settings().webhook_status_forcelist,
|
||||
total=settings.webhook_max_retries,
|
||||
backoff_factor=settings.webhook_backoff_factor,
|
||||
status_forcelist=settings.webhook_status_forcelist,
|
||||
allowed_methods={"POST"},
|
||||
)
|
||||
session.mount("https://", HTTPAdapter(max_retries=retries))
|
||||
@@ -29,9 +31,7 @@ def _post_with_retries(url, data):
|
||||
"""Send POST request with automatic retries."""
|
||||
session = _create_retry_session()
|
||||
session.headers.update(
|
||||
{
|
||||
"Authorization": f"Bearer {get_settings().webhook_api_token.get_secret_value()}" # noqa: E501
|
||||
}
|
||||
{"Authorization": f"Bearer {settings.webhook_api_token.get_secret_value()}"}
|
||||
)
|
||||
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", get_settings().webhook_url)
|
||||
logger.debug("Submitting to %s", settings.webhook_url)
|
||||
logger.debug("Request payload: %s", json.dumps(data, indent=2))
|
||||
|
||||
response = _post_with_retries(get_settings().webhook_url, data)
|
||||
response = _post_with_retries(settings.webhook_url, data)
|
||||
|
||||
try:
|
||||
response_data = response.json()
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
"""Tests for the summary service."""
|
||||
@@ -1,7 +0,0 @@
|
||||
"""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"
|
||||
@@ -1 +0,0 @@
|
||||
"""Integration tests for the summary service."""
|
||||
@@ -1,23 +0,0 @@
|
||||
"""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
|
||||
@@ -1,21 +0,0 @@
|
||||
"""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
|
||||
@@ -1,130 +0,0 @@
|
||||
"""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 +0,0 @@
|
||||
"""Unit tests for the summary service."""
|
||||
@@ -1,249 +0,0 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user