diff --git a/CHANGELOG.md b/CHANGELOG.md index d67b4cfd..1d01b5f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,7 @@ and this project adheres to - 🚸(frontend) show two initials in the Avatar when possible - 🩹(all) clear the SonarCloud reliability finding and the lint debt - 🐛(frontend) stop the installed app reopening the room it came from +- 🐛(backend) serialize lazy title in summary payload ## [1.24.0] - 2026-07-21 diff --git a/src/backend/core/recording/event/notification.py b/src/backend/core/recording/event/notification.py index 46bd1dc4..81624b1a 100644 --- a/src/backend/core/recording/event/notification.py +++ b/src/backend/core/recording/event/notification.py @@ -9,7 +9,7 @@ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from django.conf import settings from django.core.mail import send_mail from django.template.loader import render_to_string -from django.utils.translation import get_language, override +from django.utils.translation import get_language, gettext, override from django.utils.translation import gettext_lazy as _ import aiohttp @@ -121,7 +121,7 @@ class NotificationService: msg_plain = render_to_string( "mail/text/screen_recording.txt", personalized_context ) - subject = str(_("Your recording is ready")) # Force translation + subject = gettext("Your recording is ready") # Force translation try: send_mail( @@ -192,7 +192,7 @@ class NotificationService: """Generate title from context or return default.""" if recording_datetime is None: with override(locale): - return _("Transcription") + return gettext("Transcription") dt = recording_datetime if owner_timezone: diff --git a/src/backend/core/tests/recording/event/test_notification.py b/src/backend/core/tests/recording/event/test_notification.py index 625c6da1..f40bb840 100644 --- a/src/backend/core/tests/recording/event/test_notification.py +++ b/src/backend/core/tests/recording/event/test_notification.py @@ -5,6 +5,7 @@ Test event notification. # pylint: disable=assignment-from-no-return,redefined-outer-name,unused-argument,protected-access import datetime +import json import smtplib from unittest import mock @@ -418,3 +419,63 @@ def test_notify_summary_service_post_args_without_metadata( mock_is_feature_flag_enabled.assert_called_once_with( owner, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED ) + + +@mock.patch("core.recording.event.notification.requests.post") +@mock.patch("core.recording.event.notification.generate_download_s3_url") +@mock.patch.object( + NotificationService, "_get_recording_timestamps", new_callable=mock.AsyncMock +) +def test_notify_summary_service_v2_payload_json_serializable_without_timestamps( + mock_get_recording_timestamps, + mock_generate_download_s3_url, + mock_post, + settings, +): + """Regression test for a non-JSON-serializable payload when timestamps are missing. + + When the LiveKit egress can no longer be found, ``_get_recording_timestamps`` + returns ``(None, None)`` and ``_generate_title`` falls back to its default + title. That default must be a real ``str``: it used to return a lazy + ``gettext_lazy`` proxy, which ``json.dumps`` cannot serialize, so the real + ``requests.post(json=payload)`` call crashed in production with + ``TypeError: Object of type __proxy__ is not JSON serializable``. + """ + settings.SUMMARY_SERVICE_VERSION = 2 + settings.SUMMARY_SERVICE_ENDPOINT = "https://summary.test/api/v2/tasks" + settings.SUMMARY_SERVICE_API_TOKEN = "summary-token" + settings.RECORDING_DOWNLOAD_BASE_URL = "https://app.test/recordings" + settings.SCREEN_RECORDING_BASE_URL = None + settings.METADATA_COLLECTOR_ENABLED = False + + recording = factories.RecordingFactory(room__name="Daily") + owner = factories.UserFactory( + email="owner@test.com", + sub="owner-sub", + language="fr-fr", + timezone="Europe/Paris", + ) + factories.UserRecordingAccessFactory( + recording=recording, role=models.RoleChoices.OWNER, user=owner + ) + + # Egress timestamps unavailable -> default-title branch in _generate_title. + mock_get_recording_timestamps.return_value = (None, None) + mock_generate_download_s3_url.return_value = "https://storage.test/recording.mp4" + + mock_response = mock.Mock() + mock_response.raise_for_status.return_value = None + mock_response.json.return_value = {"job_id": "job-77"} + mock_post.return_value = mock_response + + result = NotificationService._notify_summary_service(recording) + + assert result is True + + payload = mock_post.call_args.kwargs["json"] + title = payload["push_to_docs_config"]["title"] + + # The title must be a plain ``str``, not a lazy translation proxy... + assert isinstance(title, str) + # ...so the payload serializes exactly the way ``requests`` serializes it. + json.dumps(payload)