updated to python v13.3 and added mockable tests
This commit is contained in:
parent
67ab05055c
commit
27e2572ded
@ -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
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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"}},
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
@ -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"),
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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,
|
||||
)
|
||||
)
|
||||
Binary file not shown.
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@ -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
|
||||
Loading…
Reference in New Issue
Block a user