Release Connectors v0.1.22 with service-desk federation
Module Package Release / publish-packages (push) Successful in 12s
Module Package Release / publish-packages (push) Successful in 12s
This commit is contained in:
@@ -0,0 +1,806 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal
|
||||
from govoplan_core.core.access import PrincipalRef
|
||||
from govoplan_core.core.search import SearchAuthorizationRequest, SearchBackfillRequest
|
||||
from govoplan_core.core.recovery import RecoveryOperation, RecoveryStatus
|
||||
from govoplan_core.core.runtime_coordination import (
|
||||
RuntimeIdentity,
|
||||
bind_process_runtime_identity,
|
||||
)
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_connectors.backend.db.models import (
|
||||
ConnectorConfiguration,
|
||||
ConnectorDefinition,
|
||||
ConnectorDefinitionRevision,
|
||||
ConnectorServiceDeskObject,
|
||||
ConnectorServiceDeskProfile,
|
||||
ConnectorServiceDeskSyncRun,
|
||||
)
|
||||
from govoplan_connectors.backend.service_desk_connector import (
|
||||
SERVICE_DESK_PROVIDER_ID,
|
||||
SERVICE_DESK_RESOURCE_TYPE,
|
||||
ServiceDeskConnectorError,
|
||||
create_profile,
|
||||
discover_profile,
|
||||
list_objects,
|
||||
synchronize_profile,
|
||||
update_profile,
|
||||
update_ticket,
|
||||
)
|
||||
from govoplan_connectors.backend.service_desk_schemas import (
|
||||
ServiceDeskDynamicFieldMapping,
|
||||
ServiceDeskProfileCreateRequest,
|
||||
ServiceDeskProfileUpdateRequest,
|
||||
ServiceDeskQueueMapping,
|
||||
ServiceDeskRouteMapping,
|
||||
ServiceDeskSyncRequest,
|
||||
ServiceDeskTicketUpdateRequest,
|
||||
)
|
||||
from govoplan_connectors.backend.service_desk_search import ExternalServiceDeskSearchSource
|
||||
from govoplan_connectors.backend.service_desk_transport import (
|
||||
ServiceDeskChangeBatch,
|
||||
ServiceDeskTransportError,
|
||||
ServiceDeskUpdateResult,
|
||||
)
|
||||
|
||||
|
||||
ALL_SCOPES = frozenset(
|
||||
{
|
||||
"connectors:service_desk:read",
|
||||
"connectors:service_desk:admin",
|
||||
"connectors:service_desk:sync",
|
||||
"connectors:service_desk:update",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def principal(
|
||||
tenant_id: str = "tenant-1",
|
||||
*,
|
||||
groups: frozenset[str] = frozenset({"agents"}),
|
||||
) -> ApiPrincipal:
|
||||
return ApiPrincipal(
|
||||
principal=PrincipalRef(
|
||||
account_id="account-1",
|
||||
membership_id="membership-1",
|
||||
tenant_id=tenant_id,
|
||||
scopes=ALL_SCOPES,
|
||||
group_ids=groups,
|
||||
),
|
||||
account=SimpleNamespace(id="account-1"),
|
||||
user=SimpleNamespace(id="account-1"),
|
||||
)
|
||||
|
||||
|
||||
def ticket(
|
||||
*,
|
||||
ticket_id: str = "42",
|
||||
revision: str = "2026-08-22T10:00:00Z",
|
||||
queue: str = "Residents",
|
||||
acl: list[str] | None = None,
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"TicketID": ticket_id,
|
||||
"TicketNumber": f"20260822{ticket_id}",
|
||||
"Title": "Resident parking inquiry",
|
||||
"Queue": queue,
|
||||
"QueueID": "3",
|
||||
"State": "open",
|
||||
"StateID": "4",
|
||||
"Priority": "3 normal",
|
||||
"PriorityID": "3",
|
||||
"Owner": "agent.a",
|
||||
"OwnerID": "7",
|
||||
"CustomerUserID": "citizen-17",
|
||||
"CustomerID": "organization-9",
|
||||
"Changed": revision,
|
||||
"GovOPlaNVisibility": "restricted",
|
||||
"GovOPlaNACL": acl or ["group:agents"],
|
||||
"DynamicField": [{"Name": "PermitKind", "Value": "resident"}],
|
||||
"Article": [
|
||||
{
|
||||
"ArticleID": "71",
|
||||
"Subject": "Question",
|
||||
"Body": "Please verify the submitted address.",
|
||||
"Created": "2026-08-22T09:58:00Z",
|
||||
"Attachment": [
|
||||
{
|
||||
"AttachmentID": "91",
|
||||
"Filename": "address.pdf",
|
||||
"Filesize": 1234,
|
||||
"ContentType": "application/pdf",
|
||||
"ContentBase64": "not-retained",
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class StaticTransport:
|
||||
def __init__(self) -> None:
|
||||
self.batches: list[ServiceDeskChangeBatch] = []
|
||||
self.change_calls: list[dict[str, object]] = []
|
||||
self.update_calls = 0
|
||||
self.update_result = ServiceDeskUpdateResult(
|
||||
ticket={
|
||||
**ticket(revision="2026-08-22T11:00:00Z"),
|
||||
"State": "pending reminder",
|
||||
},
|
||||
revision="2026-08-22T11:00:00Z",
|
||||
evidence={"verified": True},
|
||||
)
|
||||
self.update_error: ServiceDeskTransportError | None = None
|
||||
|
||||
def discover(self, **kwargs):
|
||||
del kwargs
|
||||
return {
|
||||
"product": "znuny",
|
||||
"product_version": "7.1.4",
|
||||
"api_family": "generic_interface_rest",
|
||||
"capabilities": [
|
||||
"discover",
|
||||
"link",
|
||||
"search",
|
||||
"read",
|
||||
"synchronize",
|
||||
"publish",
|
||||
],
|
||||
"maturity": "synchronize",
|
||||
"health_status": "healthy",
|
||||
"revision": "discovery-1",
|
||||
"diagnostics": [],
|
||||
"evidence": {"fixture": True},
|
||||
}
|
||||
|
||||
def changes(self, **kwargs):
|
||||
self.change_calls.append(dict(kwargs))
|
||||
if not self.batches:
|
||||
raise AssertionError("No deterministic service-desk batch remains")
|
||||
return self.batches.pop(0)
|
||||
|
||||
def update_ticket(self, **kwargs):
|
||||
del kwargs
|
||||
self.update_calls += 1
|
||||
if self.update_error is not None:
|
||||
raise self.update_error
|
||||
return self.update_result
|
||||
|
||||
|
||||
class RecordingSearchWriter:
|
||||
def __init__(self) -> None:
|
||||
self.upserts: list[object] = []
|
||||
self.deletes: list[str] = []
|
||||
|
||||
def upsert_document(self, _session, _principal, *, document) -> None:
|
||||
self.upserts.append(document)
|
||||
|
||||
def delete_document(
|
||||
self,
|
||||
_session,
|
||||
_principal,
|
||||
*,
|
||||
tenant_id,
|
||||
module_id,
|
||||
resource_type,
|
||||
resource_id,
|
||||
) -> bool:
|
||||
del tenant_id, module_id, resource_type
|
||||
self.deletes.append(resource_id)
|
||||
return True
|
||||
|
||||
def enqueue_change(self, _session, *, change) -> bool:
|
||||
del change
|
||||
return True
|
||||
|
||||
|
||||
class SearchRegistry:
|
||||
def __init__(self, writer: RecordingSearchWriter) -> None:
|
||||
self.writer = writer
|
||||
|
||||
def has_capability(self, name: str) -> bool:
|
||||
return name == "search.index_writer"
|
||||
|
||||
def capability(self, name: str):
|
||||
if not self.has_capability(name):
|
||||
raise KeyError(name)
|
||||
return self.writer
|
||||
|
||||
|
||||
class ServiceDeskConnectorTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
bind_process_runtime_identity(
|
||||
RuntimeIdentity(
|
||||
installation_id="service-desk-connector-tests",
|
||||
node_id="node-1",
|
||||
incarnation="incarnation-1",
|
||||
role="worker",
|
||||
software_version="test",
|
||||
composition_hash="a" * 64,
|
||||
)
|
||||
)
|
||||
self.engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
Base.metadata.create_all(self.engine)
|
||||
self.session = Session(self.engine)
|
||||
self.audit = patch("govoplan_connectors.backend.service_desk_connector.audit_event")
|
||||
self.audit.start()
|
||||
self.credential = patch(
|
||||
"govoplan_connectors.backend.service_desk_connector._credential",
|
||||
return_value={"user_login": "connector", "password": "secret"},
|
||||
)
|
||||
self.credential.start()
|
||||
self.transport = StaticTransport()
|
||||
self.configuration_id = self._seed_configuration()
|
||||
self.profile_id = self._create_profile()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
bind_process_runtime_identity(None)
|
||||
self.credential.stop()
|
||||
self.audit.stop()
|
||||
self.session.close()
|
||||
self.engine.dispose()
|
||||
|
||||
def _seed_configuration(self) -> str:
|
||||
definition = ConnectorDefinition(
|
||||
id="definition-1",
|
||||
tenant_id="tenant-1",
|
||||
definition_key="service-desk.znuny",
|
||||
name="Znuny",
|
||||
status="active",
|
||||
current_revision=1,
|
||||
local_definition=True,
|
||||
)
|
||||
self.session.add(definition)
|
||||
self.session.add(
|
||||
ConnectorDefinitionRevision(
|
||||
id="definition-revision-1",
|
||||
definition_id=definition.id,
|
||||
revision=1,
|
||||
specification={"provider": "znuny", "protocol": "generic_interface_rest"},
|
||||
definition_hash="definition-hash",
|
||||
origin="local",
|
||||
created_by="account-1",
|
||||
)
|
||||
)
|
||||
configuration = ConnectorConfiguration(
|
||||
id="configuration-1",
|
||||
tenant_id="tenant-1",
|
||||
definition_id=definition.id,
|
||||
name="Institutional service desk",
|
||||
status="active",
|
||||
endpoint_url="https://support.example.invalid/znuny/nph-genericinterface.pl/Webservice/GovOPlaN",
|
||||
credential_ref="credential-envelope-1",
|
||||
base_definition_revision=1,
|
||||
local_overrides={},
|
||||
protected_paths=[],
|
||||
effective_configuration={
|
||||
"provider": "znuny",
|
||||
"protocol": "generic_interface_rest",
|
||||
},
|
||||
effective_hash="configuration-hash",
|
||||
resource_revision=1,
|
||||
ambiguity_policy="manual_review",
|
||||
updated_by="account-1",
|
||||
)
|
||||
self.session.add(configuration)
|
||||
self.session.flush()
|
||||
return configuration.id
|
||||
|
||||
def _create_profile(self) -> str:
|
||||
item = create_profile(
|
||||
self.session,
|
||||
principal(),
|
||||
ServiceDeskProfileCreateRequest(
|
||||
configuration_id=self.configuration_id,
|
||||
integration_mode="synchronize",
|
||||
desired_maturity="synchronize",
|
||||
source_authority_mode="governed_sync",
|
||||
default_visibility="restricted",
|
||||
default_acl_tokens=["group:service-desk-managers"],
|
||||
routes=ServiceDeskRouteMapping(
|
||||
update_path="/Ticket/{ticket_id}",
|
||||
ticket_web_url_template="https://support.example.invalid/ticket/{ticket_id}",
|
||||
),
|
||||
queue_mappings=[
|
||||
ServiceDeskQueueMapping(
|
||||
source_queue="Residents",
|
||||
target_queue_ref="helpdesk:residents",
|
||||
visibility="restricted",
|
||||
acl_tokens=["group:agents"],
|
||||
)
|
||||
],
|
||||
dynamic_field_mappings=[
|
||||
ServiceDeskDynamicFieldMapping(
|
||||
source_name="PermitKind",
|
||||
target_name="permit_kind",
|
||||
)
|
||||
],
|
||||
),
|
||||
)
|
||||
return item.id
|
||||
|
||||
def _discover(self) -> None:
|
||||
discover_profile(
|
||||
self.session,
|
||||
principal(),
|
||||
profile_id=self.profile_id,
|
||||
transport=self.transport,
|
||||
)
|
||||
|
||||
def _sync(self, raw: list[dict[str, object]], *, key: str, complete: bool = True):
|
||||
self.transport.batches.append(
|
||||
ServiceDeskChangeBatch(
|
||||
changes=tuple(raw),
|
||||
next_cursor=(
|
||||
None
|
||||
if complete
|
||||
else json.dumps(
|
||||
{"kind": "full", "offset": len(raw), "fingerprint": "fixture"}
|
||||
)
|
||||
),
|
||||
complete=complete,
|
||||
high_watermark="2026-08-22T10:00:00Z",
|
||||
live_ids=tuple(str(value["TicketID"]) for value in raw) if complete else None,
|
||||
evidence={"fixture": True},
|
||||
)
|
||||
)
|
||||
return synchronize_profile(
|
||||
self.session,
|
||||
principal(),
|
||||
profile_id=self.profile_id,
|
||||
payload=ServiceDeskSyncRequest(idempotency_key=key),
|
||||
transport=self.transport,
|
||||
registry=None,
|
||||
)
|
||||
|
||||
def test_mapping_replay_cursor_transition_and_search_acl(self) -> None:
|
||||
self._discover()
|
||||
run = self._sync([ticket()], key="sync-1")
|
||||
self.assertEqual({"created": 1}, run.counts)
|
||||
self.assertEqual("delta", json.loads(run.cursor_after or "{}").get("kind"))
|
||||
self.assertIn("attachment_content_omitted", {item.code for item in run.diagnostics})
|
||||
|
||||
objects, _cursor = list_objects(self.session, principal(), profile_id=self.profile_id)
|
||||
self.assertEqual(1, len(objects))
|
||||
item = objects[0]
|
||||
self.assertEqual("helpdesk:residents", item.mapped_data["target_queue_ref"])
|
||||
self.assertEqual("resident", item.mapped_data["dynamic_fields"]["permit_kind"])
|
||||
self.assertFalse(item.mapped_data["attachments"][0]["content_retained"])
|
||||
self.assertEqual("article", item.mapped_data["articles"][0]["reference"]["object_type"])
|
||||
|
||||
replay = synchronize_profile(
|
||||
self.session,
|
||||
principal(),
|
||||
profile_id=self.profile_id,
|
||||
payload=ServiceDeskSyncRequest(idempotency_key="sync-1"),
|
||||
transport=self.transport,
|
||||
registry=None,
|
||||
)
|
||||
self.assertEqual(run.id, replay.id)
|
||||
self.assertEqual(1, len(self.transport.change_calls))
|
||||
|
||||
source = ExternalServiceDeskSearchSource()
|
||||
document = source.backfill(
|
||||
self.session,
|
||||
request=SearchBackfillRequest(
|
||||
tenant_id="tenant-1",
|
||||
provider_id=SERVICE_DESK_PROVIDER_ID,
|
||||
resource_type=SERVICE_DESK_RESOURCE_TYPE,
|
||||
rebuild_id="rebuild-1",
|
||||
),
|
||||
).documents[0]
|
||||
authorization = SearchAuthorizationRequest(
|
||||
reference=document.reference,
|
||||
source_revision=document.source_revision,
|
||||
)
|
||||
self.assertTrue(source.authorize(self.session, principal(), requests=[authorization])[document.reference.key])
|
||||
self.assertFalse(
|
||||
source.authorize(
|
||||
self.session,
|
||||
principal(groups=frozenset({"other"})),
|
||||
requests=[authorization],
|
||||
)[document.reference.key]
|
||||
)
|
||||
|
||||
def test_paged_full_stays_full_then_switches_to_delta(self) -> None:
|
||||
self._discover()
|
||||
first = self._sync([ticket()], key="page-1", complete=False)
|
||||
self.assertEqual("full", json.loads(first.cursor_after or "{}").get("kind"))
|
||||
second = self._sync([ticket(ticket_id="43")], key="page-2", complete=True)
|
||||
self.assertEqual("delta", json.loads(second.cursor_after or "{}").get("kind"))
|
||||
self.assertTrue(self.transport.change_calls[0]["force_full"])
|
||||
self.assertTrue(self.transport.change_calls[1]["force_full"])
|
||||
|
||||
def test_profile_updates_preserve_provider_acl_until_the_next_sync(self) -> None:
|
||||
self._discover()
|
||||
self._sync([ticket(acl=["group:provider-agents"])], key="provider-acl")
|
||||
profile = self.session.get(ConnectorServiceDeskProfile, self.profile_id)
|
||||
update_profile(
|
||||
self.session,
|
||||
principal(),
|
||||
profile_id=self.profile_id,
|
||||
payload=ServiceDeskProfileUpdateRequest(
|
||||
expected_resource_revision=profile.resource_revision,
|
||||
default_visibility="tenant",
|
||||
default_acl_tokens=[],
|
||||
),
|
||||
registry=None,
|
||||
)
|
||||
stored = self.session.scalar(
|
||||
select(ConnectorServiceDeskObject).where(
|
||||
ConnectorServiceDeskObject.profile_id == self.profile_id,
|
||||
ConnectorServiceDeskObject.external_id == "42",
|
||||
)
|
||||
)
|
||||
self.assertEqual("restricted", stored.visibility)
|
||||
self.assertEqual(["group:provider-agents"], stored.acl_tokens)
|
||||
self.assertEqual("provider", stored.mapped_data["permission_source"])
|
||||
|
||||
def test_unchanged_editor_payload_does_not_reset_discovery_or_cursor(self) -> None:
|
||||
self._discover()
|
||||
self._sync([ticket()], key="before-noop-save")
|
||||
profile = self.session.get(ConnectorServiceDeskProfile, self.profile_id)
|
||||
cursor_before = profile.last_sync_cursor
|
||||
discovered_at = profile.discovered_at
|
||||
update_profile(
|
||||
self.session,
|
||||
principal(),
|
||||
profile_id=self.profile_id,
|
||||
payload=ServiceDeskProfileUpdateRequest(
|
||||
expected_resource_revision=profile.resource_revision,
|
||||
integration_mode=profile.integration_mode,
|
||||
desired_maturity=profile.desired_maturity,
|
||||
source_authority_mode=profile.source_authority_mode,
|
||||
default_visibility=profile.default_visibility,
|
||||
default_acl_tokens=list(profile.default_acl_tokens),
|
||||
routes=ServiceDeskRouteMapping.model_validate(profile.routes),
|
||||
queue_mappings=[
|
||||
ServiceDeskQueueMapping.model_validate(value)
|
||||
for value in profile.queue_mappings
|
||||
],
|
||||
dynamic_field_mappings=[
|
||||
ServiceDeskDynamicFieldMapping.model_validate(value)
|
||||
for value in profile.dynamic_field_mappings
|
||||
],
|
||||
),
|
||||
registry=None,
|
||||
)
|
||||
self.assertEqual(cursor_before, profile.last_sync_cursor)
|
||||
self.assertEqual(discovered_at, profile.discovered_at)
|
||||
|
||||
def test_route_or_configuration_changes_require_rediscovery(self) -> None:
|
||||
self._discover()
|
||||
self._sync([ticket()], key="before-route-change")
|
||||
profile = self.session.get(ConnectorServiceDeskProfile, self.profile_id)
|
||||
update_profile(
|
||||
self.session,
|
||||
principal(),
|
||||
profile_id=self.profile_id,
|
||||
payload=ServiceDeskProfileUpdateRequest(
|
||||
expected_resource_revision=profile.resource_revision,
|
||||
routes=ServiceDeskRouteMapping(
|
||||
search_path="/GovOPlaN/Ticket/Search",
|
||||
ticket_path="/Ticket/{ticket_id}",
|
||||
update_path="/Ticket/{ticket_id}",
|
||||
),
|
||||
),
|
||||
registry=None,
|
||||
)
|
||||
self.assertIsNone(profile.discovered_at)
|
||||
self.assertEqual([], profile.capabilities)
|
||||
stored = self.session.scalar(
|
||||
select(ConnectorServiceDeskObject).where(
|
||||
ConnectorServiceDeskObject.profile_id == self.profile_id,
|
||||
ConnectorServiceDeskObject.external_id == "42",
|
||||
)
|
||||
)
|
||||
self.assertEqual("deleted", stored.status)
|
||||
with self.assertRaisesRegex(ServiceDeskConnectorError, "Discover the provider"):
|
||||
synchronize_profile(
|
||||
self.session,
|
||||
principal(),
|
||||
profile_id=self.profile_id,
|
||||
payload=ServiceDeskSyncRequest(idempotency_key="route-stale"),
|
||||
transport=self.transport,
|
||||
registry=None,
|
||||
)
|
||||
|
||||
self._discover()
|
||||
self._sync([ticket()], key="after-route-rediscovery")
|
||||
self.assertEqual("active", stored.status)
|
||||
configuration = self.session.get(ConnectorConfiguration, self.configuration_id)
|
||||
configuration.resource_revision += 1
|
||||
configuration.effective_hash = "configuration-hash-changed"
|
||||
self.session.commit()
|
||||
self.assertEqual(
|
||||
(),
|
||||
ExternalServiceDeskSearchSource()
|
||||
.backfill(
|
||||
self.session,
|
||||
request=SearchBackfillRequest(
|
||||
tenant_id="tenant-1",
|
||||
provider_id=SERVICE_DESK_PROVIDER_ID,
|
||||
resource_type=SERVICE_DESK_RESOURCE_TYPE,
|
||||
rebuild_id="stale-configuration",
|
||||
),
|
||||
)
|
||||
.documents,
|
||||
)
|
||||
with self.assertRaisesRegex(ServiceDeskConnectorError, "configuration changed"):
|
||||
synchronize_profile(
|
||||
self.session,
|
||||
principal(),
|
||||
profile_id=self.profile_id,
|
||||
payload=ServiceDeskSyncRequest(idempotency_key="configuration-stale"),
|
||||
transport=self.transport,
|
||||
registry=None,
|
||||
)
|
||||
self._discover()
|
||||
self.assertEqual("deleted", stored.status)
|
||||
self.assertIsNone(profile.last_sync_cursor)
|
||||
|
||||
def test_full_sync_reprojects_unchanged_tickets_into_search(self) -> None:
|
||||
self._discover()
|
||||
self._sync([ticket()], key="initial-search-projection")
|
||||
writer = RecordingSearchWriter()
|
||||
self.transport.batches.append(
|
||||
ServiceDeskChangeBatch(
|
||||
changes=(ticket(),),
|
||||
next_cursor=None,
|
||||
complete=True,
|
||||
high_watermark="2026-08-22T10:00:00Z",
|
||||
live_ids=("42",),
|
||||
evidence={"fixture": True},
|
||||
)
|
||||
)
|
||||
run = synchronize_profile(
|
||||
self.session,
|
||||
principal(),
|
||||
profile_id=self.profile_id,
|
||||
payload=ServiceDeskSyncRequest(
|
||||
idempotency_key="full-search-reprojection",
|
||||
mode="full",
|
||||
),
|
||||
transport=self.transport,
|
||||
registry=SearchRegistry(writer),
|
||||
)
|
||||
self.assertEqual({"unchanged": 1}, run.counts)
|
||||
self.assertEqual(1, len(writer.upserts))
|
||||
self.assertIsNone(self.transport.change_calls[-1]["cursor"])
|
||||
|
||||
def test_delta_cannot_bootstrap_without_a_completed_full_sync(self) -> None:
|
||||
self._discover()
|
||||
with self.assertRaisesRegex(ServiceDeskConnectorError, "completed full"):
|
||||
synchronize_profile(
|
||||
self.session,
|
||||
principal(),
|
||||
profile_id=self.profile_id,
|
||||
payload=ServiceDeskSyncRequest(
|
||||
idempotency_key="unsafe-delta-bootstrap",
|
||||
mode="delta",
|
||||
),
|
||||
transport=self.transport,
|
||||
registry=None,
|
||||
)
|
||||
self.assertEqual([], self.transport.change_calls)
|
||||
|
||||
def test_link_mode_omits_top_level_attachment_metadata(self) -> None:
|
||||
self._discover()
|
||||
profile = self.session.get(ConnectorServiceDeskProfile, self.profile_id)
|
||||
update_profile(
|
||||
self.session,
|
||||
principal(),
|
||||
profile_id=self.profile_id,
|
||||
payload=ServiceDeskProfileUpdateRequest(
|
||||
expected_resource_revision=profile.resource_revision,
|
||||
integration_mode="link",
|
||||
desired_maturity="link",
|
||||
source_authority_mode="linked_reference",
|
||||
),
|
||||
registry=None,
|
||||
)
|
||||
raw = ticket()
|
||||
raw.pop("Article")
|
||||
raw.pop("DynamicField")
|
||||
raw["Attachment"] = [
|
||||
{
|
||||
"AttachmentID": "top-1",
|
||||
"Filename": "metadata-only.pdf",
|
||||
"Filesize": 42,
|
||||
}
|
||||
]
|
||||
run = self._sync([raw], key="link-refresh")
|
||||
objects, _cursor = list_objects(
|
||||
self.session,
|
||||
principal(),
|
||||
profile_id=self.profile_id,
|
||||
)
|
||||
self.assertEqual([], objects[0].mapped_data["attachments"])
|
||||
self.assertIn("link_mode_content_omitted", {item.code for item in run.diagnostics})
|
||||
self.assertTrue(self.transport.change_calls[-1]["routes"]["_identity_only"])
|
||||
|
||||
def test_excluded_queue_removes_projection_and_profile_policy_fails_closed(self) -> None:
|
||||
self._discover()
|
||||
self._sync([ticket()], key="sync-active")
|
||||
profile = self.session.get(ConnectorServiceDeskProfile, self.profile_id)
|
||||
update_profile(
|
||||
self.session,
|
||||
principal(),
|
||||
profile_id=self.profile_id,
|
||||
payload=ServiceDeskProfileUpdateRequest(
|
||||
expected_resource_revision=profile.resource_revision,
|
||||
queue_mappings=[
|
||||
ServiceDeskQueueMapping(
|
||||
source_queue="Residents",
|
||||
include=False,
|
||||
visibility="restricted",
|
||||
acl_tokens=["group:agents"],
|
||||
)
|
||||
],
|
||||
),
|
||||
registry=None,
|
||||
)
|
||||
stored = self.session.scalar(
|
||||
select(ConnectorServiceDeskObject).where(
|
||||
ConnectorServiceDeskObject.profile_id == self.profile_id,
|
||||
ConnectorServiceDeskObject.external_id == "42",
|
||||
)
|
||||
)
|
||||
self.assertEqual("deleted", stored.status)
|
||||
self.assertEqual(
|
||||
(),
|
||||
ExternalServiceDeskSearchSource()
|
||||
.backfill(
|
||||
self.session,
|
||||
request=SearchBackfillRequest(
|
||||
tenant_id="tenant-1",
|
||||
provider_id=SERVICE_DESK_PROVIDER_ID,
|
||||
resource_type=SERVICE_DESK_RESOURCE_TYPE,
|
||||
rebuild_id="excluded-rebuild",
|
||||
),
|
||||
)
|
||||
.documents,
|
||||
)
|
||||
self.transport.batches.append(
|
||||
ServiceDeskChangeBatch(
|
||||
changes=(ticket(revision="2026-08-22T10:30:00Z"),),
|
||||
next_cursor=json.dumps(
|
||||
{"kind": "delta", "changed": "2026-08-22T10:30:00Z", "seen": ["42"]}
|
||||
),
|
||||
complete=True,
|
||||
high_watermark="2026-08-22T10:30:00Z",
|
||||
live_ids=None,
|
||||
evidence={"fixture": True},
|
||||
)
|
||||
)
|
||||
run = synchronize_profile(
|
||||
self.session,
|
||||
principal(),
|
||||
profile_id=self.profile_id,
|
||||
payload=ServiceDeskSyncRequest(idempotency_key="sync-excluded"),
|
||||
transport=self.transport,
|
||||
registry=None,
|
||||
)
|
||||
self.assertEqual({"ignored": 1}, run.counts)
|
||||
self.assertEqual("deleted", stored.status)
|
||||
|
||||
with self.assertRaises(ServiceDeskConnectorError):
|
||||
update_profile(
|
||||
self.session,
|
||||
principal(),
|
||||
profile_id=self.profile_id,
|
||||
payload=ServiceDeskProfileUpdateRequest(
|
||||
expected_resource_revision=self.session.get(
|
||||
ConnectorServiceDeskProfile, self.profile_id
|
||||
).resource_revision,
|
||||
integration_mode="link",
|
||||
desired_maturity="synchronize",
|
||||
source_authority_mode="linked_reference",
|
||||
),
|
||||
registry=None,
|
||||
)
|
||||
|
||||
def test_governed_update_replay_and_unknown_outcome(self) -> None:
|
||||
self._discover()
|
||||
self._sync([ticket()], key="sync-before-update")
|
||||
payload = ServiceDeskTicketUpdateRequest(
|
||||
idempotency_key="update-1",
|
||||
expected_external_revision="2026-08-22T10:00:00Z",
|
||||
state="pending reminder",
|
||||
)
|
||||
result = update_ticket(
|
||||
self.session,
|
||||
principal(),
|
||||
profile_id=self.profile_id,
|
||||
external_ticket_id="42",
|
||||
payload=payload,
|
||||
transport=self.transport,
|
||||
registry=None,
|
||||
durable_recovery=True,
|
||||
)
|
||||
replay = update_ticket(
|
||||
self.session,
|
||||
principal(),
|
||||
profile_id=self.profile_id,
|
||||
external_ticket_id="42",
|
||||
payload=payload,
|
||||
transport=self.transport,
|
||||
registry=None,
|
||||
durable_recovery=False,
|
||||
)
|
||||
self.assertTrue(result.accepted)
|
||||
self.assertEqual(result.run.id, replay.run.id)
|
||||
self.assertEqual(1, self.transport.update_calls)
|
||||
recovery = self.session.scalar(
|
||||
select(RecoveryOperation).where(
|
||||
RecoveryOperation.resource_type == SERVICE_DESK_RESOURCE_TYPE,
|
||||
RecoveryOperation.resource_id == "42",
|
||||
)
|
||||
)
|
||||
self.assertEqual(RecoveryStatus.SUCCEEDED.value, recovery.status)
|
||||
|
||||
self.transport.update_error = ServiceDeskTransportError(
|
||||
"provider_unavailable",
|
||||
"No conclusive provider response.",
|
||||
retryable=True,
|
||||
outcome_unknown=True,
|
||||
)
|
||||
with self.assertRaisesRegex(ServiceDeskConnectorError, "outcome is unknown"):
|
||||
update_ticket(
|
||||
self.session,
|
||||
principal(),
|
||||
profile_id=self.profile_id,
|
||||
external_ticket_id="42",
|
||||
payload=ServiceDeskTicketUpdateRequest(
|
||||
idempotency_key="update-unknown",
|
||||
expected_external_revision="2026-08-22T11:00:00Z",
|
||||
priority="4 high",
|
||||
),
|
||||
transport=self.transport,
|
||||
registry=None,
|
||||
durable_recovery=False,
|
||||
)
|
||||
unresolved = self.session.scalar(
|
||||
select(ConnectorServiceDeskSyncRun).where(
|
||||
ConnectorServiceDeskSyncRun.idempotency_key == "update-unknown"
|
||||
)
|
||||
)
|
||||
self.assertEqual("outcome_unknown", unresolved.status)
|
||||
|
||||
def test_tenant_isolation(self) -> None:
|
||||
with self.assertRaisesRegex(ServiceDeskConnectorError, "not found"):
|
||||
list_objects(self.session, principal("tenant-2"), profile_id=self.profile_id)
|
||||
|
||||
def test_malformed_continuous_batch_is_atomic_and_evidenced(self) -> None:
|
||||
self._discover()
|
||||
malformed = ticket()
|
||||
malformed.pop("Changed")
|
||||
with self.assertRaisesRegex(ServiceDeskConnectorError, "change timestamp"):
|
||||
self._sync([malformed], key="sync-malformed")
|
||||
self.assertIsNone(
|
||||
self.session.scalar(
|
||||
select(ConnectorServiceDeskObject).where(
|
||||
ConnectorServiceDeskObject.profile_id == self.profile_id
|
||||
)
|
||||
)
|
||||
)
|
||||
failed = self.session.scalar(
|
||||
select(ConnectorServiceDeskSyncRun).where(
|
||||
ConnectorServiceDeskSyncRun.idempotency_key == "sync-malformed"
|
||||
)
|
||||
)
|
||||
self.assertEqual("failed", failed.status)
|
||||
self.assertEqual("change_timestamp_missing", failed.diagnostics[0]["code"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user