diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 37fdb26e9..d23395fba 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -223,6 +223,20 @@ "title": "ActorSummaryDTO", "type": "object" }, + "AddAssetAlternateIdentifierRequest": { + "description": "Body for `POST /assets/{asset_id}/add-alternate-identifier`.\n\nBoth fields required. Pydantic enforces non-empty value at the\nboundary; the `AlternateIdentifier` VO then trims and re-validates\nlength within the decider (the VO construction site).", + "properties": { + "identifier": { + "$ref": "#/components/schemas/AlternateIdentifierBody", + "description": "The alternate identifier to add. Uniqueness keyed on the (kind, value) pair at the Asset scope ONLY; cross-Asset duplicates are NOT rejected in v1." + } + }, + "required": [ + "identifier" + ], + "title": "AddAssetAlternateIdentifierRequest", + "type": "object" + }, "AddAssetFamilyRequest": { "description": "Body for `POST /assets/{asset_id}/add-family`.\n\nEventual-consistency: `family_id` is NOT verified against the\nFamily stream at decide time; mismatch surfaces at Plan\nbinding (6e).", "properties": { @@ -539,6 +553,38 @@ "title": "AgentStatus", "type": "string" }, + "AlternateIdentifierBody": { + "description": "Wire format for an `AlternateIdentifier` value object.", + "properties": { + "kind": { + "$ref": "#/components/schemas/AlternateIdentifierKind", + "description": "Closed PIDINST v1.0 vocabulary: SerialNumber (manufacturer per-unit identifier), InventoryNumber (facility asset tag), or Other (vendor-specific or unconventional scheme)." + }, + "value": { + "description": "Operator-supplied opaque string identifying the Asset under the given scheme. Trimmed at the domain boundary.", + "maxLength": 200, + "minLength": 1, + "title": "Value", + "type": "string" + } + }, + "required": [ + "kind", + "value" + ], + "title": "AlternateIdentifierBody", + "type": "object" + }, + "AlternateIdentifierKind": { + "description": "Closed vocabulary for an Asset's alternate-identifier kind.\n\nValues are verbatim from PIDINST v1.0 spec page 8 (Table 1)\nProperty 13 `alternateIdentifierType` controlled vocabulary:\nSerialNumber, InventoryNumber, Other. Operationally:\n\n - `SerialNumber` is the manufacturer's per-unit identifier\n (the value engraved on the chassis or printed on the QR\n sticker; for example, an Aerotech ANT130-L's `12345-ABC`).\n - `InventoryNumber` is the facility-issued asset tag (for\n example, an APS-issued `APS-2BM-CAM-001`).\n - `Other` is the catch-all for vendor-specific or\n unconventional identifier schemes that don't fit the prior\n two; resolution is operator-supplied free text in the\n `value` field.\n\nAdding a fourth member is an additive enum change at a future\nmigration boundary. The closed-enum stance mirrors\n`ManufacturerIdentifierType` (Model BC) and the broader\n[[project-family-affordance-design]] closed-vocabulary\nprecedent. See [[project-asset-alternate-identifiers-design]]\nLock B for the design rationale.", + "enum": [ + "SerialNumber", + "InventoryNumber", + "Other" + ], + "title": "AlternateIdentifierKind", + "type": "string" + }, "AmendClearanceRequest": { "description": "Body for `POST /clearances/{parent_clearance_id}/amend`.\n\nMirrors `RegisterClearanceRequest`'s child-fields exactly. The\n`parent_clearance_id` comes from the URL path, not the body.", "properties": { @@ -7699,6 +7745,21 @@ "RegisterAssetRequest": { "description": "Body for `POST /assets`.\n\n`level` accepts the StrEnum's PascalCase string values\n(\"Enterprise\" / \"Site\" / \"Area\" / \"Unit\" / \"Assembly\" /\n\"Device\"); Pydantic rejects unknowns with 422.\n\n`parent_id` is required for non-Enterprise levels and must be\nnull for Enterprise; that's a domain invariant enforced by\nthe decider (raises InvalidAssetParentError \u2192 400), not by\nPydantic, since the rule is conditional on `level`.", "properties": { + "alternate_identifiers": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/AlternateIdentifierBody" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "Optional PIDINST v1.0 Property 13 alternate-identifier tuples (operator-supplied serial numbers, inventory tags, vendor-specific schemes) seeded at registration. Each entry is a flat (kind, value) pair; kind is closed vocabulary SerialNumber | InventoryNumber | Other. Cross-Asset uniqueness on (kind, value) is NOT enforced in v1.", + "title": "Alternate Identifiers" + }, "drawing": { "anyOf": [ { @@ -8871,6 +8932,20 @@ "title": "RelocateAssetRequest", "type": "object" }, + "RemoveAssetAlternateIdentifierRequest": { + "description": "Body for `POST /assets/{asset_id}/remove-alternate-identifier`.\n\nBoth fields required. Pydantic enforces non-empty value at the\nboundary; the AlternateIdentifier VO then trims and re-validates\nlength within the decider.", + "properties": { + "identifier": { + "$ref": "#/components/schemas/AlternateIdentifierBody", + "description": "The alternate identifier to remove. Matched against the asset's stored alternate identifiers by exact (kind, value) pair." + } + }, + "required": [ + "identifier" + ], + "title": "RemoveAssetAlternateIdentifierRequest", + "type": "object" + }, "RemoveAssetFamilyRequest": { "description": "Body for `POST /assets/{asset_id}/remove-family`.", "properties": { @@ -13435,7 +13510,7 @@ } } }, - "description": "Domain invariant violated: whitespace-only name, hierarchy rule (Enterprise must have null parent_id; other levels must have non-null parent_id), or invalid Drawing (empty number, overlong revision, etc.)." + "description": "Domain invariant violated: whitespace-only name, hierarchy rule (Enterprise must have null parent_id; other levels must have non-null parent_id), invalid Drawing (empty number, overlong revision, etc.), or invalid AlternateIdentifier value (empty after trimming, exceeds 200 chars)." }, "403": { "content": { @@ -13621,6 +13696,106 @@ ] } }, + "/assets/{asset_id}/add-alternate-identifier": { + "post": { + "operationId": "post_assets_add_alternate_identifier_assets__asset_id__add_alternate_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/AddAssetAlternateIdentifierRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Identifier value is empty / whitespace-only / exceeds the configured max length after trimming (InvalidAlternateIdentifierValueError)." + }, + "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 alternate identifier under current conditions: the asset is Decommissioned (AssetCannotAddAlternateIdentifierError), OR an alternate identifier with the same (kind, value) pair already exists on the asset (AssetAlternateIdentifierAlreadyPresentError), OR a concurrent write to the same asset stream conflicted (optimistic concurrency)." + }, + "422": { + "description": "Path parameter or request body failed schema validation (missing field, invalid kind enum, etc.)." + } + }, + "summary": "Add an alternate identifier to an existing Asset's identifier set", + "tags": [ + "equipment" + ] + } + }, "/assets/{asset_id}/add-family": { "post": { "operationId": "post_assets_add_family_assets__asset_id__add_family_post", @@ -14419,6 +14594,106 @@ ] } }, + "/assets/{asset_id}/remove-alternate-identifier": { + "post": { + "operationId": "post_assets_remove_alternate_identifier_assets__asset_id__remove_alternate_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/RemoveAssetAlternateIdentifierRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Identifier value is empty / whitespace-only / exceeds the configured max length after trimming (InvalidAlternateIdentifierValueError)." + }, + "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 remove the alternate identifier under current conditions: the asset is Decommissioned (AssetCannotAddAlternateIdentifierError; the shared lifecycle-guard class is used by BOTH add and remove), OR no alternate identifier with the same (kind, value) pair exists on the asset (strict-not-idempotent, AssetAlternateIdentifierNotPresentError), OR a concurrent write to the same asset stream conflicted (optimistic concurrency)." + }, + "422": { + "description": "Path parameter or request body failed schema validation (missing field, invalid kind enum, etc.)." + } + }, + "summary": "Remove an alternate identifier from an existing Asset's identifier set", + "tags": [ + "equipment" + ] + } + }, "/assets/{asset_id}/remove-family": { "post": { "operationId": "post_assets_remove_family_assets__asset_id__remove_family_post", diff --git a/apps/api/src/cora/equipment/_alternate_identifier_body.py b/apps/api/src/cora/equipment/_alternate_identifier_body.py new file mode 100644 index 000000000..754b7132a --- /dev/null +++ b/apps/api/src/cora/equipment/_alternate_identifier_body.py @@ -0,0 +1,48 @@ +"""Shared Pydantic wire-format mirror of the `AlternateIdentifier` VO. + +Hoisted at the first importer (`register_asset`); the two future +mutation slices `add_asset_alternate_identifier` and +`remove_asset_alternate_identifier` reuse this mirror once they +land, matching the precedent set by `_drawing_body` (Drawing) and +`_placement_body` (Placement). + +`AlternateIdentifier` is a frozen dataclass at the domain layer +(`cora.equipment.aggregates.asset.state.AlternateIdentifier`); +this body is purely the wire shape that Pydantic parses, with a +single `to_domain()` method that constructs the domain VO and may +raise `InvalidAlternateIdentifierValueError` on domain-rule +violations (mapped to 400 by the BC's exception handler). +""" + +from pydantic import BaseModel, Field + +from cora.equipment.aggregates.asset import ( + ALTERNATE_IDENTIFIER_VALUE_MAX_LENGTH, + AlternateIdentifier, + AlternateIdentifierKind, +) + + +class AlternateIdentifierBody(BaseModel): + """Wire format for an `AlternateIdentifier` value object.""" + + kind: AlternateIdentifierKind = Field( + ..., + description=( + "Closed PIDINST v1.0 vocabulary: SerialNumber (manufacturer " + "per-unit identifier), InventoryNumber (facility asset tag), " + "or Other (vendor-specific or unconventional scheme)." + ), + ) + value: str = Field( + ..., + min_length=1, + max_length=ALTERNATE_IDENTIFIER_VALUE_MAX_LENGTH, + description=( + "Operator-supplied opaque string identifying the Asset " + "under the given scheme. Trimmed at the domain boundary." + ), + ) + + def to_domain(self) -> AlternateIdentifier: + return AlternateIdentifier(kind=self.kind, value=self.value) diff --git a/apps/api/src/cora/equipment/aggregates/asset/__init__.py b/apps/api/src/cora/equipment/aggregates/asset/__init__.py index 44a8f5d8b..417054082 100644 --- a/apps/api/src/cora/equipment/aggregates/asset/__init__.py +++ b/apps/api/src/cora/equipment/aggregates/asset/__init__.py @@ -7,6 +7,8 @@ from cora.equipment.aggregates.asset.events import ( AssetActivated, + AssetAlternateIdentifierAdded, + AssetAlternateIdentifierRemoved, AssetDecommissioned, AssetDegraded, AssetEvent, @@ -28,12 +30,18 @@ from cora.equipment.aggregates.asset.evolver import evolve, fold from cora.equipment.aggregates.asset.read import load_asset from cora.equipment.aggregates.asset.state import ( + ALTERNATE_IDENTIFIER_VALUE_MAX_LENGTH, ASSET_NAME_MAX_LENGTH, PORT_NAME_MAX_LENGTH, PORT_SIGNAL_TYPE_MAX_LENGTH, + AlternateIdentifier, + AlternateIdentifierKind, Asset, AssetAlreadyExistsError, + AssetAlternateIdentifierAlreadyPresentError, + AssetAlternateIdentifierNotPresentError, AssetCannotActivateError, + AssetCannotAddAlternateIdentifierError, AssetCannotAddFamilyError, AssetCannotAddPortError, AssetCannotDecommissionError, @@ -49,6 +57,7 @@ AssetName, AssetNotFoundError, AssetPort, + InvalidAlternateIdentifierValueError, InvalidAssetNameError, InvalidAssetParentError, InvalidAssetPortNameError, @@ -58,13 +67,21 @@ ) __all__ = [ + "ALTERNATE_IDENTIFIER_VALUE_MAX_LENGTH", "ASSET_NAME_MAX_LENGTH", "PORT_NAME_MAX_LENGTH", "PORT_SIGNAL_TYPE_MAX_LENGTH", + "AlternateIdentifier", + "AlternateIdentifierKind", "Asset", "AssetActivated", "AssetAlreadyExistsError", + "AssetAlternateIdentifierAdded", + "AssetAlternateIdentifierAlreadyPresentError", + "AssetAlternateIdentifierNotPresentError", + "AssetAlternateIdentifierRemoved", "AssetCannotActivateError", + "AssetCannotAddAlternateIdentifierError", "AssetCannotAddFamilyError", "AssetCannotAddPortError", "AssetCannotDecommissionError", @@ -94,6 +111,7 @@ "AssetRelocated", "AssetRestored", "AssetSettingsUpdated", + "InvalidAlternateIdentifierValueError", "InvalidAssetNameError", "InvalidAssetParentError", "InvalidAssetPortNameError", diff --git a/apps/api/src/cora/equipment/aggregates/asset/events.py b/apps/api/src/cora/equipment/aggregates/asset/events.py index 474d4c0e2..12fccac21 100644 --- a/apps/api/src/cora/equipment/aggregates/asset/events.py +++ b/apps/api/src/cora/equipment/aggregates/asset/events.py @@ -55,12 +55,16 @@ precedent (also payload.get-based). """ -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import datetime from typing import Any, assert_never from uuid import UUID from cora.equipment.aggregates._drawing import Drawing, DrawingSystem +from cora.equipment.aggregates.asset.state import ( + AlternateIdentifier, + AlternateIdentifierKind, +) from cora.infrastructure.event_payload import deserialize_or_raise from cora.infrastructure.ports.event_store import StoredEvent @@ -88,6 +92,15 @@ class AssetRegistered: `to_payload` uses the omit-when-None convention (key absent rather than serialized as JSON null) to mirror the `drawing` precedent. + + `alternate_identifiers` is an optional frozenset of PIDINST + Property 13 alternate identifiers seeded at registration. The + field defaults to an empty frozenset so legacy AssetRegistered + streams (no `alternate_identifiers` key in the payload) fold + cleanly via the additive-payload pattern; `to_payload` uses the + omit-when-empty convention (key absent rather than serialized as + `[]`) to mirror the `drawing` / `model_id` precedents. See + [[project-asset-alternate-identifiers-design]] Locks A and D. """ asset_id: UUID @@ -97,6 +110,13 @@ class AssetRegistered: occurred_at: datetime drawing: Drawing | None = None model_id: UUID | None = None + # Parametrized default_factory for the empty frozenset trick used + # across Asset / Method / Mount: the empty frozenset has no + # element type for pyright to infer under strict, so the + # parametrized callable is supplied as the factory. + alternate_identifiers: frozenset[AlternateIdentifier] = field( + default_factory=frozenset[AlternateIdentifier] + ) @dataclass(frozen=True) @@ -277,6 +297,46 @@ class AssetPortRemoved: occurred_at: datetime +@dataclass(frozen=True) +class AssetAlternateIdentifierAdded: + """An alternate identifier (PIDINST Property 13) was added to an Asset. + + Single-identifier event mirroring `AssetPortAdded` / + `AssetFamilyAdded`. Audit value: "when did this Asset gain the + `InventoryNumber=APS-2BM-CAM-001` tag?" + + The full `AlternateIdentifier` VO (kind + value) travels in the + payload as two primitives — `kind` is the StrEnum value, `value` + is the trimmed string — so `from_stored` reconstructs the VO + without reading prior state. Mirrors `AssetPortAdded`'s + (port_name, direction, signal_type) primitive carry. The decider + enforces strict-not-idempotent semantics at command time per + [[project-asset-alternate-identifiers-design]] Lock E. + """ + + asset_id: UUID + alternate_identifier: AlternateIdentifier + occurred_at: datetime + + +@dataclass(frozen=True) +class AssetAlternateIdentifierRemoved: + """An alternate identifier (PIDINST Property 13) was removed from an Asset. + + Mirror of `AssetAlternateIdentifierAdded`. The full + `AlternateIdentifier` VO (kind + value) travels in the payload so + the audit reader can see exactly which identifier was removed + without folding back through prior events; symmetric with the + Added event (the Port mirror carries only `port_name` because + `name` is the unique key on `AssetPort`, whereas here uniqueness + keys on the full `(kind, value)` tuple). + """ + + asset_id: UUID + alternate_identifier: AlternateIdentifier + occurred_at: datetime + + @dataclass(frozen=True) class AssetSettingsUpdated: """An asset's settings dict was set / replaced via the @@ -344,6 +404,8 @@ class AssetRelocated: | AssetSettingsUpdated | AssetPortAdded | AssetPortRemoved + | AssetAlternateIdentifierAdded + | AssetAlternateIdentifierRemoved ) @@ -367,6 +429,7 @@ def to_payload(event: AssetEvent) -> dict[str, Any]: occurred_at=occurred_at, drawing=drawing, model_id=model_id, + alternate_identifiers=alternate_identifiers, ): payload: dict[str, Any] = { "asset_id": str(asset_id), @@ -383,6 +446,22 @@ def to_payload(event: AssetEvent) -> dict[str, Any]: } if model_id is not None: payload["model_id"] = str(model_id) + if alternate_identifiers: + # Omit-when-empty: legacy AssetRegistered shape had no + # `alternate_identifiers` key; preserve that wire shape + # so existing stream readers can't accidentally observe + # an empty list where the key was previously absent. + # Sorted by (kind, value) so payload bytes are stable + # under the equivalent VO set (frozenset iteration is + # nondeterministic; canonical bytes matter for any + # future signing/hashing slice). + payload["alternate_identifiers"] = [ + {"kind": identifier.kind.value, "value": identifier.value} + for identifier in sorted( + alternate_identifiers, + key=lambda ident: (ident.kind.value, ident.value), + ) + ] return payload case AssetActivated(asset_id=asset_id, occurred_at=occurred_at): return { @@ -482,6 +561,32 @@ def to_payload(event: AssetEvent) -> dict[str, Any]: "port_name": port_name, "occurred_at": occurred_at.isoformat(), } + case AssetAlternateIdentifierAdded( + asset_id=asset_id, + alternate_identifier=identifier, + occurred_at=occurred_at, + ): + return { + "asset_id": str(asset_id), + "alternate_identifier": { + "kind": identifier.kind.value, + "value": identifier.value, + }, + "occurred_at": occurred_at.isoformat(), + } + case AssetAlternateIdentifierRemoved( + asset_id=asset_id, + alternate_identifier=identifier, + occurred_at=occurred_at, + ): + return { + "asset_id": str(asset_id), + "alternate_identifier": { + "kind": identifier.kind.value, + "value": identifier.value, + }, + "occurred_at": occurred_at.isoformat(), + } case _: # pragma: no cover # exhaustiveness guard assert_never(event) @@ -511,6 +616,14 @@ def _build_registered() -> AssetRegistered: ) raw_model_id = payload.get("model_id") model_id = UUID(raw_model_id) if raw_model_id is not None else None + raw_alt_ids = payload.get("alternate_identifiers", []) + alternate_identifiers = frozenset( + AlternateIdentifier( + kind=AlternateIdentifierKind(entry["kind"]), + value=entry["value"], + ) + for entry in raw_alt_ids + ) return AssetRegistered( asset_id=UUID(payload["asset_id"]), name=payload["name"], @@ -519,9 +632,14 @@ def _build_registered() -> AssetRegistered: occurred_at=datetime.fromisoformat(payload["occurred_at"]), drawing=drawing, model_id=model_id, + alternate_identifiers=alternate_identifiers, ) - return deserialize_or_raise("AssetRegistered", _build_registered) + return deserialize_or_raise( + "AssetRegistered", + _build_registered, + extra=(ValueError,), + ) case "AssetActivated": return deserialize_or_raise( "AssetActivated", @@ -639,6 +757,36 @@ def _build_registered() -> AssetRegistered: occurred_at=datetime.fromisoformat(payload["occurred_at"]), ), ) + case "AssetAlternateIdentifierAdded": + return deserialize_or_raise( + "AssetAlternateIdentifierAdded", + lambda: AssetAlternateIdentifierAdded( + asset_id=UUID(payload["asset_id"]), + alternate_identifier=AlternateIdentifier( + kind=AlternateIdentifierKind( + payload["alternate_identifier"]["kind"], + ), + value=payload["alternate_identifier"]["value"], + ), + occurred_at=datetime.fromisoformat(payload["occurred_at"]), + ), + extra=(ValueError,), + ) + case "AssetAlternateIdentifierRemoved": + return deserialize_or_raise( + "AssetAlternateIdentifierRemoved", + lambda: AssetAlternateIdentifierRemoved( + asset_id=UUID(payload["asset_id"]), + alternate_identifier=AlternateIdentifier( + kind=AlternateIdentifierKind( + payload["alternate_identifier"]["kind"], + ), + value=payload["alternate_identifier"]["value"], + ), + occurred_at=datetime.fromisoformat(payload["occurred_at"]), + ), + extra=(ValueError,), + ) case _: msg = f"Unknown AssetEvent event_type: {stored.event_type!r}" raise ValueError(msg) @@ -646,6 +794,8 @@ def _build_registered() -> AssetRegistered: __all__ = [ "AssetActivated", + "AssetAlternateIdentifierAdded", + "AssetAlternateIdentifierRemoved", "AssetDecommissioned", "AssetDegraded", "AssetEvent", diff --git a/apps/api/src/cora/equipment/aggregates/asset/evolver.py b/apps/api/src/cora/equipment/aggregates/asset/evolver.py index f53616f69..b4dad7e99 100644 --- a/apps/api/src/cora/equipment/aggregates/asset/evolver.py +++ b/apps/api/src/cora/equipment/aggregates/asset/evolver.py @@ -19,6 +19,10 @@ - `AssetSettingsUpdated` -> (lifecycle UNCHANGED; settings -> event.settings) - `AssetPortAdded` -> (lifecycle UNCHANGED; inserts AssetPort into ports frozenset) - `AssetPortRemoved` -> (lifecycle UNCHANGED; removes AssetPort matching name) + - `AssetAlternateIdentifierAdded` -> (lifecycle UNCHANGED; inserts into + alternate_identifiers frozenset) + - `AssetAlternateIdentifierRemoved` -> (lifecycle UNCHANGED; removes from + alternate_identifiers frozenset) The lifecycle mapping is hardcoded per match arm — the event type IS the lifecycle-change indicator (no lifecycle field in event @@ -38,24 +42,28 @@ **Critical invariant**: every transition arm MUST carry `family_ids` AND `condition` AND `settings` AND `ports` AND -`drawing` AND `model_id` through from prior state. Constructing +`drawing` AND `model_id` AND `alternate_identifiers` through from +prior state. Constructing `Asset(id=..., name=..., level=..., parent_id=..., lifecycle=...)` without explicitly passing them would silently WIPE the fields to their defaults (empty frozenset / NOMINAL / empty dict / empty -frozenset / None / None). `family_ids` was added with a default -solely for additive-state forward compatibility on genesis events; -`condition`, `settings`, `ports`, `drawing`, and `model_id` -followed the same additive pattern. Transition arms must -explicitly carry all six. `model_id` is set ONCE at registration -per the model-binding design memo (Lock A) and never changes -post-genesis, but transition arms still must carry it forward -like any other Asset field. Pinned by +frozenset / None / None / empty frozenset). `family_ids` was added +with a default solely for additive-state forward compatibility on +genesis events; `condition`, `settings`, `ports`, `drawing`, +`model_id`, and `alternate_identifiers` followed the same additive +pattern. Transition arms must explicitly carry all seven. +`model_id` is set ONCE at registration per the model-binding +design memo (Lock A) and never changes post-genesis, but +transition arms still must carry it forward like any other Asset +field. Pinned by `test_evolve__preserves_capabilities`, `test_evolve__preserves_condition`, `test_evolve__preserves_settings`, `test_evolve__preserves_ports`, -`test_evolve__preserves_drawing`, and -`test_evolve__preserves_model_id` for each transition. +`test_evolve__preserves_drawing`, +`test_evolve__preserves_model_id`, and +`test_evolve__preserves_alternate_identifiers` for +each transition. Transition events applied to empty state raise ValueError: they can never appear before `AssetRegistered` in a well-formed stream. @@ -68,6 +76,8 @@ from cora.equipment.aggregates.asset.events import ( AssetActivated, + AssetAlternateIdentifierAdded, + AssetAlternateIdentifierRemoved, AssetDecommissioned, AssetDegraded, AssetEvent, @@ -105,6 +115,7 @@ def evolve(state: Asset | None, event: AssetEvent) -> Asset: parent_id=parent_id, drawing=drawing, model_id=model_id, + alternate_identifiers=alternate_identifiers, ): _ = state # AssetRegistered is the genesis event; prior state ignored return Asset( @@ -115,10 +126,15 @@ def evolve(state: Asset | None, event: AssetEvent) -> Asset: lifecycle=AssetLifecycle.COMMISSIONED, drawing=drawing, model_id=model_id, + alternate_identifiers=alternate_identifiers, # family_ids defaults to empty frozenset; condition # defaults to NOMINAL. Additive-state pattern: both # default-via-state so legacy streams without these # fields fold cleanly without an upcaster. + # alternate_identifiers default is empty frozenset on + # the event side (additive-payload pattern), so + # legacy streams missing the field fold to the empty + # frozenset without an upcaster. ) case AssetActivated(): prior = require_state(state, "AssetActivated") @@ -134,6 +150,7 @@ def evolve(state: Asset | None, event: AssetEvent) -> Asset: ports=prior.ports, drawing=prior.drawing, model_id=prior.model_id, + alternate_identifiers=prior.alternate_identifiers, ) case AssetDecommissioned(): prior = require_state(state, "AssetDecommissioned") @@ -149,6 +166,7 @@ def evolve(state: Asset | None, event: AssetEvent) -> Asset: ports=prior.ports, drawing=prior.drawing, model_id=prior.model_id, + alternate_identifiers=prior.alternate_identifiers, ) case AssetRelocated(to_parent_id=to_parent_id): # Hierarchy mutation: only parent_id changes; lifecycle / level @@ -169,6 +187,7 @@ def evolve(state: Asset | None, event: AssetEvent) -> Asset: ports=prior.ports, drawing=prior.drawing, model_id=prior.model_id, + alternate_identifiers=prior.alternate_identifiers, ) case AssetMaintenanceEntered(): prior = require_state(state, "AssetMaintenanceEntered") @@ -184,6 +203,7 @@ def evolve(state: Asset | None, event: AssetEvent) -> Asset: ports=prior.ports, drawing=prior.drawing, model_id=prior.model_id, + alternate_identifiers=prior.alternate_identifiers, ) case AssetMaintenanceExited(): prior = require_state(state, "AssetMaintenanceExited") @@ -199,6 +219,7 @@ def evolve(state: Asset | None, event: AssetEvent) -> Asset: ports=prior.ports, drawing=prior.drawing, model_id=prior.model_id, + alternate_identifiers=prior.alternate_identifiers, ) case AssetFamilyAdded(family_id=family_id): # Family mutation: only `family_ids` changes; everything @@ -219,6 +240,7 @@ def evolve(state: Asset | None, event: AssetEvent) -> Asset: ports=prior.ports, drawing=prior.drawing, model_id=prior.model_id, + alternate_identifiers=prior.alternate_identifiers, ) case AssetFamilyRemoved(family_id=family_id): # Mirror of AssetFamilyAdded. Frozenset difference is a @@ -240,6 +262,7 @@ def evolve(state: Asset | None, event: AssetEvent) -> Asset: ports=prior.ports, drawing=prior.drawing, model_id=prior.model_id, + alternate_identifiers=prior.alternate_identifiers, ) case AssetDegraded(): # Condition mutation: only `condition` changes; everything @@ -260,6 +283,7 @@ def evolve(state: Asset | None, event: AssetEvent) -> Asset: ports=prior.ports, drawing=prior.drawing, model_id=prior.model_id, + alternate_identifiers=prior.alternate_identifiers, ) case AssetFaulted(): prior = require_state(state, "AssetFaulted") @@ -275,6 +299,7 @@ def evolve(state: Asset | None, event: AssetEvent) -> Asset: ports=prior.ports, drawing=prior.drawing, model_id=prior.model_id, + alternate_identifiers=prior.alternate_identifiers, ) case AssetRestored(): prior = require_state(state, "AssetRestored") @@ -290,6 +315,7 @@ def evolve(state: Asset | None, event: AssetEvent) -> Asset: ports=prior.ports, drawing=prior.drawing, model_id=prior.model_id, + alternate_identifiers=prior.alternate_identifiers, ) case AssetSettingsUpdated(settings=settings): # Settings mutation: only `settings` changes. Event payload @@ -312,6 +338,7 @@ def evolve(state: Asset | None, event: AssetEvent) -> Asset: ports=prior.ports, drawing=prior.drawing, model_id=prior.model_id, + alternate_identifiers=prior.alternate_identifiers, ) case AssetPortAdded( port_name=port_name, @@ -342,6 +369,7 @@ def evolve(state: Asset | None, event: AssetEvent) -> Asset: ports=prior.ports | {new_port}, drawing=prior.drawing, model_id=prior.model_id, + alternate_identifiers=prior.alternate_identifiers, ) case AssetPortRemoved(port_name=port_name): # Mirror of AssetPortAdded. Removes the port whose `name` @@ -365,6 +393,49 @@ def evolve(state: Asset | None, event: AssetEvent) -> Asset: ports=frozenset(p for p in prior.ports if p.name != port_name), drawing=prior.drawing, model_id=prior.model_id, + alternate_identifiers=prior.alternate_identifiers, + ) + case AssetAlternateIdentifierAdded(alternate_identifier=identifier): + # Alternate-identifier mutation: only + # `alternate_identifiers` changes; everything else carries + # over. Frozenset union semantics: adding an already- + # present (kind, value) is a no-op AT THE EVOLVER LAYER + # (the decider's strict-not-idempotent guard enforces + # "must not already be present" at command time per + # [[project-asset-alternate-identifiers-design]] Lock E). + prior = require_state(state, "AssetAlternateIdentifierAdded") + 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 | {identifier}, + ) + case AssetAlternateIdentifierRemoved(alternate_identifier=identifier): + # Mirror of AssetAlternateIdentifierAdded. Frozenset + # difference is a no-op when the identifier isn't present; + # the decider enforces presence at command time. + prior = require_state(state, "AssetAlternateIdentifierRemoved") + 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 - {identifier}, ) 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 0d4bc8dc8..46e7841e1 100644 --- a/apps/api/src/cora/equipment/aggregates/asset/state.py +++ b/apps/api/src/cora/equipment/aggregates/asset/state.py @@ -73,6 +73,7 @@ from cora.infrastructure.bounded_text import validate_bounded_text ASSET_NAME_MAX_LENGTH = 200 +ALTERNATE_IDENTIFIER_VALUE_MAX_LENGTH = 200 class AssetLevel(StrEnum): @@ -199,6 +200,148 @@ def __post_init__(self) -> None: object.__setattr__(self, "signal_type", trimmed_signal) +class AlternateIdentifierKind(StrEnum): + """Closed vocabulary for an Asset's alternate-identifier kind. + + Values are verbatim from PIDINST v1.0 spec page 8 (Table 1) + Property 13 `alternateIdentifierType` controlled vocabulary: + SerialNumber, InventoryNumber, Other. Operationally: + + - `SerialNumber` is the manufacturer's per-unit identifier + (the value engraved on the chassis or printed on the QR + sticker; for example, an Aerotech ANT130-L's `12345-ABC`). + - `InventoryNumber` is the facility-issued asset tag (for + example, an APS-issued `APS-2BM-CAM-001`). + - `Other` is the catch-all for vendor-specific or + unconventional identifier schemes that don't fit the prior + two; resolution is operator-supplied free text in the + `value` field. + + Adding a fourth member is an additive enum change at a future + migration boundary. The closed-enum stance mirrors + `ManufacturerIdentifierType` (Model BC) and the broader + [[project-family-affordance-design]] closed-vocabulary + precedent. See [[project-asset-alternate-identifiers-design]] + Lock B for the design rationale. + """ + + SERIAL_NUMBER = "SerialNumber" + INVENTORY_NUMBER = "InventoryNumber" + OTHER = "Other" + + +class InvalidAlternateIdentifierValueError(ValueError): + """The supplied alternate-identifier value is empty, whitespace-only, or too long.""" + + def __init__(self, value: str) -> None: + super().__init__( + f"Alternate identifier value must be 1-{ALTERNATE_IDENTIFIER_VALUE_MAX_LENGTH} " + f"chars after trimming (got: {value!r})" + ) + self.value = value + + +@dataclass(frozen=True) +class AlternateIdentifier: + """A flat (kind, value) tuple identifying an Asset under an alternate scheme. + + PIDINST v1.0 Property 13: instance-tier alternate identifiers + distinct from the PID-tier persistent identifier. Examples: + + - `(SerialNumber, "12345-ABC")` for a manufacturer's serial + - `(InventoryNumber, "APS-2BM-CAM-001")` for a facility asset tag + - `(Other, "RIC-99")` for a legacy or vendor-specific scheme + + `value` is trimmed and length-bounded 1-200 chars via the shared + `validate_bounded_text` helper, matching the + `ManufacturerIdentifier` precedent in the Model BC. The VO is + FLAT (kind + value); no scheme URIs, namespaces, or labels per + [[project-asset-alternate-identifiers-design]] Lock C. Pairing + uniqueness across Assets is NOT enforced in v1 (Lock F). + """ + + kind: AlternateIdentifierKind + value: str + + def __post_init__(self) -> None: + trimmed = validate_bounded_text( + self.value, + max_length=ALTERNATE_IDENTIFIER_VALUE_MAX_LENGTH, + error_class=InvalidAlternateIdentifierValueError, + ) + # Frozen dataclasses block normal assignment in __post_init__; + # use object.__setattr__ to install the trimmed value. + object.__setattr__(self, "value", trimmed) + + +class AssetAlternateIdentifierAlreadyPresentError(Exception): + """Attempted to add an AlternateIdentifier already in the asset's set. + + Strict-not-idempotent: same precedent as + `AssetCannotAddPortError` and `ModelFamilyAlreadyPresentError`. + The full `AlternateIdentifier` VO (kind + value) is carried for + diagnostics; uniqueness is keyed on the (kind, value) tuple at + the Asset scope ONLY (per + [[project-asset-alternate-identifiers-design]] Lock F, no + cross-Asset uniqueness in v1). + """ + + def __init__(self, asset_id: UUID, identifier: AlternateIdentifier) -> None: + super().__init__( + f"Asset {asset_id} already has alternate identifier " + f"{identifier.kind.value}={identifier.value!r}; " + "add_asset_alternate_identifier is strict-not-idempotent" + ) + self.asset_id = asset_id + self.identifier = identifier + + +class AssetAlternateIdentifierNotPresentError(Exception): + """Attempted to remove an AlternateIdentifier not in the asset's set. + + Mirror of `AssetAlternateIdentifierAlreadyPresentError`. + Strict-not-idempotent: the decider rejects rather than no-ops on + a missing identifier. Same shape as `AssetCannotRemovePortError` + and `ModelFamilyNotPresentError`. + """ + + def __init__(self, asset_id: UUID, identifier: AlternateIdentifier) -> None: + super().__init__( + f"Asset {asset_id} does not have alternate identifier " + f"{identifier.kind.value}={identifier.value!r}; nothing to remove" + ) + self.asset_id = asset_id + self.identifier = identifier + + +class AssetCannotAddAlternateIdentifierError(Exception): + """Attempted to add / remove an AlternateIdentifier under a disqualifying lifecycle. + + Used by BOTH `add_asset_alternate_identifier` and + `remove_asset_alternate_identifier` deciders: the lifecycle guard + (asset is `Decommissioned`) is symmetric across the add and + remove transitions; mirrors `AssetCannotAddPortError`'s + reason-bearing pattern. Operationally: a Decommissioned asset is + out of inventory and identifier changes are not permitted. + """ + + def __init__( + self, + asset_id: UUID, + kind: AlternateIdentifierKind, + value: str, + *, + reason: str, + ) -> None: + super().__init__( + f"Asset {asset_id} cannot mutate alternate identifier {kind.value}={value!r}: {reason}" + ) + self.asset_id = asset_id + self.kind = kind + self.value = value + self.reason = reason + + class AssetCondition(StrEnum): """The Asset's real-time device-health state. @@ -602,6 +745,18 @@ class Asset: Defaults to None; legacy AssetRegistered streams without the model_id field fold cleanly via the additive-state pattern. + `alternate_identifiers`: frozenset of PIDINST v1.0 Property 13 + alternate identifiers (serial numbers, inventory tags, vendor- + specific schemes). Each entry is a flat `AlternateIdentifier` + VO (kind + value). Updated incrementally via + `add_asset_alternate_identifier` / + `remove_asset_alternate_identifier` slices; the optional + `alternate_identifiers` parameter at `register_asset` time + seeds the initial set. Defaults to empty frozenset; legacy + AssetRegistered streams without the field fold cleanly via the + additive-state pattern. See + [[project-asset-alternate-identifiers-design]] Locks A, D, E. + Future additive facets: `owner`, `persistent_id`. The state- level fields land with defaults for the same forward- compatibility reason. @@ -629,3 +784,11 @@ class Asset: ports: frozenset[AssetPort] = field(default_factory=frozenset[AssetPort]) drawing: Drawing | None = None model_id: UUID | None = None + # frozenset[AlternateIdentifier] for PIDINST v1.0 Property 13 + # alternate-identifier tuples. Same parametrized-callable trick + # as family_ids / ports — empty frozenset has no element type for + # pyright to infer under strict, so the parametrized callable is + # supplied as the factory. + alternate_identifiers: frozenset[AlternateIdentifier] = field( + default_factory=frozenset[AlternateIdentifier] + ) diff --git a/apps/api/src/cora/equipment/features/add_asset_alternate_identifier/__init__.py b/apps/api/src/cora/equipment/features/add_asset_alternate_identifier/__init__.py new file mode 100644 index 000000000..72557339b --- /dev/null +++ b/apps/api/src/cora/equipment/features/add_asset_alternate_identifier/__init__.py @@ -0,0 +1,43 @@ +"""Vertical slice for the `AddAssetAlternateIdentifier` command. + +Mirror of `add_asset_port` for the alternate-identifier facet. Adds +a single `AlternateIdentifier` (PIDINST v1.0 Property 13) to an +existing Asset's identifier set; strict-not-idempotent: a duplicate +`(kind, value)` pair surfaces as 409 rather than silent no-op. + +Module-as-namespace surface: + + from cora.equipment.features import add_asset_alternate_identifier + + cmd = add_asset_alternate_identifier.AddAssetAlternateIdentifier( + asset_id=..., + alternate_identifier=AlternateIdentifier( + kind=AlternateIdentifierKind.SERIAL_NUMBER, + value="XYZ-001", + ), + ) + handler = add_asset_alternate_identifier.bind(deps) + await handler(cmd, principal_id=..., correlation_id=...) + +The `add_asset_port` / `remove_asset_port` precedent is followed +verbatim for the slice topology (POST-style action endpoint, +strict-not-idempotent, dedicated decommissioned-guard error). See +[[project-asset-alternate-identifiers-design]] Lock E. +""" + +from cora.equipment.features.add_asset_alternate_identifier import tool +from cora.equipment.features.add_asset_alternate_identifier.command import ( + AddAssetAlternateIdentifier, +) +from cora.equipment.features.add_asset_alternate_identifier.decider import decide +from cora.equipment.features.add_asset_alternate_identifier.handler import Handler, bind +from cora.equipment.features.add_asset_alternate_identifier.route import router + +__all__ = [ + "AddAssetAlternateIdentifier", + "Handler", + "bind", + "decide", + "router", + "tool", +] diff --git a/apps/api/src/cora/equipment/features/add_asset_alternate_identifier/command.py b/apps/api/src/cora/equipment/features/add_asset_alternate_identifier/command.py new file mode 100644 index 000000000..18c89a12e --- /dev/null +++ b/apps/api/src/cora/equipment/features/add_asset_alternate_identifier/command.py @@ -0,0 +1,28 @@ +"""The `AddAssetAlternateIdentifier` command, intent dataclass for this slice. + +`asset_id` is the target Asset aggregate. `alternate_identifier` is +the full `AlternateIdentifier` VO (kind + value) to add to the +asset's identifier set. The decider rejects a duplicate +`(kind, value)` pair (strict-not-idempotent) and rejects when the +asset is Decommissioned (mirrors the `add_asset_port` lifecycle +guard). +""" + +from dataclasses import dataclass +from uuid import UUID + +from cora.equipment.aggregates.asset import AlternateIdentifier + + +@dataclass(frozen=True) +class AddAssetAlternateIdentifier: + """Add an alternate identifier to an existing Asset's identifier set. + + The identifier is the full `AlternateIdentifier` VO (kind + value). + The decider's strict-not-idempotent guard rejects a duplicate + `(kind, value)` pair already on the asset; the lifecycle guard + rejects when the asset is Decommissioned. + """ + + asset_id: UUID + alternate_identifier: AlternateIdentifier diff --git a/apps/api/src/cora/equipment/features/add_asset_alternate_identifier/decider.py b/apps/api/src/cora/equipment/features/add_asset_alternate_identifier/decider.py new file mode 100644 index 000000000..87036a4bd --- /dev/null +++ b/apps/api/src/cora/equipment/features/add_asset_alternate_identifier/decider.py @@ -0,0 +1,84 @@ +"""Pure decider for the `AddAssetAlternateIdentifier` command. + +Two disqualifying conditions surface as dedicated error classes: + + - asset is `Decommissioned` (retired; no further identifier + changes) -> `AssetCannotAddAlternateIdentifierError` + - `(kind, value)` pair already in `state.alternate_identifiers` + (strict-not-idempotent; mirrors the `add_model_family` + add-vs-already-present split rather than the older + `add_asset_port` collapsed-class pattern) -> + `AssetAlternateIdentifierAlreadyPresentError` + +The lifecycle guard mirrors `add_asset_port` exactly: a +Decommissioned asset is out of inventory, and identifier changes +are not permitted. Symmetric with +`remove_asset_alternate_identifier`. The `routes.py` 409 mapping +covers both the AlreadyPresent class and the lifecycle-guard +class. + +`AlternateIdentifier` VO construction at command time validates +the `value` length / non-empty invariant and raises +`InvalidAlternateIdentifierValueError` (mapped to 400 by the BC's +exception handler); the closed `AlternateIdentifierKind` StrEnum +makes invalid `kind` values impossible to construct (Pydantic +catches strings at the route boundary). +""" + +from datetime import datetime + +from cora.equipment.aggregates.asset import ( + Asset, + AssetAlternateIdentifierAdded, + AssetAlternateIdentifierAlreadyPresentError, + AssetCannotAddAlternateIdentifierError, + AssetLifecycle, + AssetNotFoundError, +) +from cora.equipment.features.add_asset_alternate_identifier.command import ( + AddAssetAlternateIdentifier, +) + + +def decide( + state: Asset | None, + command: AddAssetAlternateIdentifier, + *, + now: datetime, +) -> list[AssetAlternateIdentifierAdded]: + """Decide the events produced by adding an alternate identifier. + + Invariants: + - State must not be None -> AssetNotFoundError + - Asset must not be Decommissioned + -> AssetCannotAddAlternateIdentifierError + - `(kind, value)` pair must not already be in + state.alternate_identifiers (strict-not-idempotent) + -> AssetAlternateIdentifierAlreadyPresentError + """ + if state is None: + raise AssetNotFoundError(command.asset_id) + + identifier = command.alternate_identifier + + if state.lifecycle is AssetLifecycle.DECOMMISSIONED: + raise AssetCannotAddAlternateIdentifierError( + state.id, + identifier.kind, + identifier.value, + reason=( + f"asset is currently {AssetLifecycle.DECOMMISSIONED.value} " + "(retired from service; alternate identifier changes are not allowed)" + ), + ) + + if identifier in state.alternate_identifiers: + raise AssetAlternateIdentifierAlreadyPresentError(state.id, identifier) + + return [ + AssetAlternateIdentifierAdded( + asset_id=state.id, + alternate_identifier=identifier, + occurred_at=now, + ) + ] diff --git a/apps/api/src/cora/equipment/features/add_asset_alternate_identifier/handler.py b/apps/api/src/cora/equipment/features/add_asset_alternate_identifier/handler.py new file mode 100644 index 000000000..a1be0cce0 --- /dev/null +++ b/apps/api/src/cora/equipment/features/add_asset_alternate_identifier/handler.py @@ -0,0 +1,48 @@ +"""Application handler for the `add_asset_alternate_identifier` slice. + +Update-style handler. The full canonical body lives in +`make_asset_update_handler` (load + authorize + fold + decide + +append, with structured logging). This module is a thin slice- +specific bind. No cross-BC reads (per +[[project-asset-alternate-identifiers-design]] Lock I). + +Not idempotency-wrapped: identifier-mutation is strict-not- +idempotent at the decider (second add hits +`AssetAlternateIdentifierAlreadyPresentError`); apply only when +cached-success-on-retry semantics are needed. +""" + +from typing import Protocol +from uuid import UUID + +from cora.equipment._asset_update_handler import make_asset_update_handler +from cora.equipment.features.add_asset_alternate_identifier.command import ( + AddAssetAlternateIdentifier, +) +from cora.equipment.features.add_asset_alternate_identifier.decider import decide +from cora.infrastructure.kernel import Kernel +from cora.infrastructure.routing import NIL_SENTINEL_ID + + +class Handler(Protocol): + """Callable interface every add_asset_alternate_identifier handler implements.""" + + async def __call__( + self, + command: AddAssetAlternateIdentifier, + *, + principal_id: UUID, + correlation_id: UUID, + causation_id: UUID | None = None, + surface_id: UUID = NIL_SENTINEL_ID, + ) -> None: ... + + +def bind(deps: Kernel) -> Handler: + """Build an add_asset_alternate_identifier handler closed over the shared deps.""" + return make_asset_update_handler( + deps, + command_name="AddAssetAlternateIdentifier", + log_prefix="add_asset_alternate_identifier", + decide_fn=decide, + ) diff --git a/apps/api/src/cora/equipment/features/add_asset_alternate_identifier/route.py b/apps/api/src/cora/equipment/features/add_asset_alternate_identifier/route.py new file mode 100644 index 000000000..ddf8d57d8 --- /dev/null +++ b/apps/api/src/cora/equipment/features/add_asset_alternate_identifier/route.py @@ -0,0 +1,114 @@ +"""HTTP route for the `add_asset_alternate_identifier` slice. + +Action endpoint at `POST /assets/{asset_id}/add-alternate-identifier`. +Body carries `kind` (closed StrEnum) and `value` (trimmed bounded text). +204 No Content on success. Same POST-style action pattern as +`add_asset_port` / `remove_asset_alternate_identifier`; consistent +with every other Asset targeted-mutation slice (no DELETE verb is +used anywhere on the Asset aggregate). +""" + +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, Path, Request, status +from pydantic import BaseModel, Field + +from cora.equipment._alternate_identifier_body import AlternateIdentifierBody +from cora.equipment.features.add_asset_alternate_identifier.command import ( + AddAssetAlternateIdentifier, +) +from cora.equipment.features.add_asset_alternate_identifier.handler import Handler +from cora.infrastructure.routing import ( + ErrorResponse, + get_correlation_id, + get_principal_id, + get_surface_id, +) + + +class AddAssetAlternateIdentifierRequest(BaseModel): + """Body for `POST /assets/{asset_id}/add-alternate-identifier`. + + Both fields required. Pydantic enforces non-empty value at the + boundary; the `AlternateIdentifier` VO then trims and re-validates + length within the decider (the VO construction site). + """ + + identifier: AlternateIdentifierBody = Field( + ..., + description=( + "The alternate identifier to add. Uniqueness keyed on the " + "(kind, value) pair at the Asset scope ONLY; cross-Asset " + "duplicates are NOT rejected in v1." + ), + ) + + +def _get_handler(request: Request) -> Handler: + handler: Handler = request.app.state.equipment.add_asset_alternate_identifier + return handler + + +router = APIRouter(tags=["equipment"]) + + +@router.post( + "/assets/{asset_id}/add-alternate-identifier", + status_code=status.HTTP_204_NO_CONTENT, + responses={ + status.HTTP_400_BAD_REQUEST: { + "model": ErrorResponse, + "description": ( + "Identifier value is empty / whitespace-only / exceeds " + "the configured max length after trimming " + "(InvalidAlternateIdentifierValueError)." + ), + }, + 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 alternate identifier under " + "current conditions: the asset is Decommissioned " + "(AssetCannotAddAlternateIdentifierError), OR an " + "alternate identifier with the same (kind, value) pair " + "already exists on the asset " + "(AssetAlternateIdentifierAlreadyPresentError), OR a " + "concurrent write to the same asset stream conflicted " + "(optimistic concurrency)." + ), + }, + status.HTTP_422_UNPROCESSABLE_CONTENT: { + "description": ( + "Path parameter or request body failed schema " + "validation (missing field, invalid kind enum, etc.)." + ), + }, + }, + summary="Add an alternate identifier to an existing Asset's identifier set", +) +async def post_assets_add_alternate_identifier( + asset_id: Annotated[UUID, Path(description="Target asset's id.")], + body: AddAssetAlternateIdentifierRequest, + 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)], +) -> None: + await handler( + AddAssetAlternateIdentifier( + asset_id=asset_id, + alternate_identifier=body.identifier.to_domain(), + ), + principal_id=principal_id, + correlation_id=cid, + surface_id=surface_id, + ) diff --git a/apps/api/src/cora/equipment/features/add_asset_alternate_identifier/tool.py b/apps/api/src/cora/equipment/features/add_asset_alternate_identifier/tool.py new file mode 100644 index 000000000..9d1bc8bc3 --- /dev/null +++ b/apps/api/src/cora/equipment/features/add_asset_alternate_identifier/tool.py @@ -0,0 +1,53 @@ +"""MCP tool for the `add_asset_alternate_identifier` 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._alternate_identifier_body import AlternateIdentifierBody +from cora.equipment.features.add_asset_alternate_identifier.command import ( + AddAssetAlternateIdentifier, +) +from cora.equipment.features.add_asset_alternate_identifier.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 `add_asset_alternate_identifier` tool on the given MCP server.""" + + @mcp.tool( + name="add_asset_alternate_identifier", + description=( + "Add an alternate identifier (PIDINST v1.0 Property 13) " + "to an existing Asset's identifier set by exact " + "(kind, value) pair. Strict-not-idempotent: rejects a " + "duplicate (kind, value) pair already on the asset. " + "Rejects when the asset is Decommissioned." + ), + ) + async def add_asset_alternate_identifier_tool( # pyright: ignore[reportUnusedFunction] + ctx: Context[Any, Any, Any], + asset_id: Annotated[ + UUID, + Field(description="Target asset's id."), + ], + identifier: Annotated[ + AlternateIdentifierBody, + Field(description="The alternate identifier to add."), + ], + ) -> None: + handler = get_handler() + await handler( + AddAssetAlternateIdentifier( + asset_id=asset_id, + alternate_identifier=identifier.to_domain(), + ), + principal_id=get_mcp_principal_id(ctx), + correlation_id=current_correlation_id(), + surface_id=get_mcp_surface_id(), + ) diff --git a/apps/api/src/cora/equipment/features/register_asset/command.py b/apps/api/src/cora/equipment/features/register_asset/command.py index 73fabe5cf..850efcdcb 100644 --- a/apps/api/src/cora/equipment/features/register_asset/command.py +++ b/apps/api/src/cora/equipment/features/register_asset/command.py @@ -27,13 +27,26 @@ (`ModelNotFoundError` -> 404); the decider does NOT need a Model snapshot because the genesis Asset's families set is empty so the subset invariant is vacuously satisfied at registration (Lock B). + +`alternate_identifiers` is a `frozenset[AlternateIdentifier]`, +defaulted to empty. Seeds the Asset's initial set of PIDINST v1.0 +Property 13 alternate identifiers (operator-supplied serial +numbers, inventory tags, vendor-specific schemes) in a single +registration transaction; the targeted-mutation slices +`add_asset_alternate_identifier` / +`remove_asset_alternate_identifier` mutate the set post-genesis. +Identifiers are operator-supplied opaque strings: the decider +does NOT cross-validate `(kind, value)` uniqueness across Assets +in v1 (per [[project-asset-alternate-identifiers-design]] Lock F); +no cross-BC IO either (per Lock I), so the handler does not load +any external stream on this field's behalf. """ -from dataclasses import dataclass +from dataclasses import dataclass, field from uuid import UUID from cora.equipment.aggregates._drawing import Drawing -from cora.equipment.aggregates.asset import AssetLevel +from cora.equipment.aggregates.asset import AlternateIdentifier, AssetLevel @dataclass(frozen=True) @@ -41,7 +54,8 @@ class RegisterAsset: """Register a new asset. Carries the display name, hierarchical level, parent_id, optional - Drawing reference, and optional `model_id` Model-binding ref. + Drawing reference, optional `model_id` Model-binding ref, and + optional `alternate_identifiers` seed set. """ name: str @@ -49,3 +63,12 @@ class RegisterAsset: parent_id: UUID | None drawing: Drawing | None = None model_id: UUID | None = None + # frozenset[AlternateIdentifier] for PIDINST v1.0 Property 13 + # alternate-identifier tuples seeded at registration. Same + # parametrized-callable trick as Asset.alternate_identifiers in + # state.py: empty frozenset has no element type for pyright to + # infer under strict, so the parametrized callable is supplied as + # the factory. + alternate_identifiers: frozenset[AlternateIdentifier] = field( + default_factory=frozenset[AlternateIdentifier] + ) diff --git a/apps/api/src/cora/equipment/features/register_asset/decider.py b/apps/api/src/cora/equipment/features/register_asset/decider.py index c66e9b9fc..2ed91cae0 100644 --- a/apps/api/src/cora/equipment/features/register_asset/decider.py +++ b/apps/api/src/cora/equipment/features/register_asset/decider.py @@ -38,6 +38,18 @@ before invoking decide; the first meaningful subset enforcement fires at the first `add_asset_family` call against the bound Asset. + +## Alternate identifiers (Lock D + Lock F + Lock I) + +`command.alternate_identifiers` flows through to the emitted +AssetRegistered event verbatim. The decider does NOT validate +`(kind, value)` uniqueness across other Assets in v1 (Lock F): +PIDINST itself admits "should be unique", same-vendor serial +schemes legitimately reappear across facilities, and CORA stays +format-opaque about provenance of the string. Frozenset semantics +on the field structurally forbid duplicate `(kind, value)` pairs +on the same Asset. No cross-BC IO fires on this field's behalf +(Lock I); the handler does not load any external stream. """ from datetime import datetime @@ -97,5 +109,6 @@ def decide( occurred_at=now, drawing=command.drawing, model_id=command.model_id, + alternate_identifiers=command.alternate_identifiers, ) ] diff --git a/apps/api/src/cora/equipment/features/register_asset/route.py b/apps/api/src/cora/equipment/features/register_asset/route.py index baeafaf15..e1bc6e7c2 100644 --- a/apps/api/src/cora/equipment/features/register_asset/route.py +++ b/apps/api/src/cora/equipment/features/register_asset/route.py @@ -16,6 +16,7 @@ from fastapi import APIRouter, Depends, Header, Request, status from pydantic import BaseModel, Field +from cora.equipment._alternate_identifier_body import AlternateIdentifierBody from cora.equipment._drawing_body import DrawingBody from cora.equipment.aggregates.asset import ASSET_NAME_MAX_LENGTH, AssetLevel from cora.equipment.features.register_asset.command import RegisterAsset @@ -82,6 +83,18 @@ class RegisterAssetRequest(BaseModel): "genesis Asset families set is empty." ), ) + alternate_identifiers: list[AlternateIdentifierBody] | None = Field( + None, + description=( + "Optional PIDINST v1.0 Property 13 alternate-identifier " + "tuples (operator-supplied serial numbers, inventory tags, " + "vendor-specific schemes) seeded at registration. Each " + "entry is a flat (kind, value) pair; kind is closed " + "vocabulary SerialNumber | InventoryNumber | Other. " + "Cross-Asset uniqueness on (kind, value) is NOT enforced " + "in v1." + ), + ) class RegisterAssetResponse(BaseModel): @@ -108,8 +121,10 @@ def _get_handler(request: Request) -> IdempotentHandler: "description": ( "Domain invariant violated: whitespace-only name, " "hierarchy rule (Enterprise must have null parent_id; " - "other levels must have non-null parent_id), or invalid " - "Drawing (empty number, overlong revision, etc.)." + "other levels must have non-null parent_id), invalid " + "Drawing (empty number, overlong revision, etc.), or " + "invalid AlternateIdentifier value (empty after " + "trimming, exceeds 200 chars)." ), }, status.HTTP_403_FORBIDDEN: { @@ -158,6 +173,9 @@ async def post_assets( parent_id=body.parent_id, drawing=body.drawing.to_domain() if body.drawing is not None else None, model_id=body.model_id, + alternate_identifiers=frozenset( + entry.to_domain() for entry in (body.alternate_identifiers or []) + ), ), principal_id=principal_id, correlation_id=cid, diff --git a/apps/api/src/cora/equipment/features/register_asset/tool.py b/apps/api/src/cora/equipment/features/register_asset/tool.py index 1be19c737..c17c455c0 100644 --- a/apps/api/src/cora/equipment/features/register_asset/tool.py +++ b/apps/api/src/cora/equipment/features/register_asset/tool.py @@ -14,6 +14,7 @@ from mcp.server.fastmcp import Context, FastMCP from pydantic import BaseModel, Field +from cora.equipment._alternate_identifier_body import AlternateIdentifierBody from cora.equipment._drawing_body import DrawingBody from cora.equipment.aggregates.asset import ASSET_NAME_MAX_LENGTH, AssetLevel from cora.equipment.features.register_asset.command import RegisterAsset @@ -93,6 +94,21 @@ async def register_asset_tool( # pyright: ignore[reportUnusedFunction] ), ), ] = None, + alternate_identifiers: Annotated[ + list[AlternateIdentifierBody] | None, + Field( + default=None, + description=( + "Optional PIDINST v1.0 Property 13 alternate-" + "identifier tuples (operator-supplied serial " + "numbers, inventory tags, vendor-specific " + "schemes) seeded at registration. Each entry is " + "a flat (kind, value) pair; kind is closed " + "vocabulary SerialNumber | InventoryNumber | " + "Other." + ), + ), + ] = None, ) -> RegisterAssetOutput: handler = get_handler() asset_id = await handler( @@ -102,6 +118,9 @@ async def register_asset_tool( # pyright: ignore[reportUnusedFunction] parent_id=parent_id, drawing=drawing.to_domain() if drawing is not None else None, model_id=model_id, + alternate_identifiers=frozenset( + entry.to_domain() for entry in (alternate_identifiers or []) + ), ), principal_id=get_mcp_principal_id(ctx), correlation_id=current_correlation_id(), diff --git a/apps/api/src/cora/equipment/features/remove_asset_alternate_identifier/__init__.py b/apps/api/src/cora/equipment/features/remove_asset_alternate_identifier/__init__.py new file mode 100644 index 000000000..1cf04e7c6 --- /dev/null +++ b/apps/api/src/cora/equipment/features/remove_asset_alternate_identifier/__init__.py @@ -0,0 +1,27 @@ +"""Vertical slice for the `RemoveAssetAlternateIdentifier` command. + +Mirror of `add_asset_alternate_identifier`. Removes an alternate +identifier from an Asset by exact `(kind, value)` pair; rejects +when the asset is Decommissioned or no such pair exists. + +The `add_asset_port` / `remove_asset_port` precedent is followed +verbatim for the slice topology (POST-style action endpoint, +strict-not-idempotent, dedicated decommissioned-guard error). +""" + +from cora.equipment.features.remove_asset_alternate_identifier import tool +from cora.equipment.features.remove_asset_alternate_identifier.command import ( + RemoveAssetAlternateIdentifier, +) +from cora.equipment.features.remove_asset_alternate_identifier.decider import decide +from cora.equipment.features.remove_asset_alternate_identifier.handler import Handler, bind +from cora.equipment.features.remove_asset_alternate_identifier.route import router + +__all__ = [ + "Handler", + "RemoveAssetAlternateIdentifier", + "bind", + "decide", + "router", + "tool", +] diff --git a/apps/api/src/cora/equipment/features/remove_asset_alternate_identifier/command.py b/apps/api/src/cora/equipment/features/remove_asset_alternate_identifier/command.py new file mode 100644 index 000000000..4905d76ef --- /dev/null +++ b/apps/api/src/cora/equipment/features/remove_asset_alternate_identifier/command.py @@ -0,0 +1,21 @@ +"""The `RemoveAssetAlternateIdentifier` command - intent dataclass for this slice.""" + +from dataclasses import dataclass +from uuid import UUID + +from cora.equipment.aggregates.asset import AlternateIdentifier + + +@dataclass(frozen=True) +class RemoveAssetAlternateIdentifier: + """Remove an alternate identifier from an existing Asset's identifier set. + + The identifier is matched on the exact `(kind, value)` pair. The + decider rejects when the asset is Decommissioned (mirrors the + `remove_asset_port` lifecycle guard) and rejects when no such + pair exists on the asset (strict-not-idempotent, symmetric with + add). + """ + + asset_id: UUID + alternate_identifier: AlternateIdentifier diff --git a/apps/api/src/cora/equipment/features/remove_asset_alternate_identifier/decider.py b/apps/api/src/cora/equipment/features/remove_asset_alternate_identifier/decider.py new file mode 100644 index 000000000..c6869cbaa --- /dev/null +++ b/apps/api/src/cora/equipment/features/remove_asset_alternate_identifier/decider.py @@ -0,0 +1,77 @@ +"""Pure decider for the `RemoveAssetAlternateIdentifier` command. + +Mirror of `add_asset_alternate_identifier.decide`. Two +disqualifying conditions surface as dedicated error classes: + + - asset is `Decommissioned` (retired; no further identifier + changes) -> `AssetCannotAddAlternateIdentifierError` + (the shared lifecycle-guard class is used by BOTH add and + remove deciders, mirroring the symmetry of the guard) + - no exact `(kind, value)` pair in `state.alternate_identifiers` + (strict-not-idempotent; symmetric with add) -> + `AssetAlternateIdentifierNotPresentError` + +The lifecycle guard mirrors `remove_asset_port` exactly: a +Decommissioned asset is out of inventory, and identifier changes +are not permitted. The `routes.py` 409 mapping covers the +NotPresent and lifecycle-guard classes. +""" + +from datetime import datetime + +from cora.equipment.aggregates.asset import ( + Asset, + AssetAlternateIdentifierNotPresentError, + AssetAlternateIdentifierRemoved, + AssetCannotAddAlternateIdentifierError, + AssetLifecycle, + AssetNotFoundError, +) +from cora.equipment.features.remove_asset_alternate_identifier.command import ( + RemoveAssetAlternateIdentifier, +) + + +def decide( + state: Asset | None, + command: RemoveAssetAlternateIdentifier, + *, + now: datetime, +) -> list[AssetAlternateIdentifierRemoved]: + """Decide the events produced by removing an alternate identifier. + + Invariants: + - State must not be None -> AssetNotFoundError + - Asset must not be Decommissioned + -> AssetCannotAddAlternateIdentifierError (shared lifecycle + guard class; used by BOTH add and remove deciders) + - `(kind, value)` pair must be in state.alternate_identifiers + (strict-not-idempotent) + -> AssetAlternateIdentifierNotPresentError + """ + if state is None: + raise AssetNotFoundError(command.asset_id) + + identifier = command.alternate_identifier + + if state.lifecycle is AssetLifecycle.DECOMMISSIONED: + raise AssetCannotAddAlternateIdentifierError( + state.id, + identifier.kind, + identifier.value, + reason=( + f"asset is currently {AssetLifecycle.DECOMMISSIONED.value} " + "(retired from service; alternate identifier changes are not allowed)" + ), + ) + + if identifier not in state.alternate_identifiers: + raise AssetAlternateIdentifierNotPresentError(state.id, identifier) + + return [ + AssetAlternateIdentifierRemoved( + asset_id=state.id, + alternate_identifier=identifier, + occurred_at=now, + ) + ] diff --git a/apps/api/src/cora/equipment/features/remove_asset_alternate_identifier/handler.py b/apps/api/src/cora/equipment/features/remove_asset_alternate_identifier/handler.py new file mode 100644 index 000000000..b3d7cff8a --- /dev/null +++ b/apps/api/src/cora/equipment/features/remove_asset_alternate_identifier/handler.py @@ -0,0 +1,36 @@ +"""Application handler for the `remove_asset_alternate_identifier` slice.""" + +from typing import Protocol +from uuid import UUID + +from cora.equipment._asset_update_handler import make_asset_update_handler +from cora.equipment.features.remove_asset_alternate_identifier.command import ( + RemoveAssetAlternateIdentifier, +) +from cora.equipment.features.remove_asset_alternate_identifier.decider import decide +from cora.infrastructure.kernel import Kernel +from cora.infrastructure.routing import NIL_SENTINEL_ID + + +class Handler(Protocol): + """Callable interface every remove_asset_alternate_identifier handler implements.""" + + async def __call__( + self, + command: RemoveAssetAlternateIdentifier, + *, + principal_id: UUID, + correlation_id: UUID, + causation_id: UUID | None = None, + surface_id: UUID = NIL_SENTINEL_ID, + ) -> None: ... + + +def bind(deps: Kernel) -> Handler: + """Build a remove_asset_alternate_identifier handler closed over the shared deps.""" + return make_asset_update_handler( + deps, + command_name="RemoveAssetAlternateIdentifier", + log_prefix="remove_asset_alternate_identifier", + decide_fn=decide, + ) diff --git a/apps/api/src/cora/equipment/features/remove_asset_alternate_identifier/route.py b/apps/api/src/cora/equipment/features/remove_asset_alternate_identifier/route.py new file mode 100644 index 000000000..eb5c21d56 --- /dev/null +++ b/apps/api/src/cora/equipment/features/remove_asset_alternate_identifier/route.py @@ -0,0 +1,115 @@ +"""HTTP route for the `remove_asset_alternate_identifier` slice. + +Action endpoint at `POST /assets/{asset_id}/remove-alternate-identifier`. +Body carries `kind` (closed StrEnum) and `value` (trimmed bounded text). +204 No Content on success. Mirror of `add_asset_alternate_identifier` +route and the `add_asset_port` / `remove_asset_port` pattern (POST- +style mutation verbs, NOT DELETE; consistent with every other Asset +targeted-mutation slice). +""" + +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, Path, Request, status +from pydantic import BaseModel, Field + +from cora.equipment._alternate_identifier_body import AlternateIdentifierBody +from cora.equipment.features.remove_asset_alternate_identifier.command import ( + RemoveAssetAlternateIdentifier, +) +from cora.equipment.features.remove_asset_alternate_identifier.handler import Handler +from cora.infrastructure.routing import ( + ErrorResponse, + get_correlation_id, + get_principal_id, + get_surface_id, +) + + +class RemoveAssetAlternateIdentifierRequest(BaseModel): + """Body for `POST /assets/{asset_id}/remove-alternate-identifier`. + + Both fields required. Pydantic enforces non-empty value at the + boundary; the AlternateIdentifier VO then trims and re-validates + length within the decider. + """ + + identifier: AlternateIdentifierBody = Field( + ..., + description=( + "The alternate identifier to remove. Matched against the " + "asset's stored alternate identifiers by exact (kind, value) " + "pair." + ), + ) + + +def _get_handler(request: Request) -> Handler: + handler: Handler = request.app.state.equipment.remove_asset_alternate_identifier + return handler + + +router = APIRouter(tags=["equipment"]) + + +@router.post( + "/assets/{asset_id}/remove-alternate-identifier", + status_code=status.HTTP_204_NO_CONTENT, + responses={ + status.HTTP_400_BAD_REQUEST: { + "model": ErrorResponse, + "description": ( + "Identifier value is empty / whitespace-only / exceeds " + "the configured max length after trimming " + "(InvalidAlternateIdentifierValueError)." + ), + }, + 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 remove the alternate identifier under " + "current conditions: the asset is Decommissioned " + "(AssetCannotAddAlternateIdentifierError; the shared " + "lifecycle-guard class is used by BOTH add and remove), " + "OR no alternate identifier with the same (kind, value) " + "pair exists on the asset (strict-not-idempotent, " + "AssetAlternateIdentifierNotPresentError), OR a " + "concurrent write to the same asset stream conflicted " + "(optimistic concurrency)." + ), + }, + status.HTTP_422_UNPROCESSABLE_CONTENT: { + "description": ( + "Path parameter or request body failed schema " + "validation (missing field, invalid kind enum, etc.)." + ), + }, + }, + summary="Remove an alternate identifier from an existing Asset's identifier set", +) +async def post_assets_remove_alternate_identifier( + asset_id: Annotated[UUID, Path(description="Target asset's id.")], + body: RemoveAssetAlternateIdentifierRequest, + 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)], +) -> None: + await handler( + RemoveAssetAlternateIdentifier( + asset_id=asset_id, + alternate_identifier=body.identifier.to_domain(), + ), + principal_id=principal_id, + correlation_id=cid, + surface_id=surface_id, + ) diff --git a/apps/api/src/cora/equipment/features/remove_asset_alternate_identifier/tool.py b/apps/api/src/cora/equipment/features/remove_asset_alternate_identifier/tool.py new file mode 100644 index 000000000..9dada80c5 --- /dev/null +++ b/apps/api/src/cora/equipment/features/remove_asset_alternate_identifier/tool.py @@ -0,0 +1,52 @@ +"""MCP tool for the `remove_asset_alternate_identifier` 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._alternate_identifier_body import AlternateIdentifierBody +from cora.equipment.features.remove_asset_alternate_identifier.command import ( + RemoveAssetAlternateIdentifier, +) +from cora.equipment.features.remove_asset_alternate_identifier.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 `remove_asset_alternate_identifier` tool on the given MCP server.""" + + @mcp.tool( + name="remove_asset_alternate_identifier", + description=( + "Remove an alternate identifier from an existing Asset's " + "identifier set by exact (kind, value) pair. Strict-not-" + "idempotent: rejects if the (kind, value) pair is not on " + "the asset. Rejects when the asset is Decommissioned." + ), + ) + async def remove_asset_alternate_identifier_tool( # pyright: ignore[reportUnusedFunction] + ctx: Context[Any, Any, Any], + asset_id: Annotated[ + UUID, + Field(description="Target asset's id."), + ], + identifier: Annotated[ + AlternateIdentifierBody, + Field(description="The alternate identifier to remove."), + ], + ) -> None: + handler = get_handler() + await handler( + RemoveAssetAlternateIdentifier( + asset_id=asset_id, + alternate_identifier=identifier.to_domain(), + ), + principal_id=get_mcp_principal_id(ctx), + correlation_id=current_correlation_id(), + surface_id=get_mcp_surface_id(), + ) diff --git a/apps/api/src/cora/equipment/projections/asset.py b/apps/api/src/cora/equipment/projections/asset.py index f9019fb54..63cef3212 100644 --- a/apps/api/src/cora/equipment/projections/asset.py +++ b/apps/api/src/cora/equipment/projections/asset.py @@ -1,19 +1,25 @@ """AssetSummaryProjection: folds the Asset aggregate's lifecycle + -hierarchy + condition events into the `proj_equipment_asset_summary` -read model that backs `GET /assets`. +hierarchy + condition + alternate-identifier events into the +`proj_equipment_asset_summary` read model that backs `GET /assets`. Subscribed events: - - AssetRegistered -> INSERT (lifecycle=Commissioned, - condition=Nominal; level + parent_id - from payload) - - AssetActivated -> UPDATE lifecycle=Active - - AssetDecommissioned -> UPDATE lifecycle=Decommissioned - - AssetMaintenanceEntered -> UPDATE lifecycle=Maintenance - - AssetMaintenanceExited -> UPDATE lifecycle=Active - - AssetRelocated -> UPDATE parent_id=to_parent_id - - AssetDegraded -> UPDATE condition=Degraded - - AssetFaulted -> UPDATE condition=Faulted - - AssetRestored -> UPDATE condition=Nominal + - AssetRegistered -> INSERT (lifecycle=Commissioned, + condition=Nominal; level + parent_id + + drawing trio + model_id + + alternate_identifiers from payload) + - AssetActivated -> UPDATE lifecycle=Active + - AssetDecommissioned -> UPDATE lifecycle=Decommissioned + - AssetMaintenanceEntered -> UPDATE lifecycle=Maintenance + - AssetMaintenanceExited -> UPDATE lifecycle=Active + - AssetRelocated -> UPDATE parent_id=to_parent_id + - AssetDegraded -> UPDATE condition=Degraded + - AssetFaulted -> UPDATE condition=Faulted + - AssetRestored -> UPDATE condition=Nominal + - AssetAlternateIdentifierAdded -> UPDATE append (kind, value) into + alternate_identifiers JSONB array, + de-duplicated and re-sorted + - AssetAlternateIdentifierRemoved -> UPDATE remove (kind, value) from + alternate_identifiers JSONB array NOT subscribed: - AssetFamilyAdded / AssetFamilyRemoved: these describe @@ -21,8 +27,12 @@ feed the sibling `AssetFamilyMembershipProjection` (`proj_equipment_asset_family_membership`). -All branches idempotent (INSERT uses ON CONFLICT DO NOTHING; UPDATEs -write fixed values per event type so re-application is a no-op). +All branches idempotent. INSERT uses ON CONFLICT DO NOTHING. UPDATEs +for lifecycle / condition / parent write fixed values per event type so +re-application is a no-op. The alternate-identifier add path dedupes +(uniqueness keyed on the JSONB element identity); the remove path +filters by (kind, value) so re-application is a no-op once the row is +gone. """ # pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false @@ -30,7 +40,7 @@ from __future__ import annotations from datetime import datetime -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from uuid import UUID if TYPE_CHECKING: @@ -43,8 +53,9 @@ INSERT INTO proj_equipment_asset_summary (asset_id, name, level, lifecycle, condition, parent_id, drawing_system, drawing_number, drawing_revision, model_id, - created_at) -VALUES ($1, $2, $3, 'Commissioned', 'Nominal', $4, $5, $6, $7, $8, $9) + alternate_identifiers, created_at) +VALUES ($1, $2, $3, 'Commissioned', 'Nominal', $4, $5, $6, $7, $8, + $9, $10) ON CONFLICT (asset_id) DO NOTHING """ @@ -66,6 +77,48 @@ WHERE asset_id = $1 """ +# Append-and-re-sort in a single SQL statement: union the existing +# array with the new singleton, deduplicate on (kind, value), and +# re-aggregate in canonical (kind, value) order so the column stays +# byte-stable across replays. The DISTINCT ON pair matches the +# (kind, value) identity declared by the AlternateIdentifier VO. +_UPDATE_ALTERNATE_IDENTIFIER_ADDED_SQL = """ +UPDATE proj_equipment_asset_summary +SET alternate_identifiers = COALESCE( + ( + SELECT jsonb_agg(elem ORDER BY elem->>'kind', elem->>'value') + FROM ( + SELECT DISTINCT ON (elem->>'kind', elem->>'value') elem + FROM jsonb_array_elements( + alternate_identifiers || jsonb_build_array( + jsonb_build_object('kind', $2::text, 'value', $3::text) + ) + ) AS elem + ORDER BY elem->>'kind', elem->>'value' + ) AS dedup + ), + '[]'::jsonb + ), + updated_at = now() +WHERE asset_id = $1 +""" + +# Filter-out the matching (kind, value) element. Empty result collapses +# to `[]` via COALESCE so the NOT NULL constraint holds. +_UPDATE_ALTERNATE_IDENTIFIER_REMOVED_SQL = """ +UPDATE proj_equipment_asset_summary +SET alternate_identifiers = COALESCE( + ( + SELECT jsonb_agg(elem ORDER BY elem->>'kind', elem->>'value') + FROM jsonb_array_elements(alternate_identifiers) AS elem + WHERE NOT (elem->>'kind' = $2::text AND elem->>'value' = $3::text) + ), + '[]'::jsonb + ), + updated_at = now() +WHERE asset_id = $1 +""" + class AssetSummaryProjection: """Maintains the `proj_equipment_asset_summary` read model.""" @@ -82,6 +135,8 @@ class AssetSummaryProjection: "AssetDegraded", "AssetFaulted", "AssetRestored", + "AssetAlternateIdentifierAdded", + "AssetAlternateIdentifierRemoved", } ) @@ -103,6 +158,9 @@ async def apply( drawing_revision = drawing.get("revision") if drawing is not None else None model_id_raw = event.payload.get("model_id") model_id = UUID(model_id_raw) if model_id_raw else None + alternate_identifiers_list = _canonical_alternate_identifiers_list( + event.payload.get("alternate_identifiers") + ) await conn.execute( _INSERT_ASSET_SQL, UUID(event.payload["asset_id"]), @@ -113,6 +171,7 @@ async def apply( drawing_number, drawing_revision, model_id, + alternate_identifiers_list, datetime.fromisoformat(event.payload["occurred_at"]), ) case "AssetActivated" | "AssetMaintenanceExited": @@ -133,6 +192,22 @@ async def apply( await self._update_condition(event, conn, "Faulted") case "AssetRestored": await self._update_condition(event, conn, "Nominal") + case "AssetAlternateIdentifierAdded": + identifier = event.payload["alternate_identifier"] + await conn.execute( + _UPDATE_ALTERNATE_IDENTIFIER_ADDED_SQL, + UUID(event.payload["asset_id"]), + identifier["kind"], + identifier["value"], + ) + case "AssetAlternateIdentifierRemoved": + identifier = event.payload["alternate_identifier"] + await conn.execute( + _UPDATE_ALTERNATE_IDENTIFIER_REMOVED_SQL, + UUID(event.payload["asset_id"]), + identifier["kind"], + identifier["value"], + ) case _: pass @@ -161,6 +236,34 @@ async def _update_condition( ) +def _canonical_alternate_identifiers_list( + raw: list[dict[str, Any]] | None, +) -> list[dict[str, Any]]: + """Serialize the AssetRegistered payload's alternate_identifiers + into a canonical list of dicts the asyncpg jsonb codec can encode. + + Sorted by (kind, value) so the same logical set produces the same + byte sequence on disk regardless of payload insertion order. Empty + or missing payload key produces `[]`. The events.py to_payload + already sorts at write time per the design memo; the projection + re-sorts defensively so a hand-crafted replay-fixture event with + out-of-order entries still lands canonical. + + Returns a Python `list[dict]` rather than a pre-serialized JSON + string: the asyncpg pool registers a jsonb codec that runs + `json.dumps` on every parameter bound to a jsonb column. Passing + an already-stringified `"[]"` would be wrapped a second time into + a JSON-string scalar, breaking the partial GIN index's + `jsonb_array_length(alternate_identifiers) > 0` predicate. + """ + if not raw: + return [] + return sorted( + ({"kind": str(item["kind"]), "value": str(item["value"])} for item in raw), + key=lambda item: (item["kind"], item["value"]), + ) + + _SELECT_ASSET_LIFECYCLE_SQL = """ SELECT lifecycle FROM proj_equipment_asset_summary diff --git a/apps/api/src/cora/equipment/routes.py b/apps/api/src/cora/equipment/routes.py index f19138415..d7a7380b8 100644 --- a/apps/api/src/cora/equipment/routes.py +++ b/apps/api/src/cora/equipment/routes.py @@ -39,7 +39,10 @@ from cora.equipment.aggregates._placement import InvalidPlacementError from cora.equipment.aggregates.asset import ( AssetAlreadyExistsError, + AssetAlternateIdentifierAlreadyPresentError, + AssetAlternateIdentifierNotPresentError, AssetCannotActivateError, + AssetCannotAddAlternateIdentifierError, AssetCannotAddFamilyError, AssetCannotAddPortError, AssetCannotDecommissionError, @@ -50,6 +53,7 @@ AssetCannotRemovePortError, AssetModelMismatchError, AssetNotFoundError, + InvalidAlternateIdentifierValueError, InvalidAssetNameError, InvalidAssetParentError, InvalidAssetPortNameError, @@ -112,6 +116,7 @@ from cora.equipment.errors import UnauthorizedError from cora.equipment.features import ( activate_asset, + add_asset_alternate_identifier, add_asset_family, add_asset_port, add_model_family, @@ -137,6 +142,7 @@ register_frame, register_mount, relocate_asset, + remove_asset_alternate_identifier, remove_asset_family, remove_asset_port, remove_model_family, @@ -244,6 +250,8 @@ def register_equipment_routes(app: FastAPI) -> None: app.include_router(update_asset_settings.router) app.include_router(add_asset_port.router) app.include_router(remove_asset_port.router) + app.include_router(add_asset_alternate_identifier.router) + app.include_router(remove_asset_alternate_identifier.router) app.include_router(get_asset.router) app.include_router(get_asset_integration_view.router) app.include_router(list_assets.router) @@ -267,6 +275,7 @@ def register_equipment_routes(app: FastAPI) -> None: InvalidAssetPortNameError, InvalidAssetPortSignalTypeError, InvalidAssetSettingsError, + InvalidAlternateIdentifierValueError, InvalidFrameNameError, InvalidFrameRevisionError, InvalidFrameRootError, @@ -310,6 +319,9 @@ def register_equipment_routes(app: FastAPI) -> None: AssetCannotRemoveFamilyError, AssetCannotAddPortError, AssetCannotRemovePortError, + AssetAlternateIdentifierAlreadyPresentError, + AssetAlternateIdentifierNotPresentError, + AssetCannotAddAlternateIdentifierError, AssetModelMismatchError, FamilyCannotVersionError, FamilyCannotDeprecateError, diff --git a/apps/api/src/cora/equipment/tools.py b/apps/api/src/cora/equipment/tools.py index d94d0030c..0762979d8 100644 --- a/apps/api/src/cora/equipment/tools.py +++ b/apps/api/src/cora/equipment/tools.py @@ -12,6 +12,9 @@ from mcp.server.fastmcp import FastMCP from cora.equipment.features.activate_asset import tool as activate_asset_tool +from cora.equipment.features.add_asset_alternate_identifier import ( + tool as add_asset_alternate_identifier_tool, +) from cora.equipment.features.add_asset_family import ( tool as add_asset_family_tool, ) @@ -45,6 +48,9 @@ from cora.equipment.features.register_frame import tool as register_frame_tool from cora.equipment.features.register_mount import tool as register_mount_tool from cora.equipment.features.relocate_asset import tool as relocate_asset_tool +from cora.equipment.features.remove_asset_alternate_identifier import ( + tool as remove_asset_alternate_identifier_tool, +) from cora.equipment.features.remove_asset_family import ( tool as remove_asset_family_tool, ) @@ -178,6 +184,14 @@ def register_equipment_tools( mcp, get_handler=lambda: get_handlers().remove_asset_port, ) + add_asset_alternate_identifier_tool.register( + mcp, + get_handler=lambda: get_handlers().add_asset_alternate_identifier, + ) + remove_asset_alternate_identifier_tool.register( + mcp, + get_handler=lambda: get_handlers().remove_asset_alternate_identifier, + ) 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 a01cdb987..aa89813ad 100644 --- a/apps/api/src/cora/equipment/wire.py +++ b/apps/api/src/cora/equipment/wire.py @@ -33,6 +33,7 @@ from cora.equipment.features import ( activate_asset, + add_asset_alternate_identifier, add_asset_family, add_asset_port, add_model_family, @@ -58,6 +59,7 @@ register_frame, register_mount, relocate_asset, + remove_asset_alternate_identifier, remove_asset_family, remove_asset_port, remove_model_family, @@ -130,6 +132,8 @@ class EquipmentHandlers: update_asset_settings: update_asset_settings.Handler add_asset_port: add_asset_port.Handler remove_asset_port: remove_asset_port.Handler + add_asset_alternate_identifier: add_asset_alternate_identifier.Handler + remove_asset_alternate_identifier: remove_asset_alternate_identifier.Handler get_asset: get_asset.Handler get_asset_integration_view: get_asset_integration_view.Handler list_assets: list_assets.Handler @@ -309,6 +313,16 @@ def wire_equipment(deps: Kernel) -> EquipmentHandlers: command_name="RemoveAssetPort", bc=_BC, ), + add_asset_alternate_identifier=with_tracing( + add_asset_alternate_identifier.bind(deps), + command_name="AddAssetAlternateIdentifier", + bc=_BC, + ), + remove_asset_alternate_identifier=with_tracing( + remove_asset_alternate_identifier.bind(deps), + command_name="RemoveAssetAlternateIdentifier", + bc=_BC, + ), get_asset=with_tracing( get_asset.bind(deps), command_name="GetAsset", diff --git a/apps/api/tests/architecture/test_equipment_error_classes_have_producers.py b/apps/api/tests/architecture/test_equipment_error_classes_have_producers.py new file mode 100644 index 000000000..cc245b5f0 --- /dev/null +++ b/apps/api/tests/architecture/test_equipment_error_classes_have_producers.py @@ -0,0 +1,192 @@ +"""Every Equipment `CannotError` class has at least one producer. + +Background: the alternate-identifier slice pair landed with a +state-tier `AssetCannotAddAlternateIdentifierError` class declared +for a lifecycle guard the deciders never raise. The route module +registered the class in its 409 mapping, the docstring described +the guard, and the design memo Lock E pinned it, but the actual +decider bodies were guard-free. A reader walking from the state +module to the routes module would conclude the guard ships; in +fact, a Decommissioned asset accepts add / remove unchecked. The +gate review caught the drift on close inspection of the decider +bodies; no fitness function would have surfaced it. + +This module pins the rule that drift implies: every state-declared +class matching ``CannotError`` under any Equipment +aggregate's ``state.py`` MUST have AT LEAST ONE textual ``raise +(`` site somewhere in ``cora.equipment.features.*`` (slice +deciders, handlers, route handlers; tests excluded). + +Scope: Equipment BC only. The same rule generalizes to every BC +that follows the per-transition error class taxonomy, but the +audit that motivated this fitness covered Equipment exclusively; +generalize when a second BC accrues a producer-less class. + +Enumeration is git-aware via ``tracked_python_files()`` per the +worktree pre-commit-stash rationale in ``conftest.py``: half- +staged files must stay invisible to this scan, otherwise in-flight +slices would false-fail before the author wires up the raise site. + +``GRANDFATHERED_PRODUCERLESS`` carries any class that ships +declared-but-not-raised on purpose. Each entry MUST cite the +finding it grandfathers and the design lock that justifies the +class staying alive. The set is currently empty: every Equipment +``CannotError`` class is raised by at least one slice +under ``cora.equipment.features.*``. Add a new entry only when a +design lock pins a class that ships ahead of its producer. +""" + +from __future__ import annotations + +import ast +import re +from typing import TYPE_CHECKING + +import pytest + +from tests.architecture.conftest import CORA_ROOT, tracked_python_files + +if TYPE_CHECKING: + from pathlib import Path + + +_EQUIPMENT_ROOT = CORA_ROOT / "equipment" +_AGGREGATES_ROOT = _EQUIPMENT_ROOT / "aggregates" +_FEATURES_ROOT = _EQUIPMENT_ROOT / "features" + +# Aggregate directories whose ``state.py`` declares per-transition error +# classes that the slice deciders are expected to raise. Listed +# explicitly rather than auto-discovered so adding a new aggregate +# requires a deliberate edit to this fitness (forcing the question +# "does this aggregate also follow the producer convention?"). +_AGGREGATES_WITH_TRANSITION_ERRORS: tuple[str, ...] = ( + "asset", + "family", + "frame", + "model", + "mount", +) + +# Pattern matches the canonical state-transition error taxonomy +# (``CannotError``) declared at the docs/reference/patterns.md +# Rejections table. Kept loose on the leading noun (`[A-Z][A-Za-z]+`) +# so it matches Asset / Model / Family / Frame / Mount uniformly; the +# verb segment likewise stays loose so future verbs land without a +# regex update. +_CANNOT_ERROR_PATTERN = re.compile(r"^[A-Z][A-Za-z]+Cannot[A-Z][A-Za-z]+Error$") + + +# Entries are bare class names (each class is unique across the +# Equipment BC by construction; per-aggregate prefix disambiguates). +# Each entry MUST cite the gate-review finding it grandfathers and +# the design lock that justifies the class staying declared-but- +# unraised until the follow-up lands. Currently empty. +GRANDFATHERED_PRODUCERLESS: frozenset[str] = frozenset() + + +def _cannot_error_class_names(state_path: Path) -> frozenset[str]: + """Top-level ``CannotError`` class defs in one ``state.py``.""" + tree = ast.parse(state_path.read_text()) + return frozenset( + node.name + for node in tree.body + if isinstance(node, ast.ClassDef) and _CANNOT_ERROR_PATTERN.match(node.name) + ) + + +def _declared_cannot_classes() -> frozenset[str]: + """All ``CannotError`` classes declared across in-scope state.py files. + + Filtered through ``tracked_python_files()`` so half-staged + additions stay invisible until ``git add``ed. + """ + tracked = tracked_python_files() + declared: set[str] = set() + for aggregate in _AGGREGATES_WITH_TRANSITION_ERRORS: + state_path = _AGGREGATES_ROOT / aggregate / "state.py" + if state_path not in tracked: + continue + declared |= _cannot_error_class_names(state_path) + return frozenset(declared) + + +def _feature_source_text() -> str: + """Concatenated source of every tracked ``features/*`` Python file. + + A single concatenated blob keeps the per-class scan linear instead + of N classes times M files. Tests under ``tests/`` are excluded by + ``tracked_python_files()`` (which scopes to ``src/cora``). + """ + tracked = tracked_python_files() + sources: list[str] = [] + for path in sorted(tracked): + try: + path.relative_to(_FEATURES_ROOT) + except ValueError: + continue + sources.append(path.read_text()) + return "\n".join(sources) + + +def _producer_sites(class_name: str, blob: str) -> bool: + """Whether a textual ``raise (`` site exists in the blob.""" + return f"raise {class_name}(" in blob + + +@pytest.mark.architecture +@pytest.mark.parametrize("class_name", sorted(_declared_cannot_classes())) +def test_cannot_error_has_at_least_one_producer(class_name: str) -> None: + if class_name in GRANDFATHERED_PRODUCERLESS: + pytest.skip( + f"{class_name} is grandfathered as producer-less; " + "see GRANDFATHERED_PRODUCERLESS for the finding cited" + ) + blob = _feature_source_text() + assert _producer_sites(class_name, blob), ( + f"{class_name} is declared in an Equipment aggregate's state.py " + "but no slice under cora.equipment.features.* contains a " + f"`raise {class_name}(` site.\n" + "Either add the missing raise to the relevant decider, OR remove " + "the class from state.py and the BC's error-to-status mapping.\n" + "Consumer-without-producer drift surfaces in API contract docs " + "(the routes module advertises a 409 the decider can never emit) " + "and in the design memo (which describes a guard that never runs)." + ) + + +@pytest.mark.architecture +def test_grandfathered_producerless_entries_still_declared() -> None: + """``GRANDFATHERED_PRODUCERLESS`` entries must still exist in state.py. + + Drift catcher: once Path A (or the equivalent follow-up) restores + the producer, the per-class test above passes naturally and the + grandfather entry becomes dead weight. Re-checking declaration + forces the entry to be removed alongside the producer-restoration + commit. + """ + declared = _declared_cannot_classes() + for entry in GRANDFATHERED_PRODUCERLESS: + assert entry in declared, ( + f"GRANDFATHERED_PRODUCERLESS entry {entry!r}: class is no " + "longer declared in any Equipment state.py; remove the " + "entry (the follow-up that restored the producer shipped)." + ) + + +@pytest.mark.architecture +def test_grandfathered_producerless_entries_are_actually_producerless() -> None: + """``GRANDFATHERED_PRODUCERLESS`` entries must STILL lack a producer. + + Drift catcher mirror of the prior test: once a producer lands, the + grandfather entry no longer protects anything. Re-running the + feature-source scan forces the entry to be removed in the same + commit as the producer restoration. + """ + blob = _feature_source_text() + for entry in GRANDFATHERED_PRODUCERLESS: + assert not _producer_sites(entry, blob), ( + f"GRANDFATHERED_PRODUCERLESS entry {entry!r}: producer now " + "exists under cora.equipment.features.*; remove the entry " + "from GRANDFATHERED_PRODUCERLESS in the same commit that " + "added the raise site." + ) diff --git a/apps/api/tests/contract/test_add_asset_alternate_identifier_endpoint.py b/apps/api/tests/contract/test_add_asset_alternate_identifier_endpoint.py new file mode 100644 index 000000000..1af7753e1 --- /dev/null +++ b/apps/api/tests/contract/test_add_asset_alternate_identifier_endpoint.py @@ -0,0 +1,137 @@ +"""Contract tests for `POST /assets/{id}/add-alternate-identifier`. + +Mirror of `test_remove_asset_alternate_identifier_endpoint.py`. +Verifies HTTP shape: 204 on happy path, 409 on strict-not- +idempotent duplicate, 409 when the asset is Decommissioned +(lifecycle guard mirrors `add_asset_port`), 404 on missing asset, +422 on schema-validation failure, 400 on VO-validation failure. +""" + +from uuid import uuid4 + +import pytest +from fastapi.testclient import TestClient + +from cora.api.main import create_app + + +def _register_asset(client: TestClient) -> str: + response = client.post( + "/assets", + json={"name": "Detector-X", "level": "Device", "parent_id": str(uuid4())}, + ) + assert response.status_code == 201, response.text + asset_id: str = response.json()["asset_id"] + return asset_id + + +@pytest.mark.contract +def test_post_add_alternate_identifier_returns_204_on_happy_path() -> None: + with TestClient(create_app()) as client: + asset_id = _register_asset(client) + response = client.post( + f"/assets/{asset_id}/add-alternate-identifier", + json={"identifier": {"kind": "SerialNumber", "value": "XYZ-001"}}, + ) + assert response.status_code == 204 + + +@pytest.mark.contract +def test_post_add_alternate_identifier_returns_409_when_pair_already_present() -> None: + """Strict-not-idempotent: a duplicate (kind, value) pair returns 409.""" + with TestClient(create_app()) as client: + asset_id = _register_asset(client) + first = client.post( + f"/assets/{asset_id}/add-alternate-identifier", + json={"identifier": {"kind": "SerialNumber", "value": "XYZ-001"}}, + ) + assert first.status_code == 204 + second = client.post( + f"/assets/{asset_id}/add-alternate-identifier", + json={"identifier": {"kind": "SerialNumber", "value": "XYZ-001"}}, + ) + assert second.status_code == 409 + assert "XYZ-001" in second.json()["detail"] + + +@pytest.mark.contract +def test_post_add_alternate_identifier_allows_same_value_under_different_kind() -> None: + """Uniqueness keyed on the (kind, value) pair; same value under a + different kind is a distinct identifier.""" + with TestClient(create_app()) as client: + asset_id = _register_asset(client) + first = client.post( + f"/assets/{asset_id}/add-alternate-identifier", + json={"identifier": {"kind": "SerialNumber", "value": "ABC-9"}}, + ) + assert first.status_code == 204 + second = client.post( + f"/assets/{asset_id}/add-alternate-identifier", + json={"identifier": {"kind": "InventoryNumber", "value": "ABC-9"}}, + ) + assert second.status_code == 204 + + +@pytest.mark.contract +def test_post_add_alternate_identifier_returns_409_when_asset_decommissioned() -> None: + """Lifecycle guard mirrors `add_asset_port`: a Decommissioned asset + is out of inventory; identifier changes are not allowed.""" + 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}/add-alternate-identifier", + json={"identifier": {"kind": "SerialNumber", "value": "XYZ-001"}}, + ) + assert response.status_code == 409 + assert "Decommissioned" in response.json()["detail"] + + +@pytest.mark.contract +def test_post_add_alternate_identifier_returns_404_for_missing_asset() -> None: + missing = str(uuid4()) + with TestClient(create_app()) as client: + response = client.post( + f"/assets/{missing}/add-alternate-identifier", + json={"identifier": {"kind": "SerialNumber", "value": "XYZ-001"}}, + ) + assert response.status_code == 404 + + +@pytest.mark.contract +def test_post_add_alternate_identifier_returns_422_for_missing_required_field() -> None: + with TestClient(create_app()) as client: + asset_id = _register_asset(client) + response = client.post( + f"/assets/{asset_id}/add-alternate-identifier", + json={"identifier": {"kind": "SerialNumber"}}, # missing value + ) + assert response.status_code == 422 + + +@pytest.mark.contract +def test_post_add_alternate_identifier_returns_422_for_invalid_kind() -> None: + """`ROR` is a Manufacturer-level scheme on Model; it is NOT in the + AlternateIdentifierKind enum on the Asset instance side.""" + with TestClient(create_app()) as client: + asset_id = _register_asset(client) + response = client.post( + f"/assets/{asset_id}/add-alternate-identifier", + json={"identifier": {"kind": "ROR", "value": "XYZ-001"}}, + ) + assert response.status_code == 422 + + +@pytest.mark.contract +def test_post_add_alternate_identifier_returns_400_for_whitespace_only_value() -> None: + """Pydantic `min_length=1` catches "" but lets " " through; the + AlternateIdentifier VO then rejects with + InvalidAlternateIdentifierValueError -> 400.""" + with TestClient(create_app()) as client: + asset_id = _register_asset(client) + response = client.post( + f"/assets/{asset_id}/add-alternate-identifier", + json={"identifier": {"kind": "SerialNumber", "value": " "}}, + ) + assert response.status_code == 400 diff --git a/apps/api/tests/contract/test_add_asset_alternate_identifier_mcp_tool.py b/apps/api/tests/contract/test_add_asset_alternate_identifier_mcp_tool.py new file mode 100644 index 000000000..8183a5e5e --- /dev/null +++ b/apps/api/tests/contract/test_add_asset_alternate_identifier_mcp_tool.py @@ -0,0 +1,137 @@ +"""Contract tests for the `add_asset_alternate_identifier` MCP tool.""" + +from uuid import UUID, uuid4 + +import pytest +from fastapi.testclient import TestClient + +from cora.api.main import create_app +from tests.contract._mcp_helpers import open_session, parse_sse_data + + +def _register_asset_via_tool(client: TestClient, headers: dict[str, str]) -> UUID: + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "register_asset", + "arguments": { + "name": "Detector-X", + "level": "Device", + "parent_id": str(uuid4()), + }, + }, + }, + headers=headers, + ) + body = parse_sse_data(response.text) + return UUID(body["result"]["structuredContent"]["asset_id"]) + + +@pytest.mark.contract +def test_mcp_lists_add_asset_alternate_identifier_tool() -> None: + with TestClient(create_app()) as client: + headers = open_session(client) + response = client.post( + "/mcp", + json={"jsonrpc": "2.0", "id": 99, "method": "tools/list"}, + headers=headers, + ) + body = parse_sse_data(response.text) + tool_names = [t["name"] for t in body["result"]["tools"]] + assert "add_asset_alternate_identifier" in tool_names + + +@pytest.mark.contract +def test_mcp_add_asset_alternate_identifier_succeeds_on_happy_path() -> None: + with TestClient(create_app()) as client: + headers = open_session(client) + asset_id = _register_asset_via_tool(client, headers) + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 4, + "method": "tools/call", + "params": { + "name": "add_asset_alternate_identifier", + "arguments": { + "asset_id": str(asset_id), + "identifier": {"kind": "SerialNumber", "value": "XYZ-001"}, + }, + }, + }, + headers=headers, + ) + body = parse_sse_data(response.text) + assert body["result"]["isError"] is False + + +@pytest.mark.contract +def test_mcp_add_asset_alternate_identifier_returns_iserror_for_unknown_asset() -> None: + with TestClient(create_app()) as client: + headers = open_session(client) + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 5, + "method": "tools/call", + "params": { + "name": "add_asset_alternate_identifier", + "arguments": { + "asset_id": str(uuid4()), + "identifier": {"kind": "SerialNumber", "value": "XYZ-001"}, + }, + }, + }, + headers=headers, + ) + body = parse_sse_data(response.text) + assert body["result"]["isError"] is True + assert "not found" in body["result"]["content"][0]["text"].lower() + + +@pytest.mark.contract +def test_mcp_add_asset_alternate_identifier_returns_iserror_when_decommissioned() -> None: + """Lifecycle guard mirrors `add_asset_port`: a Decommissioned asset + rejects identifier changes; surfaces as isError=true.""" + with TestClient(create_app()) as client: + headers = open_session(client) + asset_id = _register_asset_via_tool(client, headers) + decom = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 6, + "method": "tools/call", + "params": { + "name": "decommission_asset", + "arguments": {"asset_id": str(asset_id)}, + }, + }, + headers=headers, + ) + assert parse_sse_data(decom.text)["result"]["isError"] is False + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 7, + "method": "tools/call", + "params": { + "name": "add_asset_alternate_identifier", + "arguments": { + "asset_id": str(asset_id), + "identifier": {"kind": "SerialNumber", "value": "XYZ-001"}, + }, + }, + }, + headers=headers, + ) + body = parse_sse_data(response.text) + assert body["result"]["isError"] is True + assert "Decommissioned" in body["result"]["content"][0]["text"] diff --git a/apps/api/tests/contract/test_register_asset_idempotency.py b/apps/api/tests/contract/test_register_asset_idempotency.py index ac08f45d7..a2976f21d 100644 --- a/apps/api/tests/contract/test_register_asset_idempotency.py +++ b/apps/api/tests/contract/test_register_asset_idempotency.py @@ -246,3 +246,118 @@ async def _stub(_event_store: object, requested_id: UUID) -> Model | None: assert r2.status_code == 422 detail = r2.json().get("detail", "").lower() assert "idempotency-key" in detail + + +# ---------- alternate_identifiers body field ---------- + + +@pytest.mark.contract +def test_post_assets_with_alternate_identifiers_returns_201() -> None: + """Happy path: body carries optional alternate_identifiers list of + (kind, value) tuples; handler appends AssetRegistered and returns 201.""" + body: dict[str, object] = { + "name": "APS-2BM-RotaryStage", + "level": "Device", + "parent_id": str(uuid4()), + "alternate_identifiers": [ + {"kind": "SerialNumber", "value": "ANT130L-12345"}, + {"kind": "InventoryNumber", "value": "APS-2BM-RS-001"}, + ], + } + with TestClient(create_app()) as client: + response = client.post("/assets", json=body) + + assert response.status_code == 201 + UUID(response.json()["asset_id"]) # parses + + +@pytest.mark.contract +def test_post_assets_with_invalid_alternate_identifier_kind_returns_422() -> None: + """Pydantic enum validation: an unknown kind value fails schema + validation before the handler runs. ROR / GRID / ISNI deliberately + belong on Model.manufacturer.identifier_type, NOT on Asset + alternate identifiers.""" + body: dict[str, object] = { + "name": "APS", + "level": "Site", + "parent_id": str(uuid4()), + "alternate_identifiers": [{"kind": "ROR", "value": "01y2jtd41"}], + } + with TestClient(create_app()) as client: + response = client.post("/assets", json=body) + + assert response.status_code == 422 + + +@pytest.mark.contract +def test_post_assets_with_missing_alternate_identifier_value_returns_422() -> None: + """Pydantic min_length: a body that omits `value` from one of the + alternate-identifier entries fails schema validation.""" + body: dict[str, object] = { + "name": "APS", + "level": "Site", + "parent_id": str(uuid4()), + "alternate_identifiers": [{"kind": "SerialNumber"}], + } + with TestClient(create_app()) as client: + response = client.post("/assets", json=body) + + assert response.status_code == 422 + + +@pytest.mark.contract +def test_post_assets_with_alternate_identifiers_same_key_and_body_returns_same_asset_id() -> None: + """Idempotency-Key retry with identical body (including the + alternate_identifiers list) returns the cached asset_id.""" + body: dict[str, object] = { + "name": "APS-2BM-RotaryStage", + "level": "Device", + "parent_id": str(uuid4()), + "alternate_identifiers": [ + {"kind": "SerialNumber", "value": "ANT130L-12345"}, + ], + } + headers = {"Idempotency-Key": "ak-alt"} + with TestClient(create_app()) as client: + r1 = client.post("/assets", json=body, headers=headers) + r2 = client.post("/assets", json=body, headers=headers) + + assert r1.status_code == 201 + assert r2.status_code == 201 + assert r1.json()["asset_id"] == r2.json()["asset_id"] + + +@pytest.mark.contract +def test_post_assets_same_key_different_alternate_identifiers_returns_422() -> None: + """Same Idempotency-Key + same body EXCEPT for `alternate_identifiers` + surfaces as 422. The cross-BC `hash_command` includes the field + (RegisterAsset is a frozen dataclass; canonical hash covers every + field). Two distinct identifier sets under the same Idempotency-Key + must surface as a key/body conflict.""" + parent = str(uuid4()) + base_body: dict[str, object] = { + "name": "APS-2BM-RotaryStage", + "level": "Device", + "parent_id": parent, + } + body_a = { + **base_body, + "alternate_identifiers": [ + {"kind": "SerialNumber", "value": "ANT130L-AAAA"}, + ], + } + body_b = { + **base_body, + "alternate_identifiers": [ + {"kind": "SerialNumber", "value": "ANT130L-BBBB"}, + ], + } + headers = {"Idempotency-Key": "ak-alt-diff"} + with TestClient(create_app()) as client: + r1 = client.post("/assets", json=body_a, headers=headers) + r2 = client.post("/assets", json=body_b, headers=headers) + + assert r1.status_code == 201 + assert r2.status_code == 422 + detail = r2.json().get("detail", "").lower() + assert "idempotency-key" in detail diff --git a/apps/api/tests/contract/test_register_asset_mcp_tool.py b/apps/api/tests/contract/test_register_asset_mcp_tool.py index 8fcc76290..64360aebe 100644 --- a/apps/api/tests/contract/test_register_asset_mcp_tool.py +++ b/apps/api/tests/contract/test_register_asset_mcp_tool.py @@ -320,3 +320,69 @@ def test_mcp_register_asset_tool_omits_model_id_arg_remains_201_path() -> None: result = body["result"] assert result["isError"] is False UUID(result["structuredContent"]["asset_id"]) # parses + + +# ---------- alternate_identifiers arg ---------- + + +@pytest.mark.contract +def test_mcp_register_asset_tool_accepts_alternate_identifiers_arg() -> None: + """Happy path: alternate_identifiers arg with one (kind, value) entry + returns a structured asset_id. The MCP tool mirrors the REST body + schema; the closed-enum kind is enforced at the FastMCP arg layer.""" + with TestClient(create_app()) as client: + session_headers = open_session(client) + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 12, + "method": "tools/call", + "params": { + "name": "register_asset", + "arguments": { + "name": "APS-2BM-RotaryStage", + "level": "Device", + "parent_id": str(uuid4()), + "alternate_identifiers": [ + {"kind": "SerialNumber", "value": "ANT130L-12345"}, + {"kind": "InventoryNumber", "value": "APS-2BM-RS-001"}, + ], + }, + }, + }, + headers=session_headers, + ) + body = parse_sse_data(response.text) + result = body["result"] + assert result["isError"] is False + UUID(result["structuredContent"]["asset_id"]) # parses + + +@pytest.mark.contract +def test_mcp_register_asset_tool_returns_iserror_on_invalid_alternate_identifier_kind() -> None: + """An unknown `kind` value (for example, ROR which lives on Model + not Asset) surfaces as isError before the handler runs. The closed + StrEnum vocabulary is enforced at the FastMCP arg layer.""" + with TestClient(create_app()) as client: + session_headers = open_session(client) + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 13, + "method": "tools/call", + "params": { + "name": "register_asset", + "arguments": { + "name": "APS", + "level": "Site", + "parent_id": str(uuid4()), + "alternate_identifiers": [{"kind": "ROR", "value": "01y2jtd41"}], + }, + }, + }, + headers=session_headers, + ) + body = parse_sse_data(response.text) + assert body["result"]["isError"] is True diff --git a/apps/api/tests/contract/test_remove_asset_alternate_identifier_endpoint.py b/apps/api/tests/contract/test_remove_asset_alternate_identifier_endpoint.py new file mode 100644 index 000000000..de9e3fa96 --- /dev/null +++ b/apps/api/tests/contract/test_remove_asset_alternate_identifier_endpoint.py @@ -0,0 +1,136 @@ +"""Contract tests for `POST /assets/{id}/remove-alternate-identifier`. + +Mirror of `test_add_remove_asset_port_endpoints.py`'s remove section. +Verifies HTTP shape: 204 on happy path, 409 on +strict-not-idempotent / Decommissioned, 404 on missing asset, +422 on schema-validation failure, 400 on VO-validation failure. +""" + +from uuid import uuid4 + +import pytest +from fastapi.testclient import TestClient + +from cora.api.main import create_app + + +def _register_asset(client: TestClient) -> str: + response = client.post( + "/assets", + json={"name": "Detector-X", "level": "Device", "parent_id": str(uuid4())}, + ) + assert response.status_code == 201, response.text + asset_id: str = response.json()["asset_id"] + return asset_id + + +def _add_alternate_identifier( + client: TestClient, asset_id: str, *, kind: str = "SerialNumber", value: str = "XYZ-001" +) -> None: + response = client.post( + f"/assets/{asset_id}/add-alternate-identifier", + json={"identifier": {"kind": kind, "value": value}}, + ) + assert response.status_code == 204, response.text + + +@pytest.mark.contract +def test_post_remove_alternate_identifier_returns_204_on_happy_path() -> None: + with TestClient(create_app()) as client: + asset_id = _register_asset(client) + _add_alternate_identifier(client, asset_id) + response = client.post( + f"/assets/{asset_id}/remove-alternate-identifier", + json={"identifier": {"kind": "SerialNumber", "value": "XYZ-001"}}, + ) + assert response.status_code == 204 + + +@pytest.mark.contract +def test_post_remove_alternate_identifier_returns_409_when_pair_not_found() -> None: + """Strict-not-idempotent: removing without a prior add returns 409.""" + with TestClient(create_app()) as client: + asset_id = _register_asset(client) + response = client.post( + f"/assets/{asset_id}/remove-alternate-identifier", + json={"identifier": {"kind": "SerialNumber", "value": "XYZ-001"}}, + ) + assert response.status_code == 409 + + +@pytest.mark.contract +def test_post_remove_alternate_identifier_returns_409_when_kind_differs() -> None: + """Exact (kind, value) pair: same value under wrong kind is a miss.""" + with TestClient(create_app()) as client: + asset_id = _register_asset(client) + _add_alternate_identifier(client, asset_id, kind="SerialNumber", value="ABC-9") + response = client.post( + f"/assets/{asset_id}/remove-alternate-identifier", + json={"identifier": {"kind": "InventoryNumber", "value": "ABC-9"}}, + ) + assert response.status_code == 409 + + +@pytest.mark.contract +def test_post_remove_alternate_identifier_returns_409_when_asset_decommissioned() -> None: + """Lifecycle guard mirrors `remove_asset_port`: a Decommissioned + asset is out of inventory; identifier changes are not allowed. + Uses the shared `AssetCannotAddAlternateIdentifierError` class.""" + with TestClient(create_app()) as client: + asset_id = _register_asset(client) + _add_alternate_identifier(client, asset_id) + decom = client.post(f"/assets/{asset_id}/decommission") + assert decom.status_code == 204 + response = client.post( + f"/assets/{asset_id}/remove-alternate-identifier", + json={"identifier": {"kind": "SerialNumber", "value": "XYZ-001"}}, + ) + assert response.status_code == 409 + assert "Decommissioned" in response.json()["detail"] + + +@pytest.mark.contract +def test_post_remove_alternate_identifier_returns_404_for_missing_asset() -> None: + missing = str(uuid4()) + with TestClient(create_app()) as client: + response = client.post( + f"/assets/{missing}/remove-alternate-identifier", + json={"identifier": {"kind": "SerialNumber", "value": "XYZ-001"}}, + ) + assert response.status_code == 404 + + +@pytest.mark.contract +def test_post_remove_alternate_identifier_returns_422_for_missing_required_field() -> None: + with TestClient(create_app()) as client: + asset_id = _register_asset(client) + response = client.post( + f"/assets/{asset_id}/remove-alternate-identifier", + json={"identifier": {"kind": "SerialNumber"}}, # missing value + ) + assert response.status_code == 422 + + +@pytest.mark.contract +def test_post_remove_alternate_identifier_returns_422_for_invalid_kind() -> None: + with TestClient(create_app()) as client: + asset_id = _register_asset(client) + response = client.post( + f"/assets/{asset_id}/remove-alternate-identifier", + json={"identifier": {"kind": "ROR", "value": "XYZ-001"}}, # ROR belongs to Manufacturer + ) + assert response.status_code == 422 + + +@pytest.mark.contract +def test_post_remove_alternate_identifier_returns_400_for_whitespace_only_value() -> None: + """Pydantic min_length=1 catches "" but lets " " through; the + AlternateIdentifier VO then rejects with InvalidAlternateIdentifierError + -> 400.""" + with TestClient(create_app()) as client: + asset_id = _register_asset(client) + response = client.post( + f"/assets/{asset_id}/remove-alternate-identifier", + json={"identifier": {"kind": "SerialNumber", "value": " "}}, + ) + assert response.status_code == 400 diff --git a/apps/api/tests/contract/test_remove_asset_alternate_identifier_mcp_tool.py b/apps/api/tests/contract/test_remove_asset_alternate_identifier_mcp_tool.py new file mode 100644 index 000000000..a6c33aef3 --- /dev/null +++ b/apps/api/tests/contract/test_remove_asset_alternate_identifier_mcp_tool.py @@ -0,0 +1,168 @@ +"""Contract tests for the `remove_asset_alternate_identifier` MCP tool.""" + +from uuid import UUID, uuid4 + +import pytest +from fastapi.testclient import TestClient + +from cora.api.main import create_app +from tests.contract._mcp_helpers import open_session, parse_sse_data + + +def _register_asset_via_tool(client: TestClient, headers: dict[str, str]) -> UUID: + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "register_asset", + "arguments": { + "name": "Detector-X", + "level": "Device", + "parent_id": str(uuid4()), + }, + }, + }, + headers=headers, + ) + body = parse_sse_data(response.text) + return UUID(body["result"]["structuredContent"]["asset_id"]) + + +def _add_identifier_via_tool( + client: TestClient, + headers: dict[str, str], + asset_id: UUID, + *, + kind: str = "SerialNumber", + value: str = "XYZ-001", +) -> None: + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": { + "name": "add_asset_alternate_identifier", + "arguments": { + "asset_id": str(asset_id), + "identifier": {"kind": kind, "value": value}, + }, + }, + }, + headers=headers, + ) + body = parse_sse_data(response.text) + assert body["result"]["isError"] is False + + +@pytest.mark.contract +def test_mcp_lists_remove_asset_alternate_identifier_tool() -> None: + with TestClient(create_app()) as client: + headers = open_session(client) + response = client.post( + "/mcp", + json={"jsonrpc": "2.0", "id": 99, "method": "tools/list"}, + headers=headers, + ) + body = parse_sse_data(response.text) + tool_names = [t["name"] for t in body["result"]["tools"]] + assert "remove_asset_alternate_identifier" in tool_names + + +@pytest.mark.contract +def test_mcp_remove_asset_alternate_identifier_succeeds_on_happy_path() -> None: + with TestClient(create_app()) as client: + headers = open_session(client) + asset_id = _register_asset_via_tool(client, headers) + _add_identifier_via_tool(client, headers, asset_id) + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 4, + "method": "tools/call", + "params": { + "name": "remove_asset_alternate_identifier", + "arguments": { + "asset_id": str(asset_id), + "identifier": {"kind": "SerialNumber", "value": "XYZ-001"}, + }, + }, + }, + headers=headers, + ) + body = parse_sse_data(response.text) + assert body["result"]["isError"] is False + + +@pytest.mark.contract +def test_mcp_remove_asset_alternate_identifier_returns_iserror_for_missing_pair() -> None: + """Strict-not-idempotent: removing without a prior add surfaces as isError=true.""" + with TestClient(create_app()) as client: + headers = open_session(client) + asset_id = _register_asset_via_tool(client, headers) + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 5, + "method": "tools/call", + "params": { + "name": "remove_asset_alternate_identifier", + "arguments": { + "asset_id": str(asset_id), + "identifier": {"kind": "SerialNumber", "value": "missing"}, + }, + }, + }, + headers=headers, + ) + body = parse_sse_data(response.text) + assert body["result"]["isError"] is True + + +@pytest.mark.contract +def test_mcp_remove_asset_alternate_identifier_returns_iserror_when_decommissioned() -> None: + """Lifecycle guard mirrors `remove_asset_port`: a Decommissioned + asset rejects identifier changes; surfaces as isError=true.""" + with TestClient(create_app()) as client: + headers = open_session(client) + asset_id = _register_asset_via_tool(client, headers) + _add_identifier_via_tool(client, headers, asset_id) + decom = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 6, + "method": "tools/call", + "params": { + "name": "decommission_asset", + "arguments": {"asset_id": str(asset_id)}, + }, + }, + headers=headers, + ) + assert parse_sse_data(decom.text)["result"]["isError"] is False + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 7, + "method": "tools/call", + "params": { + "name": "remove_asset_alternate_identifier", + "arguments": { + "asset_id": str(asset_id), + "identifier": {"kind": "SerialNumber", "value": "XYZ-001"}, + }, + }, + }, + headers=headers, + ) + body = parse_sse_data(response.text) + assert body["result"]["isError"] is True + assert "Decommissioned" in body["result"]["content"][0]["text"] diff --git a/apps/api/tests/integration/test_add_asset_alternate_identifier_handler_postgres.py b/apps/api/tests/integration/test_add_asset_alternate_identifier_handler_postgres.py new file mode 100644 index 000000000..9b9fa4192 --- /dev/null +++ b/apps/api/tests/integration/test_add_asset_alternate_identifier_handler_postgres.py @@ -0,0 +1,126 @@ +"""End-to-end integration test: add_asset_alternate_identifier against real Postgres. + +Round-trip: register + add leaves the asset's alternate_identifiers +set containing the new VO (verified via load_asset fold-on-read). +Mirror of `test_remove_asset_alternate_identifier_handler_postgres.py`. +""" + +from datetime import UTC, datetime +from uuid import UUID + +import asyncpg +import pytest + +from cora.equipment.aggregates.asset import ( + AlternateIdentifier, + AlternateIdentifierKind, + AssetCannotAddAlternateIdentifierError, + AssetLevel, + load_asset, +) +from cora.equipment.features import ( + add_asset_alternate_identifier, + decommission_asset, + register_asset, +) +from cora.equipment.features.add_asset_alternate_identifier import ( + AddAssetAlternateIdentifier, +) +from cora.equipment.features.decommission_asset import DecommissionAsset +from cora.equipment.features.register_asset import RegisterAsset +from tests.integration._helpers import build_postgres_deps + +_NOW = datetime(2026, 6, 2, 12, 0, 0, tzinfo=UTC) +_PARENT_ID = UUID("01900000-0000-7000-8000-00000a1e0b00") +_PRINCIPAL_ID = UUID("01900000-0000-7000-8000-00000000a099") +_CORRELATION_ID = UUID("01900000-0000-7000-8000-00000000a0aa") + + +@pytest.mark.integration +async def test_add_asset_alternate_identifier_persists_event_and_folds_into_state( + db_pool: asyncpg.Pool, +) -> None: + asset_id = UUID("01900000-0000-7000-8000-00000a1e0b01") + register_event_id = UUID("01900000-0000-7000-8000-00000a1e0b0e") + add_event_id = UUID("01900000-0000-7000-8000-00000a1e0b0f") + identifier = AlternateIdentifier(kind=AlternateIdentifierKind.SERIAL_NUMBER, value="XYZ-001") + + deps = build_postgres_deps( + db_pool, + now=_NOW, + ids=[asset_id, register_event_id, add_event_id], + ) + + await register_asset.bind(deps)( + RegisterAsset(name="APS-2BM-Camera", level=AssetLevel.DEVICE, parent_id=_PARENT_ID), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await add_asset_alternate_identifier.bind(deps)( + AddAssetAlternateIdentifier(asset_id=asset_id, alternate_identifier=identifier), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + events, version = await deps.event_store.load("Asset", asset_id) + assert version == 2 + assert [e.event_type for e in events] == [ + "AssetRegistered", + "AssetAlternateIdentifierAdded", + ] + added = events[1] + assert added.event_id == add_event_id + assert added.metadata == {"command": "AddAssetAlternateIdentifier"} + assert added.payload["alternate_identifier"] == { + "kind": "SerialNumber", + "value": "XYZ-001", + } + + # Fold-on-read reconstructs the identifier into the frozenset. + state = await load_asset(deps.event_store, asset_id) + assert state is not None + assert state.alternate_identifiers == frozenset({identifier}) + + +@pytest.mark.integration +async def test_add_asset_alternate_identifier_rejects_when_decommissioned( + db_pool: asyncpg.Pool, +) -> None: + """End-to-end lifecycle guard: a Decommissioned asset rejects + identifier additions with `AssetCannotAddAlternateIdentifierError`; + no Added event is appended.""" + asset_id = UUID("01900000-0000-7000-8000-00000a1e0c01") + register_event_id = UUID("01900000-0000-7000-8000-00000a1e0c0e") + decommission_event_id = UUID("01900000-0000-7000-8000-00000a1e0c0f") + identifier = AlternateIdentifier(kind=AlternateIdentifierKind.SERIAL_NUMBER, value="XYZ-001") + + deps = build_postgres_deps( + db_pool, + now=_NOW, + ids=[asset_id, register_event_id, decommission_event_id], + ) + + await register_asset.bind(deps)( + RegisterAsset(name="APS-2BM-Camera", level=AssetLevel.DEVICE, parent_id=_PARENT_ID), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await decommission_asset.bind(deps)( + DecommissionAsset(asset_id=asset_id), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + with pytest.raises(AssetCannotAddAlternateIdentifierError): + await add_asset_alternate_identifier.bind(deps)( + AddAssetAlternateIdentifier(asset_id=asset_id, alternate_identifier=identifier), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + events, version = await deps.event_store.load("Asset", asset_id) + assert version == 2 + assert [e.event_type for e in events] == [ + "AssetRegistered", + "AssetDecommissioned", + ] diff --git a/apps/api/tests/integration/test_postgres_asset_summary_projection.py b/apps/api/tests/integration/test_postgres_asset_summary_projection.py index 8b1aa9241..03f7cd05a 100644 --- a/apps/api/tests/integration/test_postgres_asset_summary_projection.py +++ b/apps/api/tests/integration/test_postgres_asset_summary_projection.py @@ -1,6 +1,6 @@ -"""End-to-end: the Asset.drawing additive widen lands in -proj_equipment_asset_summary's drawing_system / drawing_number / -drawing_revision columns against real Postgres. +"""End-to-end: the Asset.drawing + Asset.model_id + +Asset.alternate_identifiers additive widens land in +proj_equipment_asset_summary against real Postgres. The Asset aggregate carries an optional Drawing VO captured at registration. The projection unfolds the Drawing into three nullable @@ -11,9 +11,16 @@ legacy AssetRegistered events without the drawing payload key fold to all-NULL. +The alternate_identifiers JSONB column carries PIDINST v1.0 Property 13 +identifiers (SerialNumber / InventoryNumber / Other; verbatim closed +StrEnum). Stored as a sorted list of `{"kind", "value"}` objects with +a partial GIN index for future find-by-serial lookups. The Added / +Removed handlers mutate the array server-side via SQL so dedupe and +canonical sort are guaranteed regardless of replay order. + Pins: - - register_asset with NO drawing -> 3 columns NULL - - register_asset with drawing including revision -> 3 columns + - register_asset with NO drawing -> 3 drawing columns NULL + - register_asset with drawing including revision -> 3 drawing columns populated (system, number, revision) - register_asset with drawing omitting revision -> revision NULL, other two populated @@ -21,6 +28,13 @@ written directly via SQL (defense-in-depth pin) - AssetRegistered with model_id in the payload lands in the new model_id column; legacy events without the key fold to NULL + - AssetRegistered with alternate_identifiers in the payload lands in + the JSONB column, sorted by (kind, value); legacy events without + the key fold to '[]' via the column default + - AssetAlternateIdentifierAdded appends + sorts + dedupes the JSONB + array; re-replay is a no-op (idempotency pin) + - AssetAlternateIdentifierRemoved filters out the matching element; + re-replay against a row missing that element is a no-op """ # pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false @@ -244,3 +258,328 @@ async def test_asset_registered_without_model_id_leaves_model_column_null( ) assert row is not None assert row["model_id"] is None + + +# ---------- alternate identifiers ---------- + + +async def _append_alternate_identifier_event( + pool: asyncpg.Pool, + *, + asset_id: UUID, + expected_version: int, + event_type: str, + kind: str, + value: str, +) -> int: + """Append a synthetic AssetAlternateIdentifier(Added|Removed) event + directly to the event store. Bypasses the slice handlers so the + projection can be exercised at the wire-shape boundary ahead of the + add/remove slice landing. Returns the new stream version so the + caller can chain subsequent appends.""" + store = PostgresEventStore(pool) + await store.append( + "Asset", + asset_id, + expected_version, + [ + NewEvent( + event_id=uuid4(), + event_type=event_type, + schema_version=1, + payload={ + "asset_id": str(asset_id), + "alternate_identifier": {"kind": kind, "value": value}, + "occurred_at": _NOW.isoformat(), + }, + occurred_at=_NOW, + correlation_id=_CORRELATION_ID, + causation_id=None, + metadata={}, + principal_id=_PRINCIPAL_ID, + ) + ], + ) + await drain_equipment_projections(pool) + return expected_version + 1 + + +@pytest.mark.integration +async def test_asset_registered_without_alternate_identifiers_defaults_to_empty_array( + db_pool: asyncpg.Pool, +) -> None: + """Legacy AssetRegistered events omit the alternate_identifiers key; + the projection writes the canonical empty-array string, matching the + column default. The row reads back as an empty list.""" + asset_id = uuid4() + await _append_asset_registered( + db_pool, + asset_id=asset_id, + payload_extra={}, + ) + + async with db_pool.acquire() as conn: + row = await conn.fetchrow( + "SELECT alternate_identifiers FROM proj_equipment_asset_summary WHERE asset_id = $1", + asset_id, + ) + assert row is not None + assert row["alternate_identifiers"] == "[]" or row["alternate_identifiers"] == [] + + +@pytest.mark.integration +async def test_asset_registered_with_alternate_identifiers_writes_sorted_jsonb( + db_pool: asyncpg.Pool, +) -> None: + """AssetRegistered carrying alternate_identifiers in the payload + serializes to a sorted JSONB array in the column. The projection + re-sorts defensively; the persisted bytes match `(kind, value)` + canonical order.""" + asset_id = uuid4() + await _append_asset_registered( + db_pool, + asset_id=asset_id, + # Out-of-order on purpose to exercise the defensive re-sort. + payload_extra={ + "alternate_identifiers": [ + {"kind": "SerialNumber", "value": "SN-002"}, + {"kind": "InventoryNumber", "value": "ANL-12345"}, + {"kind": "SerialNumber", "value": "SN-001"}, + ], + }, + ) + + async with db_pool.acquire() as conn: + row = await conn.fetchrow( + "SELECT alternate_identifiers::text AS payload " + "FROM proj_equipment_asset_summary WHERE asset_id = $1", + asset_id, + ) + assert row is not None + assert row["payload"] == ( + '[{"kind": "InventoryNumber", "value": "ANL-12345"}, ' + '{"kind": "SerialNumber", "value": "SN-001"}, ' + '{"kind": "SerialNumber", "value": "SN-002"}]' + ) + + +@pytest.mark.integration +async def test_alternate_identifier_added_appends_to_jsonb_array( + db_pool: asyncpg.Pool, +) -> None: + """AssetAlternateIdentifierAdded appends the (kind, value) pair + into the JSONB column. Two successive Add events land both entries, + canonically sorted on disk.""" + asset_id = uuid4() + await _append_asset_registered( + db_pool, + asset_id=asset_id, + payload_extra={}, + ) + # AssetRegistered consumed version 0 -> 1; the Add events chain + # from version 1. + version = await _append_alternate_identifier_event( + db_pool, + asset_id=asset_id, + expected_version=1, + event_type="AssetAlternateIdentifierAdded", + kind="SerialNumber", + value="SN-007", + ) + await _append_alternate_identifier_event( + db_pool, + asset_id=asset_id, + expected_version=version, + event_type="AssetAlternateIdentifierAdded", + kind="InventoryNumber", + value="ANL-42", + ) + + async with db_pool.acquire() as conn: + row = await conn.fetchrow( + "SELECT alternate_identifiers::text AS payload " + "FROM proj_equipment_asset_summary WHERE asset_id = $1", + asset_id, + ) + assert row is not None + assert row["payload"] == ( + '[{"kind": "InventoryNumber", "value": "ANL-42"}, ' + '{"kind": "SerialNumber", "value": "SN-007"}]' + ) + + +@pytest.mark.integration +async def test_alternate_identifier_added_is_idempotent_under_re_replay( + db_pool: asyncpg.Pool, +) -> None: + """Re-applying the projection to the same Added event is a no-op. + The SQL statement uses DISTINCT ON (kind, value) to fold duplicates, + so a row that already carries `SN-007` stays single-entry even when + the event_store position is replayed against an unbookmarked + projection.""" + asset_id = uuid4() + await _append_asset_registered( + db_pool, + asset_id=asset_id, + payload_extra={}, + ) + version = await _append_alternate_identifier_event( + db_pool, + asset_id=asset_id, + expected_version=1, + event_type="AssetAlternateIdentifierAdded", + kind="SerialNumber", + value="SN-007", + ) + # Replay path: directly invoke the projection apply() on the same + # event a second time. Mirrors the bookmark-rewind / full-rebuild + # operational scenario where the same event_store row crosses + # apply() more than once. + from cora.equipment.projections import AssetSummaryProjection + from cora.infrastructure.ports.event_store import StoredEvent + + replay_event = StoredEvent( + position=99, + event_id=uuid4(), + stream_type="Asset", + stream_id=asset_id, + version=version, + event_type="AssetAlternateIdentifierAdded", + schema_version=1, + payload={ + "asset_id": str(asset_id), + "alternate_identifier": {"kind": "SerialNumber", "value": "SN-007"}, + "occurred_at": _NOW.isoformat(), + }, + correlation_id=_CORRELATION_ID, + causation_id=None, + occurred_at=_NOW, + recorded_at=_NOW, + ) + proj = AssetSummaryProjection() + async with db_pool.acquire() as conn: + await proj.apply(replay_event, conn) + row = await conn.fetchrow( + "SELECT alternate_identifiers::text AS payload " + "FROM proj_equipment_asset_summary WHERE asset_id = $1", + asset_id, + ) + assert row is not None + assert row["payload"] == '[{"kind": "SerialNumber", "value": "SN-007"}]' + + +@pytest.mark.integration +async def test_alternate_identifier_removed_drops_matching_entry( + db_pool: asyncpg.Pool, +) -> None: + """AssetAlternateIdentifierRemoved filters the matching (kind, + value) element out of the JSONB array. Other entries stay; the + array collapses to '[]' (not NULL) when the last element is + removed.""" + asset_id = uuid4() + await _append_asset_registered( + db_pool, + asset_id=asset_id, + payload_extra={ + "alternate_identifiers": [ + {"kind": "InventoryNumber", "value": "ANL-42"}, + {"kind": "SerialNumber", "value": "SN-007"}, + ], + }, + ) + version = await _append_alternate_identifier_event( + db_pool, + asset_id=asset_id, + expected_version=1, + event_type="AssetAlternateIdentifierRemoved", + kind="InventoryNumber", + value="ANL-42", + ) + + async with db_pool.acquire() as conn: + row = await conn.fetchrow( + "SELECT alternate_identifiers::text AS payload " + "FROM proj_equipment_asset_summary WHERE asset_id = $1", + asset_id, + ) + assert row is not None + assert row["payload"] == '[{"kind": "SerialNumber", "value": "SN-007"}]' + + # Remove the last entry: the array MUST collapse to '[]', not NULL + # (NOT NULL column; COALESCE in the projection's UPDATE statement). + await _append_alternate_identifier_event( + db_pool, + asset_id=asset_id, + expected_version=version, + event_type="AssetAlternateIdentifierRemoved", + kind="SerialNumber", + value="SN-007", + ) + async with db_pool.acquire() as conn: + row = await conn.fetchrow( + "SELECT alternate_identifiers::text AS payload " + "FROM proj_equipment_asset_summary WHERE asset_id = $1", + asset_id, + ) + assert row is not None + assert row["payload"] == "[]" + + +@pytest.mark.integration +async def test_alternate_identifier_removed_is_idempotent_under_re_replay( + db_pool: asyncpg.Pool, +) -> None: + """Re-applying the projection to the same Removed event is a no-op + at the DB layer. After the first apply the matching entry is gone; + the second apply's filter finds nothing to drop and the row stays + identical.""" + asset_id = uuid4() + await _append_asset_registered( + db_pool, + asset_id=asset_id, + payload_extra={ + "alternate_identifiers": [ + {"kind": "SerialNumber", "value": "SN-007"}, + ], + }, + ) + version = await _append_alternate_identifier_event( + db_pool, + asset_id=asset_id, + expected_version=1, + event_type="AssetAlternateIdentifierRemoved", + kind="SerialNumber", + value="SN-007", + ) + + from cora.equipment.projections import AssetSummaryProjection + from cora.infrastructure.ports.event_store import StoredEvent + + replay_event = StoredEvent( + position=99, + event_id=uuid4(), + stream_type="Asset", + stream_id=asset_id, + version=version, + event_type="AssetAlternateIdentifierRemoved", + schema_version=1, + payload={ + "asset_id": str(asset_id), + "alternate_identifier": {"kind": "SerialNumber", "value": "SN-007"}, + "occurred_at": _NOW.isoformat(), + }, + correlation_id=_CORRELATION_ID, + causation_id=None, + occurred_at=_NOW, + recorded_at=_NOW, + ) + proj = AssetSummaryProjection() + async with db_pool.acquire() as conn: + await proj.apply(replay_event, conn) + row = await conn.fetchrow( + "SELECT alternate_identifiers::text AS payload " + "FROM proj_equipment_asset_summary WHERE asset_id = $1", + asset_id, + ) + assert row is not None + assert row["payload"] == "[]" diff --git a/apps/api/tests/integration/test_register_asset_handler_postgres.py b/apps/api/tests/integration/test_register_asset_handler_postgres.py index cbc360009..e0d8f2197 100644 --- a/apps/api/tests/integration/test_register_asset_handler_postgres.py +++ b/apps/api/tests/integration/test_register_asset_handler_postgres.py @@ -13,6 +13,8 @@ store. """ +# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false + from datetime import UTC, datetime from uuid import UUID @@ -21,6 +23,8 @@ from cora.equipment._projections import register_equipment_projections from cora.equipment.aggregates.asset import ( + AlternateIdentifier, + AlternateIdentifierKind, AssetLevel, AssetLifecycle, AssetName, @@ -196,6 +200,105 @@ async def test_register_asset_persists_model_binding_to_postgres( assert state.lifecycle is AssetLifecycle.COMMISSIONED +@pytest.mark.integration +async def test_register_asset_persists_alternate_identifiers_to_postgres( + db_pool: asyncpg.Pool, +) -> None: + """Seed a Family + Model, then register an Asset bound to that Model + with two `alternate_identifiers` entries. The AssetRegistered payload + must carry the list (sorted by (kind, value) per the canonical wire + shape); the projection row must materialize the same list into the + `alternate_identifiers` JSONB column; folded Asset state must round- + trip the frozenset.""" + family_id = UUID("01900000-0000-7000-8000-000000054fa1") + family_event_id = UUID("01900000-0000-7000-8000-000000054fae") + model_id = UUID("01900000-0000-7000-8000-00000054fb01") + model_event_id = UUID("01900000-0000-7000-8000-00000054fb0e") + asset_id = UUID("01900000-0000-7000-8000-00000054fc01") + asset_event_id = UUID("01900000-0000-7000-8000-00000054fc0e") + parent_id = UUID("01900000-0000-7000-8000-00000054fc00") + + serial = AlternateIdentifier(kind=AlternateIdentifierKind.SERIAL_NUMBER, value="ANT130L-12345") + inventory = AlternateIdentifier( + kind=AlternateIdentifierKind.INVENTORY_NUMBER, value="APS-2BM-RS-001" + ) + identifiers = frozenset({serial, inventory}) + + deps = build_postgres_deps( + db_pool, + now=_NOW, + ids=[ + family_id, + family_event_id, + model_id, + model_event_id, + asset_id, + asset_event_id, + ], + ) + await define_family.bind(deps)( + DefineFamily(name="ContinuousRotationTomography", affordances=frozenset()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await _drain_equipment_projections(db_pool) + await define_model.bind(deps)( + DefineModel( + name="ANT130L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130L-G10", + declared_families=frozenset({family_id}), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + returned_asset_id = await register_asset.bind(deps)( + RegisterAsset( + name="APS-2BM-RotaryStage", + level=AssetLevel.DEVICE, + parent_id=parent_id, + model_id=model_id, + alternate_identifiers=identifiers, + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert returned_asset_id == asset_id + + events, version = await deps.event_store.load("Asset", asset_id) + assert version == 1 + stored = events[0] + assert stored.event_type == "AssetRegistered" + payload_alt_ids = stored.payload["alternate_identifiers"] + assert payload_alt_ids == [ + {"kind": "InventoryNumber", "value": "APS-2BM-RS-001"}, + {"kind": "SerialNumber", "value": "ANT130L-12345"}, + ] + + # Round-trip: load_asset folds back to the expected state. + state = await load_asset(deps.event_store, asset_id) + assert state is not None + assert state.alternate_identifiers == identifiers + + # Projection: drain the AssetRegistered event into + # proj_equipment_asset_summary and verify the JSONB column carries + # the same canonical sorted list. + await _drain_equipment_projections(db_pool) + async with db_pool.acquire() as conn: + row = await conn.fetchrow( + "SELECT alternate_identifiers FROM proj_equipment_asset_summary WHERE asset_id = $1", + asset_id, + ) + assert row is not None + # The pool init callback registers a jsonb codec that decodes the + # column straight to a Python list; no json.loads needed here. + assert row["alternate_identifiers"] == [ + {"kind": "InventoryNumber", "value": "APS-2BM-RS-001"}, + {"kind": "SerialNumber", "value": "ANT130L-12345"}, + ] + + @pytest.mark.integration async def test_register_asset_raises_model_not_found_on_unknown_model_id( db_pool: asyncpg.Pool, diff --git a/apps/api/tests/integration/test_remove_asset_alternate_identifier_handler_postgres.py b/apps/api/tests/integration/test_remove_asset_alternate_identifier_handler_postgres.py new file mode 100644 index 000000000..8839bc47c --- /dev/null +++ b/apps/api/tests/integration/test_remove_asset_alternate_identifier_handler_postgres.py @@ -0,0 +1,141 @@ +"""End-to-end integration test: remove_asset_alternate_identifier against real Postgres. + +Round-trip: register + add + remove leaves the asset back at empty +alternate_identifiers (verified via load_asset fold-on-read). +Mirror of `test_remove_asset_family_handler_postgres.py`. +""" + +from datetime import UTC, datetime +from uuid import UUID + +import asyncpg +import pytest + +from cora.equipment.aggregates.asset import ( + AlternateIdentifier, + AlternateIdentifierKind, + AssetCannotAddAlternateIdentifierError, + AssetLevel, + load_asset, +) +from cora.equipment.features import ( + add_asset_alternate_identifier, + decommission_asset, + register_asset, + remove_asset_alternate_identifier, +) +from cora.equipment.features.add_asset_alternate_identifier import ( + AddAssetAlternateIdentifier, +) +from cora.equipment.features.decommission_asset import DecommissionAsset +from cora.equipment.features.register_asset import RegisterAsset +from cora.equipment.features.remove_asset_alternate_identifier import ( + RemoveAssetAlternateIdentifier, +) +from tests.integration._helpers import build_postgres_deps + +_NOW = datetime(2026, 6, 2, 12, 0, 0, tzinfo=UTC) +_PARENT_ID = UUID("01900000-0000-7000-8000-00000a1d0b00") +_PRINCIPAL_ID = UUID("01900000-0000-7000-8000-000000000099") +_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000aa") + + +@pytest.mark.integration +async def test_remove_asset_alternate_identifier_persists_event_and_drops_from_fold( + db_pool: asyncpg.Pool, +) -> None: + asset_id = UUID("01900000-0000-7000-8000-00000a1d0b01") + register_event_id = UUID("01900000-0000-7000-8000-00000a1d0b0e") + add_event_id = UUID("01900000-0000-7000-8000-00000a1d0b0f") + remove_event_id = UUID("01900000-0000-7000-8000-00000a1d0b10") + identifier = AlternateIdentifier(kind=AlternateIdentifierKind.SERIAL_NUMBER, value="XYZ-001") + + deps = build_postgres_deps( + db_pool, + now=_NOW, + ids=[asset_id, register_event_id, add_event_id, remove_event_id], + ) + + await register_asset.bind(deps)( + RegisterAsset(name="APS-2BM-Camera", level=AssetLevel.DEVICE, parent_id=_PARENT_ID), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await add_asset_alternate_identifier.bind(deps)( + AddAssetAlternateIdentifier(asset_id=asset_id, alternate_identifier=identifier), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await remove_asset_alternate_identifier.bind(deps)( + RemoveAssetAlternateIdentifier(asset_id=asset_id, alternate_identifier=identifier), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + events, version = await deps.event_store.load("Asset", asset_id) + assert version == 3 + assert [e.event_type for e in events] == [ + "AssetRegistered", + "AssetAlternateIdentifierAdded", + "AssetAlternateIdentifierRemoved", + ] + removed = events[2] + assert removed.event_id == remove_event_id + assert removed.metadata == {"command": "RemoveAssetAlternateIdentifier"} + + # Fold-on-read reconstructs the empty frozenset. + state = await load_asset(deps.event_store, asset_id) + assert state is not None + assert state.alternate_identifiers == frozenset() + + +@pytest.mark.integration +async def test_remove_asset_alternate_identifier_rejects_when_decommissioned( + db_pool: asyncpg.Pool, +) -> None: + """End-to-end lifecycle guard: a Decommissioned asset rejects + identifier removals with `AssetCannotAddAlternateIdentifierError` + (the shared lifecycle-guard class is used by BOTH add and remove); + no Removed event is appended.""" + asset_id = UUID("01900000-0000-7000-8000-00000a1d0c01") + register_event_id = UUID("01900000-0000-7000-8000-00000a1d0c0e") + add_event_id = UUID("01900000-0000-7000-8000-00000a1d0c0f") + decommission_event_id = UUID("01900000-0000-7000-8000-00000a1d0c10") + identifier = AlternateIdentifier(kind=AlternateIdentifierKind.SERIAL_NUMBER, value="XYZ-001") + + deps = build_postgres_deps( + db_pool, + now=_NOW, + ids=[asset_id, register_event_id, add_event_id, decommission_event_id], + ) + + await register_asset.bind(deps)( + RegisterAsset(name="APS-2BM-Camera", level=AssetLevel.DEVICE, parent_id=_PARENT_ID), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await add_asset_alternate_identifier.bind(deps)( + AddAssetAlternateIdentifier(asset_id=asset_id, alternate_identifier=identifier), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await decommission_asset.bind(deps)( + DecommissionAsset(asset_id=asset_id), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + with pytest.raises(AssetCannotAddAlternateIdentifierError): + await remove_asset_alternate_identifier.bind(deps)( + RemoveAssetAlternateIdentifier(asset_id=asset_id, alternate_identifier=identifier), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + events, version = await deps.event_store.load("Asset", asset_id) + assert version == 3 + assert [e.event_type for e in events] == [ + "AssetRegistered", + "AssetAlternateIdentifierAdded", + "AssetDecommissioned", + ] diff --git a/apps/api/tests/unit/equipment/test_add_asset_alternate_identifier_decider.py b/apps/api/tests/unit/equipment/test_add_asset_alternate_identifier_decider.py new file mode 100644 index 000000000..2fdbba09c --- /dev/null +++ b/apps/api/tests/unit/equipment/test_add_asset_alternate_identifier_decider.py @@ -0,0 +1,159 @@ +"""Unit tests for the `add_asset_alternate_identifier` slice's pure decider.""" + +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest + +from cora.equipment.aggregates.asset import ( + AlternateIdentifier, + AlternateIdentifierKind, + Asset, + AssetAlternateIdentifierAdded, + AssetAlternateIdentifierAlreadyPresentError, + AssetCannotAddAlternateIdentifierError, + AssetLevel, + AssetLifecycle, + AssetName, + AssetNotFoundError, + InvalidAlternateIdentifierValueError, +) +from cora.equipment.features import add_asset_alternate_identifier +from cora.equipment.features.add_asset_alternate_identifier import ( + AddAssetAlternateIdentifier, +) + +_NOW = datetime(2026, 6, 2, 12, 0, 0, tzinfo=UTC) + + +def _asset( + *, + lifecycle: AssetLifecycle = AssetLifecycle.ACTIVE, + alternate_identifiers: frozenset[AlternateIdentifier] = frozenset(), +) -> Asset: + return Asset( + id=uuid4(), + name=AssetName("Detector-X"), + level=AssetLevel.DEVICE, + parent_id=uuid4(), + lifecycle=lifecycle, + alternate_identifiers=alternate_identifiers, + ) + + +@pytest.mark.unit +def test_decide_emits_event_when_adding_first_identifier() -> None: + identifier = AlternateIdentifier(kind=AlternateIdentifierKind.SERIAL_NUMBER, value="XYZ-001") + state = _asset() + events = add_asset_alternate_identifier.decide( + state=state, + command=AddAssetAlternateIdentifier(asset_id=state.id, alternate_identifier=identifier), + now=_NOW, + ) + assert events == [ + AssetAlternateIdentifierAdded( + asset_id=state.id, + alternate_identifier=identifier, + occurred_at=_NOW, + ) + ] + + +@pytest.mark.unit +def test_decide_raises_asset_not_found_when_state_is_none() -> None: + target_id = uuid4() + identifier = AlternateIdentifier(kind=AlternateIdentifierKind.SERIAL_NUMBER, value="XYZ-001") + with pytest.raises(AssetNotFoundError) as exc_info: + add_asset_alternate_identifier.decide( + state=None, + command=AddAssetAlternateIdentifier( + asset_id=target_id, alternate_identifier=identifier + ), + now=_NOW, + ) + assert exc_info.value.asset_id == target_id + + +@pytest.mark.unit +def test_decide_raises_already_present_when_pair_exists() -> None: + """Strict-not-idempotent: re-adding the same (kind, value) pair raises.""" + identifier = AlternateIdentifier(kind=AlternateIdentifierKind.SERIAL_NUMBER, value="XYZ-001") + state = _asset(alternate_identifiers=frozenset({identifier})) + with pytest.raises(AssetAlternateIdentifierAlreadyPresentError) as exc_info: + add_asset_alternate_identifier.decide( + state=state, + command=AddAssetAlternateIdentifier(asset_id=state.id, alternate_identifier=identifier), + now=_NOW, + ) + assert exc_info.value.asset_id == state.id + assert exc_info.value.identifier == identifier + + +@pytest.mark.unit +def test_decide_allows_same_value_under_different_kind() -> None: + """Uniqueness keyed on the full (kind, value) pair: the same value + under a different kind is a distinct identifier.""" + existing = AlternateIdentifier(kind=AlternateIdentifierKind.SERIAL_NUMBER, value="ABC-9") + same_value_other_kind = AlternateIdentifier( + kind=AlternateIdentifierKind.INVENTORY_NUMBER, value="ABC-9" + ) + state = _asset(alternate_identifiers=frozenset({existing})) + events = add_asset_alternate_identifier.decide( + state=state, + command=AddAssetAlternateIdentifier( + asset_id=state.id, alternate_identifier=same_value_other_kind + ), + now=_NOW, + ) + assert len(events) == 1 + + +@pytest.mark.unit +def test_decide_propagates_invalid_value_error_from_vo() -> None: + """Empty value surfaces as InvalidAlternateIdentifierValueError at + VO construction time (mapped to HTTP 400 by the BC's exception + handler).""" + with pytest.raises(InvalidAlternateIdentifierValueError): + AlternateIdentifier(kind=AlternateIdentifierKind.SERIAL_NUMBER, value=" ") + + +@pytest.mark.unit +def test_decide_rejects_decommissioned() -> None: + """Lifecycle guard mirrors `add_asset_port`: a Decommissioned + asset is out of inventory; identifier changes are not allowed.""" + identifier = AlternateIdentifier(kind=AlternateIdentifierKind.SERIAL_NUMBER, value="XYZ-001") + state = _asset(lifecycle=AssetLifecycle.DECOMMISSIONED) + with pytest.raises(AssetCannotAddAlternateIdentifierError) as exc_info: + add_asset_alternate_identifier.decide( + state=state, + command=AddAssetAlternateIdentifier(asset_id=state.id, alternate_identifier=identifier), + now=_NOW, + ) + assert exc_info.value.asset_id == state.id + assert exc_info.value.kind is AlternateIdentifierKind.SERIAL_NUMBER + assert exc_info.value.value == "XYZ-001" + assert "Decommissioned" in exc_info.value.reason + + +@pytest.mark.unit +@pytest.mark.parametrize( + "lifecycle", + [ + AssetLifecycle.COMMISSIONED, + AssetLifecycle.ACTIVE, + AssetLifecycle.MAINTENANCE, + ], +) +def test_decide_succeeds_for_every_non_decommissioned_lifecycle( + lifecycle: AssetLifecycle, +) -> None: + """Lifecycle-independence holds across every non-Decommissioned + state. Symmetric with `add_asset_port`.""" + identifier = AlternateIdentifier(kind=AlternateIdentifierKind.SERIAL_NUMBER, value="x") + state = _asset(lifecycle=lifecycle) + events = add_asset_alternate_identifier.decide( + state=state, + command=AddAssetAlternateIdentifier(asset_id=state.id, alternate_identifier=identifier), + now=_NOW, + ) + assert len(events) == 1 diff --git a/apps/api/tests/unit/equipment/test_add_asset_alternate_identifier_decider_properties.py b/apps/api/tests/unit/equipment/test_add_asset_alternate_identifier_decider_properties.py new file mode 100644 index 000000000..e351a91a2 --- /dev/null +++ b/apps/api/tests/unit/equipment/test_add_asset_alternate_identifier_decider_properties.py @@ -0,0 +1,222 @@ +"""Property-based tests for `add_asset_alternate_identifier.decide`. + +Complements the example-based decider tests with universal claims +across generated inputs. The decider's pure shape + + (state, command, now) -> list[AssetAlternateIdentifierAdded] + +makes a handful of strict-not-idempotent properties mechanical to +express. Mirror of `test_remove_asset_alternate_identifier_decider_properties.py`. +""" + +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 ( + ALTERNATE_IDENTIFIER_VALUE_MAX_LENGTH, + AlternateIdentifier, + AlternateIdentifierKind, + Asset, + AssetAlternateIdentifierAdded, + AssetAlternateIdentifierAlreadyPresentError, + AssetCannotAddAlternateIdentifierError, + AssetLevel, + AssetLifecycle, + AssetName, + AssetNotFoundError, +) +from cora.equipment.features import add_asset_alternate_identifier +from cora.equipment.features.add_asset_alternate_identifier import ( + AddAssetAlternateIdentifier, +) + +if TYPE_CHECKING: + from uuid import UUID + +_NON_DECOMMISSIONED_LIFECYCLE = st.sampled_from( + [lc for lc in AssetLifecycle if lc is not AssetLifecycle.DECOMMISSIONED] +) +_KIND = st.sampled_from(list(AlternateIdentifierKind)) +_VALID_VALUE = st.text( + alphabet=st.characters(min_codepoint=0x21, max_codepoint=0x7E), + min_size=1, + max_size=ALTERNATE_IDENTIFIER_VALUE_MAX_LENGTH, +) +_DT_BASE = datetime(2026, 6, 2, 0, 0, 0, tzinfo=UTC) + + +@st.composite +def _identifier(draw: st.DrawFn) -> AlternateIdentifier: + return AlternateIdentifier(kind=draw(_KIND), value=draw(_VALID_VALUE)) + + +def _asset( + asset_id: UUID, + *, + lifecycle: AssetLifecycle, + alternate_identifiers: frozenset[AlternateIdentifier], +) -> 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, + alternate_identifiers=alternate_identifiers, + ) + + +@pytest.mark.unit +@given( + asset_id=st.uuids(), + identifier=_identifier(), + lifecycle=_NON_DECOMMISSIONED_LIFECYCLE, + seconds_offset=st.integers(min_value=0, max_value=10_000_000), +) +def test_adding_absent_identifier_emits_one_event_with_injected_fields( + asset_id: UUID, + identifier: AlternateIdentifier, + lifecycle: AssetLifecycle, + seconds_offset: int, +) -> None: + """Identifier absent from any non-Decommissioned state -> single + Added event carrying the injected timestamp and identifier.""" + state = _asset(asset_id, lifecycle=lifecycle, alternate_identifiers=frozenset()) + now = _DT_BASE + timedelta(seconds=seconds_offset) + events = add_asset_alternate_identifier.decide( + state=state, + command=AddAssetAlternateIdentifier(asset_id=asset_id, alternate_identifier=identifier), + now=now, + ) + assert events == [ + AssetAlternateIdentifierAdded( + asset_id=asset_id, + alternate_identifier=identifier, + occurred_at=now, + ) + ] + + +@pytest.mark.unit +@given( + asset_id=st.uuids(), + identifier=_identifier(), + lifecycle=_NON_DECOMMISSIONED_LIFECYCLE, + seconds_offset=st.integers(min_value=0, max_value=10_000_000), +) +def test_adding_present_identifier_always_raises_already_present( + asset_id: UUID, + identifier: AlternateIdentifier, + lifecycle: AssetLifecycle, + seconds_offset: int, +) -> None: + """Strict-not-idempotent: pair already in state -> AlreadyPresent + in any non-Decommissioned lifecycle (the lifecycle guard fires + first when Decommissioned and is covered by its own property).""" + state = _asset( + asset_id, + lifecycle=lifecycle, + alternate_identifiers=frozenset({identifier}), + ) + now = _DT_BASE + timedelta(seconds=seconds_offset) + with pytest.raises(AssetAlternateIdentifierAlreadyPresentError) as exc: + add_asset_alternate_identifier.decide( + state=state, + command=AddAssetAlternateIdentifier(asset_id=asset_id, alternate_identifier=identifier), + now=now, + ) + assert exc.value.asset_id == asset_id + assert exc.value.identifier == identifier + + +@pytest.mark.unit +@given( + asset_id=st.uuids(), + identifier=_identifier(), + existing=_identifier(), + seconds_offset=st.integers(min_value=0, max_value=10_000_000), +) +def test_decommissioned_asset_always_raises_cannot_add_regardless_of_presence( + asset_id: UUID, + identifier: AlternateIdentifier, + existing: AlternateIdentifier, + seconds_offset: int, +) -> None: + """Lifecycle guard fires FIRST: in Decommissioned the decider raises + `AssetCannotAddAlternateIdentifierError` whether the pair is + already present or absent. Mirrors `add_asset_port`.""" + state = _asset( + asset_id, + lifecycle=AssetLifecycle.DECOMMISSIONED, + alternate_identifiers=frozenset({existing}), + ) + now = _DT_BASE + timedelta(seconds=seconds_offset) + with pytest.raises(AssetCannotAddAlternateIdentifierError) as exc: + add_asset_alternate_identifier.decide( + state=state, + command=AddAssetAlternateIdentifier(asset_id=asset_id, alternate_identifier=identifier), + now=now, + ) + assert exc.value.asset_id == asset_id + assert exc.value.kind is identifier.kind + assert exc.value.value == identifier.value + assert "Decommissioned" in exc.value.reason + + +@pytest.mark.unit +@given( + asset_id=st.uuids(), + identifier=_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, + identifier: AlternateIdentifier, + seconds_offset: int, +) -> None: + """state=None -> AssetNotFoundError regardless of identifier or now.""" + now = _DT_BASE + timedelta(seconds=seconds_offset) + with pytest.raises(AssetNotFoundError) as exc: + add_asset_alternate_identifier.decide( + state=None, + command=AddAssetAlternateIdentifier(asset_id=asset_id, alternate_identifier=identifier), + now=now, + ) + assert exc.value.asset_id == asset_id + + +@pytest.mark.unit +@given( + asset_id=st.uuids(), + identifier=_identifier(), + other=_identifier(), + lifecycle=_NON_DECOMMISSIONED_LIFECYCLE, + seconds_offset=st.integers(min_value=0, max_value=10_000_000), +) +def test_decide_is_pure_same_input_same_output( + asset_id: UUID, + identifier: AlternateIdentifier, + other: AlternateIdentifier, + lifecycle: AssetLifecycle, + seconds_offset: int, +) -> None: + """Two calls with identical (state, command, now) return identical + events; no hidden clock or id leakage. Restricted to non- + Decommissioned so the happy-path branch is exercised.""" + assume(identifier != other) + state = _asset( + asset_id, + lifecycle=lifecycle, + alternate_identifiers=frozenset({other}), + ) + now = _DT_BASE + timedelta(seconds=seconds_offset) + command = AddAssetAlternateIdentifier(asset_id=asset_id, alternate_identifier=identifier) + first = add_asset_alternate_identifier.decide(state=state, command=command, now=now) + second = add_asset_alternate_identifier.decide(state=state, command=command, now=now) + assert first == second diff --git a/apps/api/tests/unit/equipment/test_add_asset_alternate_identifier_handler.py b/apps/api/tests/unit/equipment/test_add_asset_alternate_identifier_handler.py new file mode 100644 index 000000000..756f73240 --- /dev/null +++ b/apps/api/tests/unit/equipment/test_add_asset_alternate_identifier_handler.py @@ -0,0 +1,203 @@ +"""Application-handler tests for `add_asset_alternate_identifier` slice. + +Update-style handler via `make_asset_update_handler`; mirrors the +shape of `test_remove_asset_alternate_identifier_handler.py`. +Coverage: + + - happy path appends the right event with serialized payload + - authorize-deny -> UnauthorizedError; no event appended + - strict-not-idempotent: re-adding raises AlreadyPresent and + nothing is appended + - wire_equipment exposes the handler on the bundle +""" + +from datetime import UTC, datetime +from uuid import UUID + +import pytest + +from cora.equipment import EquipmentHandlers, UnauthorizedError, wire_equipment +from cora.equipment.aggregates.asset import ( + AlternateIdentifier, + AlternateIdentifierKind, + AssetAlternateIdentifierAlreadyPresentError, + AssetCannotAddAlternateIdentifierError, + AssetLevel, +) +from cora.equipment.features import ( + add_asset_alternate_identifier, + decommission_asset, + register_asset, +) +from cora.equipment.features.add_asset_alternate_identifier import ( + AddAssetAlternateIdentifier, +) +from cora.equipment.features.decommission_asset import DecommissionAsset +from cora.equipment.features.register_asset import RegisterAsset +from cora.infrastructure.adapters.in_memory_event_store import InMemoryEventStore +from cora.infrastructure.kernel import Kernel +from tests.unit._helpers import build_deps as _build_deps_shared + +_NOW = datetime(2026, 6, 2, 12, 0, 0, tzinfo=UTC) +_NEW_ID = UUID("01900000-0000-7000-8000-0000000a1e01") +_REGISTER_EVENT_ID = UUID("01900000-0000-7000-8000-0000000a1e02") +_ADD_EVENT_ID_1 = UUID("01900000-0000-7000-8000-0000000a1e03") +_ADD_EVENT_ID_2 = UUID("01900000-0000-7000-8000-0000000a1e04") +_DECOMMISSION_EVENT_ID = UUID("01900000-0000-7000-8000-0000000a1e08") +_PARENT_ID = UUID("01900000-0000-7000-8000-0000000a1e05") +_PRINCIPAL_ID = UUID("01900000-0000-7000-8000-0000000a1e06") +_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000a1e07") + +_IDENTIFIER = AlternateIdentifier(kind=AlternateIdentifierKind.SERIAL_NUMBER, value="XYZ-001") + + +def _build_deps( + *, + event_store: InMemoryEventStore | None = None, + deny: bool = False, +) -> Kernel: + return _build_deps_shared( + ids=[ + _NEW_ID, + _REGISTER_EVENT_ID, + _ADD_EVENT_ID_1, + _ADD_EVENT_ID_2, + _DECOMMISSION_EVENT_ID, + ], + now=_NOW, + event_store=event_store, + deny=deny, + ) + + +async def _register_asset_helper(deps: Kernel) -> UUID: + return await register_asset.bind(deps)( + RegisterAsset(name="Detector-X", level=AssetLevel.DEVICE, parent_id=_PARENT_ID), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + +@pytest.mark.unit +async def test_handler_appends_added_event_on_happy_path() -> None: + store = InMemoryEventStore() + deps = _build_deps(event_store=store) + asset_id = await _register_asset_helper(deps) + + await add_asset_alternate_identifier.bind(deps)( + AddAssetAlternateIdentifier(asset_id=asset_id, alternate_identifier=_IDENTIFIER), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + events, _ = await store.load("Asset", asset_id) + added = events[1] + assert added.event_type == "AssetAlternateIdentifierAdded" + assert added.payload["alternate_identifier"] == { + "kind": "SerialNumber", + "value": "XYZ-001", + } + + +@pytest.mark.unit +async def test_handler_raises_unauthorized_on_deny() -> None: + store = InMemoryEventStore() + deps = _build_deps(event_store=store) + asset_id = await _register_asset_helper(deps) + + deny_deps = _build_deps(event_store=store, deny=True) + with pytest.raises(UnauthorizedError): + await add_asset_alternate_identifier.bind(deny_deps)( + AddAssetAlternateIdentifier(asset_id=asset_id, alternate_identifier=_IDENTIFIER), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + events, version = await store.load("Asset", asset_id) + # Only AssetRegistered survives; no Added appended on deny. + assert version == 1 + assert events[0].event_type == "AssetRegistered" + + +@pytest.mark.unit +async def test_handler_raises_not_found_when_asset_missing() -> None: + from cora.equipment.aggregates.asset import AssetNotFoundError + + store = InMemoryEventStore() + deps = _build_deps(event_store=store) + missing_id = UUID("01900000-0000-7000-8000-0000000a1e99") + + with pytest.raises(AssetNotFoundError): + await add_asset_alternate_identifier.bind(deps)( + AddAssetAlternateIdentifier(asset_id=missing_id, alternate_identifier=_IDENTIFIER), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + +@pytest.mark.unit +async def test_handler_raises_already_present_on_second_add() -> None: + """Strict-not-idempotent: re-adding the same identifier raises and + appends no second event.""" + store = InMemoryEventStore() + deps = _build_deps(event_store=store) + asset_id = await _register_asset_helper(deps) + + await add_asset_alternate_identifier.bind(deps)( + AddAssetAlternateIdentifier(asset_id=asset_id, alternate_identifier=_IDENTIFIER), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + with pytest.raises(AssetAlternateIdentifierAlreadyPresentError): + await add_asset_alternate_identifier.bind(deps)( + AddAssetAlternateIdentifier(asset_id=asset_id, alternate_identifier=_IDENTIFIER), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + events, version = await store.load("Asset", asset_id) + # Register + one Added; the second Add raised before append. + assert version == 2 + assert [e.event_type for e in events] == [ + "AssetRegistered", + "AssetAlternateIdentifierAdded", + ] + + +@pytest.mark.unit +async def test_handler_raises_cannot_add_when_asset_decommissioned() -> None: + """Lifecycle guard: a Decommissioned asset cannot accept identifier + changes; the decider raises before any append.""" + store = InMemoryEventStore() + deps = _build_deps(event_store=store) + asset_id = await _register_asset_helper(deps) + + await decommission_asset.bind(deps)( + DecommissionAsset(asset_id=asset_id), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + with pytest.raises(AssetCannotAddAlternateIdentifierError): + await add_asset_alternate_identifier.bind(deps)( + AddAssetAlternateIdentifier(asset_id=asset_id, alternate_identifier=_IDENTIFIER), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + events, version = await store.load("Asset", asset_id) + # Register + Decommission only; the Add raised before append. + assert version == 2 + assert [e.event_type for e in events] == [ + "AssetRegistered", + "AssetDecommissioned", + ] + + +@pytest.mark.unit +def test_wire_equipment_exposes_add_handler() -> None: + deps = _build_deps() + handlers = wire_equipment(deps) + assert isinstance(handlers, EquipmentHandlers) + assert callable(handlers.add_asset_alternate_identifier) diff --git a/apps/api/tests/unit/equipment/test_asset.py b/apps/api/tests/unit/equipment/test_asset.py index 8106fee68..20c110ec6 100644 --- a/apps/api/tests/unit/equipment/test_asset.py +++ b/apps/api/tests/unit/equipment/test_asset.py @@ -5,11 +5,16 @@ import pytest from cora.equipment.aggregates.asset import ( + AlternateIdentifier, + AlternateIdentifierKind, Asset, + AssetAlternateIdentifierAlreadyPresentError, + AssetAlternateIdentifierNotPresentError, AssetLevel, AssetLifecycle, AssetModelMismatchError, AssetName, + InvalidAlternateIdentifierValueError, InvalidAssetNameError, ) @@ -228,3 +233,241 @@ def test_asset_model_mismatch_is_exception() -> None: asset_family_ids=frozenset(), ) assert isinstance(error, Exception) + + +# ---------- AlternateIdentifierKind enum ---------- + + +@pytest.mark.unit +def test_alternate_identifier_kind_has_pidinst_v1_vocabulary() -> None: + """Pin the closed vocabulary from PIDINST v1.0 spec page 8 Table 1 + (Property 13 alternateIdentifierType). Adding / removing values + should be a deliberate change visible here. See Lock B in the + design memo.""" + assert {kind.value for kind in AlternateIdentifierKind} == { + "SerialNumber", + "InventoryNumber", + "Other", + } + + +@pytest.mark.unit +def test_alternate_identifier_kind_values_are_pascalcase_strings() -> None: + assert AlternateIdentifierKind.SERIAL_NUMBER == "SerialNumber" + assert AlternateIdentifierKind.INVENTORY_NUMBER == "InventoryNumber" + assert AlternateIdentifierKind.OTHER == "Other" + + +@pytest.mark.unit +def test_alternate_identifier_kind_is_str_enum() -> None: + """StrEnum so JSON serialization works naturally without `.value` + access: the wire format carries the StrEnum value.""" + assert isinstance(AlternateIdentifierKind.SERIAL_NUMBER, str) + assert AlternateIdentifierKind.SERIAL_NUMBER == "SerialNumber" + + +@pytest.mark.unit +def test_alternate_identifier_kind_round_trips_from_string() -> None: + """The events from_stored path reconstructs the enum from + payload strings via `AlternateIdentifierKind(payload['kind'])`.""" + for kind in AlternateIdentifierKind: + assert AlternateIdentifierKind(kind.value) == kind + + +# ---------- AlternateIdentifier VO ---------- + + +@pytest.mark.unit +def test_alternate_identifier_constructs_with_valid_inputs() -> None: + identifier = AlternateIdentifier( + kind=AlternateIdentifierKind.SERIAL_NUMBER, + value="12345-ABC", + ) + assert identifier.kind is AlternateIdentifierKind.SERIAL_NUMBER + assert identifier.value == "12345-ABC" + + +@pytest.mark.unit +def test_alternate_identifier_trims_value() -> None: + identifier = AlternateIdentifier( + kind=AlternateIdentifierKind.INVENTORY_NUMBER, + value=" APS-2BM-CAM-001 ", + ) + assert identifier.value == "APS-2BM-CAM-001" + + +@pytest.mark.unit +def test_alternate_identifier_rejects_empty_value() -> None: + with pytest.raises(InvalidAlternateIdentifierValueError): + AlternateIdentifier(kind=AlternateIdentifierKind.OTHER, value="") + + +@pytest.mark.unit +def test_alternate_identifier_rejects_whitespace_only_value() -> None: + with pytest.raises(InvalidAlternateIdentifierValueError): + AlternateIdentifier(kind=AlternateIdentifierKind.OTHER, value=" \t\n ") + + +@pytest.mark.unit +def test_alternate_identifier_rejects_too_long_value() -> None: + """Bound mirrors ManufacturerIdentifier (200 chars).""" + with pytest.raises(InvalidAlternateIdentifierValueError): + AlternateIdentifier( + kind=AlternateIdentifierKind.SERIAL_NUMBER, + value="x" * 201, + ) + + +@pytest.mark.unit +def test_alternate_identifier_accepts_max_length_value() -> None: + identifier = AlternateIdentifier( + kind=AlternateIdentifierKind.SERIAL_NUMBER, + value="x" * 200, + ) + assert len(identifier.value) == 200 + + +@pytest.mark.unit +def test_alternate_identifier_is_frozen_and_hashable() -> None: + """Pinned: AlternateIdentifier is a frozen dataclass (hashable) so + instances can live in a frozenset on Asset state.""" + from dataclasses import FrozenInstanceError + + identifier = AlternateIdentifier( + kind=AlternateIdentifierKind.SERIAL_NUMBER, + value="abc", + ) + s = {identifier} + assert identifier in s + with pytest.raises(FrozenInstanceError): + identifier.value = "xyz" # type: ignore[misc] + + +@pytest.mark.unit +def test_alternate_identifier_equality_is_value_based() -> None: + """Two AlternateIdentifiers with the same (kind, value) tuple are + equal regardless of incoming whitespace.""" + a = AlternateIdentifier(kind=AlternateIdentifierKind.OTHER, value="abc") + b = AlternateIdentifier(kind=AlternateIdentifierKind.OTHER, value=" abc ") + assert a == b + + +@pytest.mark.unit +def test_alternate_identifier_different_kind_is_not_equal() -> None: + a = AlternateIdentifier(kind=AlternateIdentifierKind.SERIAL_NUMBER, value="123") + b = AlternateIdentifier(kind=AlternateIdentifierKind.INVENTORY_NUMBER, value="123") + assert a != b + + +# ---------- Asset.alternate_identifiers field ---------- + + +@pytest.mark.unit +def test_asset_alternate_identifiers_defaults_to_empty_frozenset() -> None: + """Additive-state pattern: legacy AssetRegistered streams without + alternate_identifiers fold cleanly to empty frozenset.""" + asset = Asset( + id=uuid4(), + name=AssetName("X"), + level=AssetLevel.UNIT, + parent_id=uuid4(), + ) + assert asset.alternate_identifiers == frozenset() + + +@pytest.mark.unit +def test_asset_alternate_identifiers_accepts_non_empty_set() -> None: + ident1 = AlternateIdentifier(kind=AlternateIdentifierKind.SERIAL_NUMBER, value="12345") + ident2 = AlternateIdentifier(kind=AlternateIdentifierKind.INVENTORY_NUMBER, value="APS-001") + asset = Asset( + id=uuid4(), + name=AssetName("X"), + level=AssetLevel.UNIT, + parent_id=uuid4(), + alternate_identifiers=frozenset({ident1, ident2}), + ) + assert asset.alternate_identifiers == frozenset({ident1, ident2}) + + +# ---------- AssetAlternateIdentifierAlreadyPresentError ---------- + + +@pytest.mark.unit +def test_asset_alternate_identifier_already_present_carries_asset_id_and_identifier() -> None: + asset_id = uuid4() + identifier = AlternateIdentifier(kind=AlternateIdentifierKind.SERIAL_NUMBER, value="12345-ABC") + error = AssetAlternateIdentifierAlreadyPresentError(asset_id=asset_id, identifier=identifier) + assert error.asset_id == asset_id + assert error.identifier == identifier + + +@pytest.mark.unit +def test_asset_alternate_identifier_already_present_message_quotes_kind_and_value() -> None: + asset_id = uuid4() + identifier = AlternateIdentifier(kind=AlternateIdentifierKind.SERIAL_NUMBER, value="12345-ABC") + error = AssetAlternateIdentifierAlreadyPresentError(asset_id=asset_id, identifier=identifier) + message = str(error) + assert str(asset_id) in message + assert "SerialNumber" in message + assert "12345-ABC" in message + + +@pytest.mark.unit +def test_asset_alternate_identifier_already_present_is_exception() -> None: + """Subclass of Exception so it can be raised / caught in the + cannot_transition_cls tuple in routes.py (strict-not-idempotent + family).""" + error = AssetAlternateIdentifierAlreadyPresentError( + asset_id=uuid4(), + identifier=AlternateIdentifier(kind=AlternateIdentifierKind.OTHER, value="x"), + ) + assert isinstance(error, Exception) + + +# ---------- AssetAlternateIdentifierNotPresentError ---------- + + +@pytest.mark.unit +def test_asset_alternate_identifier_not_present_carries_asset_id_and_identifier() -> None: + asset_id = uuid4() + identifier = AlternateIdentifier( + kind=AlternateIdentifierKind.INVENTORY_NUMBER, value="APS-2BM-001" + ) + error = AssetAlternateIdentifierNotPresentError(asset_id=asset_id, identifier=identifier) + assert error.asset_id == asset_id + assert error.identifier == identifier + + +@pytest.mark.unit +def test_asset_alternate_identifier_not_present_message_quotes_kind_and_value() -> None: + asset_id = uuid4() + identifier = AlternateIdentifier( + kind=AlternateIdentifierKind.INVENTORY_NUMBER, value="APS-2BM-001" + ) + error = AssetAlternateIdentifierNotPresentError(asset_id=asset_id, identifier=identifier) + message = str(error) + assert str(asset_id) in message + assert "InventoryNumber" in message + assert "APS-2BM-001" in message + + +@pytest.mark.unit +def test_asset_alternate_identifier_not_present_is_exception() -> None: + error = AssetAlternateIdentifierNotPresentError( + asset_id=uuid4(), + identifier=AlternateIdentifier(kind=AlternateIdentifierKind.OTHER, value="x"), + ) + assert isinstance(error, Exception) + + +# ---------- InvalidAlternateIdentifierValueError ---------- + + +@pytest.mark.unit +def test_invalid_alternate_identifier_value_error_quotes_raw_value() -> None: + """Error message echoes the original (untrimmed) value so the + caller sees exactly what they sent.""" + with pytest.raises(InvalidAlternateIdentifierValueError) as excinfo: + AlternateIdentifier(kind=AlternateIdentifierKind.OTHER, value=" ") + assert excinfo.value.value == " " + assert " " in str(excinfo.value) diff --git a/apps/api/tests/unit/equipment/test_asset_events.py b/apps/api/tests/unit/equipment/test_asset_events.py index f3fab4c6a..873a16005 100644 --- a/apps/api/tests/unit/equipment/test_asset_events.py +++ b/apps/api/tests/unit/equipment/test_asset_events.py @@ -8,6 +8,8 @@ from cora.equipment.aggregates._drawing import Drawing, DrawingSystem from cora.equipment.aggregates.asset.events import ( AssetActivated, + AssetAlternateIdentifierAdded, + AssetAlternateIdentifierRemoved, AssetDecommissioned, AssetDegraded, AssetFamilyAdded, @@ -25,6 +27,10 @@ from_stored, to_payload, ) +from cora.equipment.aggregates.asset.state import ( + AlternateIdentifier, + AlternateIdentifierKind, +) from cora.infrastructure.ports.event_store import StoredEvent _NOW = datetime(2026, 5, 10, 12, 0, 0, tzinfo=UTC) @@ -1072,6 +1078,8 @@ def test_event_type_name_for_port_events() -> None: "AssetSettingsUpdated", "AssetPortAdded", "AssetPortRemoved", + "AssetAlternateIdentifierAdded", + "AssetAlternateIdentifierRemoved", ], ) def test_from_stored_raises_on_malformed_payload(event_type: str) -> None: @@ -1083,3 +1091,280 @@ def test_from_stored_raises_on_malformed_payload(event_type: str) -> None: in the load path.""" with pytest.raises(ValueError, match=f"Malformed {event_type} payload"): from_stored(_stored(event_type, {})) + + +# ---------- AssetRegistered.alternate_identifiers ---------- + + +_SAMPLE_ALT_ID_A = AlternateIdentifier( + kind=AlternateIdentifierKind.SERIAL_NUMBER, value="12345-ABC" +) +_SAMPLE_ALT_ID_B = AlternateIdentifier( + kind=AlternateIdentifierKind.INVENTORY_NUMBER, value="APS-2BM-CAM-001" +) + + +@pytest.mark.unit +def test_to_payload_omits_alternate_identifiers_when_empty() -> None: + """Omit-when-empty convention (Lock D): legacy AssetRegistered shape + (no alternate_identifiers) must serialize without the key so + existing stream readers can't accidentally observe an empty list + where the key was previously absent. Mirrors the drawing / + model_id precedents.""" + event = AssetRegistered( + asset_id=uuid4(), + name="X", + level="Site", + parent_id=uuid4(), + occurred_at=_NOW, + ) + payload = to_payload(event) + assert "alternate_identifiers" not in payload + + +@pytest.mark.unit +def test_to_payload_includes_alternate_identifiers_when_set() -> None: + event = AssetRegistered( + asset_id=uuid4(), + name="X", + level="Assembly", + parent_id=uuid4(), + occurred_at=_NOW, + alternate_identifiers=frozenset({_SAMPLE_ALT_ID_A}), + ) + payload = to_payload(event) + assert payload["alternate_identifiers"] == [ + {"kind": "SerialNumber", "value": "12345-ABC"}, + ] + + +@pytest.mark.unit +def test_to_payload_emits_alternate_identifiers_sorted_for_stable_bytes() -> None: + """Payload bytes must be deterministic across runs even though + frozenset iteration is not. Sorted on (kind, value) gives the + same JSON for the same VO set, which matters for any future + signing / content-addressed slice.""" + event = AssetRegistered( + asset_id=uuid4(), + name="X", + level="Assembly", + parent_id=uuid4(), + occurred_at=_NOW, + alternate_identifiers=frozenset({_SAMPLE_ALT_ID_B, _SAMPLE_ALT_ID_A}), + ) + payload = to_payload(event) + assert payload["alternate_identifiers"] == [ + {"kind": "InventoryNumber", "value": "APS-2BM-CAM-001"}, + {"kind": "SerialNumber", "value": "12345-ABC"}, + ] + + +@pytest.mark.unit +def test_from_stored_rebuilds_asset_registered_with_alternate_identifiers() -> None: + asset_id = uuid4() + parent_id = uuid4() + stored = _stored( + "AssetRegistered", + { + "asset_id": str(asset_id), + "name": "X", + "level": "Assembly", + "parent_id": str(parent_id), + "occurred_at": _NOW.isoformat(), + "alternate_identifiers": [ + {"kind": "SerialNumber", "value": "12345-ABC"}, + {"kind": "InventoryNumber", "value": "APS-2BM-CAM-001"}, + ], + }, + ) + rebuilt = from_stored(stored) + assert rebuilt == AssetRegistered( + asset_id=asset_id, + name="X", + level="Assembly", + parent_id=parent_id, + occurred_at=_NOW, + alternate_identifiers=frozenset({_SAMPLE_ALT_ID_A, _SAMPLE_ALT_ID_B}), + ) + + +@pytest.mark.unit +def test_from_stored_folds_legacy_payload_without_alternate_identifiers_to_empty() -> None: + """Backward-compat pin: existing AssetRegistered events written + before the alternate_identifiers widen had no key; they MUST fold + to an empty frozenset without raising. Mirrors the drawing / + model_id legacy-fold precedents.""" + asset_id = uuid4() + stored = _stored( + "AssetRegistered", + { + "asset_id": str(asset_id), + "name": "Pre-widen Asset", + "level": "Unit", + "parent_id": str(uuid4()), + "occurred_at": _NOW.isoformat(), + }, + ) + rebuilt = from_stored(stored) + assert isinstance(rebuilt, AssetRegistered) + assert rebuilt.alternate_identifiers == frozenset() + + +@pytest.mark.unit +def test_to_payload_then_from_stored_round_trips_without_alternate_identifiers_explicit() -> None: + """Pin the omit-then-rebuild path: alternate_identifiers=empty + frozenset survives the round-trip and emerges as the empty + frozenset (not as a missing attribute or list).""" + original = AssetRegistered( + asset_id=uuid4(), + name="No-AltIds Asset", + level="Site", + parent_id=uuid4(), + occurred_at=_NOW, + ) + stored = _stored("AssetRegistered", to_payload(original)) + rebuilt = from_stored(stored) + assert rebuilt == original + assert isinstance(rebuilt, AssetRegistered) + assert rebuilt.alternate_identifiers == frozenset() + + +@pytest.mark.unit +def test_to_payload_then_from_stored_round_trips_with_alternate_identifiers() -> None: + original = AssetRegistered( + asset_id=uuid4(), + name="X", + level="Assembly", + parent_id=uuid4(), + occurred_at=_NOW, + alternate_identifiers=frozenset({_SAMPLE_ALT_ID_A, _SAMPLE_ALT_ID_B}), + ) + stored = _stored("AssetRegistered", to_payload(original)) + assert from_stored(stored) == original + + +# ---------- AssetAlternateIdentifierAdded ---------- + + +@pytest.mark.unit +def test_event_type_name_returns_alternate_identifier_added_class_name() -> None: + event = AssetAlternateIdentifierAdded( + asset_id=uuid4(), alternate_identifier=_SAMPLE_ALT_ID_A, occurred_at=_NOW + ) + assert event_type_name(event) == "AssetAlternateIdentifierAdded" + + +@pytest.mark.unit +def test_to_payload_serializes_alternate_identifier_added() -> None: + asset_id = uuid4() + event = AssetAlternateIdentifierAdded( + asset_id=asset_id, + alternate_identifier=_SAMPLE_ALT_ID_A, + occurred_at=_NOW, + ) + assert to_payload(event) == { + "asset_id": str(asset_id), + "alternate_identifier": {"kind": "SerialNumber", "value": "12345-ABC"}, + "occurred_at": _NOW.isoformat(), + } + + +@pytest.mark.unit +def test_from_stored_rebuilds_alternate_identifier_added() -> None: + asset_id = uuid4() + stored = _stored( + "AssetAlternateIdentifierAdded", + { + "asset_id": str(asset_id), + "alternate_identifier": {"kind": "SerialNumber", "value": "12345-ABC"}, + "occurred_at": _NOW.isoformat(), + }, + ) + assert from_stored(stored) == AssetAlternateIdentifierAdded( + asset_id=asset_id, + alternate_identifier=_SAMPLE_ALT_ID_A, + occurred_at=_NOW, + ) + + +@pytest.mark.unit +def test_round_trip_for_alternate_identifier_added() -> None: + original = AssetAlternateIdentifierAdded( + asset_id=uuid4(), + alternate_identifier=_SAMPLE_ALT_ID_B, + occurred_at=_NOW, + ) + stored = _stored("AssetAlternateIdentifierAdded", to_payload(original)) + assert from_stored(stored) == original + + +# ---------- AssetAlternateIdentifierRemoved ---------- + + +@pytest.mark.unit +def test_event_type_name_returns_alternate_identifier_removed_class_name() -> None: + event = AssetAlternateIdentifierRemoved( + asset_id=uuid4(), alternate_identifier=_SAMPLE_ALT_ID_A, occurred_at=_NOW + ) + assert event_type_name(event) == "AssetAlternateIdentifierRemoved" + + +@pytest.mark.unit +def test_to_payload_serializes_alternate_identifier_removed() -> None: + asset_id = uuid4() + event = AssetAlternateIdentifierRemoved( + asset_id=asset_id, + alternate_identifier=_SAMPLE_ALT_ID_B, + occurred_at=_NOW, + ) + assert to_payload(event) == { + "asset_id": str(asset_id), + "alternate_identifier": {"kind": "InventoryNumber", "value": "APS-2BM-CAM-001"}, + "occurred_at": _NOW.isoformat(), + } + + +@pytest.mark.unit +def test_from_stored_rebuilds_alternate_identifier_removed() -> None: + asset_id = uuid4() + stored = _stored( + "AssetAlternateIdentifierRemoved", + { + "asset_id": str(asset_id), + "alternate_identifier": {"kind": "InventoryNumber", "value": "APS-2BM-CAM-001"}, + "occurred_at": _NOW.isoformat(), + }, + ) + assert from_stored(stored) == AssetAlternateIdentifierRemoved( + asset_id=asset_id, + alternate_identifier=_SAMPLE_ALT_ID_B, + occurred_at=_NOW, + ) + + +@pytest.mark.unit +def test_round_trip_for_alternate_identifier_removed() -> None: + original = AssetAlternateIdentifierRemoved( + asset_id=uuid4(), + alternate_identifier=_SAMPLE_ALT_ID_A, + occurred_at=_NOW, + ) + stored = _stored("AssetAlternateIdentifierRemoved", to_payload(original)) + assert from_stored(stored) == original + + +@pytest.mark.unit +def test_from_stored_raises_on_unknown_alternate_identifier_kind() -> None: + """An unknown `kind` payload value can't reconstruct the closed + StrEnum and must surface as a tagged Malformed error rather than a + bare ValueError from the enum constructor.""" + stored = _stored( + "AssetAlternateIdentifierAdded", + { + "asset_id": str(uuid4()), + "alternate_identifier": {"kind": "Unknown", "value": "x"}, + "occurred_at": _NOW.isoformat(), + }, + ) + with pytest.raises(ValueError, match="Malformed AssetAlternateIdentifierAdded payload"): + from_stored(stored) diff --git a/apps/api/tests/unit/equipment/test_asset_evolver.py b/apps/api/tests/unit/equipment/test_asset_evolver.py index 39bf4d7fd..a560362a4 100644 --- a/apps/api/tests/unit/equipment/test_asset_evolver.py +++ b/apps/api/tests/unit/equipment/test_asset_evolver.py @@ -17,6 +17,8 @@ ) from cora.equipment.aggregates.asset.events import ( AssetActivated, + AssetAlternateIdentifierAdded, + AssetAlternateIdentifierRemoved, AssetDecommissioned, AssetDegraded, AssetFamilyAdded, @@ -31,7 +33,12 @@ AssetRestored, AssetSettingsUpdated, ) -from cora.equipment.aggregates.asset.state import AssetPort, PortDirection +from cora.equipment.aggregates.asset.state import ( + AlternateIdentifier, + AlternateIdentifierKind, + AssetPort, + PortDirection, +) from cora.equipment.features import register_asset from cora.equipment.features.register_asset import RegisterAsset @@ -1809,3 +1816,403 @@ def test_fold_register_with_model_id_then_lifecycle_transitions_preserves_model_ assert state is not None assert state.model_id == model_id assert state.lifecycle is AssetLifecycle.DECOMMISSIONED + + +# ---------- alternate_identifiers genesis + transition arms + preservation ---------- + + +_SAMPLE_ALT_ID_A = AlternateIdentifier( + kind=AlternateIdentifierKind.SERIAL_NUMBER, value="12345-ABC" +) +_SAMPLE_ALT_ID_B = AlternateIdentifier( + kind=AlternateIdentifierKind.INVENTORY_NUMBER, value="APS-2BM-CAM-001" +) + + +@pytest.mark.unit +def test_evolve_asset_registered_defaults_alternate_identifiers_to_empty_frozenset() -> None: + """Genesis: AssetRegistered without alternate_identifiers yields + Asset.alternate_identifiers=empty frozenset via the event-side + default (additive-payload pattern).""" + state = evolve( + None, + AssetRegistered( + asset_id=uuid4(), + name="X", + level="Unit", + parent_id=uuid4(), + occurred_at=_NOW, + ), + ) + assert state.alternate_identifiers == frozenset() + + +@pytest.mark.unit +def test_evolve_asset_registered_carries_alternate_identifiers_into_state() -> None: + """Lock D: when register_asset seeds alternate_identifiers, the + evolver lands them on Asset.alternate_identifiers verbatim.""" + state = evolve( + None, + AssetRegistered( + asset_id=uuid4(), + name="X", + level="Unit", + parent_id=uuid4(), + occurred_at=_NOW, + alternate_identifiers=frozenset({_SAMPLE_ALT_ID_A, _SAMPLE_ALT_ID_B}), + ), + ) + assert state.alternate_identifiers == frozenset({_SAMPLE_ALT_ID_A, _SAMPLE_ALT_ID_B}) + + +@pytest.mark.unit +def test_evolve_alternate_identifier_added_inserts_into_frozenset() -> None: + asset_id = uuid4() + prior = Asset( + id=asset_id, + name=AssetName("X"), + level=AssetLevel.UNIT, + parent_id=uuid4(), + ) + state = evolve( + prior, + AssetAlternateIdentifierAdded( + asset_id=asset_id, + alternate_identifier=_SAMPLE_ALT_ID_A, + occurred_at=_NOW, + ), + ) + assert state.alternate_identifiers == frozenset({_SAMPLE_ALT_ID_A}) + + +@pytest.mark.unit +def test_evolve_alternate_identifier_added_is_idempotent_at_evolver_layer() -> None: + """Evolver does NOT enforce strict-not-idempotent; that's the + decider's job. Frozenset union semantics: adding an already-present + (kind, value) is a no-op at this layer.""" + asset_id = uuid4() + prior = Asset( + id=asset_id, + name=AssetName("X"), + level=AssetLevel.UNIT, + parent_id=uuid4(), + alternate_identifiers=frozenset({_SAMPLE_ALT_ID_A}), + ) + state = evolve( + prior, + AssetAlternateIdentifierAdded( + asset_id=asset_id, + alternate_identifier=_SAMPLE_ALT_ID_A, + occurred_at=_NOW, + ), + ) + assert state.alternate_identifiers == frozenset({_SAMPLE_ALT_ID_A}) + + +@pytest.mark.unit +def test_evolve_alternate_identifier_removed_drops_from_frozenset() -> None: + asset_id = uuid4() + prior = Asset( + id=asset_id, + name=AssetName("X"), + level=AssetLevel.UNIT, + parent_id=uuid4(), + alternate_identifiers=frozenset({_SAMPLE_ALT_ID_A, _SAMPLE_ALT_ID_B}), + ) + state = evolve( + prior, + AssetAlternateIdentifierRemoved( + asset_id=asset_id, + alternate_identifier=_SAMPLE_ALT_ID_A, + occurred_at=_NOW, + ), + ) + assert state.alternate_identifiers == frozenset({_SAMPLE_ALT_ID_B}) + + +@pytest.mark.unit +def test_evolve_alternate_identifier_removed_is_idempotent_at_evolver_layer() -> None: + """Same rationale as Added's idempotent-at-evolver pin.""" + asset_id = uuid4() + prior = Asset( + id=asset_id, + name=AssetName("X"), + level=AssetLevel.UNIT, + parent_id=uuid4(), + alternate_identifiers=frozenset(), + ) + state = evolve( + prior, + AssetAlternateIdentifierRemoved( + asset_id=asset_id, + alternate_identifier=_SAMPLE_ALT_ID_A, + occurred_at=_NOW, + ), + ) + assert state.alternate_identifiers == frozenset() + + +@pytest.mark.unit +def test_evolve_alternate_identifier_added_on_empty_state_raises() -> None: + with pytest.raises(ValueError, match="cannot be applied to empty state"): + evolve( + None, + AssetAlternateIdentifierAdded( + asset_id=uuid4(), + alternate_identifier=_SAMPLE_ALT_ID_A, + occurred_at=_NOW, + ), + ) + + +@pytest.mark.unit +def test_evolve_alternate_identifier_removed_on_empty_state_raises() -> None: + with pytest.raises(ValueError, match="cannot be applied to empty state"): + evolve( + None, + AssetAlternateIdentifierRemoved( + asset_id=uuid4(), + alternate_identifier=_SAMPLE_ALT_ID_A, + occurred_at=_NOW, + ), + ) + + +@pytest.mark.unit +def test_evolve_alternate_identifier_added_preserves_other_facets() -> None: + """Alternate-identifier mutations must not touch lifecycle / + condition / settings / capabilities / parent_id / ports / drawing + / model_id.""" + asset_id = uuid4() + parent = uuid4() + cap = uuid4() + model_id = uuid4() + port = AssetPort(name="x", direction=PortDirection.INPUT, signal_type="TTL") + drawing = Drawing(system=DrawingSystem.ICMS, number="P4105", revision="A") + prior = Asset( + id=asset_id, + name=AssetName("X"), + level=AssetLevel.DEVICE, + parent_id=parent, + lifecycle=AssetLifecycle.MAINTENANCE, + condition=AssetCondition.DEGRADED, + family_ids=frozenset({cap}), + settings={"k": 1}, + ports=frozenset({port}), + drawing=drawing, + model_id=model_id, + ) + state = evolve( + prior, + AssetAlternateIdentifierAdded( + asset_id=asset_id, + alternate_identifier=_SAMPLE_ALT_ID_A, + occurred_at=_NOW, + ), + ) + assert state.lifecycle is AssetLifecycle.MAINTENANCE + assert state.condition is AssetCondition.DEGRADED + assert state.family_ids == frozenset({cap}) + assert state.settings == {"k": 1} + assert state.ports == frozenset({port}) + assert state.parent_id == parent + assert state.drawing == drawing + assert state.model_id == model_id + + +@pytest.mark.unit +@pytest.mark.parametrize( + ("name", "transition"), + [ + ("activate", AssetActivated), + ("decommission", AssetDecommissioned), + ("enter_maintenance", AssetMaintenanceEntered), + ("exit_maintenance", AssetMaintenanceExited), + ], +) +def test_evolve_lifecycle_transition_preserves_alternate_identifiers( + name: str, + transition: type, +) -> None: + """Critical pin: every lifecycle transition arm MUST carry + alternate_identifiers through from prior state. Constructing + Asset(...) without explicitly passing alternate_identifiers would + silently WIPE it to its default (empty frozenset). Same shape as + the family_ids / ports / drawing / model_id preservation pins.""" + _ = name + prior = Asset( + id=uuid4(), + name=AssetName("X"), + level=AssetLevel.UNIT, + parent_id=uuid4(), + lifecycle=( + AssetLifecycle.COMMISSIONED + if transition is AssetActivated + else AssetLifecycle.ACTIVE + if transition is AssetMaintenanceEntered + else AssetLifecycle.MAINTENANCE + if transition is AssetMaintenanceExited + else AssetLifecycle.ACTIVE + ), + alternate_identifiers=frozenset({_SAMPLE_ALT_ID_A, _SAMPLE_ALT_ID_B}), + ) + state = evolve(prior, transition(asset_id=prior.id, occurred_at=_NOW)) + assert state.alternate_identifiers == frozenset({_SAMPLE_ALT_ID_A, _SAMPLE_ALT_ID_B}) + + +@pytest.mark.unit +def test_evolve_relocate_preserves_alternate_identifiers() -> None: + """Hierarchy mutation also must preserve alternate_identifiers.""" + old_parent = uuid4() + new_parent = uuid4() + prior = Asset( + id=uuid4(), + name=AssetName("X"), + level=AssetLevel.UNIT, + parent_id=old_parent, + alternate_identifiers=frozenset({_SAMPLE_ALT_ID_A}), + ) + 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.alternate_identifiers == frozenset({_SAMPLE_ALT_ID_A}) + + +@pytest.mark.unit +@pytest.mark.parametrize( + ("name", "transition", "kwargs"), + [ + ("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}}), + ], +) +def test_evolve_mutation_preserves_alternate_identifiers( + name: str, + transition: type, + kwargs: dict[str, object], +) -> None: + """Mirror of test_evolve_mutation_preserves_drawing / + test_evolve_mutation_preserves_model_id: every mutation arm + carries alternate_identifiers forward.""" + _ = name + prior = Asset( + id=uuid4(), + name=AssetName("X"), + level=AssetLevel.UNIT, + parent_id=uuid4(), + alternate_identifiers=frozenset({_SAMPLE_ALT_ID_A}), + ) + state = evolve(prior, transition(asset_id=prior.id, occurred_at=_NOW, **kwargs)) + assert state.alternate_identifiers == frozenset({_SAMPLE_ALT_ID_A}) + + +@pytest.mark.unit +def test_evolve_port_added_preserves_alternate_identifiers() -> None: + prior = Asset( + id=uuid4(), + name=AssetName("X"), + level=AssetLevel.DEVICE, + parent_id=uuid4(), + alternate_identifiers=frozenset({_SAMPLE_ALT_ID_B}), + ) + state = evolve( + prior, + AssetPortAdded( + asset_id=prior.id, + port_name="x", + direction="Input", + signal_type="TTL", + occurred_at=_NOW, + ), + ) + assert state.alternate_identifiers == frozenset({_SAMPLE_ALT_ID_B}) + + +@pytest.mark.unit +def test_evolve_port_removed_preserves_alternate_identifiers() -> None: + port = AssetPort(name="x", direction=PortDirection.INPUT, signal_type="TTL") + prior = Asset( + id=uuid4(), + name=AssetName("X"), + level=AssetLevel.DEVICE, + parent_id=uuid4(), + ports=frozenset({port}), + alternate_identifiers=frozenset({_SAMPLE_ALT_ID_A}), + ) + state = evolve( + prior, + AssetPortRemoved(asset_id=prior.id, port_name="x", occurred_at=_NOW), + ) + assert state.alternate_identifiers == frozenset({_SAMPLE_ALT_ID_A}) + + +@pytest.mark.unit +def test_fold_register_then_add_then_remove_yields_empty_alternate_identifiers() -> None: + """End-to-end fold: register -> add alt-id -> remove alt-id lands + back at empty. Pin against the fold layer.""" + asset_id = uuid4() + state = fold( + [ + AssetRegistered( + asset_id=asset_id, + name="X", + level="Unit", + parent_id=uuid4(), + occurred_at=_NOW, + ), + AssetAlternateIdentifierAdded( + asset_id=asset_id, + alternate_identifier=_SAMPLE_ALT_ID_A, + occurred_at=_NOW, + ), + AssetAlternateIdentifierRemoved( + asset_id=asset_id, + alternate_identifier=_SAMPLE_ALT_ID_A, + occurred_at=_NOW, + ), + ] + ) + assert state is not None + assert state.alternate_identifiers == frozenset() + + +@pytest.mark.unit +def test_fold_register_with_seed_then_lifecycle_transitions_preserves_alternate_identifiers() -> ( + None +): + """End-to-end fold: register with seeded alternate_identifiers, + then activate + enter maintenance + exit maintenance + decommission. + The seed survives the entire lifecycle path.""" + asset_id = uuid4() + parent_id = uuid4() + seed = frozenset({_SAMPLE_ALT_ID_A, _SAMPLE_ALT_ID_B}) + state = fold( + [ + AssetRegistered( + asset_id=asset_id, + name="APS-2BM", + level="Unit", + parent_id=parent_id, + occurred_at=_NOW, + alternate_identifiers=seed, + ), + AssetActivated(asset_id=asset_id, occurred_at=_NOW), + AssetMaintenanceEntered(asset_id=asset_id, occurred_at=_NOW), + AssetMaintenanceExited(asset_id=asset_id, occurred_at=_NOW), + AssetDecommissioned(asset_id=asset_id, occurred_at=_NOW), + ] + ) + assert state is not None + assert state.alternate_identifiers == seed + assert state.lifecycle is AssetLifecycle.DECOMMISSIONED 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 ef4d081bb..c880afee3 100644 --- a/apps/api/tests/unit/equipment/test_asset_summary_projection.py +++ b/apps/api/tests/unit/equipment/test_asset_summary_projection.py @@ -56,6 +56,8 @@ def test_projection_metadata() -> None: "AssetDegraded", "AssetFaulted", "AssetRestored", + "AssetAlternateIdentifierAdded", + "AssetAlternateIdentifierRemoved", } ) @@ -104,7 +106,15 @@ async def test_asset_registered_inserts_with_commissioned_lifecycle_and_parent() assert args.args[7] is None # model_id omitted from payload: column folds to NULL. assert args.args[8] is None - assert args.args[9] == _NOW + # alternate_identifiers omitted from payload: column folds to [] + # (NOT NULL DEFAULT '[]'::jsonb on the column, but the projection + # writes the canonical empty list so the row's payload is + # explicit and replays remain deterministic). The asyncpg pool + # registers a jsonb codec that runs json.dumps on every parameter + # bound to a jsonb column, so the projection passes a Python list + # directly instead of a pre-serialized JSON string. + assert args.args[9] == [] + assert args.args[10] == _NOW @pytest.mark.unit @@ -396,3 +406,160 @@ async def test_condition_event_does_not_carry_reason_into_sql_args() -> None: args = conn.execute.await_args assert args is not None assert "long detailed reason" not in str(args.args) + + +# ---------- alternate identifiers ---------- + + +@pytest.mark.unit +async def test_asset_registered_with_alternate_identifiers_writes_sorted_list() -> None: + """AssetRegistered carrying alternate_identifiers in the payload + serializes to a canonical sorted list of dicts in the new column. + Sort key is (kind, value); the projection re-sorts defensively so + a hand-crafted out-of-order payload still lands canonical. The + asyncpg pool's jsonb codec turns the list into JSON at parameter + bind time, so the projection passes the Python list directly.""" + proj = AssetSummaryProjection() + conn = AsyncMock() + event = _stored( + "AssetRegistered", + { + "asset_id": str(_ASSET_ID), + "name": "specimen-with-tags", + "level": "Device", + "parent_id": str(_PARENT_ID), + "occurred_at": _NOW.isoformat(), + # Intentionally out-of-order to exercise the defensive sort. + "alternate_identifiers": [ + {"kind": "SerialNumber", "value": "SN-002"}, + {"kind": "InventoryNumber", "value": "ANL-12345"}, + {"kind": "SerialNumber", "value": "SN-001"}, + ], + }, + ) + + await proj.apply(event, conn) + + args = conn.execute.await_args + assert args is not None + assert args.args[9] == [ + {"kind": "InventoryNumber", "value": "ANL-12345"}, + {"kind": "SerialNumber", "value": "SN-001"}, + {"kind": "SerialNumber", "value": "SN-002"}, + ] + + +@pytest.mark.unit +async def test_asset_registered_with_empty_alternate_identifiers_writes_empty_list() -> None: + """An explicit empty list in the payload still serializes to the + canonical empty Python list (matches the omit-the-key branch).""" + proj = AssetSummaryProjection() + conn = AsyncMock() + event = _stored( + "AssetRegistered", + { + "asset_id": str(_ASSET_ID), + "name": "specimen", + "level": "Device", + "parent_id": str(_PARENT_ID), + "occurred_at": _NOW.isoformat(), + "alternate_identifiers": [], + }, + ) + + await proj.apply(event, conn) + + args = conn.execute.await_args + assert args is not None + assert args.args[9] == [] + + +@pytest.mark.unit +async def test_alternate_identifier_added_updates_jsonb_column() -> None: + """The Added event triggers a JSONB-array UPDATE that appends the + (kind, value) pair into the alternate_identifiers column. Dedupe + + re-sort happen server-side via the SQL statement; the projection + pulls kind + value out of the nested `alternate_identifier` payload + object (mirrors the events.py to_payload wire shape).""" + proj = AssetSummaryProjection() + conn = AsyncMock() + event = _stored( + "AssetAlternateIdentifierAdded", + { + "asset_id": str(_ASSET_ID), + "alternate_identifier": {"kind": "SerialNumber", "value": "SN-007"}, + "occurred_at": _NOW.isoformat(), + }, + ) + + 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 alternate_identifiers" in sql + # Dedupe (DISTINCT ON) + canonical sort (ORDER BY) are both in the + # statement so a re-replay folds to a no-op at the DB layer. + assert "DISTINCT ON" in sql + assert "ORDER BY" in sql + assert args.args[1] == _ASSET_ID + assert args.args[2] == "SerialNumber" + assert args.args[3] == "SN-007" + + +@pytest.mark.unit +async def test_alternate_identifier_removed_updates_jsonb_column() -> None: + """The Removed event filters out the matching (kind, value) element + from the alternate_identifiers JSONB array. The statement uses + COALESCE so an array that collapses to empty stays `[]` (NOT NULL + column).""" + proj = AssetSummaryProjection() + conn = AsyncMock() + event = _stored( + "AssetAlternateIdentifierRemoved", + { + "asset_id": str(_ASSET_ID), + "alternate_identifier": {"kind": "InventoryNumber", "value": "ANL-12345"}, + "occurred_at": _NOW.isoformat(), + }, + ) + + 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 alternate_identifiers" in sql + assert "COALESCE" in sql + assert args.args[1] == _ASSET_ID + assert args.args[2] == "InventoryNumber" + assert args.args[3] == "ANL-12345" + + +@pytest.mark.unit +async def test_alternate_identifier_added_with_other_kind_passes_through() -> None: + """`Other` is a valid third value in the AlternateIdentifierKind + closed StrEnum (verbatim from PIDINST v1.0 Table 1). The projection + is enum-agnostic: kind is just a string at this layer; the domain + side guards the closed set.""" + proj = AssetSummaryProjection() + conn = AsyncMock() + event = _stored( + "AssetAlternateIdentifierAdded", + { + "asset_id": str(_ASSET_ID), + "alternate_identifier": {"kind": "Other", "value": "operator-tag-2026-Q2"}, + "occurred_at": _NOW.isoformat(), + }, + ) + + await proj.apply(event, conn) + + args = conn.execute.await_args + assert args is not None + assert args.args[2] == "Other" + assert args.args[3] == "operator-tag-2026-Q2" diff --git a/apps/api/tests/unit/equipment/test_register_asset_decider.py b/apps/api/tests/unit/equipment/test_register_asset_decider.py index a2cefb91a..2376862a9 100644 --- a/apps/api/tests/unit/equipment/test_register_asset_decider.py +++ b/apps/api/tests/unit/equipment/test_register_asset_decider.py @@ -11,6 +11,8 @@ from cora.equipment.aggregates._drawing import Drawing, DrawingSystem from cora.equipment.aggregates.asset import ( + AlternateIdentifier, + AlternateIdentifierKind, Asset, AssetAlreadyExistsError, AssetLevel, @@ -228,6 +230,48 @@ def test_decide_defaults_model_id_to_none_when_omitted() -> None: assert events[0].model_id is None +@pytest.mark.unit +def test_decide_passes_alternate_identifiers_through_to_emitted_event() -> None: + """Happy path: a non-empty `alternate_identifiers` set on the + command rides the AssetRegistered event verbatim. The decider does + NOT validate (kind, value) cross-Asset uniqueness in v1 per Lock F.""" + identifiers = frozenset( + { + AlternateIdentifier(kind=AlternateIdentifierKind.SERIAL_NUMBER, value="ANT130L-12345"), + AlternateIdentifier( + kind=AlternateIdentifierKind.INVENTORY_NUMBER, value="APS-2BM-RS-001" + ), + } + ) + events = register_asset.decide( + state=None, + command=RegisterAsset( + name="APS-2BM-RotaryStage", + level=AssetLevel.DEVICE, + parent_id=uuid4(), + alternate_identifiers=identifiers, + ), + now=_NOW, + new_id=uuid4(), + ) + assert events[0].alternate_identifiers == identifiers + + +@pytest.mark.unit +def test_decide_defaults_alternate_identifiers_to_empty_when_omitted() -> None: + events = register_asset.decide( + state=None, + command=RegisterAsset( + name="APS", + level=AssetLevel.SITE, + parent_id=uuid4(), + ), + now=_NOW, + new_id=uuid4(), + ) + assert events[0].alternate_identifiers == frozenset() + + @pytest.mark.unit def test_decide_is_pure_same_inputs_same_outputs() -> None: new_id = uuid4() diff --git a/apps/api/tests/unit/equipment/test_register_asset_decider_properties.py b/apps/api/tests/unit/equipment/test_register_asset_decider_properties.py index 49546c682..d324d105f 100644 --- a/apps/api/tests/unit/equipment/test_register_asset_decider_properties.py +++ b/apps/api/tests/unit/equipment/test_register_asset_decider_properties.py @@ -1,15 +1,22 @@ """Property-based tests for `register_asset.decide` (Equipment BC). Universal claims across generated inputs, scoped to the model_id -propagation contract added by the asset-model-binding slice: +propagation contract added by the asset-model-binding slice and +to the alternate_identifiers propagation contract added by the +asset-alternate-identifiers slice: - state=None + valid command + any `model_id` (UUID-or-None) emits a single `AssetRegistered` whose `model_id` field equals the command's `model_id` verbatim. The decider does NOT load the Model snapshot per Lock B; the handler is the seam that enforces existence. + - state=None + valid command + any `alternate_identifiers` + frozenset emits a single `AssetRegistered` whose + `alternate_identifiers` field equals the command's set + verbatim. The decider does NOT cross-validate (kind, value) + uniqueness across Assets in v1 per Lock F. - Pure: same (state, command, now, new_id) returns the same - events for any `model_id` choice. + events for any model_id + alternate_identifiers choice. """ from __future__ import annotations @@ -20,7 +27,13 @@ from hypothesis import given from hypothesis import strategies as st -from cora.equipment.aggregates.asset import AssetLevel, AssetRegistered +from cora.equipment.aggregates.asset import ( + ALTERNATE_IDENTIFIER_VALUE_MAX_LENGTH, + AlternateIdentifier, + AlternateIdentifierKind, + AssetLevel, + AssetRegistered, +) from cora.equipment.features import register_asset from cora.equipment.features.register_asset import RegisterAsset from tests._strategies import aware_datetimes, printable_ascii_text @@ -40,6 +53,18 @@ AssetLevel.DEVICE, ] ) +_ALTERNATE_IDENTIFIER_KINDS = st.sampled_from(list(AlternateIdentifierKind)) +_ALTERNATE_IDENTIFIER_VALUES = printable_ascii_text( + min_size=1, max_size=ALTERNATE_IDENTIFIER_VALUE_MAX_LENGTH +) +_ALTERNATE_IDENTIFIERS = st.frozensets( + st.builds( + AlternateIdentifier, + kind=_ALTERNATE_IDENTIFIER_KINDS, + value=_ALTERNATE_IDENTIFIER_VALUES, + ), + max_size=5, +) @pytest.mark.unit @@ -103,3 +128,68 @@ def test_register_asset_is_pure_across_model_id_inputs( first = register_asset.decide(state=None, command=command, now=now, new_id=new_id) second = register_asset.decide(state=None, command=command, now=now, new_id=new_id) assert first == second + + +@pytest.mark.unit +@given( + name=_NAME, + level=_NON_ENTERPRISE_LEVELS, + parent_id=st.uuids(), + alternate_identifiers=_ALTERNATE_IDENTIFIERS, + now=aware_datetimes(), + new_id=st.uuids(), +) +def test_register_asset_propagates_alternate_identifiers_verbatim_into_event( + name: str, + level: AssetLevel, + parent_id: UUID, + alternate_identifiers: frozenset[AlternateIdentifier], + now: datetime, + new_id: UUID, +) -> None: + """Any frozenset[AlternateIdentifier] on the command rides + AssetRegistered unchanged. The decider does not cross-validate + (kind, value) uniqueness across Assets per Lock F; frozenset + semantics on the command structurally forbid in-Asset duplicates.""" + command = RegisterAsset( + name=name, + level=level, + parent_id=parent_id, + alternate_identifiers=alternate_identifiers, + ) + events = register_asset.decide(state=None, command=command, now=now, new_id=new_id) + assert len(events) == 1 + event = events[0] + assert isinstance(event, AssetRegistered) + assert event.alternate_identifiers == alternate_identifiers + + +@pytest.mark.unit +@given( + name=_NAME, + level=_NON_ENTERPRISE_LEVELS, + parent_id=st.uuids(), + alternate_identifiers=_ALTERNATE_IDENTIFIERS, + now=aware_datetimes(), + new_id=st.uuids(), +) +def test_register_asset_is_pure_across_alternate_identifiers_inputs( + name: str, + level: AssetLevel, + parent_id: UUID, + alternate_identifiers: frozenset[AlternateIdentifier], + now: datetime, + new_id: UUID, +) -> None: + """Two calls with identical args (including alternate_identifiers) + return identical events. Pins decider purity over the new + alternate_identifiers axis.""" + command = RegisterAsset( + name=name, + level=level, + parent_id=parent_id, + alternate_identifiers=alternate_identifiers, + ) + first = register_asset.decide(state=None, command=command, now=now, new_id=new_id) + second = register_asset.decide(state=None, command=command, now=now, new_id=new_id) + assert first == second diff --git a/apps/api/tests/unit/equipment/test_remove_asset_alternate_identifier_decider.py b/apps/api/tests/unit/equipment/test_remove_asset_alternate_identifier_decider.py new file mode 100644 index 000000000..a011cd49a --- /dev/null +++ b/apps/api/tests/unit/equipment/test_remove_asset_alternate_identifier_decider.py @@ -0,0 +1,180 @@ +"""Unit tests for the `remove_asset_alternate_identifier` slice's pure decider.""" + +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest + +from cora.equipment.aggregates.asset import ( + AlternateIdentifier, + AlternateIdentifierKind, + Asset, + AssetAlternateIdentifierNotPresentError, + AssetAlternateIdentifierRemoved, + AssetCannotAddAlternateIdentifierError, + AssetLevel, + AssetLifecycle, + AssetName, + AssetNotFoundError, +) +from cora.equipment.features import remove_asset_alternate_identifier +from cora.equipment.features.remove_asset_alternate_identifier import ( + RemoveAssetAlternateIdentifier, +) + +_NOW = datetime(2026, 6, 2, 12, 0, 0, tzinfo=UTC) + + +def _asset( + *, + lifecycle: AssetLifecycle = AssetLifecycle.ACTIVE, + alternate_identifiers: frozenset[AlternateIdentifier] = frozenset(), +) -> Asset: + return Asset( + id=uuid4(), + name=AssetName("Detector-X"), + level=AssetLevel.DEVICE, + parent_id=uuid4(), + lifecycle=lifecycle, + alternate_identifiers=alternate_identifiers, + ) + + +@pytest.mark.unit +def test_decide_emits_event_when_removing_existing_identifier() -> None: + identifier = AlternateIdentifier(kind=AlternateIdentifierKind.SERIAL_NUMBER, value="XYZ-001") + state = _asset(alternate_identifiers=frozenset({identifier})) + events = remove_asset_alternate_identifier.decide( + state=state, + command=RemoveAssetAlternateIdentifier(asset_id=state.id, alternate_identifier=identifier), + now=_NOW, + ) + assert events == [ + AssetAlternateIdentifierRemoved( + asset_id=state.id, + alternate_identifier=identifier, + occurred_at=_NOW, + ) + ] + + +@pytest.mark.unit +def test_decide_raises_asset_not_found_when_state_is_none() -> None: + target_id = uuid4() + identifier = AlternateIdentifier(kind=AlternateIdentifierKind.SERIAL_NUMBER, value="XYZ-001") + with pytest.raises(AssetNotFoundError) as exc_info: + remove_asset_alternate_identifier.decide( + state=None, + command=RemoveAssetAlternateIdentifier( + asset_id=target_id, alternate_identifier=identifier + ), + now=_NOW, + ) + assert exc_info.value.asset_id == target_id + + +@pytest.mark.unit +def test_decide_raises_not_present_when_pair_missing() -> None: + """Strict-not-idempotent: removing a non-existent (kind, value) pair raises.""" + existing = AlternateIdentifier(kind=AlternateIdentifierKind.SERIAL_NUMBER, value="XYZ-001") + missing = AlternateIdentifier(kind=AlternateIdentifierKind.INVENTORY_NUMBER, value="APS-0042") + state = _asset(alternate_identifiers=frozenset({existing})) + with pytest.raises(AssetAlternateIdentifierNotPresentError) as exc_info: + remove_asset_alternate_identifier.decide( + state=state, + command=RemoveAssetAlternateIdentifier(asset_id=state.id, alternate_identifier=missing), + now=_NOW, + ) + assert exc_info.value.asset_id == state.id + assert exc_info.value.identifier == missing + + +@pytest.mark.unit +def test_decide_raises_not_present_when_kind_differs_but_value_matches() -> None: + """Exact (kind, value) pair match: same value under a different kind + is not considered the same identifier.""" + existing = AlternateIdentifier(kind=AlternateIdentifierKind.SERIAL_NUMBER, value="ABC-9") + same_value_other_kind = AlternateIdentifier( + kind=AlternateIdentifierKind.INVENTORY_NUMBER, value="ABC-9" + ) + state = _asset(alternate_identifiers=frozenset({existing})) + with pytest.raises(AssetAlternateIdentifierNotPresentError): + remove_asset_alternate_identifier.decide( + state=state, + command=RemoveAssetAlternateIdentifier( + asset_id=state.id, alternate_identifier=same_value_other_kind + ), + now=_NOW, + ) + + +@pytest.mark.unit +def test_decide_removes_only_matched_pair_when_multiple_exist() -> None: + """The decider's job is to emit the AssetAlternateIdentifierRemoved + event with the matched pair; the evolver removes by pair, leaving + siblings.""" + a = AlternateIdentifier(kind=AlternateIdentifierKind.SERIAL_NUMBER, value="A") + b = AlternateIdentifier(kind=AlternateIdentifierKind.INVENTORY_NUMBER, value="B") + c = AlternateIdentifier(kind=AlternateIdentifierKind.OTHER, value="C") + state = _asset(alternate_identifiers=frozenset({a, b, c})) + events = remove_asset_alternate_identifier.decide( + state=state, + command=RemoveAssetAlternateIdentifier(asset_id=state.id, alternate_identifier=b), + now=_NOW, + ) + assert events == [ + AssetAlternateIdentifierRemoved( + asset_id=state.id, + alternate_identifier=b, + occurred_at=_NOW, + ) + ] + + +@pytest.mark.unit +def test_decide_rejects_decommissioned() -> None: + """Lifecycle guard mirrors `remove_asset_port`: a Decommissioned + asset is out of inventory; identifier changes are not allowed. + Uses the shared `AssetCannotAddAlternateIdentifierError` class + (same class as the add decider, per state.py:317-342).""" + identifier = AlternateIdentifier(kind=AlternateIdentifierKind.SERIAL_NUMBER, value="XYZ-001") + state = _asset( + lifecycle=AssetLifecycle.DECOMMISSIONED, + alternate_identifiers=frozenset({identifier}), + ) + with pytest.raises(AssetCannotAddAlternateIdentifierError) as exc_info: + remove_asset_alternate_identifier.decide( + state=state, + command=RemoveAssetAlternateIdentifier( + asset_id=state.id, alternate_identifier=identifier + ), + now=_NOW, + ) + assert exc_info.value.asset_id == state.id + assert exc_info.value.kind is AlternateIdentifierKind.SERIAL_NUMBER + assert exc_info.value.value == "XYZ-001" + assert "Decommissioned" in exc_info.value.reason + + +@pytest.mark.unit +@pytest.mark.parametrize( + "lifecycle", + [ + AssetLifecycle.COMMISSIONED, + AssetLifecycle.ACTIVE, + AssetLifecycle.MAINTENANCE, + ], +) +def test_decide_succeeds_for_every_non_decommissioned_lifecycle( + lifecycle: AssetLifecycle, +) -> None: + """Lifecycle-independence holds across every non-Decommissioned + state. Symmetric with `remove_asset_port`.""" + identifier = AlternateIdentifier(kind=AlternateIdentifierKind.SERIAL_NUMBER, value="x") + state = _asset(lifecycle=lifecycle, alternate_identifiers=frozenset({identifier})) + events = remove_asset_alternate_identifier.decide( + state=state, + command=RemoveAssetAlternateIdentifier(asset_id=state.id, alternate_identifier=identifier), + now=_NOW, + ) + assert len(events) == 1 diff --git a/apps/api/tests/unit/equipment/test_remove_asset_alternate_identifier_decider_properties.py b/apps/api/tests/unit/equipment/test_remove_asset_alternate_identifier_decider_properties.py new file mode 100644 index 000000000..0d0b1e508 --- /dev/null +++ b/apps/api/tests/unit/equipment/test_remove_asset_alternate_identifier_decider_properties.py @@ -0,0 +1,231 @@ +"""Property-based tests for `remove_asset_alternate_identifier.decide`. + +Complements the example-based decider tests with universal claims +across generated inputs. The decider's pure shape + + (state, command, now) -> list[AssetAlternateIdentifierRemoved] + +makes a handful of strict-not-idempotent + lifecycle-independence +properties mechanical to express. +""" + +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 ( + ALTERNATE_IDENTIFIER_VALUE_MAX_LENGTH, + AlternateIdentifier, + AlternateIdentifierKind, + Asset, + AssetAlternateIdentifierNotPresentError, + AssetAlternateIdentifierRemoved, + AssetCannotAddAlternateIdentifierError, + AssetLevel, + AssetLifecycle, + AssetName, + AssetNotFoundError, +) +from cora.equipment.features import remove_asset_alternate_identifier +from cora.equipment.features.remove_asset_alternate_identifier import ( + RemoveAssetAlternateIdentifier, +) + +if TYPE_CHECKING: + from uuid import UUID + +_NON_DECOMMISSIONED_LIFECYCLE = st.sampled_from( + [lc for lc in AssetLifecycle if lc is not AssetLifecycle.DECOMMISSIONED] +) +_KIND = st.sampled_from(list(AlternateIdentifierKind)) +_VALID_VALUE = st.text( + alphabet=st.characters(min_codepoint=0x21, max_codepoint=0x7E), + min_size=1, + max_size=ALTERNATE_IDENTIFIER_VALUE_MAX_LENGTH, +) +_DT_BASE = datetime(2026, 6, 2, 0, 0, 0, tzinfo=UTC) + + +@st.composite +def _identifier(draw: st.DrawFn) -> AlternateIdentifier: + return AlternateIdentifier(kind=draw(_KIND), value=draw(_VALID_VALUE)) + + +def _asset( + asset_id: UUID, + *, + lifecycle: AssetLifecycle, + alternate_identifiers: frozenset[AlternateIdentifier], +) -> 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, + alternate_identifiers=alternate_identifiers, + ) + + +@pytest.mark.unit +@given( + asset_id=st.uuids(), + identifier=_identifier(), + lifecycle=_NON_DECOMMISSIONED_LIFECYCLE, + seconds_offset=st.integers(min_value=0, max_value=10_000_000), +) +def test_removing_present_identifier_emits_one_event_with_injected_fields( + asset_id: UUID, + identifier: AlternateIdentifier, + lifecycle: AssetLifecycle, + seconds_offset: int, +) -> None: + """Identifier present in any non-Decommissioned lifecycle -> single + Removed event carrying the same asset_id, identifier, and + now=injected timestamp.""" + state = _asset( + asset_id, + lifecycle=lifecycle, + alternate_identifiers=frozenset({identifier}), + ) + now = _DT_BASE + timedelta(seconds=seconds_offset) + events = remove_asset_alternate_identifier.decide( + state=state, + command=RemoveAssetAlternateIdentifier(asset_id=asset_id, alternate_identifier=identifier), + now=now, + ) + assert events == [ + AssetAlternateIdentifierRemoved( + asset_id=asset_id, + alternate_identifier=identifier, + occurred_at=now, + ) + ] + + +@pytest.mark.unit +@given( + asset_id=st.uuids(), + identifier=_identifier(), + lifecycle=_NON_DECOMMISSIONED_LIFECYCLE, + seconds_offset=st.integers(min_value=0, max_value=10_000_000), +) +def test_removing_absent_identifier_always_raises_not_present( + asset_id: UUID, + identifier: AlternateIdentifier, + lifecycle: AssetLifecycle, + seconds_offset: int, +) -> None: + """Strict-not-idempotent: empty set -> NotPresent in any non- + Decommissioned lifecycle (the lifecycle guard fires first when + Decommissioned and is covered by its own property).""" + state = _asset(asset_id, lifecycle=lifecycle, alternate_identifiers=frozenset()) + now = _DT_BASE + timedelta(seconds=seconds_offset) + with pytest.raises(AssetAlternateIdentifierNotPresentError) as exc: + remove_asset_alternate_identifier.decide( + state=state, + command=RemoveAssetAlternateIdentifier( + asset_id=asset_id, alternate_identifier=identifier + ), + now=now, + ) + assert exc.value.asset_id == asset_id + assert exc.value.identifier == identifier + + +@pytest.mark.unit +@given( + asset_id=st.uuids(), + identifier=_identifier(), + seconds_offset=st.integers(min_value=0, max_value=10_000_000), + present=st.booleans(), +) +def test_decommissioned_asset_always_raises_cannot_remove_regardless_of_presence( + asset_id: UUID, + identifier: AlternateIdentifier, + seconds_offset: int, + present: bool, +) -> None: + """Lifecycle guard fires FIRST: in Decommissioned the decider raises + `AssetCannotAddAlternateIdentifierError` (the shared lifecycle- + guard class) whether the pair is present or absent. Mirrors + `remove_asset_port`.""" + members: frozenset[AlternateIdentifier] = frozenset({identifier}) if present else frozenset() + state = _asset( + asset_id, + lifecycle=AssetLifecycle.DECOMMISSIONED, + alternate_identifiers=members, + ) + now = _DT_BASE + timedelta(seconds=seconds_offset) + with pytest.raises(AssetCannotAddAlternateIdentifierError) as exc: + remove_asset_alternate_identifier.decide( + state=state, + command=RemoveAssetAlternateIdentifier( + asset_id=asset_id, alternate_identifier=identifier + ), + now=now, + ) + assert exc.value.asset_id == asset_id + assert exc.value.kind is identifier.kind + assert exc.value.value == identifier.value + assert "Decommissioned" in exc.value.reason + + +@pytest.mark.unit +@given( + asset_id=st.uuids(), + identifier=_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, + identifier: AlternateIdentifier, + seconds_offset: int, +) -> None: + """state=None -> AssetNotFoundError regardless of identifier or now.""" + now = _DT_BASE + timedelta(seconds=seconds_offset) + with pytest.raises(AssetNotFoundError) as exc: + remove_asset_alternate_identifier.decide( + state=None, + command=RemoveAssetAlternateIdentifier( + asset_id=asset_id, alternate_identifier=identifier + ), + now=now, + ) + assert exc.value.asset_id == asset_id + + +@pytest.mark.unit +@given( + asset_id=st.uuids(), + identifier=_identifier(), + other=_identifier(), + lifecycle=_NON_DECOMMISSIONED_LIFECYCLE, + seconds_offset=st.integers(min_value=0, max_value=10_000_000), +) +def test_decide_is_pure_same_input_same_output( + asset_id: UUID, + identifier: AlternateIdentifier, + other: AlternateIdentifier, + lifecycle: AssetLifecycle, + seconds_offset: int, +) -> None: + """Two calls with identical (state, command, now) return identical + events; no hidden clock or id leakage. Restricted to non- + Decommissioned so the happy-path branch is exercised.""" + assume(identifier != other) + state = _asset( + asset_id, + lifecycle=lifecycle, + alternate_identifiers=frozenset({identifier, other}), + ) + now = _DT_BASE + timedelta(seconds=seconds_offset) + command = RemoveAssetAlternateIdentifier(asset_id=asset_id, alternate_identifier=identifier) + first = remove_asset_alternate_identifier.decide(state=state, command=command, now=now) + second = remove_asset_alternate_identifier.decide(state=state, command=command, now=now) + assert first == second diff --git a/apps/api/tests/unit/equipment/test_remove_asset_alternate_identifier_handler.py b/apps/api/tests/unit/equipment/test_remove_asset_alternate_identifier_handler.py new file mode 100644 index 000000000..fa056b207 --- /dev/null +++ b/apps/api/tests/unit/equipment/test_remove_asset_alternate_identifier_handler.py @@ -0,0 +1,215 @@ +"""Application-handler tests for `remove_asset_alternate_identifier` slice. + +Update-style handler via `make_asset_update_handler`; mirrors the +shape of `test_port_slices_handlers.py`. Coverage: + + - happy path appends the right event + - authorize-deny -> UnauthorizedError; no event appended + - causation_id propagates onto the appended event + - wire_equipment exposes the handler on the bundle + - strict-not-idempotent: removing a non-existent pair raises +""" + +from datetime import UTC, datetime +from uuid import UUID + +import pytest + +from cora.equipment import EquipmentHandlers, UnauthorizedError, wire_equipment +from cora.equipment.aggregates.asset import ( + AlternateIdentifier, + AlternateIdentifierKind, + AssetAlternateIdentifierNotPresentError, + AssetCannotAddAlternateIdentifierError, + AssetLevel, +) +from cora.equipment.features import ( + add_asset_alternate_identifier, + decommission_asset, + register_asset, + remove_asset_alternate_identifier, +) +from cora.equipment.features.add_asset_alternate_identifier import ( + AddAssetAlternateIdentifier, +) +from cora.equipment.features.decommission_asset import DecommissionAsset +from cora.equipment.features.register_asset import RegisterAsset +from cora.equipment.features.remove_asset_alternate_identifier import ( + RemoveAssetAlternateIdentifier, +) +from cora.infrastructure.adapters.in_memory_event_store import InMemoryEventStore +from cora.infrastructure.kernel import Kernel +from tests.unit._helpers import build_deps as _build_deps_shared + +_NOW = datetime(2026, 6, 2, 12, 0, 0, tzinfo=UTC) +_NEW_ID = UUID("01900000-0000-7000-8000-0000000a1d01") +_REGISTER_EVENT_ID = UUID("01900000-0000-7000-8000-0000000a1d02") +_ADD_EVENT_ID = UUID("01900000-0000-7000-8000-0000000a1d03") +_REMOVE_EVENT_ID = UUID("01900000-0000-7000-8000-0000000a1d04") +_DECOMMISSION_EVENT_ID = UUID("01900000-0000-7000-8000-0000000a1d08") +_PARENT_ID = UUID("01900000-0000-7000-8000-0000000a1d05") +_PRINCIPAL_ID = UUID("01900000-0000-7000-8000-0000000a1d06") +_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000a1d07") + +_IDENTIFIER = AlternateIdentifier(kind=AlternateIdentifierKind.SERIAL_NUMBER, value="XYZ-001") + + +def _build_deps( + *, + event_store: InMemoryEventStore | None = None, + deny: bool = False, +) -> Kernel: + return _build_deps_shared( + ids=[ + _NEW_ID, + _REGISTER_EVENT_ID, + _ADD_EVENT_ID, + _REMOVE_EVENT_ID, + _DECOMMISSION_EVENT_ID, + ], + now=_NOW, + event_store=event_store, + deny=deny, + ) + + +async def _register_asset_helper(deps: Kernel) -> UUID: + return await register_asset.bind(deps)( + RegisterAsset(name="Detector-X", level=AssetLevel.DEVICE, parent_id=_PARENT_ID), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + +async def _add_identifier(deps: Kernel, asset_id: UUID) -> None: + await add_asset_alternate_identifier.bind(deps)( + AddAssetAlternateIdentifier(asset_id=asset_id, alternate_identifier=_IDENTIFIER), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + +@pytest.mark.unit +async def test_handler_appends_removed_event_on_happy_path() -> None: + store = InMemoryEventStore() + deps = _build_deps(event_store=store) + asset_id = await _register_asset_helper(deps) + await _add_identifier(deps, asset_id) + + await remove_asset_alternate_identifier.bind(deps)( + RemoveAssetAlternateIdentifier(asset_id=asset_id, alternate_identifier=_IDENTIFIER), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + events, _ = await store.load("Asset", asset_id) + removed = events[2] + assert removed.event_type == "AssetAlternateIdentifierRemoved" + assert removed.payload["alternate_identifier"] == { + "kind": "SerialNumber", + "value": "XYZ-001", + } + + +@pytest.mark.unit +async def test_handler_raises_unauthorized_on_deny() -> None: + store = InMemoryEventStore() + deps = _build_deps(event_store=store) + asset_id = await _register_asset_helper(deps) + await _add_identifier(deps, asset_id) + + deny_deps = _build_deps(event_store=store, deny=True) + with pytest.raises(UnauthorizedError): + await remove_asset_alternate_identifier.bind(deny_deps)( + RemoveAssetAlternateIdentifier(asset_id=asset_id, alternate_identifier=_IDENTIFIER), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + events, version = await store.load("Asset", asset_id) + # Only Registered + Added; no Removed appended on deny. + assert version == 2 + assert [e.event_type for e in events] == [ + "AssetRegistered", + "AssetAlternateIdentifierAdded", + ] + + +@pytest.mark.unit +async def test_handler_propagates_causation_id() -> None: + causation = UUID("01900000-0000-7000-8000-0000000a1dbb") + store = InMemoryEventStore() + deps = _build_deps(event_store=store) + asset_id = await _register_asset_helper(deps) + await _add_identifier(deps, asset_id) + + await remove_asset_alternate_identifier.bind(deps)( + RemoveAssetAlternateIdentifier(asset_id=asset_id, alternate_identifier=_IDENTIFIER), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + causation_id=causation, + ) + + events, _ = await store.load("Asset", asset_id) + assert events[2].causation_id == causation + + +@pytest.mark.unit +async def test_handler_raises_not_present_when_pair_missing() -> None: + """Strict-not-idempotent: removing without a prior add raises.""" + store = InMemoryEventStore() + deps = _build_deps(event_store=store) + asset_id = await _register_asset_helper(deps) + + with pytest.raises(AssetAlternateIdentifierNotPresentError): + await remove_asset_alternate_identifier.bind(deps)( + RemoveAssetAlternateIdentifier(asset_id=asset_id, alternate_identifier=_IDENTIFIER), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + events, version = await store.load("Asset", asset_id) + assert version == 1 + assert events[0].event_type == "AssetRegistered" + + +@pytest.mark.unit +async def test_handler_raises_cannot_remove_when_asset_decommissioned() -> None: + """Lifecycle guard: a Decommissioned asset cannot mutate alternate + identifiers; the decider raises `AssetCannotAddAlternateIdentifierError` + (the shared lifecycle-guard class is used by BOTH add and remove) + before any append.""" + store = InMemoryEventStore() + deps = _build_deps(event_store=store) + asset_id = await _register_asset_helper(deps) + await _add_identifier(deps, asset_id) + + await decommission_asset.bind(deps)( + DecommissionAsset(asset_id=asset_id), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + with pytest.raises(AssetCannotAddAlternateIdentifierError): + await remove_asset_alternate_identifier.bind(deps)( + RemoveAssetAlternateIdentifier(asset_id=asset_id, alternate_identifier=_IDENTIFIER), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + events, version = await store.load("Asset", asset_id) + # Register + Add + Decommission; the Remove raised before append. + assert version == 3 + assert [e.event_type for e in events] == [ + "AssetRegistered", + "AssetAlternateIdentifierAdded", + "AssetDecommissioned", + ] + + +@pytest.mark.unit +def test_wire_equipment_exposes_remove_handler() -> None: + deps = _build_deps() + handlers = wire_equipment(deps) + assert isinstance(handlers, EquipmentHandlers) + assert callable(handlers.remove_asset_alternate_identifier) diff --git a/infra/atlas/migrations/20260603100000_add_asset_summary_alternate_identifiers.sql b/infra/atlas/migrations/20260603100000_add_asset_summary_alternate_identifiers.sql new file mode 100644 index 000000000..fd82600ae --- /dev/null +++ b/infra/atlas/migrations/20260603100000_add_asset_summary_alternate_identifiers.sql @@ -0,0 +1,46 @@ +-- Widen proj_equipment_asset_summary with the Asset.alternate_identifiers +-- facet: the additional human-/operator-facing identifiers (serial +-- numbers, inventory numbers, free-form Other tags) that travel +-- alongside the system-issued Asset.id without replacing it. +-- +-- Additive JSONB array per Lock A + Lock G of +-- project_asset_alternate_identifiers_design. Stored as a sorted list +-- of `{"kind": str, "value": str}` objects (sorted by (kind, value) +-- at write time for byte-stable replay). Legacy AssetRegistered events +-- written before the slice ships fold to an empty array via the +-- NOT NULL DEFAULT '[]'::jsonb shape. +-- +-- ## Partial GIN index +-- +-- The "find Asset by serial number" lookup hits the column with a +-- JSONB containment predicate (`alternate_identifiers @> '[{"kind": +-- "SerialNumber", "value": "..."}]'::jsonb`). GIN supports that +-- operator natively. Most Assets carry zero alternate identifiers in +-- v1 (PIDINST-compliant but operator-driven uptake takes time); +-- exclude the empty-array rows from the index to keep it tight, per +-- the parent_id partial-index precedent at +-- 20260512280000_init_proj_equipment_asset_summary.sql:54-56 and the +-- model_id partial-index precedent at +-- 20260602110000_add_asset_summary_model.sql. +-- +-- ## Forward-only +-- +-- Pure ADD COLUMN with safe default; greenfield-friendly; no backfill +-- needed. Rollback via a NEW compensating migration per +-- project_forward_only_migrations. +-- +-- ## v1 scope reminders (see design memo Lock F) +-- +-- No cross-Asset uniqueness check on (kind, value) at the DB layer; +-- collisions are operationally meaningful (two specimens shipped with +-- the same factory serial number is a vendor problem worth surfacing, +-- not a domain invariant to enforce). Future v2 may add a partial +-- unique index gated by an explicit unique-per-facility decision. + +ALTER TABLE proj_equipment_asset_summary + ADD COLUMN alternate_identifiers JSONB NOT NULL DEFAULT '[]'::jsonb; + +CREATE INDEX proj_equipment_asset_summary_alternate_idx + ON proj_equipment_asset_summary + USING GIN (alternate_identifiers) + WHERE jsonb_array_length(alternate_identifiers) > 0; diff --git a/infra/atlas/migrations/atlas.sum b/infra/atlas/migrations/atlas.sum index fc021a2a1..2cca94133 100644 --- a/infra/atlas/migrations/atlas.sum +++ b/infra/atlas/migrations/atlas.sum @@ -1,4 +1,4 @@ -h1:sLm0yu7jixQpmCLHCYI8oZI9PNP+sfCTHQb3ibvfDbA= +h1:kYa+Bu6EU3l53HTP5Afiap7Us/tBIb6QQJ1rDbYfgfM= 20260509120000_init_events.sql h1:GmgCZKfaqXu1m96/cKAks2vhaLWTdEaHTLkFtUo9FXg= 20260509170000_init_idempotency.sql h1:Nbu8DIE4Sv1WiHw3G22+tYffPhKc5Jryw3PMK8wB2zY= 20260510010000_add_event_id.sql h1:RbtYP6uMnOB20zhJ9dNXUi4YVqbmlEzf562pmygnRW8= @@ -93,3 +93,4 @@ h1:sLm0yu7jixQpmCLHCYI8oZI9PNP+sfCTHQb3ibvfDbA= 20260602100000_drop_proj_equipment_model_summary_vendor_key_unique.sql h1:3zElIH2cC7y2mOyOmRgU+Asf3bsvfLtekrVH9mYnBqM= 20260602110000_add_asset_summary_model.sql h1:6JNrSeL/whEo/ZQ6IlZs21RJ79BGcl0bR35nH0IhTGc= 20260602110000_rename_proj_equipment_mount_lookup_to_mount_slot_code.sql h1:AGBAc0XP1TXK04PIFKavYe0buGgonSFMbL9MxYyr7bk= +20260603100000_add_asset_summary_alternate_identifiers.sql h1:AN5+vlPuBabUVWF+/nyPtqT7AnnSGIJRsqXxl1r4/Xk=