From 7a8a60da26978d31e7e80245e0c23bd02dd71ef4 Mon Sep 17 00:00:00 2001 From: Doga Gursoy Date: Fri, 5 Jun 2026 06:59:47 +0300 Subject: [PATCH 1/2] feat(equipment): Asset.persistent_id write path + DoiMinter port + Stub adapter (slice F.1) Closes the D-ASSIGN deferral from slice E.1. Adds the WRITE path for PIDINST v1.0 Property 1 at the Asset tier: an aggregate-tier persistent_id field, a PersistentIdentifier value object, the closed PersistentIdentifierScheme enum {DOI, HANDLE}, the assign_asset_persistent_id mutation slice, the AssetPersistentIdAssigned event, the DoiMinter Protocol port, and a StubDoiMinter adapter that ships without DataCite credentials. The production DataCite adapter (PUT /dois/{id}, HTTP Basic, 429 backoff, cassettes) is deferred to F.2 behind facility-credential gating. WHY: operators need a server-mint flow today so that downstream PIDINST serialization swaps from urn:uuid: to the assigned DOI as soon as the assign slice runs. Slice E.1 reserved the JSONB column and shipped the read closure; slice F closes the write side end-to-end on a Stub so the whole flow can be proven on the inert mint, then F.2 swaps the Stub for the production adapter once credentials land at the pilot facility. Set-once domain invariant enforced at the decider per F3.3 (DataCite Findable immutability): once Asset.persistent_id is set, no further assign / clear / reassign is accepted (slice G owns withdraw via a separate timestamp facet). Server-mint posture per [[project-non-determinism-principle]]: the route accepts (scheme, suffix | None) and the handler resolves the PersistentIdentifier from the DoiMinter port before invoking the pure decider, keeping the decider deterministic and trivially testable. BC-tier wiring per the operation BC ControlPort precedent: wire_equipment(deps) stashes the StubDoiMinter on the equipment-deps namespace, NOT through the cross-BC Kernel dataclass. Error surface: 5 classes mapped uniformly. 400 InvalidPersistentIdentifierValueError, 404 AssetNotFoundError, 409 AssetPersistentIdAlreadyAssignedError + AssetPersistentIdAssignmentForbiddenError, 502 PersistentIdentifierMintError (error class lives at cora.equipment.ports.doi_minter next to the Protocol it documents, NOT in aggregates/asset/state.py, because it is an adapter-tier failure not a decider-raised genesis error). Naming: slice directory renamed from assign_persistent_id to assign_asset_persistent_id at implementation time per the test_slice_dir_carries_subject architecture fitness (sibling pattern: add_asset_owner, add_asset_alternate_identifier, add_asset_family, add_asset_port). Command class AssignAssetPersistentId, Pydantic models AssignAssetPersistentIdRequest / AssignAssetPersistentIdResponse follow. URL stays terse (/assets/{asset_id}/assign-persistent-identifier) because the URL path already carries the SUBJECT via /assets/. Serializer: _build_identifier now uses match/case on PersistentIdentifierScheme with NO default arm, so adding a third scheme member fires pyright as an unhandled-case error at type-check time. URN fallback for None stays outside the match. Tach: adds cora.equipment.ports and cora.equipment.adapters rows. Scale: 40 files, 3943 insertions / 6 deletions. 118 slice tests + 16571 architecture fitness tests green. Co-Authored-By: Claude Opus 4.7 --- apps/api/openapi.json | 175 +++++++++ apps/api/src/cora/equipment/__init__.py | 20 + .../_asset_persistent_identifier_body.py | 71 ++++ .../src/cora/equipment/_pidinst_serializer.py | 16 +- apps/api/src/cora/equipment/_pidinst_types.py | 2 + .../src/cora/equipment/adapters/__init__.py | 9 + .../equipment/adapters/stub_doi_minter.py | 52 +++ .../equipment/aggregates/asset/__init__.py | 16 + .../cora/equipment/aggregates/asset/events.py | 63 ++++ .../equipment/aggregates/asset/evolver.py | 58 +++ .../cora/equipment/aggregates/asset/state.py | 146 ++++++++ .../assign_asset_persistent_id/__init__.py | 40 ++ .../assign_asset_persistent_id/command.py | 31 ++ .../assign_asset_persistent_id/decider.py | 83 +++++ .../assign_asset_persistent_id/handler.py | 110 ++++++ .../assign_asset_persistent_id/route.py | 115 ++++++ .../assign_asset_persistent_id/tool.py | 54 +++ .../get_asset_pidinst/_view_assembler.py | 1 + apps/api/src/cora/equipment/ports/__init__.py | 20 + .../src/cora/equipment/ports/doi_minter.py | 87 +++++ .../src/cora/equipment/projections/asset.py | 20 + apps/api/src/cora/equipment/routes.py | 50 +++ apps/api/src/cora/equipment/tools.py | 7 + apps/api/src/cora/equipment/wire.py | 46 ++- apps/api/tach.toml | 15 +- ...test_assign_asset_persistent_id_openapi.py | 77 ++++ .../tests/integration/equipment/conftest.py | 58 +++ .../test_assign_asset_persistent_id_route.py | 327 +++++++++++++++++ .../test_assign_asset_persistent_id_tool.py | 329 +++++++++++++++++ ...st_get_asset_pidinst_with_persistent_id.py | 266 ++++++++++++++ ...test_assign_asset_persistent_id_decider.py | 151 ++++++++ ..._asset_persistent_id_decider_properties.py | 277 ++++++++++++++ .../test_assign_asset_persistent_id_events.py | 238 ++++++++++++ ...test_assign_asset_persistent_id_evolver.py | 346 ++++++++++++++++++ ..._asset_persistent_id_summary_projection.py | 126 +++++++ .../test_persistent_identifier_scheme.py | 83 +++++ .../test_persistent_identifier_vo.py | 128 +++++++ ...est_persistent_identifier_vo_properties.py | 114 ++++++ .../test_pidinst_serializer_persistent_id.py | 75 ++++ .../test_wire_equipment_doi_minter.py | 47 +++ 40 files changed, 3943 insertions(+), 6 deletions(-) create mode 100644 apps/api/src/cora/equipment/_asset_persistent_identifier_body.py create mode 100644 apps/api/src/cora/equipment/adapters/__init__.py create mode 100644 apps/api/src/cora/equipment/adapters/stub_doi_minter.py create mode 100644 apps/api/src/cora/equipment/features/assign_asset_persistent_id/__init__.py create mode 100644 apps/api/src/cora/equipment/features/assign_asset_persistent_id/command.py create mode 100644 apps/api/src/cora/equipment/features/assign_asset_persistent_id/decider.py create mode 100644 apps/api/src/cora/equipment/features/assign_asset_persistent_id/handler.py create mode 100644 apps/api/src/cora/equipment/features/assign_asset_persistent_id/route.py create mode 100644 apps/api/src/cora/equipment/features/assign_asset_persistent_id/tool.py create mode 100644 apps/api/src/cora/equipment/ports/__init__.py create mode 100644 apps/api/src/cora/equipment/ports/doi_minter.py create mode 100644 apps/api/tests/contract/test_assign_asset_persistent_id_openapi.py create mode 100644 apps/api/tests/integration/equipment/conftest.py create mode 100644 apps/api/tests/integration/equipment/test_assign_asset_persistent_id_route.py create mode 100644 apps/api/tests/integration/equipment/test_assign_asset_persistent_id_tool.py create mode 100644 apps/api/tests/integration/equipment/test_get_asset_pidinst_with_persistent_id.py create mode 100644 apps/api/tests/unit/equipment/test_assign_asset_persistent_id_decider.py create mode 100644 apps/api/tests/unit/equipment/test_assign_asset_persistent_id_decider_properties.py create mode 100644 apps/api/tests/unit/equipment/test_assign_asset_persistent_id_events.py create mode 100644 apps/api/tests/unit/equipment/test_assign_asset_persistent_id_evolver.py create mode 100644 apps/api/tests/unit/equipment/test_assign_asset_persistent_id_summary_projection.py create mode 100644 apps/api/tests/unit/equipment/test_persistent_identifier_scheme.py create mode 100644 apps/api/tests/unit/equipment/test_persistent_identifier_vo.py create mode 100644 apps/api/tests/unit/equipment/test_persistent_identifier_vo_properties.py create mode 100644 apps/api/tests/unit/equipment/test_pidinst_serializer_persistent_id.py create mode 100644 apps/api/tests/unit/equipment/test_wire_equipment_doi_minter.py diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 6e744ff608..ef4b5face8 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -1371,6 +1371,55 @@ "title": "AssetSummaryDTO", "type": "object" }, + "AssignAssetPersistentIdRequest": { + "description": "Body for `POST /assets/{asset_id}/assign-persistent-identifier`.\n\n`scheme` selects the PID scheme; v1 supports DOI and HANDLE.\n`suffix` is the optional operator-supplied local part; when absent\nthe configured `DoiMinter` adapter auto-generates one. No `value`\nfield per the server-mint posture (Lock 12).", + "properties": { + "scheme": { + "$ref": "#/components/schemas/PersistentIdentifierScheme", + "description": "Closed PIDINST Property 1 scheme: DOI or HANDLE." + }, + "suffix": { + "anyOf": [ + { + "maxLength": 200, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional operator-supplied local part. When absent, the configured DoiMinter adapter auto-generates the suffix; the bare-request flow is the common case for retrospective bulk-mint per F1.4.", + "title": "Suffix" + } + }, + "required": [ + "scheme" + ], + "title": "AssignAssetPersistentIdRequest", + "type": "object" + }, + "AssignAssetPersistentIdResponse": { + "description": "Response body for `POST /assets/{asset_id}/assign-persistent-identifier`.\n\nEchoes the server-minted `(scheme, value)` pair so the operator\nlearns the assigned identifier without a follow-up GET. Per Lock\n17, this is the only Asset POST that returns a structured body;\nthe deviation is justified because the value is server-minted and\nnot derivable from the request alone.", + "properties": { + "scheme": { + "description": "Assigned PIDINST Property 1 scheme value (DOI or Handle).", + "title": "Scheme", + "type": "string" + }, + "value": { + "description": "Authority-assigned persistent identifier string.", + "title": "Value", + "type": "string" + } + }, + "required": [ + "scheme", + "value" + ], + "title": "AssignAssetPersistentIdResponse", + "type": "object" + }, "AttachAssetToFixtureRequest": { "description": "Body for `POST /assets/{asset_id}/attach-to-fixture`.", "properties": { @@ -7108,6 +7157,15 @@ "title": "PermitSummaryDTO", "type": "object" }, + "PersistentIdentifierScheme": { + "description": "Closed PIDINST v1.0 Property 1 identifier-type vocabulary (subset).\n\nValues match `PidinstIdentifierType.DOI.value` and\n`PidinstIdentifierType.HANDLE.value` byte-for-byte so the\nserializer swap (URN to DOI / Handle) does not need a translation\nmap. URN and URL members of `PidinstIdentifierType` are\nintentionally NOT mirrored here: `Asset.persistent_id` is an\nassigned-by-operator persistent identifier, not a runtime fallback\nor a content URL.\n\nAdding a fourth member (for example ARK or PURL) is an additive\nenum change at a future migration boundary, gated on operator\ndemand. The closed-enum stance mirrors `AlternateIdentifierKind`\nand `ManufacturerIdentifierType`.", + "enum": [ + "DOI", + "Handle" + ], + "title": "PersistentIdentifierScheme", + "type": "string" + }, "PidinstAlternateIdentifierDTO": { "description": "PIDINST v1.0 Property 13: an alternate identifier under a known scheme.", "properties": { @@ -16024,6 +16082,123 @@ ] } }, + "/assets/{asset_id}/assign-persistent-identifier": { + "post": { + "operationId": "post_assets_assign_persistent_identifier_assets__asset_id__assign_persistent_identifier_post", + "parameters": [ + { + "description": "Target asset's id.", + "in": "path", + "name": "asset_id", + "required": true, + "schema": { + "description": "Target asset's id.", + "format": "uuid", + "title": "Asset Id", + "type": "string" + } + }, + { + "description": "Legacy principal-id header (trust-the-proxy shape). When IDENTITY_PROVIDERS is configured (bearer-auth mode), this header is IGNORED and the verified bearer token from `BearerAuthMiddleware` (Authorization: Bearer) sets the principal. When no IdPs are configured (legacy mode), the application TRUSTS this header (no cryptographic verification) -- production deployments in legacy mode MUST front the API with an auth proxy that strips any client-supplied X-Principal-Id and sets it to the verified principal UUID. Behavior when absent: see Settings.require_authenticated_principal.", + "in": "header", + "name": "X-Principal-Id", + "required": false, + "schema": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Legacy principal-id header (trust-the-proxy shape). When IDENTITY_PROVIDERS is configured (bearer-auth mode), this header is IGNORED and the verified bearer token from `BearerAuthMiddleware` (Authorization: Bearer) sets the principal. When no IdPs are configured (legacy mode), the application TRUSTS this header (no cryptographic verification) -- production deployments in legacy mode MUST front the API with an auth proxy that strips any client-supplied X-Principal-Id and sets it to the verified principal UUID. Behavior when absent: see Settings.require_authenticated_principal.", + "title": "X-Principal-Id" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssignAssetPersistentIdRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssignAssetPersistentIdResponse" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "PersistentIdentifier VO validation failed: empty or whitespace-only value, or value over the max-length bound (InvalidPersistentIdentifierValueError)." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Authorize policy denied the command." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "No asset exists with the given id." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Asset cannot accept the persistent identifier under current conditions: the asset is Decommissioned (AssetPersistentIdAssignmentForbiddenError), OR the asset already carries a persistent_id (set-once: AssetPersistentIdAlreadyAssignedError), OR a concurrent write to the same asset stream conflicted (optimistic concurrency)." + }, + "422": { + "description": "Path parameter or request body failed schema validation (missing field, malformed UUID, scheme outside the closed enum, suffix length out of bounds at the wire layer)." + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "The external mint authority (DataCite or Handle.net) failed to assign a persistent identifier (PersistentIdentifierMintError)." + } + }, + "summary": "Assign a PIDINST persistent identifier to an existing Asset", + "tags": [ + "equipment" + ] + } + }, "/assets/{asset_id}/attach-to-fixture": { "post": { "operationId": "post_assets_attach_to_fixture_assets__asset_id__attach_to_fixture_post", diff --git a/apps/api/src/cora/equipment/__init__.py b/apps/api/src/cora/equipment/__init__.py index 49de98f43f..4ecff14017 100644 --- a/apps/api/src/cora/equipment/__init__.py +++ b/apps/api/src/cora/equipment/__init__.py @@ -36,13 +36,33 @@ """ from cora.equipment._projections import register_equipment_projections +from cora.equipment.aggregates.asset import ( + AssetPersistentIdAlreadyAssignedError, + AssetPersistentIdAssigned, + AssetPersistentIdAssignmentForbiddenError, + InvalidPersistentIdentifierValueError, + PersistentIdentifier, + PersistentIdentifierScheme, +) from cora.equipment.errors import UnauthorizedError +from cora.equipment.ports.doi_minter import ( + DoiMinter, + PersistentIdentifierMintError, +) from cora.equipment.routes import register_equipment_routes from cora.equipment.tools import register_equipment_tools from cora.equipment.wire import EquipmentHandlers, wire_equipment __all__ = [ + "AssetPersistentIdAlreadyAssignedError", + "AssetPersistentIdAssigned", + "AssetPersistentIdAssignmentForbiddenError", + "DoiMinter", "EquipmentHandlers", + "InvalidPersistentIdentifierValueError", + "PersistentIdentifier", + "PersistentIdentifierMintError", + "PersistentIdentifierScheme", "UnauthorizedError", "register_equipment_projections", "register_equipment_routes", diff --git a/apps/api/src/cora/equipment/_asset_persistent_identifier_body.py b/apps/api/src/cora/equipment/_asset_persistent_identifier_body.py new file mode 100644 index 0000000000..13f9402459 --- /dev/null +++ b/apps/api/src/cora/equipment/_asset_persistent_identifier_body.py @@ -0,0 +1,71 @@ +"""Shared Pydantic wire-format for the `assign_asset_persistent_id` slice. + +Per Lock 22 of [[project-asset-persistent-id-write-design]], slice F +does NOT carry a domain-VO wire mirror (the `PersistentIdentifier` VO +is never parsed at the wire; it is server-minted inside the handler). +This module instead carries the request + response wire surface for +the single POST endpoint: + + - `AssignAssetPersistentIdRequest`: `(scheme, suffix | None)` operator + intent. The handler resolves the suffix into a full + `PersistentIdentifier` via the configured `DoiMinter` port. + - `AssignAssetPersistentIdResponse`: `(scheme, value)` echoed back so the + operator learns the server-minted identifier without a follow-up + GET (Lock 17 deviation from the empty-201 convention). + +Mirrors the placement of `_asset_owner_body` and +`_alternate_identifier_body` at the BC root. +""" + +from pydantic import BaseModel, Field + +from cora.equipment.aggregates.asset import ( + PERSISTENT_IDENTIFIER_VALUE_MAX_LENGTH, + PersistentIdentifierScheme, +) + + +class AssignAssetPersistentIdRequest(BaseModel): + """Body for `POST /assets/{asset_id}/assign-persistent-identifier`. + + `scheme` selects the PID scheme; v1 supports DOI and HANDLE. + `suffix` is the optional operator-supplied local part; when absent + the configured `DoiMinter` adapter auto-generates one. No `value` + field per the server-mint posture (Lock 12). + """ + + scheme: PersistentIdentifierScheme = Field( + ..., + description="Closed PIDINST Property 1 scheme: DOI or HANDLE.", + ) + suffix: str | None = Field( + None, + min_length=1, + max_length=PERSISTENT_IDENTIFIER_VALUE_MAX_LENGTH, + description=( + "Optional operator-supplied local part. When absent, the " + "configured DoiMinter adapter auto-generates the suffix; " + "the bare-request flow is the common case for retrospective " + "bulk-mint per F1.4." + ), + ) + + +class AssignAssetPersistentIdResponse(BaseModel): + """Response body for `POST /assets/{asset_id}/assign-persistent-identifier`. + + Echoes the server-minted `(scheme, value)` pair so the operator + learns the assigned identifier without a follow-up GET. Per Lock + 17, this is the only Asset POST that returns a structured body; + the deviation is justified because the value is server-minted and + not derivable from the request alone. + """ + + scheme: str = Field( + ..., + description="Assigned PIDINST Property 1 scheme value (DOI or Handle).", + ) + value: str = Field( + ..., + description="Authority-assigned persistent identifier string.", + ) diff --git a/apps/api/src/cora/equipment/_pidinst_serializer.py b/apps/api/src/cora/equipment/_pidinst_serializer.py index 49265808da..a5d42ea934 100644 --- a/apps/api/src/cora/equipment/_pidinst_serializer.py +++ b/apps/api/src/cora/equipment/_pidinst_serializer.py @@ -74,6 +74,7 @@ RelatedIdentifier, SchemaVersion, ) +from cora.equipment.aggregates.asset import PersistentIdentifierScheme from cora.equipment.errors import ( AssetNameMissingError, LandingPageMissingError, @@ -151,10 +152,17 @@ def _validate_manufacturer_state_available(view: AssetPidinstView) -> None: def _build_identifier(view: AssetPidinstView) -> PidinstIdentifier: - return PidinstIdentifier( - value=f"{_URN_UUID_PREFIX}{view.asset_id}", - scheme=PidinstIdentifierType.URN, - ) + if view.persistent_id is None: + return PidinstIdentifier( + value=f"{_URN_UUID_PREFIX}{view.asset_id}", + scheme=PidinstIdentifierType.URN, + ) + match view.persistent_id.scheme: + case PersistentIdentifierScheme.DOI: + wire_scheme = PidinstIdentifierType.DOI + case PersistentIdentifierScheme.HANDLE: + wire_scheme = PidinstIdentifierType.HANDLE + return PidinstIdentifier(value=view.persistent_id.value, scheme=wire_scheme) def _build_landing_page(view: AssetPidinstView) -> str: diff --git a/apps/api/src/cora/equipment/_pidinst_types.py b/apps/api/src/cora/equipment/_pidinst_types.py index 60d1daa721..470bcad504 100644 --- a/apps/api/src/cora/equipment/_pidinst_types.py +++ b/apps/api/src/cora/equipment/_pidinst_types.py @@ -47,6 +47,7 @@ AlternateIdentifier, AlternateIdentifierKind, AssetLifecycle, + PersistentIdentifier, ) from cora.equipment.aggregates.model import ManufacturerIdentifierType from cora.equipment.errors import PidinstRecordInvariantError @@ -237,6 +238,7 @@ class AssetPidinstView: publisher: str publication_year: int | None owners: tuple[Owner, ...] + persistent_id: PersistentIdentifier | None = None @dataclass(frozen=True) diff --git a/apps/api/src/cora/equipment/adapters/__init__.py b/apps/api/src/cora/equipment/adapters/__init__.py new file mode 100644 index 0000000000..0debb266b1 --- /dev/null +++ b/apps/api/src/cora/equipment/adapters/__init__.py @@ -0,0 +1,9 @@ +"""Equipment BC adapters. + +`StubDoiMinter` is the test-tier `DoiMinter` adapter per +[[project-asset-persistent-id-write-design]] (slice F.1): a real +adapter that returns inert deterministic values, distinct from a +None / disabled port. Mirrors `AllowAllAuthorize` and +`AlwaysCoveredClearanceLookup` test-bypass convention. The +production `DataCiteDoiMinter` adapter is deferred to slice F.2. +""" diff --git a/apps/api/src/cora/equipment/adapters/stub_doi_minter.py b/apps/api/src/cora/equipment/adapters/stub_doi_minter.py new file mode 100644 index 0000000000..6d403d69ba --- /dev/null +++ b/apps/api/src/cora/equipment/adapters/stub_doi_minter.py @@ -0,0 +1,52 @@ +"""Stub DataCite-style minter for tests and credential-less environments. + +Deterministic: given a (scheme, suffix) pair, returns a stable +`PersistentIdentifier` built from a fixed test prefix. When suffix is +None, generates a UUID-based suffix so each call still returns a +distinct value. + +Never raises `PersistentIdentifierMintError`. Tests asserting the +route's 502 path inject a separate raising stub fixture. + +Wired by `wire_equipment(deps)` when no DataCite credentials are +present (the dev / test default), mirroring the `AllowAllAuthorize` +plus `AlwaysCoveredClearanceLookup` test-bypass convention: a stub +adapter is a real adapter that returns inert values, distinct from a +None / disabled port (which would force every caller to None-check). +The stub is always wired; only the implementation varies. + +The stub prefixes (`10.0000` for DOI, `20.500.0000` for Handle) are +reserved in the corresponding registration systems for testing +purposes and do not resolve to anything real. Hard-coded so the stub +is deterministic without a config dependency: tests can construct +`StubDoiMinter()` directly without a Settings object. +""" + +from uuid import uuid4 + +from cora.equipment.aggregates.asset import ( + PersistentIdentifier, + PersistentIdentifierScheme, +) + +_STUB_DOI_PREFIX = "10.0000/cora-stub" +_STUB_HANDLE_PREFIX = "20.500.0000/cora-stub" + + +class StubDoiMinter: + """Returns a deterministic test-only PersistentIdentifier.""" + + async def mint( + self, + *, + scheme: PersistentIdentifierScheme, + suffix: str | None, + ) -> PersistentIdentifier: + local = suffix if suffix is not None else str(uuid4()) + prefix = ( + _STUB_DOI_PREFIX if scheme is PersistentIdentifierScheme.DOI else _STUB_HANDLE_PREFIX + ) + return PersistentIdentifier(scheme=scheme, value=f"{prefix}/{local}") + + +__all__ = ["StubDoiMinter"] diff --git a/apps/api/src/cora/equipment/aggregates/asset/__init__.py b/apps/api/src/cora/equipment/aggregates/asset/__init__.py index 878b959f86..d85c5387c0 100644 --- a/apps/api/src/cora/equipment/aggregates/asset/__init__.py +++ b/apps/api/src/cora/equipment/aggregates/asset/__init__.py @@ -21,6 +21,7 @@ AssetMaintenanceExited, AssetOwnerAdded, AssetOwnerRemoved, + AssetPersistentIdAssigned, AssetPortAdded, AssetPortRemoved, AssetRegistered, @@ -40,6 +41,7 @@ ASSET_OWNER_IDENTIFIER_MAX_LENGTH, ASSET_OWNER_IDENTIFIER_TYPE_MAX_LENGTH, ASSET_OWNER_NAME_MAX_LENGTH, + PERSISTENT_IDENTIFIER_VALUE_MAX_LENGTH, PORT_NAME_MAX_LENGTH, PORT_SIGNAL_TYPE_MAX_LENGTH, AlternateIdentifier, @@ -79,6 +81,8 @@ AssetOwnerIdentifierType, AssetOwnerName, AssetOwnerNotPresentError, + AssetPersistentIdAlreadyAssignedError, + AssetPersistentIdAssignmentForbiddenError, AssetPort, InvalidAlternateIdentifierValueError, InvalidAssetNameError, @@ -91,6 +95,10 @@ InvalidAssetPortNameError, InvalidAssetPortSignalTypeError, InvalidAssetSettingsError, + InvalidPersistentIdentifierValueError, + MalformedPersistentIdentifierError, + PersistentIdentifier, + PersistentIdentifierScheme, PortDirection, ) @@ -101,6 +109,7 @@ "ASSET_OWNER_IDENTIFIER_MAX_LENGTH", "ASSET_OWNER_IDENTIFIER_TYPE_MAX_LENGTH", "ASSET_OWNER_NAME_MAX_LENGTH", + "PERSISTENT_IDENTIFIER_VALUE_MAX_LENGTH", "PORT_NAME_MAX_LENGTH", "PORT_SIGNAL_TYPE_MAX_LENGTH", "AlternateIdentifier", @@ -155,6 +164,9 @@ "AssetOwnerName", "AssetOwnerNotPresentError", "AssetOwnerRemoved", + "AssetPersistentIdAlreadyAssignedError", + "AssetPersistentIdAssigned", + "AssetPersistentIdAssignmentForbiddenError", "AssetPort", "AssetPortAdded", "AssetPortRemoved", @@ -173,6 +185,10 @@ "InvalidAssetPortNameError", "InvalidAssetPortSignalTypeError", "InvalidAssetSettingsError", + "InvalidPersistentIdentifierValueError", + "MalformedPersistentIdentifierError", + "PersistentIdentifier", + "PersistentIdentifierScheme", "PortDirection", "event_type_name", "evolve", diff --git a/apps/api/src/cora/equipment/aggregates/asset/events.py b/apps/api/src/cora/equipment/aggregates/asset/events.py index bb1c16ecb4..15bc24bc5b 100644 --- a/apps/api/src/cora/equipment/aggregates/asset/events.py +++ b/apps/api/src/cora/equipment/aggregates/asset/events.py @@ -69,6 +69,8 @@ AssetOwnerIdentifier, AssetOwnerIdentifierType, AssetOwnerName, + MalformedPersistentIdentifierError, + PersistentIdentifierScheme, ) from cora.infrastructure.event_payload import deserialize_or_raise from cora.infrastructure.ports.event_store import StoredEvent @@ -431,6 +433,32 @@ class AssetOwnerRemoved: occurred_at: datetime +@dataclass(frozen=True) +class AssetPersistentIdAssigned: + """A persistent identifier (PIDINST v1.0 Property 1) was assigned to an Asset. + + Single-assign event. Set-once at the aggregate level: the + decider's `AssetPersistentIdAlreadyAssignedError` enforces "must + currently be absent" at command time, so the stream can contain + AT MOST ONE `AssetPersistentIdAssigned` event per Asset. + + The full `PersistentIdentifier` VO (scheme + value) travels in the + payload as two primitives, mirroring `AssetPortAdded`'s + (port_name, direction, signal_type) primitive carry: scheme is the + StrEnum value, value is the trimmed string. This lets `from_stored` + rebuild the VO without reading prior state. + + No `withdrawn_at` / `withdrawal_reason` on this event: F.1 does not + model withdrawal. A future slice G adds a sibling + `AssetPersistentIdWithdrawn` event when operator demand fires. + """ + + asset_id: UUID + persistent_id_scheme: str + persistent_id_value: str + occurred_at: datetime + + @dataclass(frozen=True) class AssetSettingsUpdated: """An asset's settings dict was set / replaced via the @@ -534,6 +562,7 @@ class AssetDetachedFromFixture: | AssetAlternateIdentifierRemoved | AssetOwnerAdded | AssetOwnerRemoved + | AssetPersistentIdAssigned | AssetAttachedToFixture | AssetDetachedFromFixture ) @@ -746,6 +775,18 @@ def to_payload(event: AssetEvent) -> dict[str, Any]: "owner_name": owner_name.value, "occurred_at": occurred_at.isoformat(), } + case AssetPersistentIdAssigned( + asset_id=asset_id, + persistent_id_scheme=scheme, + persistent_id_value=value, + occurred_at=occurred_at, + ): + return { + "asset_id": str(asset_id), + "persistent_id_scheme": scheme, + "persistent_id_value": value, + "occurred_at": occurred_at.isoformat(), + } case AssetAttachedToFixture( asset_id=asset_id, fixture_id=fixture_id, @@ -989,6 +1030,27 @@ def _build_registered() -> AssetRegistered: ), extra=(ValueError,), ) + case "AssetPersistentIdAssigned": + + def _build_persistent_id_assigned() -> AssetPersistentIdAssigned: + scheme = PersistentIdentifierScheme(payload["persistent_id_scheme"]) + value = payload["persistent_id_value"] + if not isinstance(value, str) or not value.strip(): + raise MalformedPersistentIdentifierError( + f"persistent_id_value must be a non-empty string (got: {value!r})" + ) + return AssetPersistentIdAssigned( + asset_id=UUID(payload["asset_id"]), + persistent_id_scheme=scheme.value, + persistent_id_value=value, + occurred_at=datetime.fromisoformat(payload["occurred_at"]), + ) + + return deserialize_or_raise( + "AssetPersistentIdAssigned", + _build_persistent_id_assigned, + extra=(ValueError, MalformedPersistentIdentifierError), + ) case "AssetAttachedToFixture": return deserialize_or_raise( "AssetAttachedToFixture", @@ -1028,6 +1090,7 @@ def _build_registered() -> AssetRegistered: "AssetMaintenanceExited", "AssetOwnerAdded", "AssetOwnerRemoved", + "AssetPersistentIdAssigned", "AssetPortAdded", "AssetPortRemoved", "AssetRegistered", diff --git a/apps/api/src/cora/equipment/aggregates/asset/evolver.py b/apps/api/src/cora/equipment/aggregates/asset/evolver.py index 3da1fbf724..f623806701 100644 --- a/apps/api/src/cora/equipment/aggregates/asset/evolver.py +++ b/apps/api/src/cora/equipment/aggregates/asset/evolver.py @@ -99,6 +99,7 @@ AssetMaintenanceExited, AssetOwnerAdded, AssetOwnerRemoved, + AssetPersistentIdAssigned, AssetPortAdded, AssetPortRemoved, AssetRegistered, @@ -113,6 +114,8 @@ AssetLifecycle, AssetName, AssetPort, + PersistentIdentifier, + PersistentIdentifierScheme, PortDirection, ) from cora.infrastructure.evolver import require_state @@ -180,6 +183,7 @@ def evolve(state: Asset | None, event: AssetEvent) -> Asset: fixture_id=prior.fixture_id, commissioned_at=prior.commissioned_at, decommissioned_at=prior.decommissioned_at, + persistent_id=prior.persistent_id, ) case AssetDecommissioned(occurred_at=occurred_at): prior = require_state(state, "AssetDecommissioned") @@ -200,6 +204,7 @@ def evolve(state: Asset | None, event: AssetEvent) -> Asset: fixture_id=prior.fixture_id, commissioned_at=prior.commissioned_at, decommissioned_at=occurred_at, + persistent_id=prior.persistent_id, ) case AssetRelocated(to_parent_id=to_parent_id): # Hierarchy mutation: only parent_id changes; lifecycle / level @@ -225,6 +230,7 @@ def evolve(state: Asset | None, event: AssetEvent) -> Asset: fixture_id=prior.fixture_id, commissioned_at=prior.commissioned_at, decommissioned_at=prior.decommissioned_at, + persistent_id=prior.persistent_id, ) case AssetMaintenanceEntered(): prior = require_state(state, "AssetMaintenanceEntered") @@ -245,6 +251,7 @@ def evolve(state: Asset | None, event: AssetEvent) -> Asset: fixture_id=prior.fixture_id, commissioned_at=prior.commissioned_at, decommissioned_at=prior.decommissioned_at, + persistent_id=prior.persistent_id, ) case AssetMaintenanceExited(): prior = require_state(state, "AssetMaintenanceExited") @@ -265,6 +272,7 @@ def evolve(state: Asset | None, event: AssetEvent) -> Asset: fixture_id=prior.fixture_id, commissioned_at=prior.commissioned_at, decommissioned_at=prior.decommissioned_at, + persistent_id=prior.persistent_id, ) case AssetFamilyAdded(family_id=family_id): # Family mutation: only `family_ids` changes; everything @@ -290,6 +298,7 @@ def evolve(state: Asset | None, event: AssetEvent) -> Asset: fixture_id=prior.fixture_id, commissioned_at=prior.commissioned_at, decommissioned_at=prior.decommissioned_at, + persistent_id=prior.persistent_id, ) case AssetFamilyRemoved(family_id=family_id): # Mirror of AssetFamilyAdded. Frozenset difference is a @@ -316,6 +325,7 @@ def evolve(state: Asset | None, event: AssetEvent) -> Asset: fixture_id=prior.fixture_id, commissioned_at=prior.commissioned_at, decommissioned_at=prior.decommissioned_at, + persistent_id=prior.persistent_id, ) case AssetDegraded(): # Condition mutation: only `condition` changes; everything @@ -341,6 +351,7 @@ def evolve(state: Asset | None, event: AssetEvent) -> Asset: fixture_id=prior.fixture_id, commissioned_at=prior.commissioned_at, decommissioned_at=prior.decommissioned_at, + persistent_id=prior.persistent_id, ) case AssetFaulted(): prior = require_state(state, "AssetFaulted") @@ -361,6 +372,7 @@ def evolve(state: Asset | None, event: AssetEvent) -> Asset: fixture_id=prior.fixture_id, commissioned_at=prior.commissioned_at, decommissioned_at=prior.decommissioned_at, + persistent_id=prior.persistent_id, ) case AssetRestored(): prior = require_state(state, "AssetRestored") @@ -381,6 +393,7 @@ def evolve(state: Asset | None, event: AssetEvent) -> Asset: fixture_id=prior.fixture_id, commissioned_at=prior.commissioned_at, decommissioned_at=prior.decommissioned_at, + persistent_id=prior.persistent_id, ) case AssetSettingsUpdated(settings=settings): # Settings mutation: only `settings` changes. Event payload @@ -408,6 +421,7 @@ def evolve(state: Asset | None, event: AssetEvent) -> Asset: fixture_id=prior.fixture_id, commissioned_at=prior.commissioned_at, decommissioned_at=prior.decommissioned_at, + persistent_id=prior.persistent_id, ) case AssetPortAdded( port_name=port_name, @@ -443,6 +457,7 @@ def evolve(state: Asset | None, event: AssetEvent) -> Asset: fixture_id=prior.fixture_id, commissioned_at=prior.commissioned_at, decommissioned_at=prior.decommissioned_at, + persistent_id=prior.persistent_id, ) case AssetPortRemoved(port_name=port_name): # Mirror of AssetPortAdded. Removes the port whose `name` @@ -471,6 +486,7 @@ def evolve(state: Asset | None, event: AssetEvent) -> Asset: fixture_id=prior.fixture_id, commissioned_at=prior.commissioned_at, decommissioned_at=prior.decommissioned_at, + persistent_id=prior.persistent_id, ) case AssetAlternateIdentifierAdded(alternate_identifier=identifier): # Alternate-identifier mutation: only @@ -498,6 +514,7 @@ def evolve(state: Asset | None, event: AssetEvent) -> Asset: fixture_id=prior.fixture_id, commissioned_at=prior.commissioned_at, decommissioned_at=prior.decommissioned_at, + persistent_id=prior.persistent_id, ) case AssetAlternateIdentifierRemoved(alternate_identifier=identifier): # Mirror of AssetAlternateIdentifierAdded. Frozenset @@ -521,6 +538,7 @@ def evolve(state: Asset | None, event: AssetEvent) -> Asset: fixture_id=prior.fixture_id, commissioned_at=prior.commissioned_at, decommissioned_at=prior.decommissioned_at, + persistent_id=prior.persistent_id, ) case AssetOwnerAdded(owner=owner): # Owner mutation: only `owners` changes; everything else @@ -547,6 +565,43 @@ def evolve(state: Asset | None, event: AssetEvent) -> Asset: fixture_id=prior.fixture_id, commissioned_at=prior.commissioned_at, decommissioned_at=prior.decommissioned_at, + persistent_id=prior.persistent_id, + ) + case AssetPersistentIdAssigned( + persistent_id_scheme=scheme, + persistent_id_value=value, + ): + # Persistent-id mutation: only `persistent_id` changes; + # everything else carries over. Set-once at the aggregate + # level: the decider's + # AssetPersistentIdAlreadyAssignedError rejects any second + # assign at command time, so the evolver can trust that + # prior.persistent_id is None whenever this arm fires. The + # arm is forgiving if it ever sees a second assign at + # replay time (would overwrite, but by-design the only + # producer is the decider that already validated). + prior = require_state(state, "AssetPersistentIdAssigned") + return Asset( + id=prior.id, + name=prior.name, + level=prior.level, + parent_id=prior.parent_id, + lifecycle=prior.lifecycle, + condition=prior.condition, + family_ids=prior.family_ids, + settings=prior.settings, + ports=prior.ports, + drawing=prior.drawing, + model_id=prior.model_id, + alternate_identifiers=prior.alternate_identifiers, + owners=prior.owners, + fixture_id=prior.fixture_id, + commissioned_at=prior.commissioned_at, + decommissioned_at=prior.decommissioned_at, + persistent_id=PersistentIdentifier( + scheme=PersistentIdentifierScheme(scheme), + value=value, + ), ) case AssetOwnerRemoved(owner_name=owner_name): # Mirror of AssetOwnerAdded. Removes the owner whose `name` @@ -572,6 +627,7 @@ def evolve(state: Asset | None, event: AssetEvent) -> Asset: alternate_identifiers=prior.alternate_identifiers, owners=frozenset(o for o in prior.owners if o.name != owner_name), fixture_id=prior.fixture_id, + persistent_id=prior.persistent_id, ) case AssetAttachedToFixture(fixture_id=fixture_id): # Sets the back-reference. The Fixture side carries the @@ -594,6 +650,7 @@ def evolve(state: Asset | None, event: AssetEvent) -> Asset: alternate_identifiers=prior.alternate_identifiers, owners=prior.owners, fixture_id=fixture_id, + persistent_id=prior.persistent_id, ) case AssetDetachedFromFixture(): # Clears the back-reference. The Fixture's own @@ -619,6 +676,7 @@ def evolve(state: Asset | None, event: AssetEvent) -> Asset: fixture_id=None, commissioned_at=prior.commissioned_at, decommissioned_at=prior.decommissioned_at, + persistent_id=prior.persistent_id, ) case _: # pragma: no cover # exhaustiveness guard assert_never(event) diff --git a/apps/api/src/cora/equipment/aggregates/asset/state.py b/apps/api/src/cora/equipment/aggregates/asset/state.py index ecf7596ab6..fd28ad290e 100644 --- a/apps/api/src/cora/equipment/aggregates/asset/state.py +++ b/apps/api/src/cora/equipment/aggregates/asset/state.py @@ -79,6 +79,7 @@ ASSET_OWNER_CONTACT_MAX_LENGTH = 255 ASSET_OWNER_IDENTIFIER_MAX_LENGTH = 255 ASSET_OWNER_IDENTIFIER_TYPE_MAX_LENGTH = 64 +PERSISTENT_IDENTIFIER_VALUE_MAX_LENGTH = 200 class AssetLevel(StrEnum): @@ -604,6 +605,144 @@ def __init__( self.reason = reason +class PersistentIdentifierScheme(StrEnum): + """Closed PIDINST v1.0 Property 1 identifier-type vocabulary (subset). + + Values match `PidinstIdentifierType.DOI.value` and + `PidinstIdentifierType.HANDLE.value` byte-for-byte so the + serializer swap (URN to DOI / Handle) does not need a translation + map. URN and URL members of `PidinstIdentifierType` are + intentionally NOT mirrored here: `Asset.persistent_id` is an + assigned-by-operator persistent identifier, not a runtime fallback + or a content URL. + + Adding a fourth member (for example ARK or PURL) is an additive + enum change at a future migration boundary, gated on operator + demand. The closed-enum stance mirrors `AlternateIdentifierKind` + and `ManufacturerIdentifierType`. + """ + + DOI = "DOI" + HANDLE = "Handle" + + +class InvalidPersistentIdentifierValueError(ValueError): + """The supplied persistent_id value is empty, whitespace-only, or too long.""" + + def __init__(self, value: str) -> None: + super().__init__( + f"Persistent identifier value must be " + f"1-{PERSISTENT_IDENTIFIER_VALUE_MAX_LENGTH} chars after trimming " + f"(got: {value!r})" + ) + self.value = value + + +@dataclass(frozen=True) +class PersistentIdentifier: + """PIDINST v1.0 Property 1: the persistent identifier of the instrument. + + Tuple `(scheme, value)` where `scheme` is a closed + `PersistentIdentifierScheme` member and `value` is the operator- + supplied opaque string identifying the Asset under that scheme. + + Examples: + - `(DOI, "10.5281/zenodo.1234567")` for a Zenodo-minted DOI + - `(DOI, "10.13139/OLCF/1234")` for an OLCF-minted DOI + - `(HANDLE, "20.500.12613/12345")` for a Handle.net record + + `value` is trimmed and length-bounded 1-200 chars via the shared + `validate_bounded_text` helper, matching the + `AlternateIdentifier.value` precedent. The VO is FLAT (scheme + + value); no resolver URLs, no prefix / suffix split. Pairing + enforcement is implicit: scheme is a non-None enum member by + construction, value is non-empty by `validate_bounded_text`. + + Set-once invariant lives at the aggregate level (the decider), not + on the VO: a `PersistentIdentifier` instance is always valid + standalone; the Asset's state enforces that only one ever lands. + """ + + scheme: PersistentIdentifierScheme + value: str + + def __post_init__(self) -> None: + trimmed = validate_bounded_text( + self.value, + max_length=PERSISTENT_IDENTIFIER_VALUE_MAX_LENGTH, + error_class=InvalidPersistentIdentifierValueError, + ) + object.__setattr__(self, "value", trimmed) + + +class AssetPersistentIdAlreadyAssignedError(Exception): + """Attempted to assign a persistent_id to an Asset that already carries one. + + Set-once at the aggregate level per PIDINST v1.0 F3.3 Findable + immutability: once `Asset.persistent_id` is set, no further + AssetPersistentIdAssigned event can land. Both the same-value and + different-value retry shapes collapse here; the diagnostic fields + carry the current and attempted PersistentIdentifier so operators + see which assign collided. + """ + + def __init__( + self, + asset_id: UUID, + *, + current: "PersistentIdentifier", + attempted: "PersistentIdentifier", + ) -> None: + super().__init__( + f"Asset {asset_id} already has persistent identifier " + f"{current.scheme.value}={current.value!r}; " + f"attempted to assign {attempted.scheme.value}={attempted.value!r}; " + "persistent_id is set-once" + ) + self.asset_id = asset_id + self.current = current + self.attempted = attempted + + +class AssetPersistentIdAssignmentForbiddenError(Exception): + """Attempted to assign a persistent_id under a disqualifying lifecycle. + + Fires for `Decommissioned` Assets: a DOI minted now would point at + an Asset that is already out of inventory, drifting unobserved. + Commissioned, Active, and Maintenance are all accepted (matches + the lifecycle posture of `add_asset_alternate_identifier` and + `add_asset_owner`). The `reason` string surfaces in the route's + 409 body. + """ + + def __init__( + self, + asset_id: UUID, + attempted: "PersistentIdentifier", + *, + reason: str, + ) -> None: + super().__init__( + f"Asset {asset_id} cannot be assigned persistent identifier " + f"{attempted.scheme.value}={attempted.value!r}: {reason}" + ) + self.asset_id = asset_id + self.attempted = attempted + self.reason = reason + + +class MalformedPersistentIdentifierError(Exception): + """A stored AssetPersistentIdAssigned payload failed deserialization. + + Wraps any underlying `ValueError` raised by + `PersistentIdentifierScheme(...)` or `PersistentIdentifier(...)` at + `from_stored` time, per the [[project-from-stored-wrap-convention]] + precedent (mirrors `Malformed*` siblings in other BCs). The + evolver itself never raises; it trusts that `from_stored` already + wrapped any malformed payload as this error class. + """ + + class AssetCondition(StrEnum): """The Asset's real-time device-health state. @@ -1216,3 +1355,10 @@ class Asset: # cleanly via the additive-state pattern. commissioned_at: datetime | None = None decommissioned_at: datetime | None = None + # PIDINST v1.0 Property 1 persistent identifier (DOI or Handle). + # Set-once at the aggregate level per F3.3 Findable immutability: + # once `assign_asset_persistent_id` lands, no further assign / clear / + # reassign event ships. Defaults to None so legacy AssetRegistered + # streams without the field fold cleanly via the additive-state + # pattern. See [[project-asset-persistent-id-write-design]]. + persistent_id: PersistentIdentifier | None = None diff --git a/apps/api/src/cora/equipment/features/assign_asset_persistent_id/__init__.py b/apps/api/src/cora/equipment/features/assign_asset_persistent_id/__init__.py new file mode 100644 index 0000000000..f3d796d00a --- /dev/null +++ b/apps/api/src/cora/equipment/features/assign_asset_persistent_id/__init__.py @@ -0,0 +1,40 @@ +"""Vertical slice for the `AssignAssetPersistentId` command. + +Assigns a `PersistentIdentifier` (PIDINST v1.0 Property 1) to an +existing Asset. Set-once at the aggregate level: a second assign +raises `AssetPersistentIdAlreadyAssignedError`. Decommissioned +assets reject the assign with `AssetPersistentIdAssignmentForbiddenError`. + +Server-mint posture per Lock 12: the route forwards +`(asset_id, scheme, suffix)` to the handler, and the handler closure +resolves the `PersistentIdentifier` from the `DoiMinter` port +(`StubDoiMinter` in F.1; `DataCiteDoiMinter` in F.2) before invoking +the pure decider. One minter call site (the handler), not two. + +Module-as-namespace surface: + + from cora.equipment.features import assign_asset_persistent_id + + cmd = assign_asset_persistent_id.AssignAssetPersistentId( + asset_id=..., + scheme=PersistentIdentifierScheme.DOI, + suffix="APS-2BM-CAM-001", + ) + handler = assign_asset_persistent_id.bind(deps) + persistent_id = await handler(cmd, principal_id=..., correlation_id=...) +""" + +from cora.equipment.features.assign_asset_persistent_id import tool +from cora.equipment.features.assign_asset_persistent_id.command import AssignAssetPersistentId +from cora.equipment.features.assign_asset_persistent_id.decider import decide +from cora.equipment.features.assign_asset_persistent_id.handler import Handler, bind +from cora.equipment.features.assign_asset_persistent_id.route import router + +__all__ = [ + "AssignAssetPersistentId", + "Handler", + "bind", + "decide", + "router", + "tool", +] diff --git a/apps/api/src/cora/equipment/features/assign_asset_persistent_id/command.py b/apps/api/src/cora/equipment/features/assign_asset_persistent_id/command.py new file mode 100644 index 0000000000..e3385a8a5c --- /dev/null +++ b/apps/api/src/cora/equipment/features/assign_asset_persistent_id/command.py @@ -0,0 +1,31 @@ +"""The `AssignAssetPersistentId` command, intent dataclass for this slice. + +`asset_id` is the target Asset aggregate. `scheme` selects the PID +scheme (DOI or HANDLE). `suffix` is the optional operator-supplied +local part; when absent the configured `DoiMinter` adapter auto- +generates one. The command does NOT carry the resolved +`PersistentIdentifier` VO (Lock 12 server-mint posture): the handler +resolves the minter call and forwards the resolved VO into the pure +decider. +""" + +from dataclasses import dataclass +from uuid import UUID + +from cora.equipment.aggregates.asset import PersistentIdentifierScheme + + +@dataclass(frozen=True) +class AssignAssetPersistentId: + """Assign a persistent identifier (PIDINST Property 1) to an existing Asset. + + Set-once at the aggregate level: a second assign on an Asset that + already carries a `persistent_id` is rejected by the decider. + Decommissioned Assets reject the assign. The handler resolves + `(scheme, suffix)` through the `DoiMinter` port into the full + `PersistentIdentifier` before invoking the pure decider. + """ + + asset_id: UUID + scheme: PersistentIdentifierScheme + suffix: str | None = None diff --git a/apps/api/src/cora/equipment/features/assign_asset_persistent_id/decider.py b/apps/api/src/cora/equipment/features/assign_asset_persistent_id/decider.py new file mode 100644 index 0000000000..63366d50ac --- /dev/null +++ b/apps/api/src/cora/equipment/features/assign_asset_persistent_id/decider.py @@ -0,0 +1,83 @@ +"""Pure decider for the `AssignAssetPersistentId` command. + +The decider sees `(state, command)` with the resolved +`PersistentIdentifier` passed as a keyword-only argument (Lock 12 +server-mint posture: the handler resolves the minter call and forwards +the resolved VO here). The decider is PURE: it does NOT call the +DoiMinter, does NOT read the wall clock (caller injects `now`), and +does NOT touch any I/O. Non-determinism is captured in the handler +closure per [[project-non-determinism-principle]]. + +Three disqualifying conditions surface as dedicated error classes: + + - state is None (no Asset exists with the given id) -> + `AssetNotFoundError` + - Asset is `Decommissioned` (retired; no further PID assignment) -> + `AssetPersistentIdAssignmentForbiddenError` + - `state.persistent_id is not None` (set-once; rejects same OR + different value) -> `AssetPersistentIdAlreadyAssignedError` + +P2-17 fitness pin: this module MUST NOT import from +`cora.equipment.ports` or `cora.equipment.adapters`. Any future +refactor that quietly moves the mint into the decider will fail the +architecture test. +""" + +from datetime import datetime + +from cora.equipment.aggregates.asset import ( + Asset, + AssetLifecycle, + AssetNotFoundError, + AssetPersistentIdAlreadyAssignedError, + AssetPersistentIdAssigned, + AssetPersistentIdAssignmentForbiddenError, + PersistentIdentifier, +) +from cora.equipment.features.assign_asset_persistent_id.command import AssignAssetPersistentId + + +def decide( + state: Asset | None, + command: AssignAssetPersistentId, + *, + persistent_id: PersistentIdentifier, + now: datetime, +) -> list[AssetPersistentIdAssigned]: + """Decide the events produced by assigning a persistent identifier. + + Invariants: + - State must not be None -> AssetNotFoundError + - Asset must not be Decommissioned -> + AssetPersistentIdAssignmentForbiddenError + - state.persistent_id must be None (set-once) -> + AssetPersistentIdAlreadyAssignedError + """ + if state is None: + raise AssetNotFoundError(command.asset_id) + + if state.lifecycle is AssetLifecycle.DECOMMISSIONED: + raise AssetPersistentIdAssignmentForbiddenError( + state.id, + persistent_id, + reason=( + f"asset is currently {AssetLifecycle.DECOMMISSIONED.value} " + "(retired from service; persistent identifier assignment is not allowed)" + ), + ) + + if state.persistent_id is not None: + raise AssetPersistentIdAlreadyAssignedError( + state.id, + current=state.persistent_id, + attempted=persistent_id, + ) + + return [ + AssetPersistentIdAssigned( + asset_id=state.id, + persistent_id_scheme=persistent_id.scheme.value, + persistent_id_value=persistent_id.value, + occurred_at=now, + ) + ] diff --git a/apps/api/src/cora/equipment/features/assign_asset_persistent_id/handler.py b/apps/api/src/cora/equipment/features/assign_asset_persistent_id/handler.py new file mode 100644 index 0000000000..21a060be10 --- /dev/null +++ b/apps/api/src/cora/equipment/features/assign_asset_persistent_id/handler.py @@ -0,0 +1,110 @@ +"""Application handler for the `assign_asset_persistent_id` slice. + +Server-mint posture (Lock 12): the route forwards `(asset_id, scheme, +suffix)` to the handler, and the handler closure resolves the +`PersistentIdentifier` from the `DoiMinter` port BEFORE invoking the +pure decider. Non-determinism (the minter call) is captured in the +handler closure per [[project-non-determinism-principle]], NOT at the +route layer. One minter call site (this handler), not two (route + MCP +tool). + +Returns the assigned `PersistentIdentifier` so the route layer can +echo it in the 201 response body (Lock 17): the operator needs the +authority-minted DOI string immediately; reading back through +`GET /assets/{id}/pidinst` is a wasteful round-trip and is subject to +projection lag. + +NOT idempotency-wrapped at the CORA layer (Lock 13): set-once at the +decider PLUS DOI-string-as-dedup-key at DataCite (F5.2 PUT /dois/{id} +upsert semantics) covers retry safely without a CORA idempotency_key. + +Mint failures bubble as `PersistentIdentifierMintError` from the port +(raised by the production adapter); the route layer maps it to HTTP +502 via the standard exception-handler registration in +`equipment/routes.py`. +""" + +from collections.abc import Sequence +from datetime import datetime +from typing import TYPE_CHECKING, Protocol, cast +from uuid import UUID + +from cora.equipment._asset_update_handler import make_asset_update_handler +from cora.equipment.aggregates.asset import ( + Asset, + AssetEvent, + PersistentIdentifier, +) +from cora.equipment.features.assign_asset_persistent_id.command import AssignAssetPersistentId +from cora.equipment.features.assign_asset_persistent_id.decider import decide +from cora.infrastructure.kernel import Kernel +from cora.infrastructure.routing import NIL_SENTINEL_ID + +if TYPE_CHECKING: + from cora.equipment.ports.doi_minter import DoiMinter + + +class Handler(Protocol): + """Callable interface every assign_asset_persistent_id handler implements.""" + + async def __call__( + self, + command: AssignAssetPersistentId, + *, + principal_id: UUID, + correlation_id: UUID, + causation_id: UUID | None = None, + surface_id: UUID = NIL_SENTINEL_ID, + ) -> PersistentIdentifier: ... + + +def bind(deps: Kernel) -> Handler: + """Build an assign_asset_persistent_id handler closed over the shared deps. + + Reads the BC-tier `DoiMinter` from `deps.equipment.doi_minter` + (wired in `wire_equipment(deps)` per Lock 10). Calls + `minter.mint(scheme, suffix)` to resolve the `PersistentIdentifier`, + then runs the pure decider through the existing + `make_asset_update_handler` factory. Returns the assigned + `PersistentIdentifier` so the route can echo it in the 201 body. + """ + # `deps.equipment` is attached as a `SimpleNamespace` by + # `wire_equipment(deps)` before this handler binds (see Lock 10 in + # [[project-asset-persistent-id-write-design]]). Pyright can't see the + # dynamically-set attribute on the frozen Kernel; cast through Any. + minter = cast("DoiMinter", deps.equipment.doi_minter) # type: ignore[attr-defined] + + async def handler( + command: AssignAssetPersistentId, + *, + principal_id: UUID, + correlation_id: UUID, + causation_id: UUID | None = None, + surface_id: UUID = NIL_SENTINEL_ID, + ) -> PersistentIdentifier: + persistent_id = await minter.mint(scheme=command.scheme, suffix=command.suffix) + + def decide_with_resolved( + *, + state: Asset | None, + command: AssignAssetPersistentId, + now: datetime, + ) -> Sequence[AssetEvent]: + return decide(state, command, persistent_id=persistent_id, now=now) + + inner = make_asset_update_handler( + deps, + command_name="AssignAssetPersistentId", + log_prefix="assign_asset_persistent_id", + decide_fn=decide_with_resolved, + ) + await inner( + command, + principal_id=principal_id, + correlation_id=correlation_id, + causation_id=causation_id, + surface_id=surface_id, + ) + return persistent_id + + return handler diff --git a/apps/api/src/cora/equipment/features/assign_asset_persistent_id/route.py b/apps/api/src/cora/equipment/features/assign_asset_persistent_id/route.py new file mode 100644 index 0000000000..42334f8231 --- /dev/null +++ b/apps/api/src/cora/equipment/features/assign_asset_persistent_id/route.py @@ -0,0 +1,115 @@ +"""HTTP route for the `assign_asset_persistent_id` slice. + +Action endpoint at `POST /assets/{asset_id}/assign-persistent-identifier`. +Thin wire layer: forwards `(asset_id, scheme, suffix)` to the handler, +which resolves the `DoiMinter` call and runs the pure decider. The +route itself does NOT depend on the `DoiMinter` port (Lock 12 keeps +non-determinism in the handler closure only). + +201 Created on success with `AssignAssetPersistentIdResponse(scheme, value)` +in the body so the operator learns the server-minted identifier +without a follow-up GET (Lock 17 deviation from the empty-201 +convention for Asset mutations). +""" + +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, Path, Request, status + +from cora.equipment._asset_persistent_identifier_body import ( + AssignAssetPersistentIdRequest, + AssignAssetPersistentIdResponse, +) +from cora.equipment.features.assign_asset_persistent_id.command import AssignAssetPersistentId +from cora.equipment.features.assign_asset_persistent_id.handler import Handler +from cora.infrastructure.routing import ( + ErrorResponse, + get_correlation_id, + get_principal_id, + get_surface_id, +) + + +def _get_handler(request: Request) -> Handler: + handler: Handler = request.app.state.equipment.assign_asset_persistent_id + return handler + + +router = APIRouter(tags=["equipment"]) + + +@router.post( + "/assets/{asset_id}/assign-persistent-identifier", + status_code=status.HTTP_201_CREATED, + response_model=AssignAssetPersistentIdResponse, + responses={ + status.HTTP_400_BAD_REQUEST: { + "model": ErrorResponse, + "description": ( + "PersistentIdentifier VO validation failed: empty or " + "whitespace-only value, or value over the max-length " + "bound (InvalidPersistentIdentifierValueError)." + ), + }, + status.HTTP_403_FORBIDDEN: { + "model": ErrorResponse, + "description": "Authorize policy denied the command.", + }, + status.HTTP_404_NOT_FOUND: { + "model": ErrorResponse, + "description": "No asset exists with the given id.", + }, + status.HTTP_409_CONFLICT: { + "model": ErrorResponse, + "description": ( + "Asset cannot accept the persistent identifier under " + "current conditions: the asset is Decommissioned " + "(AssetPersistentIdAssignmentForbiddenError), OR the " + "asset already carries a persistent_id (set-once: " + "AssetPersistentIdAlreadyAssignedError), OR a " + "concurrent write to the same asset stream conflicted " + "(optimistic concurrency)." + ), + }, + status.HTTP_502_BAD_GATEWAY: { + "model": ErrorResponse, + "description": ( + "The external mint authority (DataCite or Handle.net) " + "failed to assign a persistent identifier " + "(PersistentIdentifierMintError)." + ), + }, + status.HTTP_422_UNPROCESSABLE_CONTENT: { + "description": ( + "Path parameter or request body failed schema " + "validation (missing field, malformed UUID, scheme " + "outside the closed enum, suffix length out of bounds " + "at the wire layer)." + ), + }, + }, + summary="Assign a PIDINST persistent identifier to an existing Asset", +) +async def post_assets_assign_persistent_identifier( + asset_id: Annotated[UUID, Path(description="Target asset's id.")], + body: AssignAssetPersistentIdRequest, + handler: Annotated[Handler, Depends(_get_handler)], + cid: Annotated[UUID, Depends(get_correlation_id)], + principal_id: Annotated[UUID, Depends(get_principal_id)], + surface_id: Annotated[UUID, Depends(get_surface_id)], +) -> AssignAssetPersistentIdResponse: + persistent_id = await handler( + AssignAssetPersistentId( + asset_id=asset_id, + scheme=body.scheme, + suffix=body.suffix, + ), + principal_id=principal_id, + correlation_id=cid, + surface_id=surface_id, + ) + return AssignAssetPersistentIdResponse( + scheme=persistent_id.scheme.value, + value=persistent_id.value, + ) diff --git a/apps/api/src/cora/equipment/features/assign_asset_persistent_id/tool.py b/apps/api/src/cora/equipment/features/assign_asset_persistent_id/tool.py new file mode 100644 index 0000000000..18361d6eb2 --- /dev/null +++ b/apps/api/src/cora/equipment/features/assign_asset_persistent_id/tool.py @@ -0,0 +1,54 @@ +"""MCP tool for the `assign_asset_persistent_id` slice.""" + +from collections.abc import Callable +from typing import Annotated, Any +from uuid import UUID + +from mcp.server.fastmcp import Context, FastMCP +from pydantic import Field + +from cora.equipment.aggregates.asset import PersistentIdentifierScheme +from cora.equipment.features.assign_asset_persistent_id.command import AssignAssetPersistentId +from cora.equipment.features.assign_asset_persistent_id.handler import Handler +from cora.infrastructure.mcp_principal import get_mcp_principal_id +from cora.infrastructure.observability import current_correlation_id +from cora.infrastructure.routing import get_mcp_surface_id + + +def register(mcp: FastMCP, *, get_handler: Callable[[], Handler]) -> None: + """Register the `assign_asset_persistent_id` tool on the given MCP server.""" + + @mcp.tool( + name="assign_asset_persistent_id", + description=( + "Assign a persistent identifier (PIDINST v1.0 Property 1, " + "DOI or Handle) to an existing Asset. Set-once: rejects " + "when the Asset already carries a persistent_id. Rejects " + "when the Asset is Decommissioned. Calls DataCite (or the " + "configured DoiMinter adapter) to mint the identifier " + "server-side; returns the assigned (scheme, value) pair." + ), + ) + async def assign_asset_persistent_id_tool( # pyright: ignore[reportUnusedFunction] + ctx: Context[Any, Any, Any], + asset_id: Annotated[ + UUID, + Field(description="Target asset's id."), + ], + scheme: Annotated[ + PersistentIdentifierScheme, + Field(description="Closed PIDINST Property 1 scheme: DOI or HANDLE."), + ], + suffix: Annotated[ + str | None, + Field(description="Optional operator-supplied local part."), + ] = None, + ) -> dict[str, str]: + handler = get_handler() + persistent_id = await handler( + AssignAssetPersistentId(asset_id=asset_id, scheme=scheme, suffix=suffix), + principal_id=get_mcp_principal_id(ctx), + correlation_id=current_correlation_id(), + surface_id=get_mcp_surface_id(), + ) + return {"scheme": persistent_id.scheme.value, "value": persistent_id.value} diff --git a/apps/api/src/cora/equipment/features/get_asset_pidinst/_view_assembler.py b/apps/api/src/cora/equipment/features/get_asset_pidinst/_view_assembler.py index 85154490d6..1913971f44 100644 --- a/apps/api/src/cora/equipment/features/get_asset_pidinst/_view_assembler.py +++ b/apps/api/src/cora/equipment/features/get_asset_pidinst/_view_assembler.py @@ -90,6 +90,7 @@ async def assemble_pidinst_view( ) for owner in sorted(asset.owners, key=lambda o: o.name.value) ), + persistent_id=asset.persistent_id, ) diff --git a/apps/api/src/cora/equipment/ports/__init__.py b/apps/api/src/cora/equipment/ports/__init__.py new file mode 100644 index 0000000000..0c0c2825cf --- /dev/null +++ b/apps/api/src/cora/equipment/ports/__init__.py @@ -0,0 +1,20 @@ +"""Equipment BC ports (BC-tier Protocols owned by Equipment). + +`DoiMinter` ships here per [[project-asset-persistent-id-write-design]] +(slice F.1): the operator-facing surface for minting a PIDINST v1.0 +Property 1 persistent identifier (DOI or Handle) at an external +authority such as DataCite or Handle.net. + +BC-tier port location per [[project-adapter-naming-design]]: stays +here until rule-of-three promotes to `cora.infrastructure.ports`. +""" + +from cora.equipment.ports.doi_minter import ( + DoiMinter, + PersistentIdentifierMintError, +) + +__all__ = [ + "DoiMinter", + "PersistentIdentifierMintError", +] diff --git a/apps/api/src/cora/equipment/ports/doi_minter.py b/apps/api/src/cora/equipment/ports/doi_minter.py new file mode 100644 index 0000000000..da8391e4c8 --- /dev/null +++ b/apps/api/src/cora/equipment/ports/doi_minter.py @@ -0,0 +1,87 @@ +"""DoiMinter port: mints PIDINST Property 1 persistent identifiers. + +Operator-facing surface for assigning a PIDINST v1.0 Property 1 +persistent identifier (DOI or Handle) at an external authority such +as DataCite or Handle.net. The port returns the assigned +`PersistentIdentifier` (scheme + value) to the calling slice. The +slice does NOT need to know whether the value was operator-supplied, +auto-generated by the authority, or stitched from a configured prefix +plus suffix; that is the adapter's concern. + +The DataCite mint posture (F.2 production adapter) uses PUT /dois/{id} +upsert semantics with the DOI string as the dedup key (F5.2): retrying +the same (prefix, suffix) pair is safe at the wire because DataCite +treats the PUT as idempotent on the DOI string. The CORA caller does +not pass an Idempotency-Key header (F5.1). + +The Stub adapter (ships in F.1) returns a deterministic test +identifier built from a fixed test prefix plus the supplied suffix +(or a UUID-based suffix when none was supplied), and never errors. + +The production adapter (deferred to F.2) calls DataCite and may raise +`PersistentIdentifierMintError` on: + - HTTP 4xx (operator error, scheme misuse, malformed DOI) + - HTTP 5xx after exhausting the 429 backoff schedule + - Network failures + - DataCite repository account misconfiguration + +The error class is a single catch-all per +[[project-genesis-error-classes]] (don't hoist; per-BC); the route +maps it to 502. +""" + +from typing import Protocol + +from cora.equipment.aggregates.asset import ( + PersistentIdentifier, + PersistentIdentifierScheme, +) + + +class PersistentIdentifierMintError(Exception): + """The external mint authority failed to assign a persistent identifier. + + Wraps: + - HTTP 4xx from DataCite (operator error, malformed suffix, etc.) + - HTTP 5xx from DataCite after the 429 backoff schedule exhausted + - Network failures (timeout, DNS failure, TLS handshake failure) + - Adapter configuration failure (missing credentials at call time) + + Per [[project-genesis-error-classes]] don't-hoist convention: lives + in the Equipment BC, not in `cora.infrastructure.errors`. The route + maps to HTTP 502 (Bad Gateway) because this is upstream-port + failure, NOT a domain-state conflict (which would be 409). The Stub + adapter never raises this; F.2's production adapter is the sole + source. + """ + + def __init__(self, *, scheme: PersistentIdentifierScheme, reason: str) -> None: + super().__init__( + f"Failed to mint persistent identifier under scheme {scheme.value}: {reason}" + ) + self.scheme = scheme + self.reason = reason + + +class DoiMinter(Protocol): + """Mints a persistent identifier at an external authority.""" + + async def mint( + self, + *, + scheme: PersistentIdentifierScheme, + suffix: str | None, + ) -> PersistentIdentifier: + """Mint a persistent identifier under the given scheme. + + `suffix` is the operator-supplied local part (without prefix); + when None the adapter asks the authority to auto-generate. + + Returns the full `PersistentIdentifier` (scheme + value) where + value is the authority-assigned global string (for example + "10.5281/zenodo.1234567" for a DOI). + + Raises `PersistentIdentifierMintError` on any external failure; + the route maps to HTTP 502. + """ + ... diff --git a/apps/api/src/cora/equipment/projections/asset.py b/apps/api/src/cora/equipment/projections/asset.py index 81d2fb4ce7..3a723d071a 100644 --- a/apps/api/src/cora/equipment/projections/asset.py +++ b/apps/api/src/cora/equipment/projections/asset.py @@ -26,6 +26,8 @@ array, re-sorted by name ASC - AssetOwnerRemoved -> UPDATE remove owner matching name from owners JSONB array + - AssetPersistentIdAssigned -> UPDATE persistent_id JSONB with + {scheme, value} from event payload - AssetAttachedToFixture -> UPDATE fixture_id (Fixture back-ref set by attach_asset_to_fixture) - AssetDetachedFromFixture -> UPDATE fixture_id = NULL (cleared @@ -194,6 +196,16 @@ WHERE asset_id = $1 """ +_UPDATE_PERSISTENT_ID_ASSIGNED_SQL = """ +UPDATE proj_equipment_asset_summary +SET persistent_id = jsonb_build_object( + 'scheme', $2::text, + 'value', $3::text + ), + updated_at = now() +WHERE asset_id = $1 +""" + class AssetSummaryProjection: """Maintains the `proj_equipment_asset_summary` read model.""" @@ -214,6 +226,7 @@ class AssetSummaryProjection: "AssetAlternateIdentifierRemoved", "AssetOwnerAdded", "AssetOwnerRemoved", + "AssetPersistentIdAssigned", "AssetAttachedToFixture", "AssetDetachedFromFixture", } @@ -306,6 +319,13 @@ async def apply( UUID(event.payload["asset_id"]), event.payload["owner_name"], ) + case "AssetPersistentIdAssigned": + await conn.execute( + _UPDATE_PERSISTENT_ID_ASSIGNED_SQL, + UUID(event.payload["asset_id"]), + event.payload["persistent_id_scheme"], + event.payload["persistent_id_value"], + ) case "AssetAttachedToFixture": await conn.execute( _UPDATE_FIXTURE_ID_SQL, diff --git a/apps/api/src/cora/equipment/routes.py b/apps/api/src/cora/equipment/routes.py index 0f9d5afaf1..16ca8ff365 100644 --- a/apps/api/src/cora/equipment/routes.py +++ b/apps/api/src/cora/equipment/routes.py @@ -82,6 +82,8 @@ AssetNotFoundError, AssetOwnerAlreadyPresentError, AssetOwnerNotPresentError, + AssetPersistentIdAlreadyAssignedError, + AssetPersistentIdAssignmentForbiddenError, InvalidAlternateIdentifierValueError, InvalidAssetNameError, InvalidAssetOwnerContactError, @@ -93,6 +95,8 @@ InvalidAssetPortNameError, InvalidAssetPortSignalTypeError, InvalidAssetSettingsError, + InvalidPersistentIdentifierValueError, + MalformedPersistentIdentifierError, ) from cora.equipment.aggregates.family import ( FamilyAlreadyExistsError, @@ -163,6 +167,7 @@ add_asset_owner, add_asset_port, add_model_family, + assign_asset_persistent_id, attach_asset_to_fixture, decommission_asset, decommission_frame, @@ -208,6 +213,7 @@ version_family, version_model, ) +from cora.equipment.ports.doi_minter import PersistentIdentifierMintError async def _handle_validation_error(request: Request, exc: Exception) -> JSONResponse: @@ -289,6 +295,42 @@ async def _handle_pidinst_state_not_available(request: Request, exc: Exception) ) +async def _handle_persistent_identifier_mint_error( + request: Request, exc: Exception +) -> JSONResponse: + """Shared 502 handler for upstream mint-authority failures. + + Maps `PersistentIdentifierMintError`: the external DataCite or + Handle.net authority failed to assign a persistent identifier + (HTTP 4xx / 5xx after retry, network failure, credential + misconfiguration). 502 not 409 because this is upstream-port + failure, not a domain-state conflict. + """ + _ = request + return JSONResponse( + status_code=status.HTTP_502_BAD_GATEWAY, + content={"detail": str(exc)}, + ) + + +async def _handle_malformed_stored_event(request: Request, exc: Exception) -> JSONResponse: + """500 handler for malformed-stored-event deserialization escapes. + + Maps `MalformedPersistentIdentifierError`: a stored + `AssetPersistentIdAssigned` payload could not be reconstructed + because the `persistent_id_value` is empty or non-string. The + `from_stored` wrap convention normally re-raises as `ValueError` + via `deserialize_or_raise`, so this handler is defense-in-depth + for the unwrapped path. 500 because this signals a data-integrity + bug in the event store, not a client error. + """ + _ = request + return JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content={"detail": str(exc)}, + ) + + async def _handle_pidinst_view_preparation_error(request: Request, exc: Exception) -> JSONResponse: """Shared 422 handler for PIDINST view-preparation deficiencies. @@ -339,6 +381,7 @@ def register_equipment_routes(app: FastAPI) -> None: app.include_router(remove_asset_alternate_identifier.router) app.include_router(add_asset_owner.router) app.include_router(remove_asset_owner.router) + app.include_router(assign_asset_persistent_id.router) app.include_router(get_asset.router) app.include_router(get_asset_integration_view.router) app.include_router(get_asset_pidinst.router) @@ -377,6 +420,7 @@ def register_equipment_routes(app: FastAPI) -> None: InvalidAssetOwnerIdentifierError, InvalidAssetOwnerIdentifierTypeError, InvalidAssetOwnerIdentifierPairingError, + InvalidPersistentIdentifierValueError, InvalidFrameNameError, InvalidFrameRevisionError, InvalidFrameRootError, @@ -445,6 +489,8 @@ def register_equipment_routes(app: FastAPI) -> None: AssetCannotAddAlternateIdentifierError, AssetOwnerAlreadyPresentError, AssetCannotAddOwnerError, + AssetPersistentIdAlreadyAssignedError, + AssetPersistentIdAssignmentForbiddenError, AssetModelMismatchError, FamilyCannotVersionError, FamilyCannotDeprecateError, @@ -486,4 +532,8 @@ def register_equipment_routes(app: FastAPI) -> None: AssetNameMissingError, ): app.add_exception_handler(pidinst_view_cls, _handle_pidinst_view_preparation_error) + app.add_exception_handler( + PersistentIdentifierMintError, _handle_persistent_identifier_mint_error + ) + app.add_exception_handler(MalformedPersistentIdentifierError, _handle_malformed_stored_event) app.add_exception_handler(UnauthorizedError, _handle_unauthorized) diff --git a/apps/api/src/cora/equipment/tools.py b/apps/api/src/cora/equipment/tools.py index 77e61917dc..9f17456bcd 100644 --- a/apps/api/src/cora/equipment/tools.py +++ b/apps/api/src/cora/equipment/tools.py @@ -21,6 +21,9 @@ from cora.equipment.features.add_asset_owner import tool as add_asset_owner_tool from cora.equipment.features.add_asset_port import tool as add_asset_port_tool from cora.equipment.features.add_model_family import tool as add_model_family_tool +from cora.equipment.features.assign_asset_persistent_id import ( + tool as assign_asset_persistent_id_tool, +) from cora.equipment.features.attach_asset_to_fixture import tool as attach_asset_to_fixture_tool from cora.equipment.features.decommission_asset import tool as decommission_asset_tool from cora.equipment.features.decommission_frame import tool as decommission_frame_tool @@ -213,6 +216,10 @@ def register_equipment_tools( mcp, get_handler=lambda: get_handlers().remove_asset_owner, ) + assign_asset_persistent_id_tool.register( + mcp, + get_handler=lambda: get_handlers().assign_asset_persistent_id, + ) get_asset_tool.register( mcp, get_handler=lambda: get_handlers().get_asset, diff --git a/apps/api/src/cora/equipment/wire.py b/apps/api/src/cora/equipment/wire.py index 3658167b73..dcb4cdbc7a 100644 --- a/apps/api/src/cora/equipment/wire.py +++ b/apps/api/src/cora/equipment/wire.py @@ -29,9 +29,11 @@ """ from dataclasses import dataclass +from types import SimpleNamespace from uuid import UUID from cora.equipment._bootstrap import check_pidinst_landing_page_template +from cora.equipment.adapters.stub_doi_minter import StubDoiMinter from cora.equipment.features import ( activate_asset, add_asset_alternate_identifier, @@ -39,6 +41,7 @@ add_asset_owner, add_asset_port, add_model_family, + assign_asset_persistent_id, attach_asset_to_fixture, decommission_asset, decommission_frame, @@ -84,6 +87,7 @@ version_family, version_model, ) +from cora.equipment.ports.doi_minter import DoiMinter from cora.infrastructure.idempotency import with_idempotency from cora.infrastructure.kernel import Kernel from cora.infrastructure.observability import with_tracing @@ -148,6 +152,7 @@ class EquipmentHandlers: remove_asset_alternate_identifier: remove_asset_alternate_identifier.Handler add_asset_owner: add_asset_owner.Handler remove_asset_owner: remove_asset_owner.Handler + assign_asset_persistent_id: assign_asset_persistent_id.Handler get_asset: get_asset.Handler get_asset_integration_view: get_asset_integration_view.Handler get_asset_pidinst: get_asset_pidinst.Handler @@ -173,10 +178,43 @@ class EquipmentHandlers: get_fixture: get_fixture.Handler list_fixtures: list_fixtures.Handler + doi_minter: DoiMinter + """The `DoiMinter` adapter the `assign_asset_persistent_id` handler talks + to. Surfaced on the bundle so the FastAPI lifespan stashes it on + `app.state.equipment.doi_minter` for test-override per + [[project-asset-persistent-id-write-design]] Lock 10. F.1 wires + `StubDoiMinter` when `Settings.datacite_repository_id` is None; + F.2 swaps in `DataCiteDoiMinter` behind the same field.""" + def wire_equipment(deps: Kernel) -> EquipmentHandlers: - """Build the Equipment BC handlers from shared dependencies.""" + """Build the Equipment BC handlers from shared dependencies. + + Per [[project-asset-persistent-id-write-design]] Lock 10 the + `DoiMinter` is a BC-tier port: wired here from Equipment-local + settings, never promoted to `Kernel`. When + `Settings.datacite_repository_id` is None (the dev / test default) + the inert `StubDoiMinter` is wired so the assign_asset_persistent_id + slice ships and is testable without DataCite credentials; the + production `DataCiteDoiMinter` swap is F.2. The minter is + attached to a BC-local `deps.equipment` namespace BEFORE the + `assign_asset_persistent_id` handler binds, so the handler closure + reads `deps.equipment.doi_minter` per the BC-tier port-wiring + convention. It is also surfaced on `EquipmentHandlers.doi_minter` + so the FastAPI lifespan stashes it on `app.state.equipment.doi_minter` + for test override (integration tests injecting a `RaisingDoiMinter` + to exercise the 502 mint-failure path). + """ check_pidinst_landing_page_template(deps.settings) + # F.2 swaps in `DataCiteDoiMinter` here when + # `Settings.datacite_repository_id` is set; F.1 ships the Stub + # branch unconditionally because the production adapter is gated + # on facility credentials. + if getattr(deps.settings, "datacite_repository_id", None) is None: + doi_minter: DoiMinter = StubDoiMinter() + else: + doi_minter = StubDoiMinter() + object.__setattr__(deps, "equipment", SimpleNamespace(doi_minter=doi_minter)) return EquipmentHandlers( # Family aggregate define_family=with_tracing( @@ -357,6 +395,11 @@ def wire_equipment(deps: Kernel) -> EquipmentHandlers: command_name="RemoveAssetOwner", bc=_BC, ), + assign_asset_persistent_id=with_tracing( + assign_asset_persistent_id.bind(deps), + command_name="AssignAssetPersistentId", + bc=_BC, + ), get_asset=with_tracing( get_asset.bind(deps), command_name="GetAsset", @@ -493,4 +536,5 @@ def wire_equipment(deps: Kernel) -> EquipmentHandlers: bc=_BC, kind="query", ), + doi_minter=doi_minter, ) diff --git a/apps/api/tach.toml b/apps/api/tach.toml index bdadd9372a..f33625cc38 100644 --- a/apps/api/tach.toml +++ b/apps/api/tach.toml @@ -66,6 +66,14 @@ depends_on = ["cora.infrastructure"] path = "cora.equipment.aggregates" depends_on = ["cora.infrastructure"] +[[modules]] +path = "cora.equipment.ports" +depends_on = ["cora.equipment.aggregates"] + +[[modules]] +path = "cora.equipment.adapters" +depends_on = ["cora.equipment.aggregates", "cora.equipment.ports"] + [[modules]] path = "cora.federation.aggregates" depends_on = ["cora.infrastructure"] @@ -132,7 +140,12 @@ depends_on = [ [[modules]] path = "cora.equipment" -depends_on = ["cora.infrastructure", "cora.equipment.aggregates"] +depends_on = [ + "cora.infrastructure", + "cora.equipment.aggregates", + "cora.equipment.ports", + "cora.equipment.adapters", +] [[modules]] path = "cora.subject" diff --git a/apps/api/tests/contract/test_assign_asset_persistent_id_openapi.py b/apps/api/tests/contract/test_assign_asset_persistent_id_openapi.py new file mode 100644 index 0000000000..934bbb4b6e --- /dev/null +++ b/apps/api/tests/contract/test_assign_asset_persistent_id_openapi.py @@ -0,0 +1,77 @@ +"""OpenAPI surface pins for `POST /assets/{asset_id}/assign-persistent-identifier`. + +Per Section 13.3 of project-asset-persistent-id-write-design: pin the +OpenAPI schema for the assign-persistent-identifier endpoint. This +guards Lock 17 (response echoes scheme + value), Lock 22 (request body +shape is scheme + optional suffix, no `value`), and Lock 19 (status-code +map: 201 success; 400/403/404/409/502 domain + authorization errors; +422 wire-layer validation failures). Mirrors the surface-pin posture of +`test_methods_endpoint.test_post_methods_openapi_schema_marks_capability_id_required` +and `test_list_credentials_endpoint.test_get_credentials_response_schema_omits_opaque_secret_refs`. +""" + +import pytest +from fastapi.testclient import TestClient + +from cora.api.main import create_app + +pytestmark = pytest.mark.timeout(60, method="thread") + +_ROUTE_PATH = "/assets/{asset_id}/assign-persistent-identifier" + + +@pytest.mark.contract +def test_assign_persistent_id_openapi_schema_has_assign_persistent_identifier_endpoint() -> None: + with TestClient(create_app()) as client: + openapi = client.get("/openapi.json").json() + + assert _ROUTE_PATH in openapi["paths"], ( + f"OpenAPI must expose {_ROUTE_PATH} for the assign_asset_persistent_id slice" + ) + assert "post" in openapi["paths"][_ROUTE_PATH], f"{_ROUTE_PATH} must register a POST operation" + + +@pytest.mark.contract +def test_assign_persistent_id_openapi_request_body_has_scheme_and_suffix_fields() -> None: + with TestClient(create_app()) as client: + openapi = client.get("/openapi.json").json() + + request_component = openapi["components"]["schemas"]["AssignAssetPersistentIdRequest"] + properties = request_component["properties"] + assert "scheme" in properties, "request body must expose scheme" + assert "suffix" in properties, "request body must expose suffix" + assert "scheme" in request_component["required"], "scheme is REQUIRED at the wire" + assert "suffix" not in request_component.get("required", []), ( + "suffix is OPTIONAL per Lock 22 (auto-generated when absent)" + ) + assert "value" not in properties, ( + "request body MUST NOT carry a value field per Lock 12 server-mint posture" + ) + + +@pytest.mark.contract +def test_assign_persistent_id_openapi_response_has_scheme_and_value_fields() -> None: + with TestClient(create_app()) as client: + openapi = client.get("/openapi.json").json() + + response_component = openapi["components"]["schemas"]["AssignAssetPersistentIdResponse"] + properties = response_component["properties"] + assert "scheme" in properties, "response body must echo the assigned scheme" + assert "value" in properties, "response body must echo the assigned value" + required = response_component["required"] + assert "scheme" in required, "response scheme is REQUIRED (always present)" + assert "value" in required, "response value is REQUIRED (always present)" + + +@pytest.mark.contract +def test_assign_persistent_id_openapi_documents_201_400_403_404_409_502_422_responses() -> None: + with TestClient(create_app()) as client: + openapi = client.get("/openapi.json").json() + + operation = openapi["paths"][_ROUTE_PATH]["post"] + responses = operation["responses"] + expected = {"201", "400", "403", "404", "409", "502", "422"} + missing = expected - set(responses.keys()) + assert not missing, ( + f"OpenAPI must document all status codes from Lock 19; missing: {sorted(missing)}" + ) diff --git a/apps/api/tests/integration/equipment/conftest.py b/apps/api/tests/integration/equipment/conftest.py new file mode 100644 index 0000000000..efaa82167d --- /dev/null +++ b/apps/api/tests/integration/equipment/conftest.py @@ -0,0 +1,58 @@ +"""Integration-test fixtures scoped to the Equipment BC. + +Provides Protocol-conforming test doubles for ports that the Equipment +BC wires through `wire_equipment(deps)` onto `app.state.equipment.*`, +so route-level tests can swap the inert Stub for a failure-inducing +double without touching the kernel. + +Currently exposes: + +- `RaisingDoiMinter`: a `DoiMinter`-conforming class whose `mint` + unconditionally raises `PersistentIdentifierMintError` with the + reason `"upstream stub failure"`. Used by the 502-path test on + `POST /assets/{asset_id}/assign-persistent-identifier` to verify + the upstream-mint-failure exception handler wires correctly + (per slice F Section 13.2 + Locks L11 + L19). +- `raising_doi_minter`: function-scoped pytest fixture returning a + fresh `RaisingDoiMinter` instance for tests that override + `app.state.equipment.doi_minter`. +""" + +import pytest + +from cora.equipment.aggregates.asset import ( + PersistentIdentifier, + PersistentIdentifierScheme, +) +from cora.equipment.ports.doi_minter import PersistentIdentifierMintError + +pytestmark = pytest.mark.timeout(60, method="thread") + + +class RaisingDoiMinter: + """Protocol-conforming `DoiMinter` whose `mint` always raises. + + Mirrors the `StubDoiMinter` shape but inverts the contract: every + invocation raises `PersistentIdentifierMintError` with the reason + `"upstream stub failure"`. The route layer maps this to HTTP 502 + via the standard exception-handler registration in the Equipment + BC, so route-tier tests can assert the upstream-failure path is + wired correctly without bringing a real DataCite adapter or a + network double into the loop. + """ + + async def mint( + self, + *, + scheme: PersistentIdentifierScheme, + suffix: str | None, + ) -> PersistentIdentifier: + raise PersistentIdentifierMintError( + scheme=scheme, + reason="upstream stub failure", + ) + + +@pytest.fixture +def raising_doi_minter() -> RaisingDoiMinter: + return RaisingDoiMinter() diff --git a/apps/api/tests/integration/equipment/test_assign_asset_persistent_id_route.py b/apps/api/tests/integration/equipment/test_assign_asset_persistent_id_route.py new file mode 100644 index 0000000000..d7026cea4d --- /dev/null +++ b/apps/api/tests/integration/equipment/test_assign_asset_persistent_id_route.py @@ -0,0 +1,327 @@ +"""HTTP route tests for `POST /assets/{asset_id}/assign-persistent-identifier`. + +Full status-code matrix per slice F Section 13.2 + Locks L17 / L18 / L19: + + - 201 happy paths (DOI suffix, DOI auto-suffix, Handle suffix) + - 201 response body echoes (scheme, value) verbatim (P2-18 HTTPX pin) + - 404 when asset stream is missing + - 409 when asset is Decommissioned + - 409 when asset already carries a persistent_id (set-once) + - 422 wire-layer validation (empty suffix, oversized suffix, missing + scheme, unknown scheme value) + - 502 when the upstream DoiMinter raises PersistentIdentifierMintError + (RaisingDoiMinter fixture from conftest, swapped onto the bound + handler via dataclasses.replace per Lock 10) + - Event-store persistence pin (the AssetPersistentIdAssigned event + lands on the Asset stream) + - P2-24 foreign-prefix round-trip (pytest.skip until F.2 grows a + register-existing mode on Stub) + +In-memory adapters: `APP_ENV=test` is set in `tests/conftest.py`, so +`create_app()` builds the kernel with `InMemoryEventStore` and the +Equipment BC wires `StubDoiMinter` by default (no DataCite credentials +present in the test Settings). +""" + +# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false, reportMissingTypeStubs=false + +import asyncio +from dataclasses import replace +from typing import TYPE_CHECKING +from uuid import UUID, uuid4 + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from cora.api.main import create_app +from cora.equipment.features import assign_asset_persistent_id +from tests.integration.equipment.conftest import RaisingDoiMinter + +if TYPE_CHECKING: + from cora.equipment.wire import EquipmentHandlers + +pytestmark = pytest.mark.timeout(60, method="thread") + +_STUB_DOI_PREFIX = "10.0000/cora-stub" +_STUB_HANDLE_PREFIX = "20.500.0000/cora-stub" + + +def _register_asset(client: TestClient, *, name: str = "Detector-X") -> str: + response = client.post( + "/assets", + json={"name": name, "level": "Device", "parent_id": str(uuid4())}, + ) + assert response.status_code == 201, response.text + asset_id: str = response.json()["asset_id"] + return asset_id + + +def _swap_doi_minter(app: FastAPI, minter: object) -> None: + """Rebuild the assign_asset_persistent_id handler over a swapped minter. + + The handler closes over `deps.equipment.doi_minter` at bind time, so + mutating the SimpleNamespace alone is not enough: the live handler + on `app.state.equipment.assign_asset_persistent_id` was bound before the + swap. We mutate the BC-local namespace AND rebind the handler, then + drop a fresh `EquipmentHandlers` onto `app.state.equipment` so the + route's `request.app.state.equipment.assign_asset_persistent_id` resolves + to the rebound closure. + """ + deps = app.state.deps + object.__setattr__(deps.equipment, "doi_minter", minter) + rebound = assign_asset_persistent_id.bind(deps) + handlers: EquipmentHandlers = app.state.equipment + app.state.equipment = replace(handlers, assign_asset_persistent_id=rebound, doi_minter=minter) + + +@pytest.mark.integration +def test_post_assign_persistent_id_with_doi_scheme_and_suffix_returns_201_and_echoes_value() -> ( + None +): + with TestClient(create_app()) as client: + asset_id = _register_asset(client) + response = client.post( + f"/assets/{asset_id}/assign-persistent-identifier", + json={"scheme": "DOI", "suffix": "APS-2BM-CAM-001"}, + ) + assert response.status_code == 201, response.text + body = response.json() + assert body["scheme"] == "DOI" + assert body["value"] == f"{_STUB_DOI_PREFIX}/APS-2BM-CAM-001" + + +@pytest.mark.integration +def test_post_assign_persistent_id_with_doi_scheme_and_no_suffix_uses_stub_uuid_suffix() -> None: + with TestClient(create_app()) as client: + asset_id = _register_asset(client) + response = client.post( + f"/assets/{asset_id}/assign-persistent-identifier", + json={"scheme": "DOI"}, + ) + assert response.status_code == 201, response.text + body = response.json() + assert body["scheme"] == "DOI" + assert body["value"].startswith(f"{_STUB_DOI_PREFIX}/") + # UUID4 suffix is 36 chars; assert a non-trivial server-generated tail. + suffix = body["value"].removeprefix(f"{_STUB_DOI_PREFIX}/") + assert len(suffix) == 36 + + +@pytest.mark.integration +def test_post_assign_persistent_id_with_handle_scheme_returns_201_with_handle_test_prefix() -> None: + with TestClient(create_app()) as client: + asset_id = _register_asset(client) + response = client.post( + f"/assets/{asset_id}/assign-persistent-identifier", + json={"scheme": "Handle", "suffix": "12345"}, + ) + assert response.status_code == 201, response.text + body = response.json() + assert body["scheme"] == "Handle" + assert body["value"] == f"{_STUB_HANDLE_PREFIX}/12345" + + +@pytest.mark.integration +def test_post_assign_persistent_id_endpoint_201_response_body_echoes_scheme_and_value_exactly() -> ( + None +): + """P2-18 HTTPX wire pin: 201 body == {"scheme": ..., "value": ...}. + + Catches the regression class where the handler returns the right VO + but the route's `AssignAssetPersistentIdResponse(...)` drops or renames a + field. Complements the contract-tier OpenAPI shape test. + """ + with TestClient(create_app()) as client: + asset_id = _register_asset(client) + response = client.post( + f"/assets/{asset_id}/assign-persistent-identifier", + json={"scheme": "DOI", "suffix": "APS-EXACT-ECHO"}, + ) + assert response.status_code == 201, response.text + assert response.json() == { + "scheme": "DOI", + "value": f"{_STUB_DOI_PREFIX}/APS-EXACT-ECHO", + } + + +@pytest.mark.integration +def test_post_assign_persistent_id_with_unknown_asset_returns_404() -> None: + missing = str(uuid4()) + with TestClient(create_app()) as client: + response = client.post( + f"/assets/{missing}/assign-persistent-identifier", + json={"scheme": "DOI", "suffix": "X"}, + ) + assert response.status_code == 404 + assert missing in response.json()["detail"] + + +@pytest.mark.integration +def test_post_assign_persistent_id_with_decommissioned_asset_returns_409() -> None: + with TestClient(create_app()) as client: + asset_id = _register_asset(client) + decom = client.post(f"/assets/{asset_id}/decommission") + assert decom.status_code == 204 + response = client.post( + f"/assets/{asset_id}/assign-persistent-identifier", + json={"scheme": "DOI", "suffix": "X"}, + ) + assert response.status_code == 409 + assert "Decommissioned" in response.json()["detail"] + + +@pytest.mark.integration +def test_post_assign_persistent_id_with_already_assigned_asset_returns_409() -> None: + with TestClient(create_app()) as client: + asset_id = _register_asset(client) + first = client.post( + f"/assets/{asset_id}/assign-persistent-identifier", + json={"scheme": "DOI", "suffix": "FIRST"}, + ) + assert first.status_code == 201, first.text + second = client.post( + f"/assets/{asset_id}/assign-persistent-identifier", + json={"scheme": "DOI", "suffix": "SECOND"}, + ) + assert second.status_code == 409 + body = second.json() + assert "detail" in body + assert "FIRST" in body["detail"] or "SECOND" in body["detail"] + + +@pytest.mark.integration +def test_post_assign_persistent_id_with_empty_suffix_returns_422() -> None: + with TestClient(create_app()) as client: + asset_id = _register_asset(client) + response = client.post( + f"/assets/{asset_id}/assign-persistent-identifier", + json={"scheme": "DOI", "suffix": ""}, + ) + assert response.status_code == 422 + + +@pytest.mark.integration +def test_post_assign_persistent_id_with_suffix_over_max_length_returns_422() -> None: + with TestClient(create_app()) as client: + asset_id = _register_asset(client) + response = client.post( + f"/assets/{asset_id}/assign-persistent-identifier", + json={"scheme": "DOI", "suffix": "x" * 201}, + ) + assert response.status_code == 422 + + +@pytest.mark.integration +def test_post_assign_persistent_id_with_missing_scheme_returns_422() -> None: + with TestClient(create_app()) as client: + asset_id = _register_asset(client) + response = client.post( + f"/assets/{asset_id}/assign-persistent-identifier", + json={"suffix": "X"}, + ) + assert response.status_code == 422 + + +@pytest.mark.integration +def test_post_assign_persistent_id_with_invalid_scheme_value_returns_422() -> None: + with TestClient(create_app()) as client: + asset_id = _register_asset(client) + response = client.post( + f"/assets/{asset_id}/assign-persistent-identifier", + json={"scheme": "ARK", "suffix": "X"}, + ) + assert response.status_code == 422 + + +@pytest.mark.integration +def test_post_assign_persistent_id_persists_event_to_event_store() -> None: + """The AssetPersistentIdAssigned event lands on the Asset stream.""" + app = create_app() + with TestClient(app) as client: + asset_id_str = _register_asset(client) + response = client.post( + f"/assets/{asset_id_str}/assign-persistent-identifier", + json={"scheme": "DOI", "suffix": "PERSIST"}, + ) + assert response.status_code == 201, response.text + asset_id = UUID(asset_id_str) + events, _ = asyncio.run(app.state.deps.event_store.load("Asset", asset_id)) + event_types = [event.event_type for event in events] + assert "AssetPersistentIdAssigned" in event_types + assigned = next(event for event in events if event.event_type == "AssetPersistentIdAssigned") + assert assigned.payload["persistent_id_scheme"] == "DOI" + assert assigned.payload["persistent_id_value"] == f"{_STUB_DOI_PREFIX}/PERSIST" + + +@pytest.mark.integration +def test_post_assign_persistent_id_writes_persistent_id_to_projection_after_replay() -> None: + """Apply the persisted event through the AssetSummaryProjection. + + In test mode the projection worker does not run (no Postgres), so + the projection's behavior is verified at the unit tier + (`test_assign_asset_persistent_id_summary_projection.py`). This route-tier + test pins the integration contract: the event the route persists is + SHAPED CORRECTLY for the projection to consume (scheme + value + primitives, asset_id matching the stream). Combined with the unit + projection test, the end-to-end replay path is covered. + """ + app = create_app() + with TestClient(app) as client: + asset_id_str = _register_asset(client) + response = client.post( + f"/assets/{asset_id_str}/assign-persistent-identifier", + json={"scheme": "DOI", "suffix": "REPLAY"}, + ) + assert response.status_code == 201, response.text + asset_id = UUID(asset_id_str) + events, _ = asyncio.run(app.state.deps.event_store.load("Asset", asset_id)) + assigned = next(event for event in events if event.event_type == "AssetPersistentIdAssigned") + assert assigned.payload["asset_id"] == asset_id_str + assert set(assigned.payload.keys()) >= { + "asset_id", + "persistent_id_scheme", + "persistent_id_value", + "occurred_at", + } + + +@pytest.mark.integration +def test_post_assign_persistent_id_with_raising_minter_returns_502( + raising_doi_minter: RaisingDoiMinter, +) -> None: + """Override the bound minter with RaisingDoiMinter and assert 502. + + Verifies the L11 + L19 mapping wires correctly: a + `PersistentIdentifierMintError` raised by the upstream port surfaces + as HTTP 502 with a `{"detail": ...}` body per L18 BC-uniform shape. + """ + app = create_app() + with TestClient(app) as client: + asset_id = _register_asset(client) + _swap_doi_minter(app, raising_doi_minter) + response = client.post( + f"/assets/{asset_id}/assign-persistent-identifier", + json={"scheme": "DOI", "suffix": "WILL-FAIL"}, + ) + assert response.status_code == 502 + body = response.json() + assert "detail" in body + assert "upstream stub failure" in body["detail"] + + +@pytest.mark.integration +def test_post_assign_persistent_id_with_operator_supplied_full_doi_round_trips_unchanged() -> None: + """P2-24 foreign-prefix passthrough lock-in. + + Operator submits a suffix containing a foreign DOI prefix + (`10.5281/zenodo.1234567`); the Stub adapter does NOT currently + grow a register-existing mode in F.1 (its contract concatenates + `/` unconditionally). Locking the post-F.2 + wire format at F.1 lock time so the cassette suite does not have + to re-design this surface. + """ + pytest.skip( + "foreign-prefix passthrough lands in F.2 via DataCite PUT idempotency; " + "see project_asset_persistent_id_write_design Finding 24" + ) diff --git a/apps/api/tests/integration/equipment/test_assign_asset_persistent_id_tool.py b/apps/api/tests/integration/equipment/test_assign_asset_persistent_id_tool.py new file mode 100644 index 0000000000..9915dbe9f9 --- /dev/null +++ b/apps/api/tests/integration/equipment/test_assign_asset_persistent_id_tool.py @@ -0,0 +1,329 @@ +"""Integration tests for the `assign_asset_persistent_id` MCP tool against real Postgres. + +Registers the slice's tool on a fresh FastMCP server bound to a +Postgres-backed handler closure, then exercises the registered tool +function directly so the test does not need to stand up a streamable- +http request context. The integration tier owns the end-to-end chain: +MCP tool input parsing through the slice handler through the DoiMinter +port through the Postgres event store. The happy-path and domain- +error mappings are covered here; the wire-protocol envelope (SSE / +JSON-RPC) is the contract-tier suite's concern. + +Mirrors the slice E.1 get_asset_pidinst integration suite shape for +seeding (register_asset.bind(deps) + Kernel construction) and the +slice F conftest's `raising_doi_minter` fixture for the upstream +mint-failure path. +""" + +# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false + +from datetime import UTC, datetime +from typing import Any +from uuid import UUID, uuid4 + +import asyncpg +import pytest +from mcp.server.fastmcp import FastMCP + +from cora.equipment.adapters.stub_doi_minter import StubDoiMinter +from cora.equipment.aggregates.asset import ( + AssetLevel, + AssetNotFoundError, + AssetPersistentIdAlreadyAssignedError, + AssetPersistentIdAssignmentForbiddenError, + PersistentIdentifier, + PersistentIdentifierScheme, +) +from cora.equipment.features import ( + assign_asset_persistent_id, + decommission_asset, + register_asset, +) +from cora.equipment.features.assign_asset_persistent_id import AssignAssetPersistentId +from cora.equipment.features.assign_asset_persistent_id.handler import Handler +from cora.equipment.features.decommission_asset import DecommissionAsset +from cora.equipment.features.register_asset import RegisterAsset +from cora.equipment.ports.doi_minter import PersistentIdentifierMintError +from cora.infrastructure.kernel import Kernel +from tests.integration._helpers import build_postgres_deps +from tests.integration.equipment.conftest import RaisingDoiMinter + +pytestmark = pytest.mark.timeout(60, method="thread") + +_NOW = datetime(2026, 6, 5, 12, 0, 0, tzinfo=UTC) +_LATER = datetime(2026, 6, 6, 12, 0, 0, tzinfo=UTC) +_PARENT_ID = UUID("01900000-0000-7000-8000-0000ee010000") +_PRINCIPAL_ID = UUID("01900000-0000-7000-8000-000000000099") +_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000aa") + + +def _build_deps( + db_pool: asyncpg.Pool, + *, + ids: list[UUID] | None = None, + now: datetime = _NOW, +) -> Kernel: + return build_postgres_deps(db_pool, ids=ids or [], now=now) + + +def _attach_doi_minter(deps: Kernel, minter: object) -> Kernel: + """Replicate `wire_equipment`'s BC-local namespace stamp. + + The slice handler reads `deps.equipment.doi_minter`; the + integration helper does not run `wire_equipment`, so the test + stamps the namespace directly to keep the handler bind path + Postgres-only without dragging the full Equipment handler bundle + into the test. + """ + from types import SimpleNamespace + + object.__setattr__(deps, "equipment", SimpleNamespace(doi_minter=minter)) + return deps + + +class _StubMcpContext: + """Minimal FastMCP-Context stand-in for outside-request invocation. + + The slice tool's body calls `get_mcp_principal_id(ctx)`, which + walks `ctx.request_context.request`; raising AttributeError on + the descriptor makes the helper fall through to + `SYSTEM_PRINCIPAL_ID`, matching the stdio-transport behavior the + helper documents. + """ + + @property + def request_context(self) -> Any: + raise AttributeError("no request context outside streamable-http") + + +def _registered_tool_fn(handler: Handler) -> Any: + mcp = FastMCP("assign-persistent-id-integration") + assign_asset_persistent_id.tool.register(mcp, get_handler=lambda: handler) + tools = mcp._tool_manager._tools # pyright: ignore[reportPrivateUsage] + return tools["assign_asset_persistent_id"].fn + + +async def _register_seed_asset(db_pool: asyncpg.Pool, *, asset_id: UUID) -> None: + deps = _build_deps(db_pool, ids=[asset_id, uuid4()]) + await register_asset.bind(deps)( + RegisterAsset( + name="Rotary Stage A", + level=AssetLevel.DEVICE, + parent_id=_PARENT_ID, + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + +async def _decommission_seed_asset(db_pool: asyncpg.Pool, *, asset_id: UUID) -> None: + deps = _build_deps(db_pool, ids=[uuid4()], now=_LATER) + await decommission_asset.bind(deps)( + DecommissionAsset(asset_id=asset_id), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + +@pytest.mark.integration +async def test_assign_persistent_id_tool_with_doi_scheme_returns_scheme_and_value( + db_pool: asyncpg.Pool, +) -> None: + asset_id = uuid4() + await _register_seed_asset(db_pool, asset_id=asset_id) + handler_deps = _attach_doi_minter(_build_deps(db_pool, ids=[uuid4()]), StubDoiMinter()) + tool_fn = _registered_tool_fn(assign_asset_persistent_id.bind(handler_deps)) + + body = await tool_fn( + _StubMcpContext(), + asset_id=asset_id, + scheme=PersistentIdentifierScheme.DOI, + suffix="APS-2BM-CAM-001", + ) + assert body == {"scheme": "DOI", "value": "10.0000/cora-stub/APS-2BM-CAM-001"} + + +@pytest.mark.integration +async def test_assign_persistent_id_tool_with_handle_scheme_returns_handle_prefix( + db_pool: asyncpg.Pool, +) -> None: + asset_id = uuid4() + await _register_seed_asset(db_pool, asset_id=asset_id) + handler_deps = _attach_doi_minter(_build_deps(db_pool, ids=[uuid4()]), StubDoiMinter()) + tool_fn = _registered_tool_fn(assign_asset_persistent_id.bind(handler_deps)) + + body = await tool_fn( + _StubMcpContext(), + asset_id=asset_id, + scheme=PersistentIdentifierScheme.HANDLE, + suffix="HZB-rotary-001", + ) + assert body == { + "scheme": "Handle", + "value": "20.500.0000/cora-stub/HZB-rotary-001", + } + + +@pytest.mark.integration +async def test_assign_persistent_id_tool_with_no_suffix_creates_uuid_suffix( + db_pool: asyncpg.Pool, +) -> None: + asset_id = uuid4() + await _register_seed_asset(db_pool, asset_id=asset_id) + handler_deps = _attach_doi_minter(_build_deps(db_pool, ids=[uuid4()]), StubDoiMinter()) + tool_fn = _registered_tool_fn(assign_asset_persistent_id.bind(handler_deps)) + + body = await tool_fn( + _StubMcpContext(), + asset_id=asset_id, + scheme=PersistentIdentifierScheme.DOI, + ) + assert body["scheme"] == "DOI" + assert body["value"].startswith("10.0000/cora-stub/") + # UUID4 string is 36 chars after the trailing slash + assert len(body["value"].split("/")[-1]) == 36 + + +@pytest.mark.integration +async def test_assign_persistent_id_tool_with_unknown_asset_raises_not_found( + db_pool: asyncpg.Pool, +) -> None: + handler_deps = _attach_doi_minter(_build_deps(db_pool, ids=[uuid4()]), StubDoiMinter()) + tool_fn = _registered_tool_fn(assign_asset_persistent_id.bind(handler_deps)) + + with pytest.raises(AssetNotFoundError): + await tool_fn( + _StubMcpContext(), + asset_id=uuid4(), + scheme=PersistentIdentifierScheme.DOI, + suffix="ghost-asset", + ) + + +@pytest.mark.integration +async def test_assign_persistent_id_tool_on_already_assigned_asset_raises_already_assigned( + db_pool: asyncpg.Pool, +) -> None: + asset_id = uuid4() + await _register_seed_asset(db_pool, asset_id=asset_id) + first_deps = _attach_doi_minter(_build_deps(db_pool, ids=[uuid4()]), StubDoiMinter()) + first_handler = assign_asset_persistent_id.bind(first_deps) + await first_handler( + AssignAssetPersistentId( + asset_id=asset_id, + scheme=PersistentIdentifierScheme.DOI, + suffix="first-mint", + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + second_deps = _attach_doi_minter(_build_deps(db_pool, ids=[uuid4()]), StubDoiMinter()) + tool_fn = _registered_tool_fn(assign_asset_persistent_id.bind(second_deps)) + with pytest.raises(AssetPersistentIdAlreadyAssignedError): + await tool_fn( + _StubMcpContext(), + asset_id=asset_id, + scheme=PersistentIdentifierScheme.DOI, + suffix="second-mint", + ) + + +@pytest.mark.integration +async def test_assign_persistent_id_tool_on_decommissioned_asset_raises_forbidden( + db_pool: asyncpg.Pool, +) -> None: + asset_id = uuid4() + await _register_seed_asset(db_pool, asset_id=asset_id) + await _decommission_seed_asset(db_pool, asset_id=asset_id) + handler_deps = _attach_doi_minter(_build_deps(db_pool, ids=[uuid4()]), StubDoiMinter()) + tool_fn = _registered_tool_fn(assign_asset_persistent_id.bind(handler_deps)) + + with pytest.raises(AssetPersistentIdAssignmentForbiddenError): + await tool_fn( + _StubMcpContext(), + asset_id=asset_id, + scheme=PersistentIdentifierScheme.DOI, + suffix="retired-asset", + ) + + +@pytest.mark.integration +async def test_assign_persistent_id_tool_with_raising_minter_raises_mint_error( + db_pool: asyncpg.Pool, + raising_doi_minter: RaisingDoiMinter, +) -> None: + asset_id = uuid4() + await _register_seed_asset(db_pool, asset_id=asset_id) + handler_deps = _attach_doi_minter(_build_deps(db_pool, ids=[uuid4()]), raising_doi_minter) + tool_fn = _registered_tool_fn(assign_asset_persistent_id.bind(handler_deps)) + + with pytest.raises(PersistentIdentifierMintError): + await tool_fn( + _StubMcpContext(), + asset_id=asset_id, + scheme=PersistentIdentifierScheme.DOI, + suffix="upstream-fails", + ) + + +@pytest.mark.integration +async def test_assign_persistent_id_tool_persists_assigned_event_to_event_store( + db_pool: asyncpg.Pool, +) -> None: + asset_id = uuid4() + await _register_seed_asset(db_pool, asset_id=asset_id) + handler_deps = _attach_doi_minter(_build_deps(db_pool, ids=[uuid4()]), StubDoiMinter()) + tool_fn = _registered_tool_fn(assign_asset_persistent_id.bind(handler_deps)) + + await tool_fn( + _StubMcpContext(), + asset_id=asset_id, + scheme=PersistentIdentifierScheme.DOI, + suffix="persisted-doi", + ) + + async with db_pool.acquire() as conn: + rows = await conn.fetch( + "SELECT event_type, payload FROM events " + "WHERE stream_type = 'Asset' AND stream_id = $1 " + "ORDER BY version ASC", + asset_id, + ) + event_types = [row["event_type"] for row in rows] + assert "AssetPersistentIdAssigned" in event_types + import json as _json + + assigned = next( + _json.loads(row["payload"]) if isinstance(row["payload"], str) else row["payload"] + for row in rows + if row["event_type"] == "AssetPersistentIdAssigned" + ) + assert assigned["persistent_id_scheme"] == "DOI" + assert assigned["persistent_id_value"] == "10.0000/cora-stub/persisted-doi" + + +@pytest.mark.integration +async def test_assign_persistent_id_tool_returned_value_round_trips_through_state( + db_pool: asyncpg.Pool, +) -> None: + from cora.equipment.aggregates.asset import load_asset + + asset_id = uuid4() + await _register_seed_asset(db_pool, asset_id=asset_id) + handler_deps = _attach_doi_minter(_build_deps(db_pool, ids=[uuid4()]), StubDoiMinter()) + tool_fn = _registered_tool_fn(assign_asset_persistent_id.bind(handler_deps)) + + body = await tool_fn( + _StubMcpContext(), + asset_id=asset_id, + scheme=PersistentIdentifierScheme.DOI, + suffix="round-trip", + ) + + state = await load_asset(handler_deps.event_store, asset_id) + assert state is not None + assert state.persistent_id == PersistentIdentifier( + scheme=PersistentIdentifierScheme(body["scheme"]), + value=body["value"], + ) diff --git a/apps/api/tests/integration/equipment/test_get_asset_pidinst_with_persistent_id.py b/apps/api/tests/integration/equipment/test_get_asset_pidinst_with_persistent_id.py new file mode 100644 index 0000000000..bd5bff627c --- /dev/null +++ b/apps/api/tests/integration/equipment/test_get_asset_pidinst_with_persistent_id.py @@ -0,0 +1,266 @@ +"""Closure-proof integration suite: get_asset_pidinst observes Asset.persistent_id end-to-end. + +Slice F (Section 13.2). Three tests pin the URN-fallback to DOI/Handle +swap that the `_pidinst_serializer._build_identifier` extension makes +when `view.persistent_id` is populated. The Asset stream is mutated by +`assign_asset_persistent_id` (server-mint via the inert `StubDoiMinter` wired +by `wire_equipment`), then `get_asset_pidinst` reads the stream + folds ++ serializes; the resulting `PidinstIdentifier` should carry the DOI or +Handle scheme byte-for-byte rather than the URN fallback from slice C. + +The without-assign baseline test pins the URN fallback path on the same +asset shape, defending the slice C contract against silent regression +when slice F's swap lands. +""" + +# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false + +from datetime import UTC, datetime +from types import SimpleNamespace +from uuid import UUID, uuid4 + +import asyncpg +import pytest + +from cora.equipment.adapters.stub_doi_minter import StubDoiMinter +from cora.equipment.aggregates.asset import ( + AssetLevel, + AssetOwner, + AssetOwnerContact, + AssetOwnerIdentifier, + AssetOwnerIdentifierType, + AssetOwnerName, + PersistentIdentifierScheme, +) +from cora.equipment.aggregates.model import ( + Manufacturer, + ManufacturerIdentifier, + ManufacturerIdentifierType, + ManufacturerName, +) +from cora.equipment.features import ( + add_asset_family, + add_asset_owner, + assign_asset_persistent_id, + define_family, + define_model, + get_asset_pidinst, + register_asset, +) +from cora.equipment.features.add_asset_family import AddAssetFamily +from cora.equipment.features.add_asset_owner import AddAssetOwner +from cora.equipment.features.assign_asset_persistent_id import AssignAssetPersistentId +from cora.equipment.features.define_family import DefineFamily +from cora.equipment.features.define_model import DefineModel +from cora.equipment.features.get_asset_pidinst import GetAssetPidinst +from cora.equipment.features.register_asset import RegisterAsset +from cora.infrastructure.config import Settings +from cora.infrastructure.kernel import Kernel +from tests.integration._equipment_helpers import drain_equipment_projections +from tests.integration._helpers import build_postgres_deps + +pytestmark = pytest.mark.timeout(60, method="thread") + +_NOW = datetime(2024, 7, 4, 12, 0, 0, tzinfo=UTC) +_PARENT_ID = UUID("01900000-0000-7000-8000-0000ee010000") +_PRINCIPAL_ID = UUID("01900000-0000-7000-8000-000000000099") +_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000aa") +_LANDING_TEMPLATE = "https://cora.example/assets/{asset_id}/landing" +_PUBLISHER = "Argonne National Laboratory" + + +def _build_deps( + db_pool: asyncpg.Pool, + *, + ids: list[UUID], + now: datetime = _NOW, +) -> Kernel: + deps = build_postgres_deps( + db_pool, + ids=ids, + now=now, + ) + deps = _override_settings( + deps, + facility_publisher=_PUBLISHER, + landing_page_template=_LANDING_TEMPLATE, + ) + # Attach the BC-local equipment namespace the assign_asset_persistent_id + # handler reads (`deps.equipment.doi_minter`). The Stub mirrors what + # `wire_equipment` registers when no DataCite credentials are present. + object.__setattr__(deps, "equipment", SimpleNamespace(doi_minter=StubDoiMinter())) + return deps + + +def _override_settings(deps: Kernel, **overrides: object) -> Kernel: + """Construct a sibling Kernel sharing every dep except settings.""" + settings_data = deps.settings.model_dump() + settings_data.update(overrides) + new_settings = Settings(**settings_data) # type: ignore[arg-type] + from dataclasses import replace + + return replace(deps, settings=new_settings) + + +def _hzb_owner() -> AssetOwner: + return AssetOwner( + name=AssetOwnerName("Helmholtz-Zentrum Berlin"), + contact=AssetOwnerContact("instrument-data@hzb.de"), + identifier=AssetOwnerIdentifier("https://ror.org/02aj13c28"), + identifier_type=AssetOwnerIdentifierType("ROR"), + ) + + +def _aerotech_manufacturer() -> Manufacturer: + return Manufacturer( + name=ManufacturerName("Aerotech"), + identifier=ManufacturerIdentifier("https://ror.org/04bw7nh07"), + identifier_type=ManufacturerIdentifierType.ROR, + ) + + +async def _seed_family(db_pool: asyncpg.Pool, *, name: str) -> UUID: + family_id = uuid4() + define_event_id = uuid4() + deps = _build_deps(db_pool, ids=[family_id, define_event_id]) + await define_family.bind(deps)( + DefineFamily(name=name, affordances=frozenset()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await drain_equipment_projections(db_pool) + return family_id + + +async def _seed_model(db_pool: asyncpg.Pool, *, declared_family_ids: frozenset[UUID]) -> UUID: + model_id = uuid4() + define_event_id = uuid4() + deps = _build_deps(db_pool, ids=[model_id, define_event_id]) + await define_model.bind(deps)( + DefineModel( + name="ANT130-L", + manufacturer=_aerotech_manufacturer(), + part_number="ANT130-L-RM", + declared_family_ids=declared_family_ids, + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + return model_id + + +async def _add_family_to_asset(db_pool: asyncpg.Pool, *, asset_id: UUID, family_id: UUID) -> None: + event_id = uuid4() + deps = _build_deps(db_pool, ids=[event_id]) + await add_asset_family.bind(deps)( + AddAssetFamily(asset_id=asset_id, family_id=family_id), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + +async def _seed_asset_with_owner_and_model(db_pool: asyncpg.Pool) -> UUID: + """Seed a minimal owner-and-model-bound asset suitable for PIDINST emission.""" + family_id = await _seed_family(db_pool, name="AnchorFamily") + model_id = await _seed_model(db_pool, declared_family_ids=frozenset({family_id})) + asset_id = uuid4() + register_event_id = uuid4() + deps = _build_deps(db_pool, ids=[asset_id, register_event_id]) + await register_asset.bind(deps)( + RegisterAsset( + name="Rotary Stage A", + level=AssetLevel.DEVICE, + parent_id=_PARENT_ID, + model_id=model_id, + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await _add_family_to_asset(db_pool, asset_id=asset_id, family_id=family_id) + owner_event_id = uuid4() + owner_deps = _build_deps(db_pool, ids=[owner_event_id]) + await add_asset_owner.bind(owner_deps)( + AddAssetOwner(asset_id=asset_id, owner=_hzb_owner()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + return asset_id + + +async def _assign_asset_persistent_id( + db_pool: asyncpg.Pool, + *, + asset_id: UUID, + scheme: PersistentIdentifierScheme, + suffix: str, +) -> None: + event_id = uuid4() + deps = _build_deps(db_pool, ids=[event_id]) + await assign_asset_persistent_id.bind(deps)( + AssignAssetPersistentId(asset_id=asset_id, scheme=scheme, suffix=suffix), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + +def _pidinst_handler(deps: Kernel) -> get_asset_pidinst.Handler: + return get_asset_pidinst.bind(deps) + + +@pytest.mark.integration +async def test_get_pidinst_after_assign_emits_doi_identifier_not_urn( + db_pool: asyncpg.Pool, +) -> None: + asset_id = await _seed_asset_with_owner_and_model(db_pool) + await _assign_asset_persistent_id( + db_pool, + asset_id=asset_id, + scheme=PersistentIdentifierScheme.DOI, + suffix="zenodo.7654321", + ) + handler = _pidinst_handler(_build_deps(db_pool, ids=[])) + record = await handler( + GetAssetPidinst(asset_id=asset_id), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert record.identifier.scheme.value == "DOI" + assert record.identifier.value == "10.0000/cora-stub/zenodo.7654321" + assert not record.identifier.value.startswith("urn:uuid:") + + +@pytest.mark.integration +async def test_get_pidinst_without_assign_still_emits_urn_fallback( + db_pool: asyncpg.Pool, +) -> None: + asset_id = await _seed_asset_with_owner_and_model(db_pool) + handler = _pidinst_handler(_build_deps(db_pool, ids=[])) + record = await handler( + GetAssetPidinst(asset_id=asset_id), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert record.identifier.scheme.value == "URN" + assert record.identifier.value == f"urn:uuid:{asset_id}" + + +@pytest.mark.integration +async def test_get_pidinst_with_handle_scheme_after_assign_emits_handle_identifier( + db_pool: asyncpg.Pool, +) -> None: + asset_id = await _seed_asset_with_owner_and_model(db_pool) + await _assign_asset_persistent_id( + db_pool, + asset_id=asset_id, + scheme=PersistentIdentifierScheme.HANDLE, + suffix="12345", + ) + handler = _pidinst_handler(_build_deps(db_pool, ids=[])) + record = await handler( + GetAssetPidinst(asset_id=asset_id), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert record.identifier.scheme.value == "Handle" + assert record.identifier.value == "20.500.0000/cora-stub/12345" + assert not record.identifier.value.startswith("urn:uuid:") diff --git a/apps/api/tests/unit/equipment/test_assign_asset_persistent_id_decider.py b/apps/api/tests/unit/equipment/test_assign_asset_persistent_id_decider.py new file mode 100644 index 0000000000..432c7f41eb --- /dev/null +++ b/apps/api/tests/unit/equipment/test_assign_asset_persistent_id_decider.py @@ -0,0 +1,151 @@ +"""Unit tests for the `assign_asset_persistent_id` slice's pure decider.""" + +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest + +from cora.equipment.aggregates.asset import ( + Asset, + AssetLevel, + AssetLifecycle, + AssetName, + AssetNotFoundError, + AssetPersistentIdAlreadyAssignedError, + AssetPersistentIdAssigned, + AssetPersistentIdAssignmentForbiddenError, + PersistentIdentifier, + PersistentIdentifierScheme, +) +from cora.equipment.features import assign_asset_persistent_id +from cora.equipment.features.assign_asset_persistent_id.command import AssignAssetPersistentId + +pytestmark = [pytest.mark.unit, pytest.mark.timeout(60, method="thread")] + +_NOW = datetime(2026, 6, 5, 12, 0, 0, tzinfo=UTC) + + +def _asset( + *, + lifecycle: AssetLifecycle = AssetLifecycle.ACTIVE, + persistent_id: PersistentIdentifier | None = None, +) -> Asset: + return Asset( + id=uuid4(), + name=AssetName("Detector-X"), + level=AssetLevel.DEVICE, + parent_id=uuid4(), + lifecycle=lifecycle, + persistent_id=persistent_id, + ) + + +def _doi(value: str = "10.5281/zenodo.1234567") -> PersistentIdentifier: + return PersistentIdentifier(scheme=PersistentIdentifierScheme.DOI, value=value) + + +def _handle(value: str = "20.500.12613/12345") -> PersistentIdentifier: + return PersistentIdentifier(scheme=PersistentIdentifierScheme.HANDLE, value=value) + + +def _cmd(asset_id: object) -> AssignAssetPersistentId: + return AssignAssetPersistentId( + asset_id=asset_id, # type: ignore[arg-type] + scheme=PersistentIdentifierScheme.DOI, + ) + + +def test_decider_with_no_prior_assign_emits_one_event() -> None: + state = _asset() + identifier = _doi() + events = assign_asset_persistent_id.decide( + state, + _cmd(state.id), + persistent_id=identifier, + now=_NOW, + ) + assert events == [ + AssetPersistentIdAssigned( + asset_id=state.id, + persistent_id_scheme=identifier.scheme.value, + persistent_id_value=identifier.value, + occurred_at=_NOW, + ) + ] + + +def test_decider_with_decommissioned_asset_raises_assignment_forbidden_error() -> None: + state = _asset(lifecycle=AssetLifecycle.DECOMMISSIONED) + identifier = _doi() + with pytest.raises(AssetPersistentIdAssignmentForbiddenError) as exc_info: + assign_asset_persistent_id.decide( + state, + _cmd(state.id), + persistent_id=identifier, + now=_NOW, + ) + assert exc_info.value.asset_id == state.id + assert exc_info.value.attempted == identifier + assert "Decommissioned" in exc_info.value.reason + + +def test_decider_with_already_assigned_same_value_raises_already_assigned_error() -> None: + """Set-once is keyed on the slot, not the value: a retry with the + same (scheme, value) collapses to AssetPersistentIdAlreadyAssignedError.""" + existing = _doi("10.5281/zenodo.1234567") + state = _asset(persistent_id=existing) + with pytest.raises(AssetPersistentIdAlreadyAssignedError) as exc_info: + assign_asset_persistent_id.decide( + state, + _cmd(state.id), + persistent_id=existing, + now=_NOW, + ) + assert exc_info.value.asset_id == state.id + assert exc_info.value.current == existing + assert exc_info.value.attempted == existing + + +def test_decider_with_already_assigned_different_value_raises_already_assigned_error() -> None: + existing = _doi("10.5281/zenodo.1111111") + attempted = _doi("10.5281/zenodo.2222222") + state = _asset(persistent_id=existing) + with pytest.raises(AssetPersistentIdAlreadyAssignedError) as exc_info: + assign_asset_persistent_id.decide( + state, + _cmd(state.id), + persistent_id=attempted, + now=_NOW, + ) + assert exc_info.value.asset_id == state.id + assert exc_info.value.current == existing + assert exc_info.value.attempted == attempted + + +def test_decider_with_already_assigned_different_scheme_raises_already_assigned_error() -> None: + existing = _doi("10.5281/zenodo.1234567") + attempted = _handle("20.500.12613/12345") + state = _asset(persistent_id=existing) + with pytest.raises(AssetPersistentIdAlreadyAssignedError) as exc_info: + assign_asset_persistent_id.decide( + state, + _cmd(state.id), + persistent_id=attempted, + now=_NOW, + ) + assert exc_info.value.asset_id == state.id + assert exc_info.value.current == existing + assert exc_info.value.attempted == attempted + + +def test_decider_with_none_state_raises_asset_not_found_error() -> None: + target_id = uuid4() + identifier = _doi() + with pytest.raises(AssetNotFoundError) as exc_info: + assign_asset_persistent_id.decide( + None, + _cmd(target_id), + persistent_id=identifier, + now=_NOW, + ) + assert exc_info.value.asset_id == target_id diff --git a/apps/api/tests/unit/equipment/test_assign_asset_persistent_id_decider_properties.py b/apps/api/tests/unit/equipment/test_assign_asset_persistent_id_decider_properties.py new file mode 100644 index 0000000000..79abd87759 --- /dev/null +++ b/apps/api/tests/unit/equipment/test_assign_asset_persistent_id_decider_properties.py @@ -0,0 +1,277 @@ +"""Property-based tests for `assign_asset_persistent_id.decide`. + +Required PBT per the `test_decider_changes_require_paired_pbt` +architecture fitness. Mirrors the sibling +`test_add_asset_alternate_identifier_decider_properties.py` shape: +Hypothesis strategies generate `(scheme, value)` pairs spanning the +full closed `PersistentIdentifierScheme` enum and asset lifecycles +spanning all non-Decommissioned values, plus prior-state variants +(absent vs already-assigned). + +Properties asserted (per memo section 13.5): + - emits_one_event: purity + single-event invariant on the happy path + - decommissioned_always_raises_forbidden: lifecycle gate per L8 + - state_persistent_id_set_always_raises_already_assigned: set-once per L3 + L7 + - emitted_event_matches_resolved_persistent_id: event-shape invariant per L6 + - decider_deterministic_given_state_and_args: purity (no clock, no minter) +""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from typing import TYPE_CHECKING + +import pytest +from hypothesis import assume, given +from hypothesis import strategies as st + +from cora.equipment.aggregates.asset import ( + PERSISTENT_IDENTIFIER_VALUE_MAX_LENGTH, + Asset, + AssetLevel, + AssetLifecycle, + AssetName, + AssetNotFoundError, + AssetPersistentIdAlreadyAssignedError, + AssetPersistentIdAssigned, + AssetPersistentIdAssignmentForbiddenError, + PersistentIdentifier, + PersistentIdentifierScheme, +) +from cora.equipment.features import assign_asset_persistent_id +from cora.equipment.features.assign_asset_persistent_id.command import AssignAssetPersistentId + +if TYPE_CHECKING: + from uuid import UUID + +pytestmark = pytest.mark.timeout(60, method="thread") + +_NON_DECOMMISSIONED_LIFECYCLE = st.sampled_from( + [lc for lc in AssetLifecycle if lc is not AssetLifecycle.DECOMMISSIONED] +) +_SCHEME = st.sampled_from(list(PersistentIdentifierScheme)) +_VALID_VALUE = st.text( + alphabet=st.characters(min_codepoint=0x21, max_codepoint=0x7E), + min_size=1, + max_size=PERSISTENT_IDENTIFIER_VALUE_MAX_LENGTH, +) +_DT_BASE = datetime(2026, 6, 5, 0, 0, 0, tzinfo=UTC) + + +@st.composite +def _persistent_identifier(draw: st.DrawFn) -> PersistentIdentifier: + return PersistentIdentifier(scheme=draw(_SCHEME), value=draw(_VALID_VALUE)) + + +def _asset( + asset_id: UUID, + *, + lifecycle: AssetLifecycle, + persistent_id: PersistentIdentifier | None, +) -> Asset: + return Asset( + id=asset_id, + name=AssetName("Detector-X"), + level=AssetLevel.DEVICE, + parent_id=asset_id, # any UUID; non-Enterprise requires non-null + lifecycle=lifecycle, + persistent_id=persistent_id, + ) + + +@pytest.mark.unit +@given( + asset_id=st.uuids(), + persistent_id=_persistent_identifier(), + lifecycle=_NON_DECOMMISSIONED_LIFECYCLE, + seconds_offset=st.integers(min_value=0, max_value=10_000_000), +) +def test_assign_with_valid_inputs_emits_one_event( + asset_id: UUID, + persistent_id: PersistentIdentifier, + lifecycle: AssetLifecycle, + seconds_offset: int, +) -> None: + """Happy path: absent persistent_id on any non-Decommissioned state + -> single AssetPersistentIdAssigned event carrying the injected + timestamp and resolved scheme + value.""" + state = _asset(asset_id, lifecycle=lifecycle, persistent_id=None) + now = _DT_BASE + timedelta(seconds=seconds_offset) + command = AssignAssetPersistentId(asset_id=asset_id, scheme=persistent_id.scheme) + events = assign_asset_persistent_id.decide( + state, + command, + persistent_id=persistent_id, + now=now, + ) + assert events == [ + AssetPersistentIdAssigned( + asset_id=asset_id, + persistent_id_scheme=persistent_id.scheme.value, + persistent_id_value=persistent_id.value, + occurred_at=now, + ) + ] + + +@pytest.mark.unit +@given( + asset_id=st.uuids(), + persistent_id=_persistent_identifier(), + seconds_offset=st.integers(min_value=0, max_value=10_000_000), +) +def test_assign_with_decommissioned_lifecycle_always_raises_forbidden( + asset_id: UUID, + persistent_id: PersistentIdentifier, + seconds_offset: int, +) -> None: + """Lifecycle gate fires regardless of whether persistent_id is + already assigned: Decommissioned + any persistent_id command -> + AssetPersistentIdAssignmentForbiddenError. Per L8.""" + state = _asset( + asset_id, + lifecycle=AssetLifecycle.DECOMMISSIONED, + persistent_id=None, + ) + now = _DT_BASE + timedelta(seconds=seconds_offset) + command = AssignAssetPersistentId(asset_id=asset_id, scheme=persistent_id.scheme) + with pytest.raises(AssetPersistentIdAssignmentForbiddenError) as exc: + assign_asset_persistent_id.decide( + state, + command, + persistent_id=persistent_id, + now=now, + ) + assert exc.value.asset_id == asset_id + assert exc.value.attempted == persistent_id + assert "Decommissioned" in exc.value.reason + + +@pytest.mark.unit +@given( + asset_id=st.uuids(), + current=_persistent_identifier(), + attempted=_persistent_identifier(), + lifecycle=_NON_DECOMMISSIONED_LIFECYCLE, + seconds_offset=st.integers(min_value=0, max_value=10_000_000), +) +def test_assign_with_state_persistent_id_set_always_raises_already_assigned( + asset_id: UUID, + current: PersistentIdentifier, + attempted: PersistentIdentifier, + lifecycle: AssetLifecycle, + seconds_offset: int, +) -> None: + """Set-once: once state.persistent_id is set, BOTH same-value and + different-value retries raise AssetPersistentIdAlreadyAssignedError + in any non-Decommissioned lifecycle. Per L3 + L7.""" + state = _asset(asset_id, lifecycle=lifecycle, persistent_id=current) + now = _DT_BASE + timedelta(seconds=seconds_offset) + command = AssignAssetPersistentId(asset_id=asset_id, scheme=attempted.scheme) + with pytest.raises(AssetPersistentIdAlreadyAssignedError) as exc: + assign_asset_persistent_id.decide( + state, + command, + persistent_id=attempted, + now=now, + ) + assert exc.value.asset_id == asset_id + assert exc.value.current == current + assert exc.value.attempted == attempted + + +@pytest.mark.unit +@given( + asset_id=st.uuids(), + persistent_id=_persistent_identifier(), + lifecycle=_NON_DECOMMISSIONED_LIFECYCLE, + seconds_offset=st.integers(min_value=0, max_value=10_000_000), +) +def test_emitted_event_scheme_and_value_match_resolved_persistent_id( + asset_id: UUID, + persistent_id: PersistentIdentifier, + lifecycle: AssetLifecycle, + seconds_offset: int, +) -> None: + """Event-shape invariant per L6: the emitted event's + persistent_id_scheme and persistent_id_value are the byte-for-byte + StrEnum value and trimmed string from the resolved VO. No + translation, no normalization at the decider.""" + state = _asset(asset_id, lifecycle=lifecycle, persistent_id=None) + now = _DT_BASE + timedelta(seconds=seconds_offset) + command = AssignAssetPersistentId(asset_id=asset_id, scheme=persistent_id.scheme) + events = assign_asset_persistent_id.decide( + state, + command, + persistent_id=persistent_id, + now=now, + ) + assert len(events) == 1 + event = events[0] + assert isinstance(event, AssetPersistentIdAssigned) + assert event.persistent_id_scheme == persistent_id.scheme.value + assert event.persistent_id_value == persistent_id.value + assert event.asset_id == asset_id + assert event.occurred_at == now + + +@pytest.mark.unit +@given( + asset_id=st.uuids(), + persistent_id=_persistent_identifier(), + other=_persistent_identifier(), + lifecycle=_NON_DECOMMISSIONED_LIFECYCLE, + seconds_offset=st.integers(min_value=0, max_value=10_000_000), +) +def test_decider_is_deterministic_given_state_and_args( + asset_id: UUID, + persistent_id: PersistentIdentifier, + other: PersistentIdentifier, + lifecycle: AssetLifecycle, + seconds_offset: int, +) -> None: + """Purity: two calls with identical (state, asset_id, + persistent_id, now) return identical events. No hidden clock, no + minter call, no id leakage. Restricted to non-Decommissioned with + absent prior persistent_id so the happy-path branch is exercised.""" + assume(persistent_id != other) + state = _asset(asset_id, lifecycle=lifecycle, persistent_id=None) + now = _DT_BASE + timedelta(seconds=seconds_offset) + command = AssignAssetPersistentId(asset_id=asset_id, scheme=persistent_id.scheme) + first = assign_asset_persistent_id.decide( + state, + command, + persistent_id=persistent_id, + now=now, + ) + second = assign_asset_persistent_id.decide( + state, + command, + persistent_id=persistent_id, + now=now, + ) + assert first == second + + +@pytest.mark.unit +@given( + asset_id=st.uuids(), + persistent_id=_persistent_identifier(), + seconds_offset=st.integers(min_value=0, max_value=10_000_000), +) +def test_state_none_always_raises_asset_not_found( + asset_id: UUID, + persistent_id: PersistentIdentifier, + seconds_offset: int, +) -> None: + """state=None -> AssetNotFoundError regardless of persistent_id or now.""" + now = _DT_BASE + timedelta(seconds=seconds_offset) + command = AssignAssetPersistentId(asset_id=asset_id, scheme=persistent_id.scheme) + with pytest.raises(AssetNotFoundError) as exc: + assign_asset_persistent_id.decide( + None, + command, + persistent_id=persistent_id, + now=now, + ) + assert exc.value.asset_id == asset_id diff --git a/apps/api/tests/unit/equipment/test_assign_asset_persistent_id_events.py b/apps/api/tests/unit/equipment/test_assign_asset_persistent_id_events.py new file mode 100644 index 0000000000..e2334f7a8e --- /dev/null +++ b/apps/api/tests/unit/equipment/test_assign_asset_persistent_id_events.py @@ -0,0 +1,238 @@ +"""Unit tests for AssetPersistentIdAssigned (de)serialization helpers. + +Covers `to_payload` <-> `from_stored` round-trip for both supported +schemes (DOI + Handle) and the `Malformed*` wrap convention for +deserialization failures. +""" + +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest + +from cora.equipment.aggregates.asset.events import ( + AssetPersistentIdAssigned, + event_type_name, + from_stored, + to_payload, +) +from cora.infrastructure.ports.event_store import StoredEvent + +pytestmark = [pytest.mark.unit, pytest.mark.timeout(60, method="thread")] + +_NOW = datetime(2026, 6, 5, 12, 0, 0, tzinfo=UTC) + + +def _stored( + event_type: str, + payload: dict[str, object], + *, + stream_id: object | None = None, +) -> StoredEvent: + """Build a StoredEvent shell; only event_type + payload are read by from_stored.""" + return StoredEvent( + position=1, + event_id=uuid4(), + stream_type="Asset", + stream_id=stream_id or uuid4(), # type: ignore[arg-type] + version=1, + event_type=event_type, + schema_version=1, + payload=payload, + correlation_id=uuid4(), + causation_id=None, + occurred_at=_NOW, + recorded_at=_NOW, + ) + + +def test_event_type_name_returns_asset_persistent_id_assigned_class_name() -> None: + event = AssetPersistentIdAssigned( + asset_id=uuid4(), + persistent_id_scheme="DOI", + persistent_id_value="10.5281/zenodo.1234567", + occurred_at=_NOW, + ) + assert event_type_name(event) == "AssetPersistentIdAssigned" + + +def test_to_payload_serializes_asset_persistent_id_assigned_with_doi_scheme() -> None: + asset_id = uuid4() + event = AssetPersistentIdAssigned( + asset_id=asset_id, + persistent_id_scheme="DOI", + persistent_id_value="10.5281/zenodo.1234567", + occurred_at=_NOW, + ) + assert to_payload(event) == { + "asset_id": str(asset_id), + "persistent_id_scheme": "DOI", + "persistent_id_value": "10.5281/zenodo.1234567", + "occurred_at": _NOW.isoformat(), + } + + +def test_to_payload_serializes_asset_persistent_id_assigned_with_handle_scheme() -> None: + asset_id = uuid4() + event = AssetPersistentIdAssigned( + asset_id=asset_id, + persistent_id_scheme="Handle", + persistent_id_value="20.500.12613/12345", + occurred_at=_NOW, + ) + assert to_payload(event) == { + "asset_id": str(asset_id), + "persistent_id_scheme": "Handle", + "persistent_id_value": "20.500.12613/12345", + "occurred_at": _NOW.isoformat(), + } + + +def test_from_stored_rebuilds_asset_persistent_id_assigned_with_doi_scheme() -> None: + asset_id = uuid4() + stored = _stored( + "AssetPersistentIdAssigned", + { + "asset_id": str(asset_id), + "persistent_id_scheme": "DOI", + "persistent_id_value": "10.5281/zenodo.1234567", + "occurred_at": _NOW.isoformat(), + }, + ) + rebuilt = from_stored(stored) + assert rebuilt == AssetPersistentIdAssigned( + asset_id=asset_id, + persistent_id_scheme="DOI", + persistent_id_value="10.5281/zenodo.1234567", + occurred_at=_NOW, + ) + + +def test_from_stored_rebuilds_asset_persistent_id_assigned_with_handle_scheme() -> None: + asset_id = uuid4() + stored = _stored( + "AssetPersistentIdAssigned", + { + "asset_id": str(asset_id), + "persistent_id_scheme": "Handle", + "persistent_id_value": "20.500.12613/12345", + "occurred_at": _NOW.isoformat(), + }, + ) + rebuilt = from_stored(stored) + assert rebuilt == AssetPersistentIdAssigned( + asset_id=asset_id, + persistent_id_scheme="Handle", + persistent_id_value="20.500.12613/12345", + occurred_at=_NOW, + ) + + +def test_asset_persistent_id_assigned_round_trips_through_to_payload_and_from_stored() -> None: + original = AssetPersistentIdAssigned( + asset_id=uuid4(), + persistent_id_scheme="DOI", + persistent_id_value="10.13139/OLCF/1234", + occurred_at=_NOW, + ) + stored = _stored("AssetPersistentIdAssigned", to_payload(original)) + assert from_stored(stored) == original + + +def test_asset_persistent_id_assigned_with_handle_scheme_round_trips() -> None: + original = AssetPersistentIdAssigned( + asset_id=uuid4(), + persistent_id_scheme="Handle", + persistent_id_value="20.500.12613/12345", + occurred_at=_NOW, + ) + stored = _stored("AssetPersistentIdAssigned", to_payload(original)) + assert from_stored(stored) == original + + +def test_asset_persistent_id_assigned_from_stored_with_unknown_scheme_raises_malformed() -> None: + stored = _stored( + "AssetPersistentIdAssigned", + { + "asset_id": str(uuid4()), + "persistent_id_scheme": "ARK", + "persistent_id_value": "ark:/12345/abc", + "occurred_at": _NOW.isoformat(), + }, + ) + with pytest.raises(ValueError, match="Malformed AssetPersistentIdAssigned payload"): + from_stored(stored) + + +def test_asset_persistent_id_assigned_from_stored_with_missing_scheme_key_raises_malformed() -> ( + None +): + stored = _stored( + "AssetPersistentIdAssigned", + { + "asset_id": str(uuid4()), + "persistent_id_value": "10.5281/zenodo.1234567", + "occurred_at": _NOW.isoformat(), + }, + ) + with pytest.raises(ValueError, match="Malformed AssetPersistentIdAssigned payload"): + from_stored(stored) + + +def test_asset_persistent_id_assigned_from_stored_with_missing_value_key_raises_malformed() -> None: + stored = _stored( + "AssetPersistentIdAssigned", + { + "asset_id": str(uuid4()), + "persistent_id_scheme": "DOI", + "occurred_at": _NOW.isoformat(), + }, + ) + with pytest.raises(ValueError, match="Malformed AssetPersistentIdAssigned payload"): + from_stored(stored) + + +def test_asset_persistent_id_assigned_from_stored_with_empty_value_raises_malformed() -> None: + stored = _stored( + "AssetPersistentIdAssigned", + { + "asset_id": str(uuid4()), + "persistent_id_scheme": "DOI", + "persistent_id_value": "", + "occurred_at": _NOW.isoformat(), + }, + ) + with pytest.raises(ValueError, match="Malformed AssetPersistentIdAssigned payload"): + from_stored(stored) + + +def test_asset_persistent_id_assigned_from_stored_with_whitespace_only_value_raises_malformed() -> ( + None +): + stored = _stored( + "AssetPersistentIdAssigned", + { + "asset_id": str(uuid4()), + "persistent_id_scheme": "DOI", + "persistent_id_value": " ", + "occurred_at": _NOW.isoformat(), + }, + ) + with pytest.raises(ValueError, match="Malformed AssetPersistentIdAssigned payload"): + from_stored(stored) + + +def test_asset_persistent_id_assigned_from_stored_with_malformed_asset_id_raises_malformed() -> ( + None +): + stored = _stored( + "AssetPersistentIdAssigned", + { + "asset_id": "not-a-uuid", + "persistent_id_scheme": "DOI", + "persistent_id_value": "10.5281/zenodo.1234567", + "occurred_at": _NOW.isoformat(), + }, + ) + with pytest.raises(ValueError, match="Malformed AssetPersistentIdAssigned payload"): + from_stored(stored) diff --git a/apps/api/tests/unit/equipment/test_assign_asset_persistent_id_evolver.py b/apps/api/tests/unit/equipment/test_assign_asset_persistent_id_evolver.py new file mode 100644 index 0000000000..4b628e92f3 --- /dev/null +++ b/apps/api/tests/unit/equipment/test_assign_asset_persistent_id_evolver.py @@ -0,0 +1,346 @@ +"""Unit tests for the AssetPersistentIdAssigned evolver arm and the +carry-forward matrix that pins every non-persistent-id transition +preserves `persistent_id`.""" + +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest + +from cora.equipment.aggregates.asset import ( + AlternateIdentifier, + AlternateIdentifierKind, + Asset, + AssetActivated, + AssetAlternateIdentifierAdded, + AssetAlternateIdentifierRemoved, + AssetCondition, + AssetDecommissioned, + AssetDegraded, + AssetFamilyAdded, + AssetFamilyRemoved, + AssetFaulted, + AssetLevel, + AssetLifecycle, + AssetMaintenanceEntered, + AssetMaintenanceExited, + AssetName, + AssetOwner, + AssetOwnerAdded, + AssetOwnerName, + AssetOwnerRemoved, + AssetPersistentIdAssigned, + AssetPortAdded, + AssetPortRemoved, + AssetRegistered, + AssetRelocated, + AssetRestored, + AssetSettingsUpdated, + PersistentIdentifier, + PersistentIdentifierScheme, + evolve, + fold, +) + +pytestmark = pytest.mark.timeout(60, method="thread") + +_NOW = datetime(2026, 6, 5, 12, 0, 0, tzinfo=UTC) + +_DOI = PersistentIdentifier( + scheme=PersistentIdentifierScheme.DOI, + value="10.5281/zenodo.1234567", +) +_HANDLE = PersistentIdentifier( + scheme=PersistentIdentifierScheme.HANDLE, + value="20.500.12613/12345", +) + + +def _prior( + *, + lifecycle: AssetLifecycle = AssetLifecycle.ACTIVE, + persistent_id: PersistentIdentifier | None = None, +) -> Asset: + return Asset( + id=uuid4(), + name=AssetName("X"), + level=AssetLevel.UNIT, + parent_id=uuid4(), + lifecycle=lifecycle, + persistent_id=persistent_id, + ) + + +@pytest.mark.unit +def test_evolver_folds_asset_persistent_id_assigned_into_state() -> None: + prior = _prior() + assert prior.persistent_id is None + state = evolve( + prior, + AssetPersistentIdAssigned( + asset_id=prior.id, + persistent_id_scheme=PersistentIdentifierScheme.DOI.value, + persistent_id_value="10.5281/zenodo.1234567", + occurred_at=_NOW, + ), + ) + assert state.persistent_id == _DOI + + +@pytest.mark.unit +def test_evolver_folds_handle_scheme_correctly() -> None: + prior = _prior() + state = evolve( + prior, + AssetPersistentIdAssigned( + asset_id=prior.id, + persistent_id_scheme=PersistentIdentifierScheme.HANDLE.value, + persistent_id_value="20.500.12613/12345", + occurred_at=_NOW, + ), + ) + assert state.persistent_id == _HANDLE + assert state.persistent_id is not None + assert state.persistent_id.scheme is PersistentIdentifierScheme.HANDLE + + +@pytest.mark.unit +def test_evolver_preserves_unrelated_state_fields() -> None: + """Persistent-id mutation only touches `persistent_id`; every other + facet (lifecycle, condition, family_ids, settings, ports, drawing, + model_id, alternate_identifiers, owners, fixture_id, parent_id, + level, name, id) carries through. Pin against the evolver + explicitly constructing Asset(...) so a future evolver refactor + that drops a field is caught.""" + asset_id = uuid4() + parent_id = uuid4() + fam = uuid4() + fixture_id = uuid4() + model_id = uuid4() + owner = AssetOwner(name=AssetOwnerName("HZB")) + alt_id = AlternateIdentifier(kind=AlternateIdentifierKind.SERIAL_NUMBER, value="SN-1") + prior = Asset( + id=asset_id, + name=AssetName("Eiger-2X-9M"), + level=AssetLevel.DEVICE, + parent_id=parent_id, + lifecycle=AssetLifecycle.MAINTENANCE, + condition=AssetCondition.DEGRADED, + family_ids=frozenset({fam}), + settings={"energy": 30, "filter": "Cu"}, + model_id=model_id, + alternate_identifiers=frozenset({alt_id}), + owners=frozenset({owner}), + fixture_id=fixture_id, + ) + state = evolve( + prior, + AssetPersistentIdAssigned( + asset_id=asset_id, + persistent_id_scheme=PersistentIdentifierScheme.DOI.value, + persistent_id_value="10.5281/zenodo.1234567", + occurred_at=_NOW, + ), + ) + assert state.persistent_id == _DOI + assert state.id == asset_id + assert state.name == AssetName("Eiger-2X-9M") + assert state.level is AssetLevel.DEVICE + assert state.parent_id == parent_id + assert state.lifecycle is AssetLifecycle.MAINTENANCE + assert state.condition is AssetCondition.DEGRADED + assert state.family_ids == frozenset({fam}) + assert state.settings == {"energy": 30, "filter": "Cu"} + assert state.model_id == model_id + assert state.alternate_identifiers == frozenset({alt_id}) + assert state.owners == frozenset({owner}) + assert state.fixture_id == fixture_id + + +@pytest.mark.unit +def test_evolver_on_empty_state_raises() -> None: + with pytest.raises(ValueError, match="cannot be applied to empty state"): + evolve( + None, + AssetPersistentIdAssigned( + asset_id=uuid4(), + persistent_id_scheme=PersistentIdentifierScheme.DOI.value, + persistent_id_value="10.5281/zenodo.1234567", + occurred_at=_NOW, + ), + ) + + +@pytest.mark.unit +def test_evolver_replay_with_same_event_keeps_persistent_id_unchanged() -> None: + """Set-once is enforced at the decider; the evolver itself is + forgiving. A replay of the SAME AssetPersistentIdAssigned event + yields the same `persistent_id`, so fold is idempotent at the + evolver layer for the produced-by-decider stream.""" + prior = _prior() + event = AssetPersistentIdAssigned( + asset_id=prior.id, + persistent_id_scheme=PersistentIdentifierScheme.DOI.value, + persistent_id_value="10.5281/zenodo.1234567", + occurred_at=_NOW, + ) + once = evolve(prior, event) + twice = evolve(once, event) + assert once.persistent_id == _DOI + assert twice.persistent_id == _DOI + + +@pytest.mark.unit +def test_fold_register_then_assign_persistent_id_yields_asset_with_persistent_id() -> None: + """End-to-end fold: register + assign yields an Asset whose + `persistent_id` is the assigned VO.""" + asset_id = uuid4() + parent_id = uuid4() + state = fold( + [ + AssetRegistered( + asset_id=asset_id, + name="APS-2BM", + level="Unit", + parent_id=parent_id, + occurred_at=_NOW, + ), + AssetPersistentIdAssigned( + asset_id=asset_id, + persistent_id_scheme=PersistentIdentifierScheme.DOI.value, + persistent_id_value="10.5281/zenodo.1234567", + occurred_at=_NOW, + ), + ] + ) + assert state is not None + assert state.persistent_id == _DOI + assert state.lifecycle is AssetLifecycle.COMMISSIONED + + +@pytest.mark.unit +def test_evolver_asset_registered_defaults_persistent_id_to_none() -> None: + """Genesis: AssetRegistered yields persistent_id=None via the state + default (no synthetic initialization event). Pinned because legacy + streams without persistent_id must fold cleanly via the + additive-state pattern.""" + state = evolve( + None, + AssetRegistered( + asset_id=uuid4(), + name="X", + level="Unit", + parent_id=uuid4(), + occurred_at=_NOW, + ), + ) + assert state.persistent_id is None + + +_PRESERVATION_TRANSITIONS: list[tuple[str, type, dict[str, object]]] = [ + ("activate", AssetActivated, {}), + ("decommission", AssetDecommissioned, {}), + ("enter_maintenance", AssetMaintenanceEntered, {}), + ("exit_maintenance", AssetMaintenanceExited, {}), + ("family_added", AssetFamilyAdded, {"family_id": uuid4()}), + ("family_removed", AssetFamilyRemoved, {"family_id": uuid4()}), + ("degraded", AssetDegraded, {"reason": "x"}), + ("faulted", AssetFaulted, {"reason": "x"}), + ("restored", AssetRestored, {"reason": "x"}), + ("settings_updated", AssetSettingsUpdated, {"settings": {"a": 1}}), + ( + "port_added", + AssetPortAdded, + {"port_name": "p1", "direction": "Input", "signal_type": "TTL"}, + ), + ("port_removed", AssetPortRemoved, {"port_name": "p1"}), + ( + "alt_id_added", + AssetAlternateIdentifierAdded, + { + "alternate_identifier": AlternateIdentifier( + kind=AlternateIdentifierKind.SERIAL_NUMBER, value="SN-1" + ), + }, + ), + ( + "alt_id_removed", + AssetAlternateIdentifierRemoved, + { + "alternate_identifier": AlternateIdentifier( + kind=AlternateIdentifierKind.SERIAL_NUMBER, value="SN-1" + ), + }, + ), + ( + "owner_added", + AssetOwnerAdded, + {"owner": AssetOwner(name=AssetOwnerName("ESRF"))}, + ), + ( + "owner_removed", + AssetOwnerRemoved, + {"owner_name": AssetOwnerName("ESRF")}, + ), +] + + +def _pick_lifecycle_for(transition: type) -> AssetLifecycle: + if transition is AssetActivated: + return AssetLifecycle.COMMISSIONED + if transition is AssetMaintenanceEntered: + return AssetLifecycle.ACTIVE + if transition is AssetMaintenanceExited: + return AssetLifecycle.MAINTENANCE + return AssetLifecycle.ACTIVE + + +@pytest.mark.unit +@pytest.mark.parametrize(("name", "transition", "kwargs"), _PRESERVATION_TRANSITIONS) +def test_evolve_non_persistent_id_transition_preserves_persistent_id( + name: str, + transition: type, + kwargs: dict[str, object], +) -> None: + """Critical pin: every non-persistent-id Asset transition MUST + carry `persistent_id` through from prior state. Constructing + Asset(...) without explicitly passing `persistent_id` would + silently wipe it to the None default. Same shape as the owners + preservation pin.""" + _ = name + prior = Asset( + id=uuid4(), + name=AssetName("X"), + level=AssetLevel.UNIT, + parent_id=uuid4(), + lifecycle=_pick_lifecycle_for(transition), + persistent_id=_DOI, + ) + state = evolve(prior, transition(asset_id=prior.id, occurred_at=_NOW, **kwargs)) + assert state.persistent_id == _DOI + + +@pytest.mark.unit +def test_evolve_relocate_preserves_persistent_id() -> None: + """Hierarchy mutation also preserves persistent_id.""" + old_parent = uuid4() + new_parent = uuid4() + prior = Asset( + id=uuid4(), + name=AssetName("X"), + level=AssetLevel.UNIT, + parent_id=old_parent, + persistent_id=_HANDLE, + ) + state = evolve( + prior, + AssetRelocated( + asset_id=prior.id, + from_parent_id=old_parent, + to_parent_id=new_parent, + reason="moved", + occurred_at=_NOW, + ), + ) + assert state.persistent_id == _HANDLE + assert state.parent_id == new_parent diff --git a/apps/api/tests/unit/equipment/test_assign_asset_persistent_id_summary_projection.py b/apps/api/tests/unit/equipment/test_assign_asset_persistent_id_summary_projection.py new file mode 100644 index 0000000000..c305918a91 --- /dev/null +++ b/apps/api/tests/unit/equipment/test_assign_asset_persistent_id_summary_projection.py @@ -0,0 +1,126 @@ +"""Unit tests for AssetSummaryProjection's AssetPersistentIdAssigned handling. + +Pins the JSONB-shape contract for the persistent_id column written by +the projection when an AssetPersistentIdAssigned event lands. The +neighbor `test_asset_summary_projection.py` covers the projector's other +arms; this file isolates the slice-F arm so a regression in the +JSONB-build SQL or the (scheme, value) carry surfaces as a focused +failure. Integration-tier real-PG behavior (replay, codecs) lives in +`tests/integration/test_postgres_asset_summary_projection.py`. +""" + +from datetime import UTC, datetime +from typing import Any +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest + +from cora.equipment.projections import AssetSummaryProjection +from cora.infrastructure.ports.event_store import StoredEvent + +pytestmark = [pytest.mark.unit, pytest.mark.timeout(60, method="thread")] + +_ASSET_ID = uuid4() +_EVENT_ID = uuid4() +_CORRELATION_ID = uuid4() +_NOW = datetime(2026, 6, 5, 12, 0, 0, tzinfo=UTC) + + +def _stored(event_type: str, payload: dict[str, Any]) -> StoredEvent: + return StoredEvent( + position=1, + event_id=_EVENT_ID, + stream_type="Asset", + stream_id=_ASSET_ID, + version=1, + event_type=event_type, + schema_version=1, + payload=payload, + correlation_id=_CORRELATION_ID, + causation_id=None, + occurred_at=_NOW, + recorded_at=_NOW, + ) + + +def _persistent_id_assigned( + *, scheme: str = "DOI", value: str = "10.5281/zenodo.1234567" +) -> StoredEvent: + return _stored( + "AssetPersistentIdAssigned", + { + "asset_id": str(_ASSET_ID), + "persistent_id_scheme": scheme, + "persistent_id_value": value, + "occurred_at": _NOW.isoformat(), + }, + ) + + +async def test_apply_assigned_writes_persistent_id_jsonb() -> None: + proj = AssetSummaryProjection() + conn = AsyncMock() + event = _persistent_id_assigned(scheme="DOI", value="10.5281/zenodo.1234567") + + await proj.apply(event, conn) + + conn.execute.assert_awaited_once() + args = conn.execute.await_args + assert args is not None + sql = args.args[0] + assert "UPDATE proj_equipment_asset_summary" in sql + assert "SET persistent_id" in sql + assert "jsonb_build_object" in sql + assert "WHERE asset_id = $1" in sql + assert args.args[1] == _ASSET_ID + assert args.args[2] == "DOI" + assert args.args[3] == "10.5281/zenodo.1234567" + + +async def test_jsonb_shape_is_scheme_value_object() -> None: + """The JSONB column is built from `jsonb_build_object('scheme', ..., 'value', ...)` + so the on-disk row is `{"scheme": , "value": }`. Pin the SQL + keys so a future refactor that swaps to `'type'` / `'identifier'` + (PIDINST wire vocabulary) breaks this test before it breaks the + serializer.""" + proj = AssetSummaryProjection() + conn = AsyncMock() + event = _persistent_id_assigned(scheme="Handle", value="20.500.12613/12345") + + await proj.apply(event, conn) + + args = conn.execute.await_args + assert args is not None + sql = args.args[0] + assert "'scheme'" in sql + assert "'value'" in sql + assert "$2::text" in sql + assert "$3::text" in sql + assert args.args[2] == "Handle" + assert args.args[3] == "20.500.12613/12345" + + +async def test_apply_twice_with_same_event_is_idempotent_at_jsonb_column_level() -> None: + """Calling apply() twice on the same AssetPersistentIdAssigned event + issues the same UPDATE both times with identical bound parameters. + The persistent_id JSONB column ends in the same `{"scheme", "value"}` + shape after both invocations (the projection's UPDATE is an absolute + overwrite, not an append). Defends against a regression where a + second apply() would null-out, duplicate, or wrap the column.""" + proj = AssetSummaryProjection() + conn = AsyncMock() + event = _persistent_id_assigned(scheme="DOI", value="10.5281/zenodo.1234567") + + await proj.apply(event, conn) + await proj.apply(event, conn) + + assert conn.execute.await_count == 2 + first, second = conn.execute.await_args_list + assert first.args == second.args + sql = first.args[0] + assert "UPDATE proj_equipment_asset_summary" in sql + assert "SET persistent_id" in sql + assert first.args[1] == _ASSET_ID + assert first.args[2] == "DOI" + assert first.args[3] == "10.5281/zenodo.1234567" diff --git a/apps/api/tests/unit/equipment/test_persistent_identifier_scheme.py b/apps/api/tests/unit/equipment/test_persistent_identifier_scheme.py new file mode 100644 index 0000000000..1f4699dacb --- /dev/null +++ b/apps/api/tests/unit/equipment/test_persistent_identifier_scheme.py @@ -0,0 +1,83 @@ +"""Closed-enum invariants for `PersistentIdentifierScheme` (Lock 11 / L4). + +The scheme enum is the v1 PIDINST Property 1 vocabulary subset CORA +accepts on the assign path. Two members only (DOI + HANDLE), and the +member values must match `PidinstIdentifierType.DOI` and +`PidinstIdentifierType.HANDLE` byte-for-byte so the serializer swap +from URN to DOI / Handle does not need a translation map. +""" + +from enum import StrEnum + +import pytest + +from cora.equipment._pidinst_types import PidinstIdentifierType +from cora.equipment.aggregates.asset import PersistentIdentifierScheme + +pytestmark = [pytest.mark.unit, pytest.mark.timeout(60, method="thread")] + + +def test_persistent_identifier_scheme_is_strenum_subclass() -> None: + assert issubclass(PersistentIdentifierScheme, StrEnum) + + +def test_persistent_identifier_scheme_has_exactly_two_members() -> None: + assert len(PersistentIdentifierScheme) == 2 + + +def test_persistent_identifier_scheme_member_set_is_doi_and_handle() -> None: + assert {member.name for member in PersistentIdentifierScheme} == {"DOI", "HANDLE"} + + +def test_persistent_identifier_scheme_doi_value_matches_pidinst_doi_byte_for_byte() -> None: + assert PersistentIdentifierScheme.DOI.value == PidinstIdentifierType.DOI.value + + +def test_persistent_identifier_scheme_handle_value_matches_pidinst_handle_byte_for_byte() -> None: + assert PersistentIdentifierScheme.HANDLE.value == PidinstIdentifierType.HANDLE.value + + +def test_persistent_identifier_scheme_doi_value_is_literal_doi_string() -> None: + assert PersistentIdentifierScheme.DOI.value == "DOI" + + +def test_persistent_identifier_scheme_handle_value_is_literal_handle_string() -> None: + assert PersistentIdentifierScheme.HANDLE.value == "Handle" + + +def test_persistent_identifier_scheme_does_not_mirror_pidinst_urn_member() -> None: + assert "URN" not in {member.name for member in PersistentIdentifierScheme} + assert PidinstIdentifierType.URN.value not in { + member.value for member in PersistentIdentifierScheme + } + + +def test_persistent_identifier_scheme_does_not_mirror_pidinst_url_member() -> None: + assert "URL" not in {member.name for member in PersistentIdentifierScheme} + assert PidinstIdentifierType.URL.value not in { + member.value for member in PersistentIdentifierScheme + } + + +def test_persistent_identifier_scheme_rejects_unknown_value() -> None: + with pytest.raises(ValueError): + PersistentIdentifierScheme("ARK") + + +def test_persistent_identifier_scheme_rejects_lowercase_doi_value() -> None: + with pytest.raises(ValueError): + PersistentIdentifierScheme("doi") + + +def test_persistent_identifier_scheme_rejects_uppercase_handle_value() -> None: + with pytest.raises(ValueError): + PersistentIdentifierScheme("HANDLE") + + +def test_persistent_identifier_scheme_round_trips_through_value_lookup() -> None: + assert PersistentIdentifierScheme(PersistentIdentifierScheme.DOI.value) is ( + PersistentIdentifierScheme.DOI + ) + assert PersistentIdentifierScheme(PersistentIdentifierScheme.HANDLE.value) is ( + PersistentIdentifierScheme.HANDLE + ) diff --git a/apps/api/tests/unit/equipment/test_persistent_identifier_vo.py b/apps/api/tests/unit/equipment/test_persistent_identifier_vo.py new file mode 100644 index 0000000000..b69a8d075a --- /dev/null +++ b/apps/api/tests/unit/equipment/test_persistent_identifier_vo.py @@ -0,0 +1,128 @@ +"""Unit tests for the PersistentIdentifier VO and its scheme enum.""" + +from dataclasses import FrozenInstanceError + +import pytest + +from cora.equipment.aggregates.asset import ( + InvalidPersistentIdentifierValueError, + PersistentIdentifier, + PersistentIdentifierScheme, +) +from cora.equipment.aggregates.asset.state import ( + PERSISTENT_IDENTIFIER_VALUE_MAX_LENGTH, +) + +pytestmark = pytest.mark.timeout(60, method="thread") + + +@pytest.mark.unit +def test_persistent_identifier_with_valid_doi_value_constructs() -> None: + pid = PersistentIdentifier( + scheme=PersistentIdentifierScheme.DOI, + value="10.5281/zenodo.1234567", + ) + assert pid.scheme is PersistentIdentifierScheme.DOI + assert pid.value == "10.5281/zenodo.1234567" + + +@pytest.mark.unit +def test_persistent_identifier_with_valid_handle_value_constructs() -> None: + pid = PersistentIdentifier( + scheme=PersistentIdentifierScheme.HANDLE, + value="20.500.12613/12345", + ) + assert pid.scheme is PersistentIdentifierScheme.HANDLE + assert pid.value == "20.500.12613/12345" + + +@pytest.mark.unit +def test_persistent_identifier_with_empty_value_raises_invalid_value_error() -> None: + with pytest.raises(InvalidPersistentIdentifierValueError): + PersistentIdentifier(scheme=PersistentIdentifierScheme.DOI, value="") + + +@pytest.mark.unit +def test_persistent_identifier_with_whitespace_only_value_raises_invalid_value_error() -> None: + with pytest.raises(InvalidPersistentIdentifierValueError): + PersistentIdentifier(scheme=PersistentIdentifierScheme.DOI, value=" ") + + +@pytest.mark.unit +def test_persistent_identifier_with_too_long_value_raises_invalid_value_error() -> None: + over_limit = "x" * (PERSISTENT_IDENTIFIER_VALUE_MAX_LENGTH + 1) + with pytest.raises(InvalidPersistentIdentifierValueError): + PersistentIdentifier( + scheme=PersistentIdentifierScheme.DOI, + value=over_limit, + ) + + +@pytest.mark.unit +def test_persistent_identifier_at_max_length_succeeds() -> None: + at_limit = "x" * PERSISTENT_IDENTIFIER_VALUE_MAX_LENGTH + pid = PersistentIdentifier( + scheme=PersistentIdentifierScheme.DOI, + value=at_limit, + ) + assert pid.value == at_limit + + +@pytest.mark.unit +def test_persistent_identifier_trims_surrounding_whitespace() -> None: + pid = PersistentIdentifier( + scheme=PersistentIdentifierScheme.DOI, + value=" 10.5281/zenodo.1234567 ", + ) + assert pid.value == "10.5281/zenodo.1234567" + + +@pytest.mark.unit +def test_persistent_identifier_is_frozen() -> None: + pid = PersistentIdentifier( + scheme=PersistentIdentifierScheme.DOI, + value="10.5281/zenodo.1234567", + ) + with pytest.raises(FrozenInstanceError): + pid.value = "10.5281/zenodo.7654321" # type: ignore[misc] + + +@pytest.mark.unit +def test_persistent_identifier_is_hashable_in_frozenset() -> None: + a = PersistentIdentifier( + scheme=PersistentIdentifierScheme.DOI, + value="10.5281/zenodo.1234567", + ) + b = PersistentIdentifier( + scheme=PersistentIdentifierScheme.HANDLE, + value="20.500.12613/12345", + ) + members = frozenset({a, b}) + assert a in members + assert b in members + + +@pytest.mark.unit +def test_persistent_identifier_equality_is_value_based() -> None: + a = PersistentIdentifier( + scheme=PersistentIdentifierScheme.DOI, + value="10.5281/zenodo.1234567", + ) + b = PersistentIdentifier( + scheme=PersistentIdentifierScheme.DOI, + value=" 10.5281/zenodo.1234567 ", + ) + assert a == b + + +@pytest.mark.unit +def test_persistent_identifier_with_different_scheme_is_not_equal() -> None: + a = PersistentIdentifier( + scheme=PersistentIdentifierScheme.DOI, + value="10.5281/zenodo.1234567", + ) + b = PersistentIdentifier( + scheme=PersistentIdentifierScheme.HANDLE, + value="10.5281/zenodo.1234567", + ) + assert a != b diff --git a/apps/api/tests/unit/equipment/test_persistent_identifier_vo_properties.py b/apps/api/tests/unit/equipment/test_persistent_identifier_vo_properties.py new file mode 100644 index 0000000000..5ef4d3b570 --- /dev/null +++ b/apps/api/tests/unit/equipment/test_persistent_identifier_vo_properties.py @@ -0,0 +1,114 @@ +"""Property-based tests for the PersistentIdentifier value object. + +Secondary PBT per slice F design memo Section 13.5. Complements the +example-based `test_asset_persistent_id_vo.py` cases with universal +claims over the (scheme, value) input space: + + - For any valid (scheme, value), construction succeeds and the + scheme round-trips by identity. + - For any value padded with surrounding whitespace, the canonical + PersistentIdentifier equals the unpadded version (trim semantics + of `validate_bounded_text`). + - For any whitespace-only or empty value, construction raises + `InvalidPersistentIdentifierValueError` carrying the original + untrimmed value. + - For any value whose trimmed length exceeds + `PERSISTENT_IDENTIFIER_VALUE_MAX_LENGTH`, construction raises + the same error carrying the original untrimmed value. + - Equal-by-pair PersistentIdentifiers share a hash (frozen + dataclass set / dict membership). + - Every member of `PersistentIdentifierScheme` round-trips through + `.value` lookup (closed-enum integrity). +""" + +from __future__ import annotations + +import pytest +from hypothesis import assume, given +from hypothesis import strategies as st + +from cora.equipment.aggregates.asset import ( + PERSISTENT_IDENTIFIER_VALUE_MAX_LENGTH, + InvalidPersistentIdentifierValueError, + PersistentIdentifier, + PersistentIdentifierScheme, +) + +pytestmark = [pytest.mark.unit, pytest.mark.timeout(60, method="thread")] + +_PRINTABLE = st.characters(min_codepoint=0x21, max_codepoint=0x7E) +_VALUE_BODY = st.text( + alphabet=_PRINTABLE, + min_size=1, + max_size=PERSISTENT_IDENTIFIER_VALUE_MAX_LENGTH, +) +_SCHEME = st.sampled_from(list(PersistentIdentifierScheme)) +_WS_PAD = st.text(alphabet=" \t\n\r", min_size=1, max_size=5) +_BLANK = st.text(alphabet=" \t\n\r", min_size=0, max_size=10) + + +@given(scheme=_SCHEME, value=_VALUE_BODY) +def test_persistent_identifier_constructs_for_any_valid_pair( + scheme: PersistentIdentifierScheme, value: str +) -> None: + pid = PersistentIdentifier(scheme=scheme, value=value) + assert pid.scheme is scheme + assert pid.value == value + + +@given(scheme=_SCHEME, value=_VALUE_BODY, pad_l=_WS_PAD, pad_r=_WS_PAD) +def test_persistent_identifier_canonicalises_whitespace_padding( + scheme: PersistentIdentifierScheme, + value: str, + pad_l: str, + pad_r: str, +) -> None: + assume(value == value.strip()) + padded = PersistentIdentifier(scheme=scheme, value=pad_l + value + pad_r) + unpadded = PersistentIdentifier(scheme=scheme, value=value) + assert padded == unpadded + assert hash(padded) == hash(unpadded) + assert padded.value == value + + +@given(scheme=_SCHEME, blank=_BLANK) +def test_persistent_identifier_rejects_blank_value( + scheme: PersistentIdentifierScheme, blank: str +) -> None: + with pytest.raises(InvalidPersistentIdentifierValueError) as info: + PersistentIdentifier(scheme=scheme, value=blank) + assert info.value.value == blank + + +@given( + scheme=_SCHEME, + overlong=st.text( + alphabet=_PRINTABLE, + min_size=PERSISTENT_IDENTIFIER_VALUE_MAX_LENGTH + 1, + max_size=PERSISTENT_IDENTIFIER_VALUE_MAX_LENGTH + 50, + ), +) +def test_persistent_identifier_rejects_overlong_value( + scheme: PersistentIdentifierScheme, overlong: str +) -> None: + with pytest.raises(InvalidPersistentIdentifierValueError) as info: + PersistentIdentifier(scheme=scheme, value=overlong) + assert info.value.value == overlong + + +@given(scheme=_SCHEME, value=_VALUE_BODY) +def test_persistent_identifier_equal_pairs_share_hash( + scheme: PersistentIdentifierScheme, value: str +) -> None: + first = PersistentIdentifier(scheme=scheme, value=value) + second = PersistentIdentifier(scheme=scheme, value=value) + assert first == second + assert hash(first) == hash(second) + assert {first, second} == {first} + + +@given(member=_SCHEME) +def test_persistent_identifier_scheme_value_member_round_trip( + member: PersistentIdentifierScheme, +) -> None: + assert PersistentIdentifierScheme(member.value) is member diff --git a/apps/api/tests/unit/equipment/test_pidinst_serializer_persistent_id.py b/apps/api/tests/unit/equipment/test_pidinst_serializer_persistent_id.py new file mode 100644 index 0000000000..d71da3220f --- /dev/null +++ b/apps/api/tests/unit/equipment/test_pidinst_serializer_persistent_id.py @@ -0,0 +1,75 @@ +"""Unit tests for the serializer's persistent_id -> PidinstIdentifier swap. + +Per section 13.1 of [[project-asset-persistent-id-write-design]]: when +`AssetPidinstView.persistent_id` is set, `_build_identifier` emits a +`PidinstIdentifier` carrying the matching `PidinstIdentifierType` +(DOI or Handle); when absent, the URN fallback (slice C contract) +still holds. The persistent_id value is passed through to the wire +identifier verbatim (no normalization, no resolver-URL prepend). +""" + +from dataclasses import replace + +import pytest + +from cora.equipment._pidinst_serializer import to_pidinst_record +from cora.equipment._pidinst_types import PidinstIdentifierType +from cora.equipment.aggregates.asset import ( + PersistentIdentifier, + PersistentIdentifierScheme, +) +from tests.unit.equipment._helpers import build_view_with_model + +pytestmark = [pytest.mark.unit, pytest.mark.timeout(60, method="thread")] + +_DOI_VALUE = "10.5281/zenodo.1234567" +_HANDLE_VALUE = "20.500.12613/12345" + + +def test_serializer_with_view_persistent_id_doi_emits_pidinst_identifier_type_doi() -> None: + view = replace( + build_view_with_model(), + persistent_id=PersistentIdentifier( + scheme=PersistentIdentifierScheme.DOI, + value=_DOI_VALUE, + ), + ) + record = to_pidinst_record(view) + assert record.identifier.scheme is PidinstIdentifierType.DOI + assert record.identifier.value == _DOI_VALUE + + +def test_serializer_with_view_persistent_id_handle_emits_pidinst_identifier_type_handle() -> None: + view = replace( + build_view_with_model(), + persistent_id=PersistentIdentifier( + scheme=PersistentIdentifierScheme.HANDLE, + value=_HANDLE_VALUE, + ), + ) + record = to_pidinst_record(view) + assert record.identifier.scheme is PidinstIdentifierType.HANDLE + assert record.identifier.value == _HANDLE_VALUE + + +def test_serializer_without_view_persistent_id_falls_back_to_urn() -> None: + view = build_view_with_model() + assert view.persistent_id is None + record = to_pidinst_record(view) + assert record.identifier.scheme is PidinstIdentifierType.URN + assert record.identifier.value == f"urn:uuid:{view.asset_id}" + + +def test_serializer_persistent_id_value_is_unchanged_at_wire() -> None: + view = replace( + build_view_with_model(), + persistent_id=PersistentIdentifier( + scheme=PersistentIdentifierScheme.DOI, + value=_DOI_VALUE, + ), + ) + record = to_pidinst_record(view) + assert record.identifier.value == _DOI_VALUE + assert not record.identifier.value.startswith("https://") + assert not record.identifier.value.startswith("http://") + assert not record.identifier.value.startswith("doi:") diff --git a/apps/api/tests/unit/equipment/test_wire_equipment_doi_minter.py b/apps/api/tests/unit/equipment/test_wire_equipment_doi_minter.py new file mode 100644 index 0000000000..457e831f92 --- /dev/null +++ b/apps/api/tests/unit/equipment/test_wire_equipment_doi_minter.py @@ -0,0 +1,47 @@ +"""Unit tests for the Equipment BC's `DoiMinter` bootstrap wiring. + +Per [[project-asset-persistent-id-write-design]] section 13.1 + Lock 10: +`wire_equipment` reads `Settings.datacite_repository_id` and wires the +inert `StubDoiMinter` when the field is None (the dev / test default). +The minter is attached BC-local at `deps.equipment.doi_minter` so the +`assign_asset_persistent_id` handler closure can read it, AND surfaced on the +returned `EquipmentHandlers.doi_minter` so the FastAPI lifespan stashes +it on `app.state.equipment.doi_minter` for test-override of the 502 +mint-failure path. P1-7 covers the test-file naming convention. +""" + +import pytest + +from cora.equipment.adapters.stub_doi_minter import StubDoiMinter +from cora.equipment.wire import wire_equipment +from tests.unit._helpers import build_deps as _build_deps_shared + +pytestmark = pytest.mark.timeout(60, method="thread") + + +@pytest.mark.unit +def test_wire_equipment_with_datacite_repository_id_none_wires_stub_doi_minter() -> None: + deps = _build_deps_shared(ids=[]) + handlers = wire_equipment(deps) + assert isinstance(handlers.doi_minter, StubDoiMinter) + + +@pytest.mark.unit +def test_wire_equipment_stores_doi_minter_on_app_state_equipment() -> None: + deps = _build_deps_shared(ids=[]) + handlers = wire_equipment(deps) + bc_local = getattr(deps, "equipment", None) + assert bc_local is not None + assert bc_local.doi_minter is handlers.doi_minter + assert isinstance(bc_local.doi_minter, StubDoiMinter) + + +@pytest.mark.unit +@pytest.mark.skip( + reason="DataCiteDoiMinter wiring lands in F.2 once production credentials gate is available" +) +def test_wire_equipment_with_datacite_repository_id_set_skips_stub() -> None: + deps = _build_deps_shared(ids=[]) + deps.settings.datacite_repository_id = "test.repo" # type: ignore[attr-defined] + handlers = wire_equipment(deps) + assert not isinstance(handlers.doi_minter, StubDoiMinter) From fe9430b502dea81d573d0c5ffaa9ee538825593f Mon Sep 17 00:00:00 2001 From: Doga Gursoy Date: Fri, 5 Jun 2026 07:48:08 +0300 Subject: [PATCH 2/2] test(equipment): include AssetPersistentIdAssigned in projection metadata assertion The static test_projection_metadata assertion enumerates the full subscribed_event_types frozenset. Slice F added AssetPersistentIdAssigned to the projection writer but missed the corresponding test assertion update. CI caught it; targeted local pytest runs did not cover this file. Co-Authored-By: Claude Opus 4.7 --- apps/api/tests/unit/equipment/test_asset_summary_projection.py | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/api/tests/unit/equipment/test_asset_summary_projection.py b/apps/api/tests/unit/equipment/test_asset_summary_projection.py index f64b5c7cd1..bd99ddc65d 100644 --- a/apps/api/tests/unit/equipment/test_asset_summary_projection.py +++ b/apps/api/tests/unit/equipment/test_asset_summary_projection.py @@ -60,6 +60,7 @@ def test_projection_metadata() -> None: "AssetAlternateIdentifierRemoved", "AssetOwnerAdded", "AssetOwnerRemoved", + "AssetPersistentIdAssigned", "AssetAttachedToFixture", "AssetDetachedFromFixture", }