trial-implementation-Not-beheren #59

Open
MirjamHTM wants to merge 2 commits from trial-implementation-Not-beheren into develop
17 changed files with 321 additions and 20 deletions
Showing only changes of commit 27e2572ded - Show all commits

View File

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

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

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

View File

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

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

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

View File

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

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