feat: extend resident permit service journey
This commit is contained in:
@@ -5,6 +5,7 @@ from datetime import UTC, datetime, timedelta
|
||||
import json
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
import unittest
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
@@ -15,6 +16,7 @@ from govoplan_core.core.access import PrincipalRef
|
||||
from govoplan_core.core.institutional import (
|
||||
CAPABILITY_FORM_DEFINITIONS,
|
||||
CAPABILITY_SERVICE_DEFINITIONS,
|
||||
EvidenceReference,
|
||||
FormDefinition,
|
||||
FormFieldDefinition,
|
||||
InstitutionalReference,
|
||||
@@ -23,6 +25,11 @@ from govoplan_core.core.institutional import (
|
||||
TemporalRevision,
|
||||
service_launch_capability,
|
||||
)
|
||||
from govoplan_core.core.notifications import CAPABILITY_NOTIFICATIONS_DISPATCH
|
||||
from govoplan_core.core.payments import (
|
||||
ManualPaymentReconciliationCommand,
|
||||
PaymentRequestCommand,
|
||||
)
|
||||
from govoplan_core.core.runtime_coordination import (
|
||||
DistributedLease,
|
||||
RuntimeIdentity,
|
||||
@@ -50,6 +57,9 @@ from govoplan_forms_runtime.backend.db.models import (
|
||||
FormInstanceRevision,
|
||||
FormIntakeProfile,
|
||||
FormIntakeSession,
|
||||
FormStatusAccessGrant,
|
||||
FormStatusAccessPolicy,
|
||||
FormStatusAccessToken,
|
||||
)
|
||||
from govoplan_forms_runtime.backend.intake import FormIntakeService
|
||||
from govoplan_forms_runtime.backend.service import (
|
||||
@@ -57,7 +67,14 @@ from govoplan_forms_runtime.backend.service import (
|
||||
FormRuntimeService,
|
||||
FormsServiceLauncher,
|
||||
)
|
||||
from govoplan_forms_runtime.backend.status_access import FormStatusAccessService
|
||||
from govoplan_portal.backend.service_directory import PortalServiceDirectory
|
||||
from govoplan_payments.backend.db.models import (
|
||||
PaymentEvent,
|
||||
PaymentObligation,
|
||||
PaymentReconciliation,
|
||||
)
|
||||
from govoplan_payments.backend.service import SqlPaymentRequestProvider
|
||||
from govoplan_tasks.backend.aggregation import aggregate_work_items
|
||||
from govoplan_workflow_engine.backend.db.models import (
|
||||
WorkflowDefinition,
|
||||
@@ -173,9 +190,29 @@ class _FormRegistry(_Registry):
|
||||
self.capabilities[service_launch_capability("form")] = FormsServiceLauncher(
|
||||
self
|
||||
)
|
||||
self.notifications = _NotificationProvider()
|
||||
self.capabilities[CAPABILITY_NOTIFICATIONS_DISPATCH] = self.notifications
|
||||
|
||||
def has(self, module_id: str) -> bool:
|
||||
return module_id in {"portal", "forms", "forms_runtime"}
|
||||
return module_id in {"portal", "forms", "forms_runtime", "notifications"}
|
||||
|
||||
|
||||
class _NotificationProvider:
|
||||
def __init__(self) -> None:
|
||||
self.requests: list[object] = []
|
||||
|
||||
def tenant_id_for_notification(self, session, *, notification_id):
|
||||
return "tenant-1"
|
||||
|
||||
def enqueue_notification(self, session, request, *, enqueue_delivery=True):
|
||||
self.requests.append(request)
|
||||
return {"id": f"notification-{len(self.requests)}"}
|
||||
|
||||
def deliver_notification(self, session, *, notification_id):
|
||||
return {"id": notification_id}
|
||||
|
||||
def deliver_pending(self, session, *, tenant_id=None, limit=50):
|
||||
return {"delivered": 0}
|
||||
|
||||
|
||||
class _WorkflowTaskRegistry:
|
||||
@@ -248,8 +285,10 @@ class InstitutionalServiceJourneyTests(unittest.TestCase):
|
||||
def test_reference_fixture_names_remaining_manual_target_evidence(self) -> None:
|
||||
self.assertEqual("Anwohnerparkausweis", JOURNEY["title_de"])
|
||||
self.assertEqual("de-DE", JOURNEY["locale"])
|
||||
self.assertEqual(6, len(JOURNEY["acceptance"]["automated"]))
|
||||
self.assertEqual(5, len(JOURNEY["acceptance"]["manual_or_target"]))
|
||||
self.assertEqual("email_link", JOURNEY["status_access"]["mode"])
|
||||
self.assertEqual("manual", JOURNEY["payment"]["mode"])
|
||||
self.assertEqual(8, len(JOURNEY["acceptance"]["automated"]))
|
||||
self.assertEqual(6, len(JOURNEY["acceptance"]["manual_or_target"]))
|
||||
|
||||
def test_portal_launches_exact_form_revision_and_persists_submission(self) -> None:
|
||||
engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
@@ -290,6 +329,13 @@ class InstitutionalServiceJourneyTests(unittest.TestCase):
|
||||
required=True,
|
||||
constraints={"min_length": 2},
|
||||
),
|
||||
FormFieldDefinition(
|
||||
key="applicant_email",
|
||||
label="Applicant email",
|
||||
value_type="email",
|
||||
required=True,
|
||||
constraints={"min_length": 5},
|
||||
),
|
||||
FormFieldDefinition(
|
||||
key="residence_address",
|
||||
label="Primary residence address",
|
||||
@@ -395,6 +441,9 @@ class InstitutionalServiceJourneyTests(unittest.TestCase):
|
||||
FormIntakeProfile.__table__,
|
||||
FormIntakeSession.__table__,
|
||||
FormAssistedConfirmation.__table__,
|
||||
FormStatusAccessPolicy.__table__,
|
||||
FormStatusAccessGrant.__table__,
|
||||
FormStatusAccessToken.__table__,
|
||||
):
|
||||
table.create(engine)
|
||||
sessions = sessionmaker(bind=engine)
|
||||
@@ -424,6 +473,9 @@ class InstitutionalServiceJourneyTests(unittest.TestCase):
|
||||
FormFieldDefinition(
|
||||
key=key,
|
||||
label=key.replace("_", " ").title(),
|
||||
value_type=(
|
||||
"email" if key == "applicant_email" else "text"
|
||||
),
|
||||
required=True,
|
||||
constraints={"min_length": 2},
|
||||
)
|
||||
@@ -435,6 +487,20 @@ class InstitutionalServiceJourneyTests(unittest.TestCase):
|
||||
),
|
||||
)
|
||||
registry = _FormRegistry(_service())
|
||||
status_access = JOURNEY["status_access"]
|
||||
FormStatusAccessService(registry).upsert_policy(
|
||||
session,
|
||||
principal,
|
||||
definition_ref=form.reference,
|
||||
mode=status_access["mode"],
|
||||
enabled=True,
|
||||
email_field_key=status_access["email_field_key"],
|
||||
token_ttl_seconds=status_access["token_ttl_seconds"],
|
||||
request_limit_per_hour=status_access[
|
||||
"request_limit_per_hour"
|
||||
],
|
||||
recorded_at=NOW,
|
||||
)
|
||||
intake = FormIntakeService(registry)
|
||||
profile = intake.create_profile(
|
||||
session,
|
||||
@@ -536,6 +602,50 @@ class InstitutionalServiceJourneyTests(unittest.TestCase):
|
||||
self.assertEqual("submitted", submitted.status)
|
||||
self.assertEqual(current.revision, confirmation.instance_revision)
|
||||
self.assertEqual(assisted["affected_party_ref"], confirmation.confirmed_by_ref)
|
||||
status_service = FormStatusAccessService(registry)
|
||||
access = status_service.access_summary_for_instance(
|
||||
resumed,
|
||||
tenant_id="tenant-1",
|
||||
instance_id=instance_id,
|
||||
)
|
||||
self.assertIsNotNone(access)
|
||||
tracking_id = str(access["tracking_id"])
|
||||
challenge = status_service.public_access_challenge(
|
||||
resumed,
|
||||
tracking_id=tracking_id,
|
||||
)
|
||||
self.assertEqual("email_link", challenge["mode"])
|
||||
self.assertFalse(
|
||||
status_service.request_email_link(
|
||||
resumed,
|
||||
tracking_id=tracking_id,
|
||||
email="wrong@example.test",
|
||||
requested_at=NOW + timedelta(minutes=5),
|
||||
)
|
||||
)
|
||||
self.assertTrue(
|
||||
status_service.request_email_link(
|
||||
resumed,
|
||||
tracking_id=tracking_id,
|
||||
email=JOURNEY["form"]["fields"]["applicant_email"],
|
||||
requested_at=NOW + timedelta(minutes=6),
|
||||
)
|
||||
)
|
||||
notification = registry.notifications.requests[-1]
|
||||
query = parse_qs(urlparse(notification.action_url).query)
|
||||
projection = status_service.get_public_projection(
|
||||
resumed,
|
||||
tracking_id=tracking_id,
|
||||
token=query["token"][0],
|
||||
observed_at=NOW + timedelta(minutes=7),
|
||||
)
|
||||
self.assertEqual("submitted", projection["status"])
|
||||
self.assertEqual(JOURNEY["title"], projection["title"])
|
||||
self.assertEqual(
|
||||
["submitted"],
|
||||
[item["status"] for item in projection["timeline"]],
|
||||
)
|
||||
self.assertNotIn("values", projection)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
@@ -682,6 +792,76 @@ class InstitutionalServiceJourneyTests(unittest.TestCase):
|
||||
bind_process_runtime_identity(None)
|
||||
engine.dispose()
|
||||
|
||||
def test_case_bound_payment_handoff_is_replay_safe_and_evidence_bound(
|
||||
self,
|
||||
) -> None:
|
||||
engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
for table in (
|
||||
PaymentObligation.__table__,
|
||||
PaymentReconciliation.__table__,
|
||||
PaymentEvent.__table__,
|
||||
):
|
||||
table.create(engine)
|
||||
session = Session(engine)
|
||||
provider = SqlPaymentRequestProvider()
|
||||
payment = JOURNEY["payment"]
|
||||
try:
|
||||
command = PaymentRequestCommand(
|
||||
tenant_id="tenant-1",
|
||||
source_module="cases",
|
||||
source_resource_type="case",
|
||||
source_resource_id="case-1",
|
||||
amount_minor=payment["amount_minor"],
|
||||
currency=payment["currency"],
|
||||
subject=payment["subject"],
|
||||
idempotency_key="resident-permit-case-1-fee",
|
||||
requested_at=NOW + timedelta(days=1),
|
||||
requested_by_ref="workflow:resident-parking-permit-review",
|
||||
due_at=NOW + timedelta(days=1 + payment["due_days"]),
|
||||
context_refs={
|
||||
"case": "case-1",
|
||||
"workflow": "workflow:resident-parking-permit-review",
|
||||
},
|
||||
)
|
||||
requested = provider.request_payment(session, command)
|
||||
replay = provider.request_payment(session, command)
|
||||
self.assertEqual(requested["payment_id"], replay["payment_id"])
|
||||
self.assertTrue(replay["replayed"])
|
||||
self.assertEqual("case-1", requested["source"]["resource_id"])
|
||||
|
||||
paid = provider.reconcile_manual_payment(
|
||||
session,
|
||||
ManualPaymentReconciliationCommand(
|
||||
tenant_id="tenant-1",
|
||||
payment_id=str(requested["payment_id"]),
|
||||
amount_minor=payment["amount_minor"],
|
||||
currency=payment["currency"],
|
||||
transaction_reference="BANK-RPP-2026-0001",
|
||||
evidence_ref=EvidenceReference(
|
||||
kind="document",
|
||||
owner_module=payment["evidence_owner"],
|
||||
evidence_id="file-payment-rpp-1",
|
||||
tenant_id="tenant-1",
|
||||
version="1",
|
||||
checksum="b" * 64,
|
||||
),
|
||||
idempotency_key="resident-permit-bank-receipt-1",
|
||||
received_at=NOW + timedelta(days=2),
|
||||
recorded_at=NOW + timedelta(days=2, minutes=5),
|
||||
recorded_by_ref="account:payment-officer-1",
|
||||
),
|
||||
)
|
||||
session.commit()
|
||||
self.assertEqual("paid", paid["status"])
|
||||
self.assertEqual(
|
||||
"BANK-RPP-2026-0001",
|
||||
paid["reconciliation"]["transaction_reference"],
|
||||
)
|
||||
self.assertEqual(2, len(paid["events"]))
|
||||
finally:
|
||||
session.close()
|
||||
engine.dispose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user