diff --git a/src/python/notificatiesbeheren/notification-preferences-example/README.md b/src/python/notificatiesbeheren/notification-preferences-example/README.md index 07436ed..a2594dd 100644 --- a/src/python/notificatiesbeheren/notification-preferences-example/README.md +++ b/src/python/notificatiesbeheren/notification-preferences-example/README.md @@ -16,32 +16,81 @@ Reference implementation for the Service Engine `GET /customers/notificationpref ## Runtime options -### Container App / local FastAPI +### Local FastAPI with real CRUD APIs -```bash -pip install -r requirements.txt -export NOTIFICATIONS_CRUD_BASE_URL=https://your-notifications-crud-api.example.nl -export CUSTOMERS_CRUD_BASE_URL=https://your-customers-crud-api.example.nl -uvicorn app.entrypoints.fastapi_app:app --reload +```powershell +.\.venv\Scripts\python.exe -m pip install -r requirements.txt +$env:NOTIFICATIONS_CRUD_BASE_URL="https://your-notifications-crud-api.example.nl" +$env:CUSTOMERS_CRUD_BASE_URL="https://your-customers-crud-api.example.nl" +.\.venv\Scripts\python.exe -m uvicorn app.entrypoints.fastapi_app:app --reload ``` Call: -```bash -curl -H "X-HTM-CUSTOMER-PROFILE-ID-HEADER: 42" \ - "http://localhost:8000/customers/notificationpreferences?deviceId=5bedce29-af0c-4f3c-b182-2caa8a1f9377" +```powershell +Invoke-RestMethod ` + -Uri "http://127.0.0.1:8000/customers/notificationpreferences?deviceId=device-1" ` + -Headers @{"X-HTM-CUSTOMER-PROFILE-ID-HEADER"="42"} ``` +### Local FastAPI with mock data + +Use mock mode when you want to demo or test the transformation without live CRUD APIs. + +```powershell +$env:USE_MOCK_DATA="true" +$env:MOCK_SCENARIO="mixed" +.\.venv\Scripts\python.exe -m uvicorn app.entrypoints.fastapi_app:app --reload +``` + +Open: + +```text +http://127.0.0.1:8000/docs +``` + +Health check: + +```text +http://127.0.0.1:8000/health +``` + +Available mock scenarios: + +| Scenario | Purpose | +|---|---| +| `mixed` | Default-on push, default-off email, plus customer deviations | +| `defaults_only` | No customer preferences, so only defaults are applied | +| `global_override` | Preference with `resourceIdentifier = null` applies to all resources | +| `inactive_subscription` | Subscription is returned but marked inactive | + ### Azure Function Use `app/entrypoints/azure_function.py` as the HTTP-trigger body. Keep the `app/services` and `app/domain` modules unchanged. +## Running automated tests + +```powershell +.\.venv\Scripts\python.exe -m pytest +``` + +The tests cover: + +- default-on channel without preference -> active +- default-on channel with preference -> inactive deviation +- default-off channel without preference -> inactive +- default-off channel with preference -> active deviation +- global preference where `resourceIdentifier = null` +- device filtering via `deviceId` +- inactive subscription still returned as inactive + ## Design choice The implementation deliberately separates: - HTTP entrypoints: request/response only - CRUD client: raw REST calls only +- Mock CRUD client: deterministic local/demo data - Service layer: composition and transformation logic - Domain models: frontend/SE response shape diff --git a/src/python/notificatiesbeheren/notification-preferences-example/app/__pycache__/service_factory.cpython-314.pyc b/src/python/notificatiesbeheren/notification-preferences-example/app/__pycache__/service_factory.cpython-314.pyc new file mode 100644 index 0000000..1f5b7a4 Binary files /dev/null and b/src/python/notificatiesbeheren/notification-preferences-example/app/__pycache__/service_factory.cpython-314.pyc differ diff --git a/src/python/notificatiesbeheren/notification-preferences-example/app/__pycache__/settings.cpython-314.pyc b/src/python/notificatiesbeheren/notification-preferences-example/app/__pycache__/settings.cpython-314.pyc new file mode 100644 index 0000000..330c52c Binary files /dev/null and b/src/python/notificatiesbeheren/notification-preferences-example/app/__pycache__/settings.cpython-314.pyc differ diff --git a/src/python/notificatiesbeheren/notification-preferences-example/app/clients/__pycache__/crud_client.cpython-314.pyc b/src/python/notificatiesbeheren/notification-preferences-example/app/clients/__pycache__/crud_client.cpython-314.pyc new file mode 100644 index 0000000..8543126 Binary files /dev/null and b/src/python/notificatiesbeheren/notification-preferences-example/app/clients/__pycache__/crud_client.cpython-314.pyc differ diff --git a/src/python/notificatiesbeheren/notification-preferences-example/app/clients/__pycache__/mock_crud_client.cpython-314.pyc b/src/python/notificatiesbeheren/notification-preferences-example/app/clients/__pycache__/mock_crud_client.cpython-314.pyc new file mode 100644 index 0000000..ca8fd0f Binary files /dev/null and b/src/python/notificatiesbeheren/notification-preferences-example/app/clients/__pycache__/mock_crud_client.cpython-314.pyc differ diff --git a/src/python/notificatiesbeheren/notification-preferences-example/app/clients/mock_crud_client.py b/src/python/notificatiesbeheren/notification-preferences-example/app/clients/mock_crud_client.py new file mode 100644 index 0000000..253d00e --- /dev/null +++ b/src/python/notificatiesbeheren/notification-preferences-example/app/clients/mock_crud_client.py @@ -0,0 +1,162 @@ +from __future__ import annotations +from copy import deepcopy +from typing import Any, Optional + + +class MockCrudClient: + """Deterministic in-memory CRUD client for local demos and tests. + + It implements the same methods as CrudApiClient, so the service layer does + not know whether data comes from real CRUD APIs or from mock fixtures. + """ + + def __init__(self, scenario: str = "mixed"): + self.scenario = scenario + + async def get_notification_subscriptions(self, customer_profile_id: int) -> list[dict[str, Any]]: + subscriptions = deepcopy(MOCK_SUBSCRIPTIONS.get(self.scenario, MOCK_SUBSCRIPTIONS["mixed"])) + for subscription in subscriptions: + subscription["customerProfileId"] = customer_profile_id + return subscriptions + + async def get_notification_category_defaults(self, notification_category_id: int) -> Optional[dict[str, Any]]: + return deepcopy(MOCK_CATEGORIES.get(notification_category_id)) + + async def get_ovpay_token(self, ovpay_token_id: Optional[int]) -> Optional[dict[str, Any]]: + if ovpay_token_id is None: + return None + return deepcopy(MOCK_OVPAY_TOKENS.get(ovpay_token_id, {"ovPayTokenId": ovpay_token_id, "alias": None})) + + async def get_devices(self, customer_profile_id: int) -> list[dict[str, Any]]: + return deepcopy(MOCK_DEVICES) + + async def get_persons(self, customer_profile_id: int) -> list[dict[str, Any]]: + return deepcopy(MOCK_CUSTOMERS) + + +MOCK_CATEGORIES: dict[int, dict[str, Any]] = { + 1: { + "notificationCategoryId": 1, + "name": "Reizen", + "groupName": "Mijn passen", + "eventTypes": [ + { + "eventTypeId": 10, + "name": "OVPAY_CHECK_IN", + "subName": "Check-in", + "prettyName": "Check-in bevestiging", + "eventTypeChannels": [ + { + "eventTypeChannelId": "etc-push-default-on", + "channel": { + "channelId": 1, + "name": "Push", + "resourceName": {"resourceNameId": 8, "name": "devices"}, + }, + "isDefault": True, + "isMandatory": False, + }, + { + "eventTypeChannelId": "etc-email-default-off", + "channel": { + "channelId": 2, + "name": "E-mail", + "resourceName": {"resourceNameId": 4, "name": "customers"}, + }, + "isDefault": False, + "isMandatory": False, + }, + ], + }, + { + "eventTypeId": 20, + "name": "SERVICE_MESSAGE", + "subName": "Service", + "prettyName": "Servicebericht", + "eventTypeChannels": [ + { + "eventTypeChannelId": "etc-email-mandatory", + "channel": { + "channelId": 2, + "name": "E-mail", + "resourceName": {"resourceNameId": 4, "name": "customers"}, + }, + "isDefault": True, + "isMandatory": True, + } + ], + }, + ], + } +} + +MOCK_SUBSCRIPTIONS: dict[str, list[dict[str, Any]]] = { + # Mixed scenario demonstrates both default behavior and customer deviations. + "mixed": [ + { + "notificationSubscriptionId": "sub-mixed-1", + "notificationCategoryId": 1, + "notificationCategory": {"notificationCategoryId": 1, "name": "Reizen", "groupName": "Mijn passen"}, + "customerProfileId": 42, + "ovPayTokenId": 112, + "subscriptionActivities": [{"timestamp": "2026-04-01T10:00:00Z", "isActive": True}], + "notificationPreferences": [ + # Default push=true: this row opts out only device-2. + {"notificationPreferenceId": "pref-push-device-2-off", "eventTypeChannelId": "etc-push-default-on", "resourceIdentifier": "device-2"}, + # Default email=false: this row opts in the customer's email resource. + {"notificationPreferenceId": "pref-email-on", "eventTypeChannelId": "etc-email-default-off", "resourceIdentifier": "42"}, + ], + } + ], + # No preferences: output should be entirely based on category defaults. + "defaults_only": [ + { + "notificationSubscriptionId": "sub-defaults-1", + "notificationCategoryId": 1, + "notificationCategory": {"notificationCategoryId": 1, "name": "Reizen", "groupName": "Mijn passen"}, + "customerProfileId": 42, + "ovPayTokenId": 112, + "subscriptionActivities": [{"timestamp": "2026-04-01T10:00:00Z", "isActive": True}], + "notificationPreferences": [], + } + ], + # Global preference: null resourceIdentifier applies the deviation to all resources. + "global_override": [ + { + "notificationSubscriptionId": "sub-global-1", + "notificationCategoryId": 1, + "notificationCategory": {"notificationCategoryId": 1, "name": "Reizen", "groupName": "Mijn passen"}, + "customerProfileId": 42, + "ovPayTokenId": 112, + "subscriptionActivities": [{"timestamp": "2026-04-01T10:00:00Z", "isActive": True}], + "notificationPreferences": [ + {"notificationPreferenceId": "pref-all-push-off", "eventTypeChannelId": "etc-push-default-on", "resourceIdentifier": None} + ], + } + ], + # Subscription inactive while preferences are still written out for the frontend. + "inactive_subscription": [ + { + "notificationSubscriptionId": "sub-inactive-1", + "notificationCategoryId": 1, + "notificationCategory": {"notificationCategoryId": 1, "name": "Reizen", "groupName": "Mijn passen"}, + "customerProfileId": 42, + "ovPayTokenId": 112, + "subscriptionActivities": [{"timestamp": "2026-04-01T10:00:00Z", "isActive": False}], + "notificationPreferences": [], + } + ], +} + +MOCK_OVPAY_TOKENS = { + 112: {"ovPayTokenId": 112, "alias": "Mijn betaalpas"}, +} + +MOCK_DEVICES = [ + {"deviceId": "device-1", "alias": "Mijn iPhone"}, + {"deviceId": "device-2", "alias": "Werktelefoon"}, +] + +MOCK_CUSTOMERS = [ + {"customerProfileId": 42, "person": {"emailAddress": "klant@example.nl"}}, +] diff --git a/src/python/notificatiesbeheren/notification-preferences-example/app/domain/__pycache__/models.cpython-314.pyc b/src/python/notificatiesbeheren/notification-preferences-example/app/domain/__pycache__/models.cpython-314.pyc new file mode 100644 index 0000000..d1110ec Binary files /dev/null and b/src/python/notificatiesbeheren/notification-preferences-example/app/domain/__pycache__/models.cpython-314.pyc differ diff --git a/src/python/notificatiesbeheren/notification-preferences-example/app/entrypoints/__pycache__/fastapi_app.cpython-314.pyc b/src/python/notificatiesbeheren/notification-preferences-example/app/entrypoints/__pycache__/fastapi_app.cpython-314.pyc new file mode 100644 index 0000000..ef09b8e Binary files /dev/null and b/src/python/notificatiesbeheren/notification-preferences-example/app/entrypoints/__pycache__/fastapi_app.cpython-314.pyc differ diff --git a/src/python/notificatiesbeheren/notification-preferences-example/app/entrypoints/azure_function.py b/src/python/notificatiesbeheren/notification-preferences-example/app/entrypoints/azure_function.py index 9822074..25d9a57 100644 --- a/src/python/notificatiesbeheren/notification-preferences-example/app/entrypoints/azure_function.py +++ b/src/python/notificatiesbeheren/notification-preferences-example/app/entrypoints/azure_function.py @@ -1,9 +1,7 @@ from __future__ import annotations import azure.functions as func -from app.clients.crud_client import NotificationsCrudClient -from app.services.notification_preferences_service import NotificationPreferencesService -from app.settings import settings +from app.service_factory import create_notification_preferences_service async def main(req: func.HttpRequest) -> func.HttpResponse: @@ -11,7 +9,7 @@ async def main(req: func.HttpRequest) -> func.HttpResponse: if not customer_profile_id: return func.HttpResponse("Missing X-HTM-CUSTOMER-PROFILE-ID-HEADER", status_code=400) - service = NotificationPreferencesService(NotificationsCrudClient(settings.notifications_crud_base_url, settings.customers_crud_base_url)) + service = create_notification_preferences_service() result = await service.get_customer_notification_preferences( customer_profile_id=int(customer_profile_id), device_id=req.params.get("deviceId"), diff --git a/src/python/notificatiesbeheren/notification-preferences-example/app/entrypoints/fastapi_app.py b/src/python/notificatiesbeheren/notification-preferences-example/app/entrypoints/fastapi_app.py index 567dc8d..909e3ca 100644 --- a/src/python/notificatiesbeheren/notification-preferences-example/app/entrypoints/fastapi_app.py +++ b/src/python/notificatiesbeheren/notification-preferences-example/app/entrypoints/fastapi_app.py @@ -1,16 +1,16 @@ from __future__ import annotations from fastapi import FastAPI, Header, Query -from app.clients.crud_client import NotificationsCrudClient from app.domain.models import CustomerNotificationPreferencesResponse -from app.services.notification_preferences_service import NotificationPreferencesService +from app.service_factory import create_notification_preferences_service from app.settings import settings app = FastAPI(title="SE Notifications example") -def get_service() -> NotificationPreferencesService: - return NotificationPreferencesService(NotificationsCrudClient(settings.notifications_crud_base_url, settings.customers_crud_base_url)) +@app.get("/health") +async def health(): + return {"status": "ok", "useMockData": settings.use_mock_data, "mockScenario": settings.mock_scenario} @app.get("/customers/notificationpreferences", response_model=CustomerNotificationPreferencesResponse) @@ -18,7 +18,8 @@ async def get_customer_notification_preferences( x_htm_customer_profile_id_header: int = Header(..., alias="X-HTM-CUSTOMER-PROFILE-ID-HEADER"), device_id: str | None = Query(default=None, alias="deviceId"), ): - return await get_service().get_customer_notification_preferences( + service = create_notification_preferences_service() + return await service.get_customer_notification_preferences( customer_profile_id=x_htm_customer_profile_id_header, device_id=device_id, ) diff --git a/src/python/notificatiesbeheren/notification-preferences-example/app/service_factory.py b/src/python/notificatiesbeheren/notification-preferences-example/app/service_factory.py new file mode 100644 index 0000000..4cbe60b --- /dev/null +++ b/src/python/notificatiesbeheren/notification-preferences-example/app/service_factory.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from app.clients.crud_client import CrudApiClient +from app.clients.mock_crud_client import MockCrudClient +from app.services.notification_preferences_service import NotificationPreferencesService +from app.settings import settings + + +def create_notification_preferences_service() -> NotificationPreferencesService: + if settings.use_mock_data: + return NotificationPreferencesService(MockCrudClient(settings.mock_scenario)) + + return NotificationPreferencesService( + CrudApiClient( + notifications_base_url=settings.notifications_crud_base_url, + customers_base_url=settings.customers_crud_base_url, + ) + ) diff --git a/src/python/notificatiesbeheren/notification-preferences-example/app/services/__pycache__/notification_preferences_service.cpython-314.pyc b/src/python/notificatiesbeheren/notification-preferences-example/app/services/__pycache__/notification_preferences_service.cpython-314.pyc new file mode 100644 index 0000000..b970be8 Binary files /dev/null and b/src/python/notificatiesbeheren/notification-preferences-example/app/services/__pycache__/notification_preferences_service.cpython-314.pyc differ diff --git a/src/python/notificatiesbeheren/notification-preferences-example/app/settings.py b/src/python/notificatiesbeheren/notification-preferences-example/app/settings.py index da3cd45..a347ded 100644 --- a/src/python/notificatiesbeheren/notification-preferences-example/app/settings.py +++ b/src/python/notificatiesbeheren/notification-preferences-example/app/settings.py @@ -5,5 +5,9 @@ class Settings(BaseSettings): notifications_crud_base_url: str = "http://localhost:8001" customers_crud_base_url: str = "http://localhost:8002" + # local development aid: set USE_MOCK_DATA=true to run without CRUD APIs + use_mock_data: bool = False + mock_scenario: str = "mixed" + settings = Settings() diff --git a/src/python/notificatiesbeheren/notification-preferences-example/requirements.txt b/src/python/notificatiesbeheren/notification-preferences-example/requirements.txt index 238304d..d64d8e4 100644 --- a/src/python/notificatiesbeheren/notification-preferences-example/requirements.txt +++ b/src/python/notificatiesbeheren/notification-preferences-example/requirements.txt @@ -1,8 +1,8 @@ fastapi==0.115.6 uvicorn[standard]==0.34.0 httpx==0.28.1 -pydantic==2.10.4 -pydantic-settings==2.7.1 +pydantic>=2.12.0 +pydantic-settings>=2.7.1 azure-functions==1.21.3 pytest==8.3.4 pytest-asyncio==0.25.2 diff --git a/src/python/notificatiesbeheren/notification-preferences-example/tests/__pycache__/test_mock_scenarios.cpython-314-pytest-8.3.4.pyc b/src/python/notificatiesbeheren/notification-preferences-example/tests/__pycache__/test_mock_scenarios.cpython-314-pytest-8.3.4.pyc new file mode 100644 index 0000000..557f6b0 Binary files /dev/null and b/src/python/notificatiesbeheren/notification-preferences-example/tests/__pycache__/test_mock_scenarios.cpython-314-pytest-8.3.4.pyc differ diff --git a/src/python/notificatiesbeheren/notification-preferences-example/tests/__pycache__/test_transformation.cpython-314-pytest-8.3.4.pyc b/src/python/notificatiesbeheren/notification-preferences-example/tests/__pycache__/test_transformation.cpython-314-pytest-8.3.4.pyc new file mode 100644 index 0000000..9a22c80 Binary files /dev/null and b/src/python/notificatiesbeheren/notification-preferences-example/tests/__pycache__/test_transformation.cpython-314-pytest-8.3.4.pyc differ diff --git a/src/python/notificatiesbeheren/notification-preferences-example/tests/test_mock_scenarios.py b/src/python/notificatiesbeheren/notification-preferences-example/tests/test_mock_scenarios.py new file mode 100644 index 0000000..15da9dc --- /dev/null +++ b/src/python/notificatiesbeheren/notification-preferences-example/tests/test_mock_scenarios.py @@ -0,0 +1,69 @@ +import pytest + +from app.clients.mock_crud_client import MockCrudClient +from app.services.notification_preferences_service import NotificationPreferencesService + + +def _channels(result): + subscription = result.notificationCategories[0].notificationSubscriptions[0] + return { + channel.eventTypeChannelId: channel + for event_type in subscription.eventTypes + for channel in event_type.eventTypeChannels + } + + +@pytest.mark.asyncio +async def test_mixed_scenario_applies_default_on_and_default_off_deviations(): + service = NotificationPreferencesService(MockCrudClient("mixed")) + result = await service.get_customer_notification_preferences(42) + channels = _channels(result) + + push_resources = channels["etc-push-default-on"].resources + assert [(r.resourceIdentifier, r.isActive) for r in push_resources] == [ + ("device-1", True), + ("device-2", False), + ] + + email_resources = channels["etc-email-default-off"].resources + assert [(r.resourceIdentifier, r.isActive) for r in email_resources] == [("42", True)] + + +@pytest.mark.asyncio +async def test_defaults_only_scenario_writes_all_resources_from_defaults(): + service = NotificationPreferencesService(MockCrudClient("defaults_only")) + result = await service.get_customer_notification_preferences(42) + channels = _channels(result) + + assert [r.isActive for r in channels["etc-push-default-on"].resources] == [True, True] + assert [r.isActive for r in channels["etc-email-default-off"].resources] == [False] + assert channels["etc-email-mandatory"].isMandatory is True + assert [r.isActive for r in channels["etc-email-mandatory"].resources] == [True] + + +@pytest.mark.asyncio +async def test_global_override_applies_to_all_resources_when_resource_identifier_is_null(): + service = NotificationPreferencesService(MockCrudClient("global_override")) + result = await service.get_customer_notification_preferences(42) + channels = _channels(result) + + assert [r.isActive for r in channels["etc-push-default-on"].resources] == [False, False] + + +@pytest.mark.asyncio +async def test_device_filter_returns_only_requested_device_for_device_channels(): + service = NotificationPreferencesService(MockCrudClient("mixed")) + result = await service.get_customer_notification_preferences(42, device_id="device-1") + channels = _channels(result) + + assert [(r.resourceIdentifier, r.isActive) for r in channels["etc-push-default-on"].resources] == [("device-1", True)] + + +@pytest.mark.asyncio +async def test_inactive_subscription_is_visible_but_marked_inactive(): + service = NotificationPreferencesService(MockCrudClient("inactive_subscription")) + result = await service.get_customer_notification_preferences(42) + subscription = result.notificationCategories[0].notificationSubscriptions[0] + + assert subscription.isActive is False + assert subscription.eventTypes