trial-implementation-Not-beheren #59

Open
MirjamHTM wants to merge 2 commits from trial-implementation-Not-beheren into develop
25 changed files with 893 additions and 0 deletions

View File

@ -0,0 +1,121 @@
# Notification preferences transformation service - Python example
Reference implementation for the Service Engine `GET /customers/notificationpreferences` endpoint. It composes raw CRUD API data into the frontend-friendly response model from `SE-notifications.yaml`.
## Supplied contracts used
- `notifications-crud.yaml`
- `GET /notificationsubscriptions?customerProfileId={id}&expand=eventTypeChannel`
- `GET /notificationcategories?notificationCategoryId={id}&expand=eventTypeChannel`
- `customers-crud-v2.yaml`
- `GET /ovpaytokens?ovPayTokenId={id}`
- `GET /devices?customerProfileId={id}`
- `GET /customers?customerProfileId={id}` for person/email resource data
- `SE-notifications.yaml`
- `GET /customers/notificationpreferences`
## Runtime options
### Local FastAPI with real CRUD APIs
```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:
```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
This makes the same transformation reusable in Azure Functions, Container Apps, and tests.
## Business rule interpretation
For each customer subscription:
1. Load customer `NotificationSubscriptions` with `NotificationPreferences` expanded to `EventTypeChannel`.
2. Load the default category definition with all `EventTypes` and `EventTypeChannels`.
3. Load customer resources for each channel resource type:
- `resourceNameId = 8` -> `GET /devices?customerProfileId={id}`
- `resourceNameId = 4` -> `GET /customers?customerProfileId={id}` and use the embedded `person` email data
4. For every default `EventTypeChannel`, write out resources explicitly.
5. If the channel is default-on and no preference exists, resource is active.
6. If the channel is default-off and no preference exists, resource is inactive.
7. A `NotificationPreference` row is interpreted as a deviation:
- default-on + preference -> inactive unless explicit `isActive` says otherwise
- default-off + preference -> active unless explicit `isActive` says otherwise
8. A null `resourceIdentifier` applies to all resources for that EventTypeChannel.
## Production hardening points
- Add authentication propagation from the SE API to both CRUD APIs.
- Decide whether category defaults should be cached across requests.
- Decide whether missing category defaults should become `502 Bad Gateway`, partial data, or an empty category.
- Confirm whether the real `NotificationPreference` has explicit `isActive`; this example supports it, but also works when preference existence alone means deviation.

View File

@ -0,0 +1,89 @@
from __future__ import annotations
from typing import Any, Optional
import httpx
class CrudApiClient:
"""Thin async client around the ABT CRUD APIs.
Keep business/transformation logic out of this class. The client only knows how
to call the source endpoints and unwrap the collection names from the CRUD
contracts.
"""
def __init__(
self,
notifications_base_url: str,
customers_base_url: str,
timeout_seconds: float = 10.0,
):
self.notifications_base_url = notifications_base_url.rstrip("/")
self.customers_base_url = customers_base_url.rstrip("/")
self.timeout_seconds = timeout_seconds
async def _get(
self,
base_url: str,
path: str,
params: Optional[dict[str, Any]] = None,
) -> dict[str, Any]:
async with httpx.AsyncClient(base_url=base_url, timeout=self.timeout_seconds) as client:
response = await client.get(path, params=params)
response.raise_for_status()
return response.json()
# notifications-crud.yaml
async def get_notification_subscriptions(self, customer_profile_id: int) -> list[dict[str, Any]]:
payload = await self._get(
self.notifications_base_url,
"/notificationsubscriptions",
params={"customerProfileId": customer_profile_id, "expand": "eventTypeChannel"},
)
return payload.get("notificationSubscriptions", [])
async def get_notification_category_defaults(self, notification_category_id: int) -> Optional[dict[str, Any]]:
payload = await self._get(
self.notifications_base_url,
"/notificationcategories",
params={"notificationCategoryId": notification_category_id, "expand": "eventTypeChannel"},
)
categories = payload.get("notificationCategories", [])
return categories[0] if categories else None
# customers-crud-v2.yaml
async def get_ovpay_token(self, ovpay_token_id: Optional[int]) -> Optional[dict[str, Any]]:
if ovpay_token_id is None:
return None
payload = await self._get(
self.customers_base_url,
"/ovpaytokens",
params={"ovPayTokenId": ovpay_token_id},
)
tokens = payload.get("ovPayTokens", [])
return tokens[0] if tokens else None
async def get_devices(self, customer_profile_id: int) -> list[dict[str, Any]]:
payload = await self._get(
self.customers_base_url,
"/devices",
params={"customerProfileId": customer_profile_id},
)
return payload.get("devices", [])
async def get_persons(self, customer_profile_id: int) -> list[dict[str, Any]]:
"""Return customer/person resources used for email-like channels.
customers-crud-v2.yaml exposes person details through GET /customers,
filtered by customerProfileId. For the transformation service we normalize
the matching customer profile into one resource with the customer's email.
"""
payload = await self._get(
self.customers_base_url,
"/customers",
params={"customerProfileId": customer_profile_id},
)
return payload.get("customers", [])
# Backwards-compatible alias used by the service type hints.
NotificationsCrudClient = CrudApiClient

View File

@ -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"}},
]

View File

@ -0,0 +1,60 @@
from __future__ import annotations
from typing import Any, Optional
from pydantic import BaseModel, Field
class Resource(BaseModel):
resourceName: str
resourceIdentifier: str
alias: Optional[str] = None
isActive: bool
class EventTypeChannelPreference(BaseModel):
eventTypeChannelId: str
channelId: int
name: str
isMandatory: bool = False
resources: list[Resource] = Field(default_factory=list)
class EventTypePreference(BaseModel):
eventTypeId: int
name: str
subName: Optional[str] = None
prettyName: str
eventTypeChannels: list[EventTypeChannelPreference] = Field(default_factory=list)
class OvPayToken(BaseModel):
ovPayTokenId: int
alias: Optional[str] = None
class NotificationSubscriptionPreference(BaseModel):
notificationSubscriptionId: str
customerProfileId: int
ovPayToken: Optional[OvPayToken] = None
isActive: bool
eventTypes: list[EventTypePreference] = Field(default_factory=list)
class NotificationCategoryPreference(BaseModel):
notificationCategoryId: int
name: str
groupName: Optional[str] = None
notificationSubscriptions: list[NotificationSubscriptionPreference] = Field(default_factory=list)
class CustomerNotificationPreferencesResponse(BaseModel):
notificationCategories: list[NotificationCategoryPreference] = Field(default_factory=list)
def latest_activity_is_active(subscription: dict[str, Any]) -> bool:
"""CRUD returns subscriptionActivities; the most recent/current one determines active state."""
activities = subscription.get("subscriptionActivities") or []
if not activities:
return False
# CRUD activityLimit defaults to 1; when more are supplied, sort defensively on timestamp.
latest = sorted(activities, key=lambda a: a.get("timestamp", ""), reverse=True)[0]
return bool(latest.get("isActive"))

View File

@ -0,0 +1,17 @@
from __future__ import annotations
import azure.functions as func
from app.service_factory import create_notification_preferences_service
async def main(req: func.HttpRequest) -> func.HttpResponse:
customer_profile_id = req.headers.get("X-HTM-CUSTOMER-PROFILE-ID-HEADER")
if not customer_profile_id:
return func.HttpResponse("Missing X-HTM-CUSTOMER-PROFILE-ID-HEADER", status_code=400)
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"),
)
return func.HttpResponse(result.model_dump_json(exclude_none=True), mimetype="application/json", status_code=200)

View File

@ -0,0 +1,25 @@
from __future__ import annotations
from fastapi import FastAPI, Header, Query
from app.domain.models import CustomerNotificationPreferencesResponse
from app.service_factory import create_notification_preferences_service
from app.settings import settings
app = FastAPI(title="SE Notifications example")
@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)
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"),
):
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,
)

View File

@ -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,
)
)

View File

@ -0,0 +1,228 @@
from __future__ import annotations
from collections import defaultdict
from typing import Any, Optional
from app.clients.crud_client import NotificationsCrudClient
from app.domain.models import (
CustomerNotificationPreferencesResponse,
EventTypeChannelPreference,
EventTypePreference,
NotificationCategoryPreference,
NotificationSubscriptionPreference,
OvPayToken,
Resource,
latest_activity_is_active,
)
RESOURCE_DEVICES_ID = 8
RESOURCE_PERSONS_ID = 4
class NotificationPreferencesService:
def __init__(self, crud_client: NotificationsCrudClient):
self.crud_client = crud_client
async def get_customer_notification_preferences(
self,
customer_profile_id: int,
device_id: Optional[str] = None,
) -> CustomerNotificationPreferencesResponse:
subscriptions = await self.crud_client.get_notification_subscriptions(customer_profile_id)
categories_by_id: dict[int, NotificationCategoryPreference] = {}
# Small in-request caches avoid repeated calls for the same category/resources.
default_category_cache: dict[int, dict[str, Any]] = {}
resource_cache: dict[int, list[dict[str, Any]]] = {}
for subscription in subscriptions:
category_id = _extract_category_id(subscription)
default_category = default_category_cache.get(category_id)
if default_category is None:
default_category = await self.crud_client.get_notification_category_defaults(category_id)
if default_category is None:
# Defensive choice: skip orphaned subscriptions rather than returning incomplete UI data.
continue
default_category_cache[category_id] = default_category
category_response = categories_by_id.setdefault(
category_id,
NotificationCategoryPreference(
notificationCategoryId=category_id,
name=default_category.get("name") or subscription.get("notificationCategory", {}).get("name"),
groupName=default_category.get("groupName") or subscription.get("notificationCategory", {}).get("groupName"),
),
)
token_payload = await self.crud_client.get_ovpay_token(subscription.get("ovPayTokenId"))
token = _to_ovpay_token(token_payload, subscription.get("ovPayTokenId"))
subscription_response = NotificationSubscriptionPreference(
notificationSubscriptionId=subscription["notificationSubscriptionId"],
customerProfileId=subscription["customerProfileId"],
ovPayToken=token,
isActive=latest_activity_is_active(subscription),
)
preferences_by_event_type_channel = _index_preferences(subscription)
for event_type in default_category.get("eventTypes", []):
event_type_response = EventTypePreference(
eventTypeId=event_type["eventTypeId"],
name=event_type["name"],
subName=event_type.get("subName"),
prettyName=event_type.get("prettyName") or event_type["name"],
)
for event_type_channel in event_type.get("eventTypeChannels", []):
channel = event_type_channel.get("channel", {})
resource_name = channel.get("resourceName", {})
resource_name_id = resource_name.get("resourceNameId")
resource_name_text = resource_name.get("name") or channel.get("name")
if resource_name_id not in resource_cache:
resource_cache[resource_name_id] = await self._load_resources(resource_name_id, customer_profile_id)
source_resources = resource_cache[resource_name_id]
if device_id and resource_name_id == RESOURCE_DEVICES_ID:
source_resources = [r for r in source_resources if str(_resource_identifier(r)) == str(device_id)]
if not source_resources:
# SE swagger states a deviceId filter returns only ETCs with resources for that device.
continue
matching_preferences = preferences_by_event_type_channel.get(
str(event_type_channel["eventTypeChannelId"]), []
)
resources = self._normalize_resources(
resource_name=resource_name_text,
event_type_channel=event_type_channel,
source_resources=source_resources,
preferences=matching_preferences,
)
event_type_response.eventTypeChannels.append(
EventTypeChannelPreference(
eventTypeChannelId=event_type_channel["eventTypeChannelId"],
channelId=channel["channelId"],
name=channel["name"],
isMandatory=bool(event_type_channel.get("isMandatory")),
resources=resources,
)
)
if event_type_response.eventTypeChannels:
subscription_response.eventTypes.append(event_type_response)
category_response.notificationSubscriptions.append(subscription_response)
return CustomerNotificationPreferencesResponse(notificationCategories=list(categories_by_id.values()))
async def _load_resources(self, resource_name_id: int, customer_profile_id: int) -> list[dict[str, Any]]:
if resource_name_id == RESOURCE_DEVICES_ID:
return await self.crud_client.get_devices(customer_profile_id)
if resource_name_id == RESOURCE_PERSONS_ID:
return await self.crud_client.get_persons(customer_profile_id)
return []
def _normalize_resources(
self,
resource_name: str,
event_type_channel: dict[str, Any],
source_resources: list[dict[str, Any]],
preferences: list[dict[str, Any]],
) -> list[Resource]:
default_active = bool(event_type_channel.get("isDefault"))
if not preferences:
return [
Resource(
resourceName=resource_name,
resourceIdentifier=str(_resource_identifier(resource)),
alias=_resource_alias(resource),
isActive=default_active,
)
for resource in source_resources
]
# Preference rows are exceptions/deviations. A null resourceIdentifier means the exception applies to all resources.
preference_by_resource: dict[str, dict[str, Any]] = {}
global_preference: Optional[dict[str, Any]] = None
for preference in preferences:
pref_resource_id = preference.get("resourceIdentifier")
if pref_resource_id is None:
global_preference = preference
else:
preference_by_resource[str(pref_resource_id)] = preference
normalized: list[Resource] = []
for resource in source_resources:
resource_id = str(_resource_identifier(resource))
preference = preference_by_resource.get(resource_id) or global_preference
if preference is None:
is_active = default_active
else:
# The CRUD example only shows the existence of a preference row, no explicit isActive.
# Therefore: for default-on channels a row means opted out; for default-off a row means opted in.
is_active = bool(preference.get("isActive", not default_active))
normalized.append(
Resource(
resourceName=resource_name,
resourceIdentifier=resource_id,
alias=_resource_alias(resource),
isActive=is_active,
)
)
return normalized
def _extract_category_id(subscription: dict[str, Any]) -> int:
if subscription.get("notificationCategoryId") is not None:
return int(subscription["notificationCategoryId"])
return int(subscription["notificationCategory"]["notificationCategoryId"])
def _index_preferences(subscription: dict[str, Any]) -> dict[str, list[dict[str, Any]]]:
result: dict[str, list[dict[str, Any]]] = defaultdict(list)
for preference in subscription.get("notificationPreferences", []) or []:
event_type_channel_id = preference.get("eventTypeChannelId")
if isinstance(event_type_channel_id, dict):
event_type_channel_id = event_type_channel_id.get("eventTypeChannelId")
if event_type_channel_id is None and isinstance(preference.get("eventTypeChannel"), dict):
event_type_channel_id = preference["eventTypeChannel"].get("eventTypeChannelId")
if event_type_channel_id is not None:
result[str(event_type_channel_id)].append(preference)
return result
def _to_ovpay_token(payload: Optional[dict[str, Any]], fallback_id: Optional[int]) -> Optional[OvPayToken]:
if fallback_id is None:
return None
# Support both {ovPayToken: {...}} and direct {...} shapes.
token = (payload or {}).get("ovPayToken", payload or {})
return OvPayToken(ovPayTokenId=token.get("ovPayTokenId", fallback_id), alias=token.get("alias"))
def _resource_identifier(resource: dict[str, Any]) -> Any:
# Device resources use deviceId. Customer/person email resources are normalized
# to customerProfileId because customers-crud-v2 exposes person under /customers.
return (
resource.get("resourceIdentifier")
or resource.get("deviceId")
or resource.get("personId")
or resource.get("customerProfileId")
or resource.get("id")
)
def _resource_alias(resource: dict[str, Any]) -> Optional[str]:
person = resource.get("person") or {}
return (
resource.get("alias")
or resource.get("emailAddress")
or resource.get("emailAddresses")
or resource.get("email")
or person.get("emailAddress")
or person.get("emailAddresses")
or resource.get("name")
)

View File

@ -0,0 +1,13 @@
from pydantic_settings import BaseSettings
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()

View File

@ -0,0 +1,8 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
httpx==0.28.1
pydantic>=2.12.0
pydantic-settings>=2.7.1
azure-functions==1.21.3
pytest==8.3.4
pytest-asyncio==0.25.2

View File

@ -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

View File

@ -0,0 +1,83 @@
import pytest
from app.services.notification_preferences_service import NotificationPreferencesService
class FakeCrudClient:
async def get_notification_subscriptions(self, customer_profile_id: int):
return [
{
"notificationSubscriptionId": "sub-1",
"notificationCategory": {"notificationCategoryId": 1, "name": "Reizen", "groupName": "Mijn passen"},
"customerProfileId": customer_profile_id,
"ovPayTokenId": 112,
"subscriptionActivities": [{"timestamp": "2026-01-01T00:00:00Z", "isActive": True}],
"notificationPreferences": [
# Default push=true, this row switches device-2 off only.
{"notificationPreferenceId": "pref-1", "eventTypeChannelId": "etc-push", "resourceIdentifier": "device-2"},
# Default email=false, this row switches customer email on.
{"notificationPreferenceId": "pref-2", "eventTypeChannelId": "etc-email", "resourceIdentifier": "42"},
],
}
]
async def get_notification_category_defaults(self, notification_category_id: int):
return {
"notificationCategoryId": 1,
"name": "Reizen",
"groupName": "Mijn passen",
"eventTypes": [
{
"eventTypeId": 2,
"name": "GBO",
"subName": "CI",
"prettyName": "Normal Check-in",
"eventTypeChannels": [
{
"eventTypeChannelId": "etc-push",
"channel": {"channelId": 1, "name": "push", "resourceName": {"resourceNameId": 8, "name": "devices"}},
"isDefault": True,
"isMandatory": False,
},
{
"eventTypeChannelId": "etc-email",
"channel": {"channelId": 2, "name": "email", "resourceName": {"resourceNameId": 4, "name": "customers"}},
"isDefault": False,
"isMandatory": False,
},
],
}
],
}
async def get_ovpay_token(self, ovpay_token_id):
return {"ovPayToken": {"ovPayTokenId": ovpay_token_id, "alias": "Mijn ING bankpas"}}
async def get_devices(self, customer_profile_id: int):
return [
{"deviceId": "device-1", "alias": "Mijn iPhone"},
{"deviceId": "device-2", "alias": "Mijn Pixel"},
]
async def get_persons(self, customer_profile_id: int):
return [{"customerProfileId": 42, "emailAddress": "customer@example.nl"}]
@pytest.mark.asyncio
async def test_normalizes_defaults_and_customer_exceptions():
result = await NotificationPreferencesService(FakeCrudClient()).get_customer_notification_preferences(42)
event_type = result.notificationCategories[0].notificationSubscriptions[0].eventTypes[0]
push_resources = event_type.eventTypeChannels[0].resources
assert [r.isActive for r in push_resources] == [True, False]
email_resources = event_type.eventTypeChannels[1].resources
assert email_resources[0].isActive is True
@pytest.mark.asyncio
async def test_device_filter_only_returns_that_device_resource():
result = await NotificationPreferencesService(FakeCrudClient()).get_customer_notification_preferences(42, device_id="device-1")
event_type = result.notificationCategories[0].notificationSubscriptions[0].eventTypes[0]
assert len(event_type.eventTypeChannels[0].resources) == 1
assert event_type.eventTypeChannels[0].resources[0].resourceIdentifier == "device-1"