← Notifications

Test cases

notifications · workzone
Accepted decisions
The config matrix isn’t duplicated here Channels, routes, series dedup, and the builtin seed are verified from the Admin side (admin-panel/test_notifications.py): delete-builtin → reject, a locked route (CHECK (enabled OR NOT locked)), in_app can’t be disabled. Here it’s the dispatcher and delivery layer: idempotency, the FK split, the two addressing models.
FK ondelete is split deliberately The delivery audit survives channel deletion (channel_idSET NULL, row intact), but goes with the notification and the user (CASCADE). Easy to confuse — kept as a dedicated test with an explicit contrast.
Stack and infrastructure
Available done pytest, pytest-asyncio, pytest-cov
Test DB testcontainers (PostgreSQL) — real CHECK / FK / triggers; per-test rollback
Factories factory_boy — notification, channel, delivery; builtin channels from the migration
Markers @pytest.mark.integration — the whole delivery layer runs against a real DB
Integration · P0 Delivery idempotency and the FK split
test_delivery_idempotency.py
notification_deliveries — the idempotency key
Cases
UNIQUE(notification, channel, user)a dispatcher repeat / SAQ retry for the same recipient and channel → one row, an update, no duplicate
partial UNIQUE WHERE user_id IS NULLwebhook broadcast (user_id = NULL) is idempotent without a user — UNIQUE(notification, channel) holds one row
two channels — two rowsone notification over in_app and email → two delivery rows (different channel_id), no conflict
two recipients — two rowsbroadcast on one channel to two people → two rows (different user_id), uniqueness per-user
test_delivery_fk.py
The ondelete split — SET NULL (audit) vs CASCADE (lifecycle)
Cases
DELETE channel → SET NULLwebhook channel deleted → deliveries.channel_id = NULL, audit row intact, the delivery fact preserved
one channel DELETE — two outcomesdeleting a notification_channels row: its notification_routes go by cascade (channel_idCASCADE, a route is config), while deliveries.channel_idNULL (SET NULL, audit intact); same parent, different ondelete on two children → Channels and routes
DELETE notification → CASCADEnotifications row deleted → its delivery rows go by cascade
DELETE user → CASCADEusers row deleted → their personal delivery rows go by cascade
contrast in one rundeleting a channel keeps the row (audit), deleting an event / user removes it (lifecycle); different ondelete across three FKs of one table
Integration · P1 Addressing, personal narrowing, the state machine
test_addressing.py
notifications — two addressing models via target_user_id
Cases
broadcast — target_user_id NULLrecipients are a role slice (owner / admin, status=active), the fan-out is computed by query, not a column
targeted — target_user_id = Nagent / account addressed to one user; FK → users, ON DELETE CASCADE
DELETE user → cascaderecipient deleted → their targeted notifications and deliveries go by cascade; broadcast facts intact
severity CHECKinfo / warning / critical — outside the set → DB reject; orthogonal to event_type
event_type CHECKsync · security · budget · system · discovery · agent · account; any other value → DB reject
test_prefs.py
notification_prefs — personal narrowing per type
Cases
UNIQUE(user, event_type)a second preference of the same type for a user → conflict, duplicate rejected
user_id FK CASCADEusers row deleted → their preference rows go by cascade
no row → catalog defaultthe effective preference = the type’s catalog default (personal agent · account: email opt-in false), NOT a global “both on”
email_enabled without a column DEFAULTthe column has no server_default; the value at INSERT is written by code from the type catalog — platform true, personal false
test_delivery_state.py
notification_deliveries — the state machine
IntegrationP1 → Delivery state
Cases
state CHECKqueued / sent / failed / read; any other value → DB reject
read only for in_appread is allowed only for in_app delivery; email / webhook don’t transition to read
set_updated_at triggera state change moves updated_at strictly forward (new > previous)
failed carries errora transition to failed comes with a populated error
sent carries sent_atsent sets sent_at; read sets read_at
API · P1 Personal feed — HTTP contract httpx · ASGI app · → HTTP API · → Conformance
test_api_inbox.py
Reading one’s own feed, the counter, mark-as-read
Cases
own feed onlyGET → the caller’s in_app deliveries: their targeted plus broadcast for their role; others’ targeted don’t appear in the output
unread counterGET unread → the count of in_app deliveries not in the read state; decreases after marking
mark-as-readdelivery transitions to readread_at; idempotent (a repeat is a no-op); in_app only
feed under one contractpagination / sort by recency — the shared list contract of the summary, not its own schema
can’t mark someone else’smarking another user’s delivery → 404 (we don’t disclose existence); anonymous → 401
Structure Test file structure

The delivery layer is fully integration — CHECK, FK and triggers are verified against a real PostgreSQL. The config matrix (channels, routes, dedup, builtin seed) lives in admin-panel/tests.html and isn’t duplicated here.

  • tests/notifications/module catalog
    • conftest.pytestcontainers PG, factories notification / channel / delivery
    • integration/against a real DB
      • delivery-idempotency · delivery-fkidempotency key, ondelete split
      • addressing · prefs · delivery-statetwo addressing models, personal narrowing, state machine