feat(tickets): deliver canonical operational lifecycle

This commit is contained in:
2026-08-22 11:41:45 +02:00
parent b4bb52d8b8
commit 7cbf3bed96
25 changed files with 4742 additions and 106 deletions
+56 -19
View File
@@ -2,39 +2,76 @@ from __future__ import annotations
import unittest
from govoplan_core.core.tickets import (
CAPABILITY_TICKET_CASE_ESCALATION,
CAPABILITY_TICKET_ROUTING,
)
from govoplan_tickets.backend.dsar_provider import TICKETS_DSAR_CAPABILITY
from govoplan_tickets.backend.manifest import (
ADMIN_SCOPE,
ASSIGN_SCOPE,
READ_SCOPE,
REPORT_SCOPE,
RESOLVE_SCOPE,
TRIAGE_SCOPE,
WRITE_SCOPE,
get_manifest,
)
from govoplan_tickets.backend.service import CAPABILITY_TICKETS_REGISTRY
class ManifestSeedTests(unittest.TestCase):
def test_manifest_registers_seed_contract(self) -> None:
class ManifestTests(unittest.TestCase):
def test_manifest_registers_the_vertical_slice(self) -> None:
manifest = get_manifest()
self.assertEqual(manifest.id, "tickets")
self.assertEqual(manifest.name, "Tickets")
self.assertEqual(manifest.dependencies, ("access",))
self.assertEqual({permission.scope for permission in manifest.permissions}, {READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE})
self.assertEqual("tickets", manifest.id)
self.assertEqual("0.1.20", manifest.version)
self.assertEqual(("access",), manifest.dependencies)
self.assertEqual(
{
READ_SCOPE,
REPORT_SCOPE,
TRIAGE_SCOPE,
ASSIGN_SCOPE,
RESOLVE_SCOPE,
ADMIN_SCOPE,
WRITE_SCOPE,
},
{permission.scope for permission in manifest.permissions},
)
self.assertEqual(
{
"tickets_reporter",
"tickets_manager",
"tickets_viewer",
"tickets_administrator",
},
{role.slug for role in manifest.role_templates},
{"tickets_manager", "tickets_viewer"},
)
self.assertTrue(manifest.documentation)
topic = manifest.documentation[0]
self.assertEqual("reference", topic.metadata["kind"])
self.assertIn("seed_boundary", topic.metadata["consequence_classes"])
self.assertTrue(
all(
topic.translations.get("de", {}).get(field)
for field in ("title", "summary", "body")
self.assertIsNotNone(manifest.route_factory)
self.assertIsNotNone(manifest.migration_spec)
self.assertIsNotNone(manifest.frontend)
self.assertEqual(1, len(manifest.search_sources))
self.assertIn(CAPABILITY_TICKETS_REGISTRY, manifest.capability_factories)
self.assertIn(TICKETS_DSAR_CAPABILITY, manifest.capability_factories)
self.assertEqual(
{CAPABILITY_TICKET_ROUTING, CAPABILITY_TICKET_CASE_ESCALATION},
set(manifest.optional_capabilities),
)
self.assertEqual("vertical_slice", manifest.architecture.maturity)
def test_documentation_has_static_and_workflow_baselines_in_german(self) -> None:
topics = get_manifest().documentation
self.assertTrue(any(topic.layer == "available" for topic in topics))
self.assertTrue(any(topic.metadata.get("kind") == "workflow" for topic in topics))
for topic in topics:
self.assertTrue(
all(topic.translations.get("de", {}).get(field) for field in ("title", "summary", "body")),
topic.id,
)
)
self.assertIsNone(manifest.route_factory)
self.assertIsNone(manifest.migration_spec)
self.assertIsNone(manifest.frontend)
optional = next(topic for topic in topics if topic.id == "tickets.optional-integrations")
self.assertIn("Helpdesk", optional.body)
self.assertIn("Search", optional.body)
if __name__ == "__main__":
+367
View File
@@ -0,0 +1,367 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from govoplan_core.core.events import EventObjectRef, EventTenantRef, PlatformEvent
from govoplan_core.core.search import SearchBackfillRequest
from govoplan_core.core.tickets import (
CAPABILITY_TICKET_CASE_ESCALATION,
CAPABILITY_TICKET_ROUTING,
TicketCaseEscalationResult,
TicketRoutingPlan,
)
from govoplan_core.db.base import Base
from govoplan_tickets.backend.db.models import TicketComment, TicketEscalation, TicketHistory
from govoplan_tickets.backend.domain import TicketLink, TicketRecord, TicketSubjectRef
from govoplan_tickets.backend.search_source import PROVIDER_ID, RESOURCE_TYPE, TicketsSearchSource
from govoplan_tickets.backend.service import (
ADMIN_SCOPE,
ASSIGN_SCOPE,
READ_SCOPE,
REPORT_SCOPE,
RESOLVE_SCOPE,
TRIAGE_SCOPE,
TicketConflictError,
add_ticket_comment,
add_ticket_link,
assign_ticket,
create_ticket,
escalate_ticket_to_case,
get_ticket,
list_ticket_comments,
list_tickets,
resolve_ticket,
ticket_history,
triage_ticket,
)
NOW = datetime(2026, 8, 22, 9, 0, tzinfo=UTC)
class _Principal:
def __init__(self, account_id: str, scopes: set[str], tenant_id: str = "tenant-1") -> None:
self.account_id = account_id
self.identity_id = None
self.membership_id = f"membership-{account_id}"
self.tenant_id = tenant_id
self.scopes = frozenset(scopes)
self.group_ids = frozenset()
self.role_ids = frozenset()
self.function_assignment_ids = frozenset()
self.acting_assignment_id = None
self.user = SimpleNamespace(id=account_id)
def has(self, scope: str) -> bool:
return scope in self.scopes
class _Integrations:
def route_ticket(self, session, principal, *, request):
del session, principal
return TicketRoutingPlan(
provider_id="helpdesk",
queue_ref=request.queue_hint or "citizen-service",
service_target_at=request.received_at + timedelta(hours=24),
explanation="Matched the default service profile.",
)
def escalate_ticket(self, session, principal, *, command):
del session, principal
return TicketCaseEscalationResult(
provider_id="cases",
case_id=f"case-{command.ticket_id}",
case_number=f"CASE-{command.ticket_number}",
case_url=f"/cases/case-{command.ticket_id}",
)
class _Registry:
def __init__(self, integrations: _Integrations | None = None) -> None:
self.capabilities = (
{
CAPABILITY_TICKET_ROUTING: integrations,
CAPABILITY_TICKET_CASE_ESCALATION: integrations,
}
if integrations
else {}
)
def has_capability(self, name):
return name in self.capabilities
def capability(self, name):
return self.capabilities[name]
def active_module_ids(self):
return ("tickets", "cases", "helpdesk", "search") if self.capabilities else ("tickets",)
class TicketServiceTests(unittest.TestCase):
def setUp(self) -> None:
engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
Base.metadata.create_all(engine)
self.Session = sessionmaker(bind=engine, expire_on_commit=False)
self.manager = _Principal(
"manager-1",
{READ_SCOPE, REPORT_SCOPE, TRIAGE_SCOPE, ASSIGN_SCOPE, RESOLVE_SCOPE, ADMIN_SCOPE},
)
self.reporter = _Principal("reporter-1", {READ_SCOPE, REPORT_SCOPE})
def test_lifecycle_is_tenant_safe_replay_safe_and_revisioned(self) -> None:
registry = _Registry(_Integrations())
with self.Session() as session:
created = create_ticket(
session,
self.reporter,
record=_record(reporter="reporter-1"),
idempotency_key="report-1",
registry=registry,
)
session.commit()
self.assertEqual("citizen-service", created.queue_ref)
self.assertEqual(NOW + timedelta(hours=24), created.service_target_at)
replay = create_ticket(
session,
self.reporter,
record=_record(reporter="reporter-1"),
idempotency_key="report-1",
registry=registry,
)
self.assertEqual(created.to_dict(), replay.to_dict())
triaged = triage_ticket(
session,
self.manager,
ticket_id=created.ticket_id,
expected_revision=1,
changes={"status": "triaged", "priority": "high", "visibility": "restricted"},
recorded_at=NOW + timedelta(minutes=5),
change_reason="Classified by the service desk.",
idempotency_key="triage-1",
registry=registry,
)
assigned = assign_ticket(
session,
self.manager,
ticket_id=created.ticket_id,
expected_revision=triaged.revision,
assignee=TicketSubjectRef(kind="account", id="manager-1", label="Manager"),
recorded_at=NOW + timedelta(minutes=10),
change_reason="Assigned to the duty manager.",
idempotency_key="assign-1",
registry=registry,
)
resolved = resolve_ticket(
session,
self.manager,
ticket_id=created.ticket_id,
expected_revision=assigned.revision,
target_status="resolved",
resolution_summary="Streetlight repair was commissioned.",
recorded_at=NOW + timedelta(hours=2),
change_reason="Resolution confirmed.",
idempotency_key="resolve-1",
registry=registry,
)
session.commit()
self.assertEqual(4, resolved.revision)
self.assertEqual("resolved", resolved.status)
self.assertEqual(4, session.query(TicketHistory).count())
outsider = _Principal("outsider", {READ_SCOPE})
self.assertIsNone(get_ticket(session, outsider, ticket_id=created.ticket_id))
self.assertIsNotNone(get_ticket(session, self.reporter, ticket_id=created.ticket_id))
other_tenant = _Principal("manager-1", self.manager.scopes, tenant_id="tenant-2")
self.assertIsNone(get_ticket(session, other_tenant, ticket_id=created.ticket_id))
def test_links_comments_history_and_case_escalation_are_auditable(self) -> None:
registry = _Registry(_Integrations())
with self.Session() as session:
created = create_ticket(
session,
self.manager,
record=_record(reporter="manager-1"),
idempotency_key="report-2",
registry=registry,
)
linked = add_ticket_link(
session,
self.manager,
ticket_id=created.ticket_id,
expected_revision=1,
link=TicketLink(
link_id="file-1",
kind="attachment",
owner_module="files",
resource_type="file",
resource_id="file-1",
label="Photo",
url="/files/file-1",
),
recorded_at=NOW + timedelta(minutes=1),
change_reason="Attached inspection photo.",
idempotency_key="link-1",
registry=registry,
)
commented, comment = add_ticket_comment(
session,
self.manager,
ticket_id=created.ticket_id,
expected_revision=linked.revision,
comment_id="comment-1",
body="Forwarded to maintenance.",
visibility="internal",
recorded_at=NOW + timedelta(minutes=2),
idempotency_key="comment-op-1",
registry=registry,
)
escalated, outcome = escalate_ticket_to_case(
session,
self.manager,
ticket_id=created.ticket_id,
expected_revision=commented.revision,
case_type_key="service-request",
occurred_at=NOW + timedelta(minutes=3),
handoff_note="Formal procedure required.",
idempotency_key="escalate-1",
registry=registry,
)
session.commit()
self.assertEqual("comment-1", comment["comment_id"])
self.assertEqual("case-ticket-1", outcome["case_id"])
self.assertEqual(1, session.query(TicketComment).count())
self.assertEqual(1, session.query(TicketEscalation).count())
self.assertTrue(any(item.kind == "case" for item in escalated.links))
self.assertEqual(4, len(ticket_history(session, self.manager, ticket_id=created.ticket_id)))
self.assertEqual(1, len(list_ticket_comments(session, self.manager, ticket_id=created.ticket_id)))
replayed, replay_outcome = escalate_ticket_to_case(
session,
self.manager,
ticket_id=created.ticket_id,
expected_revision=commented.revision,
case_type_key="service-request",
occurred_at=NOW + timedelta(minutes=3),
handoff_note="Formal procedure required.",
idempotency_key="escalate-1",
registry=registry,
)
self.assertEqual(escalated.revision, replayed.revision)
self.assertTrue(replay_outcome["replayed"])
def test_revision_conflicts_and_search_rechecks_fail_closed(self) -> None:
with self.Session() as session:
created = create_ticket(
session,
self.manager,
record=_record(reporter="manager-1"),
idempotency_key="report-3",
)
triage_ticket(
session,
self.manager,
ticket_id=created.ticket_id,
expected_revision=1,
changes={"status": "triaged"},
recorded_at=NOW + timedelta(minutes=1),
change_reason="Triaged.",
idempotency_key="triage-3",
)
session.commit()
with self.assertRaises(TicketConflictError):
triage_ticket(
session,
self.manager,
ticket_id=created.ticket_id,
expected_revision=1,
changes={"priority": "urgent"},
recorded_at=NOW + timedelta(minutes=2),
change_reason="Stale update.",
idempotency_key="triage-stale",
)
session.rollback()
source = TicketsSearchSource()
page = source.backfill(
session,
request=SearchBackfillRequest(
tenant_id="tenant-1",
provider_id=PROVIDER_ID,
resource_type=RESOURCE_TYPE,
rebuild_id="rebuild-1",
),
)
self.assertEqual(1, len(page.documents))
event = PlatformEvent(
type="tickets.ticket.triaged",
module_id="tickets",
tenant=EventTenantRef(id="tenant-1"),
resource=EventObjectRef(type="ticket", id=created.ticket_id),
)
changes = source.index_changes_for_event(session, event=event, delivery_key="delivery-1")
self.assertEqual("upsert", changes[0].kind)
items, total = list_tickets(session, self.manager)
self.assertEqual(1, total)
self.assertEqual(created.ticket_id, items[0].ticket_id)
def test_report_and_comment_permissions_are_enforced_below_the_router(self) -> None:
viewer = _Principal("viewer-1", {READ_SCOPE})
with self.Session() as session:
with self.assertRaises(PermissionError):
create_ticket(
session,
viewer,
record=_record(reporter="viewer-1"),
idempotency_key="viewer-report",
)
created = create_ticket(
session,
self.reporter,
record=_record(reporter="reporter-1"),
idempotency_key="report-comment-test",
)
with self.assertRaises(PermissionError):
add_ticket_comment(
session,
viewer,
ticket_id=created.ticket_id,
expected_revision=created.revision,
comment_id="viewer-comment",
body="A read-only viewer must not append a comment.",
visibility="external",
recorded_at=NOW + timedelta(minutes=1),
idempotency_key="viewer-comment-op",
)
def _record(*, reporter: str) -> TicketRecord:
return TicketRecord(
tenant_id="tenant-1",
ticket_id="ticket-1",
ticket_number="TKT-2026-0001",
revision=1,
ticket_type="request",
priority="normal",
status="new",
title="Broken streetlight",
description="The lamp at the town square is not working.",
visibility="restricted",
reporter=TicketSubjectRef(kind="account", id=reporter),
requester=TicketSubjectRef(kind="account", id=reporter),
received_at=NOW,
recorded_at=NOW,
change_reason="Reported through the authenticated portal.",
)
if __name__ == "__main__":
unittest.main()