diff --git a/.env.example b/.env.example index 9135e0d..0bcca13 100644 --- a/.env.example +++ b/.env.example @@ -32,3 +32,4 @@ ENVIRONMENT=local # Local convention: nginx is the public entrypoint on http://localhost. # If you change the published nginx port, update SITE_BASE_URL and trusted origins to match. +# In local/debug mode, failed or attempted reservation emails also log the confirmation URL for manual browser testing. diff --git a/backend/azionelab/settings.py b/backend/azionelab/settings.py index fb69863..8f5f621 100644 --- a/backend/azionelab/settings.py +++ b/backend/azionelab/settings.py @@ -9,6 +9,7 @@ from django.core.exceptions import ImproperlyConfigured BASE_DIR = Path(__file__).resolve().parent.parent DEBUG = os.environ.get("DJANGO_DEBUG", "false").lower() == "true" +ENVIRONMENT = os.environ.get("ENVIRONMENT", "production").lower() SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY") if not SECRET_KEY: if DEBUG: @@ -25,6 +26,7 @@ ALLOWED_HOSTS = csv_env("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1") CSRF_TRUSTED_ORIGINS = csv_env("DJANGO_CSRF_TRUSTED_ORIGINS") CORS_ALLOWED_ORIGINS = csv_env("CORS_ALLOWED_ORIGINS") SITE_BASE_URL = os.environ.get("SITE_BASE_URL", "http://localhost").rstrip("/") +LOG_RESERVATION_CONFIRMATION_URLS = DEBUG or ENVIRONMENT == "local" INSTALLED_APPS = [ "django.contrib.admin", diff --git a/backend/bookings/emailing.py b/backend/bookings/emailing.py index e8719fb..5de1ca8 100644 --- a/backend/bookings/emailing.py +++ b/backend/bookings/emailing.py @@ -13,6 +13,18 @@ def build_confirmation_link(raw_confirmation_token): return f"{settings.SITE_BASE_URL}{CONFIRMATION_PATH}?token={raw_confirmation_token}" +def _log_confirmation_link_for_local_debug(*, reservation, confirmation_link, reason): + if not settings.LOG_RESERVATION_CONFIRMATION_URLS: + return + + logger.info( + "Local reservation confirmation link for manual testing (%s) for reservation %s: %s", + reason, + reservation.id, + confirmation_link, + ) + + def send_confirmation_email(*, reservation, raw_confirmation_token): confirmation_link = build_confirmation_link(raw_confirmation_token) subject = f"Confirm your reservation for {reservation.performance.show.title}" @@ -23,6 +35,12 @@ def send_confirmation_email(*, reservation, raw_confirmation_token): "If you did not request this reservation, you can ignore this email." ) + _log_confirmation_link_for_local_debug( + reservation=reservation, + confirmation_link=confirmation_link, + reason="email send attempt", + ) + try: send_mail( subject=subject, @@ -32,6 +50,11 @@ def send_confirmation_email(*, reservation, raw_confirmation_token): fail_silently=False, ) except Exception: + _log_confirmation_link_for_local_debug( + reservation=reservation, + confirmation_link=confirmation_link, + reason="email send failure fallback", + ) logger.exception( "Failed to send confirmation email for reservation %s.", reservation.id, diff --git a/backend/bookings/test_services.py b/backend/bookings/test_services.py index 99cccc7..b6fe073 100644 --- a/backend/bookings/test_services.py +++ b/backend/bookings/test_services.py @@ -96,6 +96,34 @@ class BookingServiceTests(TestCase): self.assertEqual(len(callbacks), 1) self.assertEqual(len(mail.outbox), 0) + @override_settings( + EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend", + LOG_RESERVATION_CONFIRMATION_URLS=True, + SITE_BASE_URL="https://tickets.azionelab.example", + ) + def test_create_pending_reservation_logs_confirmation_link_in_local_mode(self): + with self.assertLogs("bookings.emailing", level="INFO") as captured_logs: + with self.captureOnCommitCallbacks(execute=True): + result = create_pending_reservation( + performance_id=self.performance.id, + name="Maria Rossi", + email="maria@example.com", + party_size=1, + ) + + self.assertEqual(result.reservation.status, Reservation.Status.PENDING) + self.assertTrue( + any( + ( + "Local reservation confirmation link for manual testing " + "(email send attempt)" + ) in log_entry + and result.raw_confirmation_token in log_entry + for log_entry in captured_logs.output + ) + ) + + @override_settings(LOG_RESERVATION_CONFIRMATION_URLS=False) @patch("bookings.emailing.send_mail", side_effect=RuntimeError("SMTP down")) def test_create_pending_reservation_logs_email_failure_without_crashing(self, mocked_send_mail): with self.assertLogs("bookings.emailing", level="ERROR") as captured_logs: @@ -116,6 +144,40 @@ class BookingServiceTests(TestCase): for log_entry in captured_logs.output ) ) + self.assertFalse( + any(result.raw_confirmation_token in log_entry for log_entry in captured_logs.output) + ) + + @override_settings( + LOG_RESERVATION_CONFIRMATION_URLS=True, + SITE_BASE_URL="https://tickets.azionelab.example", + ) + @patch("bookings.emailing.send_mail", side_effect=RuntimeError("SMTP down")) + def test_create_pending_reservation_logs_confirmation_link_on_email_failure_in_local_mode( + self, + mocked_send_mail, + ): + with self.assertLogs("bookings.emailing", level="INFO") as captured_logs: + with self.captureOnCommitCallbacks(execute=True): + result = create_pending_reservation( + performance_id=self.performance.id, + name="Maria Rossi", + email="maria@example.com", + party_size=1, + ) + + self.assertEqual(result.reservation.status, Reservation.Status.PENDING) + mocked_send_mail.assert_called_once() + self.assertTrue( + any( + ( + "Local reservation confirmation link for manual testing " + "(email send failure fallback)" + ) in log_entry + and result.raw_confirmation_token in log_entry + for log_entry in captured_logs.output + ) + ) def test_generate_confirmation_token_returns_raw_token_once(self): reservation = self.create_reservation() diff --git a/docs/deployment.md b/docs/deployment.md index 4206b81..eb40ff3 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -136,6 +136,7 @@ Local Docker convention: - set `SITE_BASE_URL=http://localhost`; - keep `DJANGO_CSRF_TRUSTED_ORIGINS` and browser-facing `CORS_ALLOWED_ORIGINS` aligned with that public URL; - if you publish nginx on a different port, update `SITE_BASE_URL` and trusted origins to the same host and port. +- local/debug reservation email sends also log the confirmation URL so browser testing can continue even if SMTP is missing or fails. Required database configuration: