Add toast notification system
Django CI/CD / test (push) Successful in 35s
Django CI/CD / build-and-push (push) Successful in 54s

Add more toast types
This commit is contained in:
2026-05-11 13:22:42 +02:00
parent 4e3b0ddb08
commit f82c61ef1e
18 changed files with 1026 additions and 109 deletions
+111
View File
@@ -0,0 +1,111 @@
import json
import os
from datetime import datetime
from zoneinfo import ZoneInfo
import django
from django.conf import settings
from django.test import TestCase, Client
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "timetracker.settings")
django.setup()
from games.models import Device, Game, Platform, Purchase, Session
from django.contrib.auth.models import User
class MiddlewareIntegrationTest(TestCase):
"""Integration tests for HTMXMessagesMiddleware.
These tests hit real endpoints that use messages.success() to verify
the full chain: API endpoint → messages → middleware → HX-Trigger header.
"""
@staticmethod
def _create_user():
return User.objects.create_user(
username="testuser", password="testpass123"
)
def setUp(self):
self.client = Client()
self.user = self._create_user()
self.client.force_login(self.user)
pl = Platform(name="Test Platform")
pl.save()
self.game = Game(name="Test Game", platform=pl)
self.game.save()
def test_non_htmx_request_with_message_gets_hx_trigger(self):
"""
Regression test: vanilla fetch() requests that set Django messages
must receive HX-Trigger so fetchWithHtmxTriggers can read them.
"""
response = self.client.patch(
f"/api/games/{self.game.id}/status",
data=json.dumps({"status": "played"}),
content_type="application/json",
)
self.assertEqual(response.status_code, 204)
self.assertIn("HX-Trigger", response)
data = json.loads(response["HX-Trigger"])
self.assertIn("show-toast", data)
self.assertEqual(data["show-toast"]["type"], "success")
def test_session_device_api_endpoint_sends_hx_trigger(self):
"""
Verify the session device API endpoint also produces HX-Trigger.
This is the exact endpoint used by sessiondevice_selector.html.
"""
device = Device(name="Test Device")
device.save()
zt = ZoneInfo(settings.TIME_ZONE)
session = Session(
game=self.game,
device=device,
timestamp_start=datetime(2022, 9, 26, 14, 58, tzinfo=zt),
)
session.save()
response = self.client.patch(
f"/api/session/{session.id}/device",
data=json.dumps({"device_id": device.id}),
content_type="application/json",
)
self.assertEqual(response.status_code, 204)
self.assertIn("HX-Trigger", response)
data = json.loads(response["HX-Trigger"])
self.assertIn("show-toast", data)
self.assertEqual(data["show-toast"]["message"], "Device updated")
def test_refund_purchase_returns_updated_row_with_hx_trigger(
self,
):
"""
Verify the refund endpoint returns the updated row HTML so the page
swaps it in place without navigating away (preserving URL/query params).
"""
purchase = Purchase.objects.create(
date_purchased=datetime(2023, 1, 1),
platform=Platform.objects.first() or pl,
)
purchase.games.set([self.game])
response = self.client.post(
f"/tracker/purchase/{purchase.id}/refund",
data={"set_abandoned": ""},
)
self.assertEqual(response.status_code, 200)
self.assertNotIn("HX-Redirect", response)
self.assertIn("HX-Trigger", response)
data = json.loads(response["HX-Trigger"])
self.assertIn("show-toast", data)
self.assertEqual(data["show-toast"]["message"], "Purchase refunded")
# Verify the row HTML contains the updated row id
body = response.content.decode()
self.assertIn(f'purchase-row-{purchase.id}', body)
# Verify OoO modal close element
self.assertIn('hx-swap-oob', body)
self.assertIn('refund-confirmation-modal', body)
# Verify the purchase is actually refunded
purchase.refresh_from_db()
self.assertIsNotNone(purchase.date_refunded)
+121
View File
@@ -0,0 +1,121 @@
import json
import os
import django
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "timetracker.settings")
django.setup()
from django.contrib.messages import constants as message_constants
from django.contrib.messages.storage.fallback import FallbackStorage
from django.http import HttpRequest, HttpResponse
from django.test import TestCase
from games.htmx_middleware import HTMXMessagesMiddleware
def get_response_ok(request):
return HttpResponse("OK")
class HtmxDetails:
boosted = False
current_url = ""
target_id = ""
class HTMXMessagesMiddlewareTest(TestCase):
def _build_request(self, htmx=True):
"""Build a request with FallbackStorage message backend."""
request = HttpRequest()
request.method = "GET"
request.path = "/test"
request.META = {"SERVER_NAME": "localhost", "SERVER_PORT": "80"}
request.session = {}
storage = FallbackStorage(request)
request._messages = storage
if htmx:
request.htmx = HtmxDetails()
return request
def test_htmx_request_with_messages_sends_hx_trigger(self):
"""HTMX request with messages should include HX-Trigger header."""
request = self._build_request(htmx=True)
request._messages.add(message_constants.SUCCESS, "Item saved")
middleware = HTMXMessagesMiddleware(get_response_ok)
response = middleware(request)
self.assertIn("HX-Trigger", response)
data = json.loads(response["HX-Trigger"])
self.assertIn("show-toast", data)
self.assertEqual(data["show-toast"]["message"], "Item saved")
self.assertEqual(data["show-toast"]["type"], "success")
def test_htmx_request_with_error_message(self):
"""Error messages should map to 'error' toast type."""
request = self._build_request(htmx=True)
request._messages.add(message_constants.ERROR, "Something failed")
middleware = HTMXMessagesMiddleware(get_response_ok)
response = middleware(request)
data = json.loads(response["HX-Trigger"])
self.assertEqual(data["show-toast"]["type"], "error")
def test_htmx_request_with_success_message(self):
"""Success messages should map to 'success' toast type."""
request = self._build_request(htmx=True)
request._messages.add(message_constants.SUCCESS, "Saved successfully")
middleware = HTMXMessagesMiddleware(get_response_ok)
response = middleware(request)
data = json.loads(response["HX-Trigger"])
self.assertEqual(data["show-toast"]["type"], "success")
def test_non_htmx_request_also_sends_hx_trigger(self):
"""Non-HTMX requests should also include HX-Trigger header."""
request = self._build_request(htmx=False)
request._messages.add(message_constants.SUCCESS, "Hello")
middleware = HTMXMessagesMiddleware(get_response_ok)
response = middleware(request)
self.assertIn("HX-Trigger", response)
data = json.loads(response["HX-Trigger"])
self.assertIn("show-toast", data)
self.assertEqual(data["show-toast"]["message"], "Hello")
def test_htmx_request_without_messages_no_hx_trigger(self):
"""HTMX request without messages should not include HX-Trigger header."""
request = self._build_request(htmx=True)
middleware = HTMXMessagesMiddleware(get_response_ok)
response = middleware(request)
self.assertNotIn("HX-Trigger", response)
def test_warning_message_maps_to_warning(self):
"""Warning messages should map to 'warning' toast type."""
request = self._build_request(htmx=True)
request._messages.add(message_constants.WARNING, "Warning message")
middleware = HTMXMessagesMiddleware(get_response_ok)
response = middleware(request)
data = json.loads(response["HX-Trigger"])
self.assertEqual(data["show-toast"]["type"], "warning")
def test_debug_message_maps_to_debug(self):
"""Debug messages should map to 'debug' toast type."""
request = self._build_request(htmx=True)
request._messages.add(message_constants.DEBUG, "Debug info")
middleware = HTMXMessagesMiddleware(get_response_ok)
response = middleware(request)
data = json.loads(response["HX-Trigger"])
self.assertEqual(data["show-toast"]["type"], "debug")