diff --git a/apps/api/openapi.json b/apps/api/openapi.json index e44b34d54..939860791 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -274,6 +274,22 @@ "title": "AddAssetPortRequest", "type": "object" }, + "AddModelFamilyRequest": { + "description": "Body for `POST /models/{model_id}/families`.", + "properties": { + "family_id": { + "description": "Family id to add to the Model.declared_families set.", + "format": "uuid", + "title": "Family Id", + "type": "string" + } + }, + "required": [ + "family_id" + ], + "title": "AddModelFamilyRequest", + "type": "object" + }, "AddPlanWireRequest": { "description": "Body for `POST /plans/{plan_id}/add-wire`.\n\nAll four fields are required. Pydantic enforces non-empty port\nnames at the boundary; the `Wire` VO then trims and re-validates\nlength within the decider. Direction + signal_type validation\nhappens in the decider against the loaded Asset.ports.", "properties": { @@ -4174,6 +4190,76 @@ "title": "DefineMethodResponse", "type": "object" }, + "DefineModelRequest": { + "description": "Body for `POST /models`.", + "properties": { + "declared_families": { + "description": "Family ids the catalog entry satisfies. At least one required; deduplicated server-side.", + "items": { + "format": "uuid", + "type": "string" + }, + "minItems": 1, + "title": "Declared Families", + "type": "array" + }, + "manufacturer": { + "$ref": "#/components/schemas/ManufacturerBody", + "description": "Vendor identity (name plus optional ROR/GRID/ISNI identifier)." + }, + "name": { + "description": "Display name for the new Model.", + "maxLength": 200, + "minLength": 1, + "title": "Name", + "type": "string" + }, + "part_number": { + "description": "Vendor SKU; case-sensitive (RV120CCHL and rv120cchl are different Newport entries).", + "maxLength": 100, + "minLength": 1, + "title": "Part Number", + "type": "string" + }, + "version_tag": { + "anyOf": [ + { + "maxLength": 50, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional initial revision label (e.g., 'rev-A').", + "title": "Version Tag" + } + }, + "required": [ + "name", + "manufacturer", + "part_number", + "declared_families" + ], + "title": "DefineModelRequest", + "type": "object" + }, + "DefineModelResponse": { + "description": "Response body for `POST /models`.", + "properties": { + "model_id": { + "format": "uuid", + "title": "Model Id", + "type": "string" + } + }, + "required": [ + "model_id" + ], + "title": "DefineModelResponse", + "type": "object" + }, "DefinePermitRequest": { "additionalProperties": false, "description": "Body for `POST /federation/permits`.", @@ -4596,6 +4682,23 @@ "title": "DeprecateCapabilityRequest", "type": "object" }, + "DeprecateModelRequest": { + "description": "Body for `POST /models/{model_id}/deprecation`.\n\n`reason` is operator free text recording why the catalog entry is\nbeing retired (for example \"superseded by part RV120CCHL\", \"vendor\nEOL 2026\"). Trimmed and length-validated at the\n`ModelDeprecationReason` VO; whitespace-only is rejected as a\ndomain invariant violation (400).", + "properties": { + "reason": { + "description": "Operator-supplied rationale for retiring this Model (for example 'superseded by RV120CCHL', 'vendor EOL 2026'). Free text; trimmed server-side.", + "maxLength": 500, + "minLength": 1, + "title": "Reason", + "type": "string" + } + }, + "required": [ + "reason" + ], + "title": "DeprecateModelRequest", + "type": "object" + }, "DeregisterSupplyRequest": { "description": "Body for `POST /supplies/{supply_id}/deregister`.\n\n`reason` is operator-supplied free text (audit-log breadcrumb)\nexplaining why the supply is being deregistered. Examples:\n\"typo on scope at registration; re-registering correctly\",\n\"beamline retired\", \"duplicate of supply \".", "properties": { @@ -5499,6 +5602,96 @@ "title": "ListPermissionsResponse", "type": "object" }, + "ManufacturerBody": { + "description": "Pydantic mirror of the Manufacturer VO for the request body.\n\n`identifier` and `identifier_type` are both optional but must be\nsupplied together or both omitted (the pairing invariant is\nenforced at the VO constructor; a bare identifier with no scheme\ncannot be resolved).", + "properties": { + "identifier": { + "anyOf": [ + { + "maxLength": 200, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional opaque identifier value. If supplied, `identifier_type` is required (and vice versa).", + "title": "Identifier" + }, + "identifier_type": { + "anyOf": [ + { + "$ref": "#/components/schemas/ManufacturerIdentifierType" + }, + { + "type": "null" + } + ], + "description": "Closed scheme for the optional manufacturer identifier." + }, + "name": { + "description": "Display name of the manufacturer.", + "maxLength": 200, + "minLength": 1, + "title": "Name", + "type": "string" + } + }, + "required": [ + "name" + ], + "title": "ManufacturerBody", + "type": "object" + }, + "ManufacturerIdentifierType": { + "description": "Closed scheme for the optional manufacturer identifier.\n\nThree members ship in v1: ROR (Research Organization Registry),\nGRID (Global Research Identifier Database; subsumed by ROR but\nstill in active use at many facilities), ISNI (International\nStandard Name Identifier). Adding a fourth scheme is an additive\nenum change.", + "enum": [ + "ROR", + "GRID", + "ISNI" + ], + "title": "ManufacturerIdentifierType", + "type": "string" + }, + "ManufacturerResponse": { + "description": "Nested DTO for a model's manufacturer.\n\n`name` is required; `identifier` and `identifier_type` are both\nset or both null (pairing invariant enforced by the domain VO).\n`identifier_type` is the closed-StrEnum scheme string value\n(ROR / GRID / ISNI) when present.", + "properties": { + "identifier": { + "anyOf": [ + { + "maxLength": 200, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Identifier" + }, + "identifier_type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Identifier Type" + }, + "name": { + "maxLength": 200, + "title": "Name", + "type": "string" + } + }, + "required": [ + "name" + ], + "title": "ManufacturerResponse", + "type": "object" + }, "MarkSupplyAvailableRequest": { "description": "Body for `POST /supplies/{supply_id}/mark-available`.\n\n`reason` is operator-supplied free text (audit-log breadcrumb)\nexplaining the first-observation declaration. Examples:\n\"operator walkdown confirms LN2 dewar pressure nominal\", \"control\nroom reports beam delivered after morning startup\", \"first-time\ncommissioning verified by ops\".", "properties": { @@ -5814,6 +6007,63 @@ "title": "ModelRefResponse", "type": "object" }, + "ModelResponse": { + "description": "Read-side DTO at the API boundary.\n\nCarries primitives, not domain VOs. Decouples the wire format\nfrom the domain model so the two can evolve independently.\n`status` is the StrEnum's string value (Defined / Versioned /\nDeprecated). `version_tag` is the operator-supplied label of the\nmost recent version_model call (null until first version).\n`declared_families` serializes as a sorted list of Family UUIDs\n(frozenset semantics in domain state, list at the JSON boundary;\nsorted by UUID string form for response determinism).", + "properties": { + "declared_families": { + "items": { + "format": "uuid", + "type": "string" + }, + "title": "Declared Families", + "type": "array" + }, + "manufacturer": { + "$ref": "#/components/schemas/ManufacturerResponse" + }, + "model_id": { + "format": "uuid", + "title": "Model Id", + "type": "string" + }, + "name": { + "maxLength": 200, + "title": "Name", + "type": "string" + }, + "part_number": { + "maxLength": 100, + "title": "Part Number", + "type": "string" + }, + "status": { + "title": "Status", + "type": "string" + }, + "version_tag": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Version Tag" + } + }, + "required": [ + "model_id", + "name", + "manufacturer", + "part_number", + "declared_families", + "status", + "version_tag" + ], + "title": "ModelResponse", + "type": "object" + }, "MountSubjectRequest": { "description": "Body for `POST /subjects/{subject_id}/mount`.", "properties": { @@ -10708,6 +10958,55 @@ "title": "VersionMethodRequest", "type": "object" }, + "VersionModelRequest": { + "description": "Body for `POST /models/{model_id}/versions`.\n\nA new version IS a new declaration: every field REPLACES the prior\nvalue wholesale (no diff/merge semantics). `version_tag` is\nREQUIRED here, unlike `define_model` where it is optional.", + "properties": { + "declared_families": { + "description": "Replacement Family ids the catalog entry satisfies at this version. At least one required; deduplicated server-side.", + "items": { + "format": "uuid", + "type": "string" + }, + "minItems": 1, + "title": "Declared Families", + "type": "array" + }, + "manufacturer": { + "$ref": "#/components/schemas/ManufacturerBody", + "description": "Replacement vendor identity (name plus optional ROR/GRID/ISNI identifier)." + }, + "name": { + "description": "Replacement display name for the Model at this version.", + "maxLength": 200, + "minLength": 1, + "title": "Name", + "type": "string" + }, + "part_number": { + "description": "Replacement vendor SKU; case-sensitive (RV120CCHL and rv120cchl are different Newport entries).", + "maxLength": 100, + "minLength": 1, + "title": "Part Number", + "type": "string" + }, + "version_tag": { + "description": "Operator-supplied label for this revision (for example 'rev-B', '2026-Q3'). Free text; institution-specific.", + "maxLength": 50, + "minLength": 1, + "title": "Version Tag", + "type": "string" + } + }, + "required": [ + "name", + "manufacturer", + "part_number", + "declared_families", + "version_tag" + ], + "title": "VersionModelRequest", + "type": "object" + }, "VersionPlanRequest": { "description": "Body for `POST /plans/{plan_id}/version`.", "properties": { @@ -23108,6 +23407,578 @@ ] } }, + "/models": { + "post": { + "operationId": "post_models_models_post", + "parameters": [ + { + "description": "Optional client-supplied unique key per logical request. Retries with the same key + same body return the cached response instead of re-creating the model.", + "in": "header", + "name": "Idempotency-Key", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional client-supplied unique key per logical request. Retries with the same key + same body return the cached response instead of re-creating the model.", + "title": "Idempotency-Key" + } + }, + { + "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/DefineModelRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefineModelResponse" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Domain invariant violated (for example whitespace-only name)." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Authorize port denied the command." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "One or more declared families does not resolve to a registered Family." + }, + "422": { + "description": "Request body failed schema validation OR Idempotency-Key was reused with a different request body." + } + }, + "summary": "Define a new vendor-catalog Model", + "tags": [ + "equipment" + ] + } + }, + "/models/{model_id}": { + "get": { + "operationId": "get_models_models__model_id__get", + "parameters": [ + { + "description": "Target model's id.", + "in": "path", + "name": "model_id", + "required": true, + "schema": { + "description": "Target model's id.", + "format": "uuid", + "title": "Model 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" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelResponse" + } + } + }, + "description": "Successful Response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Authorize port denied the query." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "No model exists with the given id." + }, + "422": { + "description": "Path parameter failed schema validation." + } + }, + "summary": "Get a model by id", + "tags": [ + "equipment" + ] + } + }, + "/models/{model_id}/deprecation": { + "post": { + "operationId": "post_models_deprecation_models__model_id__deprecation_post", + "parameters": [ + { + "description": "Target model's id.", + "in": "path", + "name": "model_id", + "required": true, + "schema": { + "description": "Target model's id.", + "format": "uuid", + "title": "Model 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/DeprecateModelRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Domain invariant violated (for example whitespace-only reason after trimming)." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Authorize port denied the command." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "No model exists with the given id." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Model is already in `Deprecated` status (deprecate requires `Defined` or `Versioned`), OR a concurrent write to the same model stream conflicted (optimistic concurrency)." + }, + "422": { + "description": "Path parameter or request body failed schema validation." + } + }, + "summary": "Mark an existing Model as deprecated", + "tags": [ + "equipment" + ] + } + }, + "/models/{model_id}/families": { + "post": { + "operationId": "post_models_add_family_models__model_id__families_post", + "parameters": [ + { + "description": "Target model's id.", + "in": "path", + "name": "model_id", + "required": true, + "schema": { + "description": "Target model's id.", + "format": "uuid", + "title": "Model 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/AddModelFamilyRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Domain invariant violated." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Authorize port denied the command." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "No model exists with the given id, OR the supplied family_id does not resolve to a registered Family." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Model is in `Deprecated` status (mutation requires `Defined` or `Versioned`), OR the family_id is already in the Model's declared_families set, OR a concurrent write to the same model stream conflicted (optimistic concurrency)." + }, + "422": { + "description": "Path parameter or request body failed schema validation." + } + }, + "summary": "Add a Family to an existing Model's declared_families set", + "tags": [ + "equipment" + ] + } + }, + "/models/{model_id}/families/{family_id}": { + "delete": { + "operationId": "delete_models_family_models__model_id__families__family_id__delete", + "parameters": [ + { + "description": "Target model's id.", + "in": "path", + "name": "model_id", + "required": true, + "schema": { + "description": "Target model's id.", + "format": "uuid", + "title": "Model Id", + "type": "string" + } + }, + { + "description": "Family id to remove from the Model.declared_families set.", + "in": "path", + "name": "family_id", + "required": true, + "schema": { + "description": "Family id to remove from the Model.declared_families set.", + "format": "uuid", + "title": "Family 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" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Authorize port denied the command." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "No model exists with the given id." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Model is in `Deprecated` status (mutation requires `Defined` or `Versioned`), OR the family_id is not in the Model's declared_families set, OR a concurrent write to the same model stream conflicted (optimistic concurrency)." + }, + "422": { + "description": "Path parameter failed schema validation." + } + }, + "summary": "Remove a Family from an existing Model's declared_families set", + "tags": [ + "equipment" + ] + } + }, + "/models/{model_id}/versions": { + "post": { + "operationId": "post_models_versions_models__model_id__versions_post", + "parameters": [ + { + "description": "Target model's id.", + "in": "path", + "name": "model_id", + "required": true, + "schema": { + "description": "Target model's id.", + "format": "uuid", + "title": "Model 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/VersionModelRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Domain invariant violated (for example whitespace-only name, whitespace-only part_number, whitespace-only version_tag, or empty declared_families)." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Authorize port denied the command." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "No model exists with the given id." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Model is in `Deprecated` status (version requires `Defined` or `Versioned`), OR a concurrent write to the same model stream conflicted (optimistic concurrency)." + }, + "422": { + "description": "Path parameter or request body failed schema validation." + } + }, + "summary": "Issue a new version declaration for an existing Model", + "tags": [ + "equipment" + ] + } + }, "/mounts": { "post": { "operationId": "post_mounts_mounts_post", diff --git a/apps/api/src/cora/equipment/_projections.py b/apps/api/src/cora/equipment/_projections.py index ca1cd0713..3868f59c3 100644 --- a/apps/api/src/cora/equipment/_projections.py +++ b/apps/api/src/cora/equipment/_projections.py @@ -15,6 +15,7 @@ FrameChildrenProjection, FrameConsumersProjection, FrameSummaryProjection, + ModelSummaryProjection, MountChildrenProjection, MountLookupProjection, MountSummaryProjection, @@ -32,6 +33,7 @@ def register_equipment_projections( registry.register(AssetSummaryProjection()) registry.register(AssetFamilyMembershipProjection()) registry.register(FamilySummaryProjection()) + registry.register(ModelSummaryProjection()) registry.register(FrameSummaryProjection()) registry.register(FrameChildrenProjection()) registry.register(FrameConsumersProjection()) diff --git a/apps/api/src/cora/equipment/aggregates/family/__init__.py b/apps/api/src/cora/equipment/aggregates/family/__init__.py index 71dcdf10d..72deadc83 100644 --- a/apps/api/src/cora/equipment/aggregates/family/__init__.py +++ b/apps/api/src/cora/equipment/aggregates/family/__init__.py @@ -22,6 +22,7 @@ from cora.equipment.aggregates.family.evolver import evolve, fold from cora.equipment.aggregates.family.read import ( FamilyLifecycleTimestamps, + list_all_family_ids, list_asset_ids_in_families, list_family_ids, load_family, @@ -70,6 +71,7 @@ "evolve", "fold", "from_stored", + "list_all_family_ids", "list_asset_ids_in_families", "list_family_ids", "load_family", diff --git a/apps/api/src/cora/equipment/aggregates/family/read.py b/apps/api/src/cora/equipment/aggregates/family/read.py index 1299a270c..d1017fab9 100644 --- a/apps/api/src/cora/equipment/aggregates/family/read.py +++ b/apps/api/src/cora/equipment/aggregates/family/read.py @@ -13,6 +13,25 @@ resource-API precedent. Mirrors `load_method_timestamps` / `load_plan_timestamps` / `load_practice_timestamps`. +## Two list_*_family_ids helpers + +Two read helpers enumerate Family ids from the summary projection; +they differ ONLY in whether Deprecated Families are filtered out. + +`list_family_ids` EXCLUDES Deprecated Families. It backs the +operator-facing discovery path: `inspect_plan_binding`'s candidate +enumeration should not offer a Deprecated Family as a source for +new wiring. + +`list_all_family_ids` INCLUDES Deprecated Families. It backs the +cross-BC existence-check path: `define_model` and `add_model_family` +verify that every referenced Family id resolves to a real Family +stream, and per the Model aggregate's design memo Family.deprecation +is an authoring signal, NOT a runtime gate. Binding a Model to a +Deprecated Family is permitted (mirrors the Asset-to-Deprecated-Family +posture); using the discovery filter here would surface a misleading +`FamilyNotFoundError` for a Family that genuinely exists. + `_STREAM_TYPE = "Family"`. The stream-type string is the event store's internal categorization key for this aggregate. """ @@ -90,10 +109,11 @@ async def load_family_timestamps( """ -async def list_family_ids(pool: asyncpg.Pool) -> list[UUID]: +async def list_family_ids(pool: asyncpg.Pool | None) -> list[UUID]: """Read every non-Deprecated Family id from the summary projection. - Used by `inspect_plan_binding`'s candidate enumeration: callers + Used by `inspect_plan_binding`'s candidate enumeration and by + `define_model`'s cross-BC family_lookup precondition. Callers iterate every Family, load its aggregate state via `load_family`, and filter by `Family.affordances` membership. Deprecated Families are excluded at the SQL layer so they're not offered @@ -101,6 +121,11 @@ async def list_family_ids(pool: asyncpg.Pool) -> list[UUID]: when they're directly wired into a Plan; this is discovery-side only). + Returns `[]` when `pool is None` (test / no-database app_env), + mirroring the `load_asset_lifecycle` / `load_asset_location` + null-pool short-circuit. Tests that need a populated lookup + must wire a real pool. + The summary projection doesn't carry an affordances column today (5j deferred it); when the first caller demands affordance- filtered queries at scale, ship the column + GIN index here and @@ -109,11 +134,43 @@ async def list_family_ids(pool: asyncpg.Pool) -> list[UUID]: `inspect_plan_binding` crosses 200ms. Pilot scale (~9 Families) keeps the load-all-then-filter approach cheap. """ + if pool is None: + return [] async with pool.acquire() as conn: rows = await conn.fetch(_SELECT_FAMILY_IDS_SQL) return [row["family_id"] for row in rows] +_SELECT_ALL_FAMILY_IDS_SQL = """ +SELECT family_id +FROM proj_equipment_family_summary +ORDER BY family_id::text +""" + + +async def list_all_family_ids(pool: asyncpg.Pool | None) -> list[UUID]: + """Read every Family id from the summary projection, INCLUDING Deprecated. + + Used by `define_model` and `add_model_family` to verify that every + declared/added Family id resolves to a real Family stream. Per the + Model aggregate's design memo, Family.deprecation is an authoring + signal, NOT a runtime gate: a Model is allowed to declare a + Deprecated Family. Filtering Deprecated rows out here (as + `list_family_ids` does for the discovery path) would surface a + misleading `FamilyNotFoundError` for a Family that genuinely + exists. + + Returns `[]` when `pool is None` (test / no-database app_env), + mirroring `list_family_ids`. Tests that need a populated lookup + must wire a real pool. + """ + if pool is None: + return [] + async with pool.acquire() as conn: + rows = await conn.fetch(_SELECT_ALL_FAMILY_IDS_SQL) + return [row["family_id"] for row in rows] + + _SELECT_ASSET_IDS_BY_FAMILIES_SQL = """ SELECT DISTINCT asset_id FROM proj_equipment_asset_family_membership diff --git a/apps/api/src/cora/equipment/aggregates/model/__init__.py b/apps/api/src/cora/equipment/aggregates/model/__init__.py new file mode 100644 index 000000000..e3af8ca4e --- /dev/null +++ b/apps/api/src/cora/equipment/aggregates/model/__init__.py @@ -0,0 +1,102 @@ +"""Model aggregate: state, status enum, errors, events, evolver. + +Vertical slices that operate on this aggregate live under +`cora.equipment.features._model/` and import from here for +state and event types. +""" + +from cora.equipment.aggregates.model.events import ( + ModelDefined, + ModelDeprecated, + ModelEvent, + ModelFamilyAdded, + ModelFamilyRemoved, + ModelVersioned, + event_type_name, + from_stored, + to_payload, +) +from cora.equipment.aggregates.model.evolver import evolve, fold +from cora.equipment.aggregates.model.read import list_model_ids, load_model +from cora.equipment.aggregates.model.state import ( + MANUFACTURER_IDENTIFIER_MAX_LENGTH, + MANUFACTURER_NAME_MAX_LENGTH, + MODEL_DEPRECATION_REASON_MAX_LENGTH, + MODEL_NAME_MAX_LENGTH, + MODEL_PART_NUMBER_MAX_LENGTH, + MODEL_VERSION_TAG_MAX_LENGTH, + InvalidDeclaredFamiliesError, + InvalidManufacturerIdentifierError, + InvalidManufacturerIdentifierPairingError, + InvalidManufacturerNameError, + InvalidModelDeprecationReasonError, + InvalidModelNameError, + InvalidModelVersionTagError, + InvalidPartNumberError, + Manufacturer, + ManufacturerIdentifier, + ManufacturerIdentifierType, + ManufacturerName, + Model, + ModelAlreadyExistsError, + ModelCannotAddFamilyError, + ModelCannotDeprecateError, + ModelCannotRemoveFamilyError, + ModelCannotVersionError, + ModelDeprecationReason, + ModelFamilyAlreadyPresentError, + ModelFamilyNotPresentError, + ModelName, + ModelNotFoundError, + ModelStatus, + ModelVersionTag, + PartNumber, +) + +__all__ = [ + "MANUFACTURER_IDENTIFIER_MAX_LENGTH", + "MANUFACTURER_NAME_MAX_LENGTH", + "MODEL_DEPRECATION_REASON_MAX_LENGTH", + "MODEL_NAME_MAX_LENGTH", + "MODEL_PART_NUMBER_MAX_LENGTH", + "MODEL_VERSION_TAG_MAX_LENGTH", + "InvalidDeclaredFamiliesError", + "InvalidManufacturerIdentifierError", + "InvalidManufacturerIdentifierPairingError", + "InvalidManufacturerNameError", + "InvalidModelDeprecationReasonError", + "InvalidModelNameError", + "InvalidModelVersionTagError", + "InvalidPartNumberError", + "Manufacturer", + "ManufacturerIdentifier", + "ManufacturerIdentifierType", + "ManufacturerName", + "Model", + "ModelAlreadyExistsError", + "ModelCannotAddFamilyError", + "ModelCannotDeprecateError", + "ModelCannotRemoveFamilyError", + "ModelCannotVersionError", + "ModelDefined", + "ModelDeprecated", + "ModelDeprecationReason", + "ModelEvent", + "ModelFamilyAdded", + "ModelFamilyAlreadyPresentError", + "ModelFamilyNotPresentError", + "ModelFamilyRemoved", + "ModelName", + "ModelNotFoundError", + "ModelStatus", + "ModelVersionTag", + "ModelVersioned", + "PartNumber", + "event_type_name", + "evolve", + "fold", + "from_stored", + "list_model_ids", + "load_model", + "to_payload", +] diff --git a/apps/api/src/cora/equipment/aggregates/model/events.py b/apps/api/src/cora/equipment/aggregates/model/events.py new file mode 100644 index 000000000..99ccf61f6 --- /dev/null +++ b/apps/api/src/cora/equipment/aggregates/model/events.py @@ -0,0 +1,297 @@ +"""Domain events emitted by the Model aggregate, plus the discriminated union. + +Event types: `ModelDefined`, `ModelVersioned`, `ModelDeprecated`, +`ModelFamilyAdded`, `ModelFamilyRemoved`. Status is NOT carried in +event payloads; the event type itself encodes the state change. The +evolver hardcodes the mapping per match arm. + +Targeted-mutation pattern: `ModelFamilyAdded` and `ModelFamilyRemoved` +carry a single `family_id` change rather than the whole +`declared_families` set. The operational pattern at a beamline is +"vendor shipped firmware update, one extra Family declared" rather +than wholesale re-author; targeted mutation preserves the operator +intent signal. `ModelVersioned` accepts the wholesale replacement +when a revision genuinely re-authors the catalog entry. +""" + +from dataclasses import dataclass +from datetime import datetime +from typing import Any, assert_never +from uuid import UUID + +from cora.equipment.aggregates.model.state import ( + Manufacturer, + ManufacturerIdentifier, + ManufacturerIdentifierType, + ManufacturerName, +) +from cora.infrastructure.event_payload import deserialize_or_raise +from cora.infrastructure.ports.event_store import StoredEvent + + +@dataclass(frozen=True) +class ModelDefined: + """A new vendor-catalog entry was defined. + + Status is implicit (`Defined`); the evolver sets it. `version_tag` + is the optional initial revision label (e.g., `rev-A`); `None` + means no initial label and `Model.version` stays `None` until the + first `version_model` call. + """ + + model_id: UUID + name: str + manufacturer: Manufacturer + part_number: str + declared_families: frozenset[UUID] + occurred_at: datetime + version_tag: str | None = None + + +@dataclass(frozen=True) +class ModelVersioned: + """A model's catalog entry was revised; a new version label was issued. + + Multi-source transition: `Defined | Versioned -> Versioned`. + REPLACES `name`, `manufacturer`, `part_number`, `declared_families`, + and `version_tag` wholesale (a new version IS a new declaration). + Matches Family/Method/Plan/Practice replace-on-version precedent. + """ + + model_id: UUID + name: str + manufacturer: Manufacturer + part_number: str + declared_families: frozenset[UUID] + version_tag: str + occurred_at: datetime + + +@dataclass(frozen=True) +class ModelDeprecated: + """A model was marked as no longer recommended for new Assets. + + Multi-source transition: `Defined | Versioned -> Deprecated`. + Existing Assets with `model_id` pointing at this Model continue + to function; deprecation is an authoring signal, not a runtime + gate. + """ + + model_id: UUID + reason: str + occurred_at: datetime + + +@dataclass(frozen=True) +class ModelFamilyAdded: + """A family was added to the model's `declared_families` set. + + Targeted-mutation event. Strict-not-idempotent: re-adding a + present family raises `ModelFamilyAlreadyPresentError`. Allowed + from `Defined | Versioned`; rejected from `Deprecated`. + """ + + model_id: UUID + family_id: UUID + occurred_at: datetime + + +@dataclass(frozen=True) +class ModelFamilyRemoved: + """A family was removed from the model's `declared_families` set. + + Targeted-mutation event. Strict-not-idempotent: removing an + absent family raises `ModelFamilyNotPresentError`. Allowed from + `Defined | Versioned`; rejected from `Deprecated`. Does NOT + cascade through existing Assets bound to this Model. + """ + + model_id: UUID + family_id: UUID + occurred_at: datetime + + +# Discriminated union of every event the Model aggregate emits. +ModelEvent = ModelDefined | ModelVersioned | ModelDeprecated | ModelFamilyAdded | ModelFamilyRemoved + + +def event_type_name(event: ModelEvent) -> str: + """Discriminator string written into StoredEvent.event_type.""" + return type(event).__name__ + + +def _manufacturer_to_payload(manufacturer: Manufacturer) -> dict[str, Any]: + """Serialize a Manufacturer VO to a JSON-friendly dict. + + `identifier` and `identifier_type` are omitted when both are None + (the optional pair drops together per the VO's pairing invariant). + """ + payload: dict[str, Any] = {"name": manufacturer.name.value} + if manufacturer.identifier is not None and manufacturer.identifier_type is not None: + payload["identifier"] = manufacturer.identifier.value + payload["identifier_type"] = manufacturer.identifier_type.value + return payload + + +def to_payload(event: ModelEvent) -> dict[str, Any]: + """Serialize a Model event to a JSON-friendly dict for jsonb storage.""" + match event: + case ModelDefined( + model_id=model_id, + name=name, + manufacturer=manufacturer, + part_number=part_number, + declared_families=declared_families, + occurred_at=occurred_at, + version_tag=version_tag, + ): + payload: dict[str, Any] = { + "model_id": str(model_id), + "name": name, + "manufacturer": _manufacturer_to_payload(manufacturer), + "part_number": part_number, + # Sorted for deterministic payload serialization. + "declared_families": sorted(str(family_id) for family_id in declared_families), + "occurred_at": occurred_at.isoformat(), + } + if version_tag is not None: + payload["version_tag"] = version_tag + return payload + case ModelVersioned( + model_id=model_id, + name=name, + manufacturer=manufacturer, + part_number=part_number, + declared_families=declared_families, + version_tag=version_tag, + occurred_at=occurred_at, + ): + return { + "model_id": str(model_id), + "name": name, + "manufacturer": _manufacturer_to_payload(manufacturer), + "part_number": part_number, + "declared_families": sorted(str(family_id) for family_id in declared_families), + "version_tag": version_tag, + "occurred_at": occurred_at.isoformat(), + } + case ModelDeprecated(model_id=model_id, reason=reason, occurred_at=occurred_at): + return { + "model_id": str(model_id), + "reason": reason, + "occurred_at": occurred_at.isoformat(), + } + case ModelFamilyAdded(model_id=model_id, family_id=family_id, occurred_at=occurred_at): + return { + "model_id": str(model_id), + "family_id": str(family_id), + "occurred_at": occurred_at.isoformat(), + } + case ModelFamilyRemoved(model_id=model_id, family_id=family_id, occurred_at=occurred_at): + return { + "model_id": str(model_id), + "family_id": str(family_id), + "occurred_at": occurred_at.isoformat(), + } + case _: # pragma: no cover # exhaustiveness guard + assert_never(event) + + +def _manufacturer_from_payload(payload: dict[str, Any]) -> Manufacturer: + """Load a Manufacturer VO from a payload's `manufacturer` sub-dict. + + Tolerates: missing `identifier` and `identifier_type` keys (both + must be absent together or present together per the VO's pairing + invariant). Unknown `identifier_type` values raise via the + StrEnum constructor, same fail-loud stance as the top-level + `from_stored` dispatch on unknown event types. + """ + name = ManufacturerName(payload["name"]) + raw_identifier = payload.get("identifier") + raw_identifier_type = payload.get("identifier_type") + identifier = ManufacturerIdentifier(raw_identifier) if raw_identifier is not None else None + identifier_type = ( + ManufacturerIdentifierType(raw_identifier_type) if raw_identifier_type is not None else None + ) + return Manufacturer(name=name, identifier=identifier, identifier_type=identifier_type) + + +def _declared_families_from_payload(payload: dict[str, Any]) -> frozenset[UUID]: + """Load the declared_families frozenset from a payload list field.""" + raw = payload.get("declared_families", []) + return frozenset(UUID(family_id) for family_id in raw) + + +def from_stored(stored: StoredEvent) -> ModelEvent: + """Rebuild a Model event from a StoredEvent loaded from the event store.""" + payload = stored.payload + match stored.event_type: + case "ModelDefined": + return deserialize_or_raise( + "ModelDefined", + lambda: ModelDefined( + model_id=UUID(payload["model_id"]), + name=payload["name"], + manufacturer=_manufacturer_from_payload(payload["manufacturer"]), + part_number=payload["part_number"], + declared_families=_declared_families_from_payload(payload), + occurred_at=datetime.fromisoformat(payload["occurred_at"]), + version_tag=payload.get("version_tag"), + ), + ) + case "ModelVersioned": + return deserialize_or_raise( + "ModelVersioned", + lambda: ModelVersioned( + model_id=UUID(payload["model_id"]), + name=payload["name"], + manufacturer=_manufacturer_from_payload(payload["manufacturer"]), + part_number=payload["part_number"], + declared_families=_declared_families_from_payload(payload), + version_tag=payload["version_tag"], + occurred_at=datetime.fromisoformat(payload["occurred_at"]), + ), + ) + case "ModelDeprecated": + return deserialize_or_raise( + "ModelDeprecated", + lambda: ModelDeprecated( + model_id=UUID(payload["model_id"]), + reason=payload["reason"], + occurred_at=datetime.fromisoformat(payload["occurred_at"]), + ), + ) + case "ModelFamilyAdded": + return deserialize_or_raise( + "ModelFamilyAdded", + lambda: ModelFamilyAdded( + model_id=UUID(payload["model_id"]), + family_id=UUID(payload["family_id"]), + occurred_at=datetime.fromisoformat(payload["occurred_at"]), + ), + ) + case "ModelFamilyRemoved": + return deserialize_or_raise( + "ModelFamilyRemoved", + lambda: ModelFamilyRemoved( + model_id=UUID(payload["model_id"]), + family_id=UUID(payload["family_id"]), + occurred_at=datetime.fromisoformat(payload["occurred_at"]), + ), + ) + case _: + msg = f"Unknown ModelEvent event_type: {stored.event_type!r}" + raise ValueError(msg) + + +__all__ = [ + "ModelDefined", + "ModelDeprecated", + "ModelEvent", + "ModelFamilyAdded", + "ModelFamilyRemoved", + "ModelVersioned", + "event_type_name", + "from_stored", + "to_payload", +] diff --git a/apps/api/src/cora/equipment/aggregates/model/evolver.py b/apps/api/src/cora/equipment/aggregates/model/evolver.py new file mode 100644 index 000000000..b8b942b88 --- /dev/null +++ b/apps/api/src/cora/equipment/aggregates/model/evolver.py @@ -0,0 +1,127 @@ +"""Evolver: replay events to reconstruct Model state. + +Status mapping per event type: + - `ModelDefined` -> DEFINED (genesis; version=None unless + ModelDefined.version_tag was set) + - `ModelVersioned` -> VERSIONED (version=event.version_tag; + multi-source: Defined | Versioned; + replaces name, manufacturer, + part_number, declared_families) + - `ModelDeprecated` -> DEPRECATED (everything else preserved; + multi-source: Defined | Versioned) + - `ModelFamilyAdded` -> status preserved; declared_families + gains family_id (targeted mutation) + - `ModelFamilyRemoved` -> status preserved; declared_families + loses family_id + +The mapping is hardcoded per match arm; the event type IS the +state-change indicator (no status field in event payloads). + +Transition events applied to empty state raise via `require_state`. +""" + +from collections.abc import Sequence +from typing import assert_never + +from cora.equipment.aggregates.model.events import ( + ModelDefined, + ModelDeprecated, + ModelEvent, + ModelFamilyAdded, + ModelFamilyRemoved, + ModelVersioned, +) +from cora.equipment.aggregates.model.state import ( + Model, + ModelName, + ModelStatus, + PartNumber, +) +from cora.infrastructure.evolver import require_state + + +def evolve(state: Model | None, event: ModelEvent) -> Model: + """Apply one event to the current state.""" + match event: + case ModelDefined( + model_id=model_id, + name=name, + manufacturer=manufacturer, + part_number=part_number, + declared_families=declared_families, + version_tag=version_tag, + ): + _ = state # ModelDefined is the genesis event; prior state ignored + return Model( + id=model_id, + name=ModelName(name), + manufacturer=manufacturer, + part_number=PartNumber(part_number), + declared_families=declared_families, + status=ModelStatus.DEFINED, + version=version_tag, + ) + case ModelVersioned( + name=name, + manufacturer=manufacturer, + part_number=part_number, + declared_families=declared_families, + version_tag=version_tag, + ): + prior = require_state(state, "ModelVersioned") + return Model( + id=prior.id, + # Wholesale replacement (a new version IS a new declaration). + name=ModelName(name), + manufacturer=manufacturer, + part_number=PartNumber(part_number), + declared_families=declared_families, + status=ModelStatus.VERSIONED, + version=version_tag, + ) + case ModelDeprecated(): + prior = require_state(state, "ModelDeprecated") + return Model( + id=prior.id, + name=prior.name, + manufacturer=prior.manufacturer, + part_number=prior.part_number, + # declared_families PRESERVED across deprecation; the + # historical declaration stays visible for audit. + declared_families=prior.declared_families, + status=ModelStatus.DEPRECATED, + version=prior.version, + ) + case ModelFamilyAdded(family_id=family_id): + prior = require_state(state, "ModelFamilyAdded") + return Model( + id=prior.id, + name=prior.name, + manufacturer=prior.manufacturer, + part_number=prior.part_number, + declared_families=prior.declared_families | {family_id}, + # Status preserved across targeted mutation. + status=prior.status, + version=prior.version, + ) + case ModelFamilyRemoved(family_id=family_id): + prior = require_state(state, "ModelFamilyRemoved") + return Model( + id=prior.id, + name=prior.name, + manufacturer=prior.manufacturer, + part_number=prior.part_number, + declared_families=prior.declared_families - {family_id}, + status=prior.status, + version=prior.version, + ) + case _: # pragma: no cover # exhaustiveness guard + assert_never(event) + + +def fold(events: Sequence[ModelEvent]) -> Model | None: + """Replay a stream of events from the empty initial state.""" + state: Model | None = None + for event in events: + state = evolve(state, event) + return state diff --git a/apps/api/src/cora/equipment/aggregates/model/read.py b/apps/api/src/cora/equipment/aggregates/model/read.py new file mode 100644 index 000000000..af2c7bf4a --- /dev/null +++ b/apps/api/src/cora/equipment/aggregates/model/read.py @@ -0,0 +1,68 @@ +# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false + +"""Read repositories for the Model aggregate. + +`load_model(event_store, model_id) -> Model | None` mirrors +`load_family` / `load_actor` / `load_subject` / etc. + +`list_model_ids(pool) -> list[UUID]` reads every non-Deprecated Model +id from the summary projection. Mirrors `list_family_ids`; intended +for `inspect_plan_binding`-style candidate enumeration and for +cross-BC catalog-lookup preconditions in future slices. + +The Model summary projection (`proj_equipment_model_summary`) does +NOT carry per-FSM-transition timestamps (versioned_at, deprecated_at) +the way `proj_equipment_family_summary` does; the projection's +`created_at` is the only lifecycle timestamp materialized today. +Consumers that need transition timestamps would either fold the +event stream directly or trigger a future projection-schema +addition. No `load_model_timestamps` ships in this slice as a result. + +`_STREAM_TYPE = "Model"`. The stream-type string is the event store's +internal categorization key for this aggregate. +""" + +from uuid import UUID + +import asyncpg + +from cora.equipment.aggregates.model.events import from_stored +from cora.equipment.aggregates.model.evolver import fold +from cora.equipment.aggregates.model.state import Model +from cora.infrastructure.ports import EventStore + +_STREAM_TYPE = "Model" + + +async def load_model(event_store: EventStore, model_id: UUID) -> Model | None: + """Load and fold a Model's event stream into current state.""" + stored, _version = await event_store.load(_STREAM_TYPE, model_id) + events = [from_stored(s) for s in stored] + return fold(events) + + +_SELECT_MODEL_IDS_SQL = """ +SELECT model_id +FROM proj_equipment_model_summary +WHERE status <> 'Deprecated' +ORDER BY model_id::text +""" + + +async def list_model_ids(pool: asyncpg.Pool | None) -> list[UUID]: + """Read every non-Deprecated Model id from the summary projection. + + Mirrors `list_family_ids`: returns `[]` when `pool is None` + (test / no-database app_env), so callers do not need a defensive + None-check at every site. Tests that need a populated lookup + must wire a real pool. + + Deprecated Models are excluded at the SQL layer so they are not + offered as candidate sources in future cross-BC lookups; operators + can still inspect Deprecated Models directly via `get_model`. + """ + if pool is None: + return [] + async with pool.acquire() as conn: + rows = await conn.fetch(_SELECT_MODEL_IDS_SQL) + return [row["model_id"] for row in rows] diff --git a/apps/api/src/cora/equipment/aggregates/model/state.py b/apps/api/src/cora/equipment/aggregates/model/state.py new file mode 100644 index 000000000..c6bed800b --- /dev/null +++ b/apps/api/src/cora/equipment/aggregates/model/state.py @@ -0,0 +1,458 @@ +"""Model aggregate state, status enum, errors, and value objects. + +`Model` is the Equipment BC's vendor-catalog entry: HOW a deployed +`Asset` is identified as an instance of "Vendor X part number Y". +A `Model` pins together a `Manufacturer` (name plus optional +identifier in a closed-StrEnum scheme), a `part_number` (vendor SKU), +and a `declared_families: frozenset[UUID]` pointing at one or more +registered `Family` aggregates that the catalog entry satisfies. + +## Aggregate scope + +Model sits between `Family` (the device-class kind) and `Asset` (the +deployed instance) in the Equipment ladder. Examples: an Aerotech +ANT130-L rotary stage is one Model; the two PCO Edge 5.5 cameras +mounted at 2-BM share a single Model. Asset gains an optional +`model_id` pointer; if set, `Model.declared_families` must be a +subset of `Asset.family_ids` at `register_asset` and `add_asset_family` +time (cross-BC subset invariant). + +`declared_families: frozenset[UUID]` is REQUIRED at `define_model` +time with cardinality at least one (empty rejected at the API +boundary). The set mutates incrementally through `add_model_family` +and `remove_model_family` (targeted-mutation events), or wholesale +through `version_model` (a new version IS a new declaration; matches +Family/Method/Plan/Practice replace-on-version precedent). + +## Catalog-tier required-manufacturer rationale + +`Manufacturer` is required: a catalog entry without a manufacturer is +incoherent across the four catalog-tier traditions (CMMS Equipment +Type per ISO 14224, AAS Type-AAS DigitalNameplate IDTA 02006, OPC UA +vendor profile, ECLASS-augmented Property). The PIDINST property 6 +`1-n Mandatory` cardinality is an INSTANCE-tier obligation (PIDINST +v1.0 spec page 1: "The group considers instrument instances, e.g. +the individual physical objects, as opposed to instrument types or +models") and transfers to `Asset.alternate_identifiers` in a future +slice, NOT to `Model.manufacturer` at the catalog tier. + +## Status as enum-in-state, derived-from-event-type-in-evolver + +`ModelStatus` is a `StrEnum` so the values would serialize naturally +as JSON-friendly strings IF carried in an event payload. Today they +aren't: state holds the enum (typed) and the evolver derives the new +status from the event TYPE, mirroring `FamilyStatus`. + +## Closed `ManufacturerIdentifierType` enum + +`ManufacturerIdentifierType` is a closed StrEnum (`ROR | GRID | ISNI`) +per the [[project-family-affordance-design]] closed-vocabulary +precedent. Adding a fourth scheme (e.g., `WIKIDATA`) is an additive +enum change at a future migration boundary. + +## Bounded-name VOs + +`ModelName`, `PartNumber`, `ManufacturerName`, `ManufacturerIdentifier`, +`ModelVersionTag`, and `ModelDeprecationReason` follow the +trimmed-bounded-text VO pattern via the shared +`validate_bounded_text` helper. Part numbers are NOT case-folded +because vendor SKUs are case-sensitive (`RV120CCHL` and `rv120cchl` +are different Newport entries). +""" + +from dataclasses import dataclass +from enum import StrEnum +from uuid import UUID + +from cora.infrastructure.bounded_text import validate_bounded_text + +MODEL_NAME_MAX_LENGTH = 200 +MODEL_PART_NUMBER_MAX_LENGTH = 100 +MODEL_VERSION_TAG_MAX_LENGTH = 50 +MODEL_DEPRECATION_REASON_MAX_LENGTH = 500 +MANUFACTURER_NAME_MAX_LENGTH = 200 +MANUFACTURER_IDENTIFIER_MAX_LENGTH = 200 + + +class ModelStatus(StrEnum): + """The Model's lifecycle state. + + Transitions: + - Defined -> Versioned (version_model) + - (Defined | Versioned) -> Deprecated (deprecate_model) + + `Defined` is the genesis state set by `define_model`. Multi-source + `(Defined | Versioned) -> Versioned` matches the Family precedent + at `family/state.py` (`FamilyCannotVersionError`). + """ + + DEFINED = "Defined" + VERSIONED = "Versioned" + DEPRECATED = "Deprecated" + + +class ManufacturerIdentifierType(StrEnum): + """Closed scheme for the optional manufacturer identifier. + + Three members ship in v1: ROR (Research Organization Registry), + GRID (Global Research Identifier Database; subsumed by ROR but + still in active use at many facilities), ISNI (International + Standard Name Identifier). Adding a fourth scheme is an additive + enum change. + """ + + ROR = "ROR" + GRID = "GRID" + ISNI = "ISNI" + + +class InvalidModelNameError(ValueError): + """The supplied model name is empty, whitespace-only, or too long.""" + + def __init__(self, value: str) -> None: + super().__init__( + f"Model name must be 1-{MODEL_NAME_MAX_LENGTH} chars after trimming (got: {value!r})" + ) + self.value = value + + +class InvalidPartNumberError(ValueError): + """The supplied part number is empty, whitespace-only, or too long.""" + + def __init__(self, value: str) -> None: + super().__init__( + f"Part number must be 1-{MODEL_PART_NUMBER_MAX_LENGTH} chars after trimming " + f"(got: {value!r})" + ) + self.value = value + + +class InvalidManufacturerNameError(ValueError): + """The supplied manufacturer name is empty, whitespace-only, or too long.""" + + def __init__(self, value: str) -> None: + super().__init__( + f"Manufacturer name must be 1-{MANUFACTURER_NAME_MAX_LENGTH} chars after trimming " + f"(got: {value!r})" + ) + self.value = value + + +class InvalidManufacturerIdentifierError(ValueError): + """The supplied manufacturer identifier is empty, whitespace-only, or too long.""" + + def __init__(self, value: str) -> None: + super().__init__( + f"Manufacturer identifier must be 1-{MANUFACTURER_IDENTIFIER_MAX_LENGTH} chars " + f"after trimming (got: {value!r})" + ) + self.value = value + + +class InvalidManufacturerIdentifierPairingError(ValueError): + """`identifier` and `identifier_type` must be both set or both None. + + Cross-field invariant: setting only one half of the optional pair + is ambiguous (a bare identifier with no scheme cannot be resolved; + a scheme with no identifier is meaningless). Both together, or + both None. + """ + + def __init__(self, *, identifier: str | None, identifier_type: object) -> None: + super().__init__( + "Manufacturer.identifier and Manufacturer.identifier_type must be both set " + f"or both None (got identifier={identifier!r}, identifier_type={identifier_type!r})" + ) + self.identifier = identifier + self.identifier_type = identifier_type + + +class InvalidModelVersionTagError(ValueError): + """The supplied version tag is empty, whitespace-only, or too long.""" + + def __init__(self, value: str) -> None: + super().__init__( + f"Model version tag must be 1-{MODEL_VERSION_TAG_MAX_LENGTH} chars after trimming " + f"(got: {value!r})" + ) + self.value = value + + +class InvalidModelDeprecationReasonError(ValueError): + """The supplied deprecation reason is empty, whitespace-only, or too long.""" + + def __init__(self, value: str) -> None: + super().__init__( + f"Model deprecation reason must be 1-{MODEL_DEPRECATION_REASON_MAX_LENGTH} chars " + f"after trimming (got: {value!r})" + ) + self.value = value + + +class InvalidDeclaredFamiliesError(ValueError): + """`declared_families` is empty (cardinality at least one required).""" + + def __init__(self) -> None: + super().__init__( + "Model.declared_families must contain at least one Family id " + "(empty set rejected at the catalog tier)" + ) + + +class ModelAlreadyExistsError(Exception): + """Attempted to define a model whose stream already has events.""" + + def __init__(self, model_id: UUID) -> None: + super().__init__(f"Model {model_id} already exists") + self.model_id = model_id + + +class ModelNotFoundError(Exception): + """Attempted an operation on a model whose stream has no events.""" + + def __init__(self, model_id: UUID) -> None: + super().__init__(f"Model {model_id} not found") + self.model_id = model_id + + +class ModelCannotVersionError(Exception): + """Attempted to version a model not in `Defined` or `Versioned`. + + Multi-source guard: `version_model` accepts both `Defined` and + `Versioned`. Only `Deprecated` is rejected. + """ + + def __init__(self, model_id: UUID, current_status: "ModelStatus") -> None: + super().__init__( + f"Model {model_id} cannot be versioned: currently in status " + f"{current_status.value}, version requires " + f"{ModelStatus.DEFINED.value} or {ModelStatus.VERSIONED.value}" + ) + self.model_id = model_id + self.current_status = current_status + + +class ModelCannotDeprecateError(Exception): + """Attempted to deprecate a model not in `Defined` or `Versioned`.""" + + def __init__(self, model_id: UUID, current_status: "ModelStatus") -> None: + super().__init__( + f"Model {model_id} cannot be deprecated: currently in status " + f"{current_status.value}, deprecate requires " + f"{ModelStatus.DEFINED.value} or {ModelStatus.VERSIONED.value}" + ) + self.model_id = model_id + self.current_status = current_status + + +class ModelCannotAddFamilyError(Exception): + """Attempted to add a family to a model not in `Defined` or `Versioned`. + + Mirrors `ModelCannotVersionError` and `ModelCannotDeprecateError`: + `add_model_family` accepts both `Defined` and `Versioned` source + states. Only `Deprecated` is rejected; the rejection rationale is + the same "deprecated catalog entry is frozen" guard that drives + version and deprecate. + """ + + def __init__(self, model_id: UUID, current_status: "ModelStatus") -> None: + super().__init__( + f"Model {model_id} cannot add family: currently in status " + f"{current_status.value}, add_model_family requires " + f"{ModelStatus.DEFINED.value} or {ModelStatus.VERSIONED.value}" + ) + self.model_id = model_id + self.current_status = current_status + + +class ModelCannotRemoveFamilyError(Exception): + """Attempted to remove a family from a model not in `Defined` or `Versioned`. + + Mirrors `ModelCannotAddFamilyError`: `remove_model_family` accepts + both `Defined` and `Versioned` source states. Only `Deprecated` + is rejected on the same frozen-catalog-entry rationale. + """ + + def __init__(self, model_id: UUID, current_status: "ModelStatus") -> None: + super().__init__( + f"Model {model_id} cannot remove family: currently in status " + f"{current_status.value}, remove_model_family requires " + f"{ModelStatus.DEFINED.value} or {ModelStatus.VERSIONED.value}" + ) + self.model_id = model_id + self.current_status = current_status + + +class ModelFamilyAlreadyPresentError(Exception): + """Attempted to add a family already present in `declared_families`.""" + + def __init__(self, model_id: UUID, family_id: UUID) -> None: + super().__init__( + f"Model {model_id} already declares family {family_id}; " + "add_model_family is strict-not-idempotent" + ) + self.model_id = model_id + self.family_id = family_id + + +class ModelFamilyNotPresentError(Exception): + """Attempted to remove a family not present in `declared_families`.""" + + def __init__(self, model_id: UUID, family_id: UUID) -> None: + super().__init__(f"Model {model_id} does not declare family {family_id}; nothing to remove") + self.model_id = model_id + self.family_id = family_id + + +@dataclass(frozen=True) +class ModelName: + """Display name for a model. Trimmed; 1-200 chars.""" + + value: str + + def __post_init__(self) -> None: + trimmed = validate_bounded_text( + self.value, + max_length=MODEL_NAME_MAX_LENGTH, + error_class=InvalidModelNameError, + ) + object.__setattr__(self, "value", trimmed) + + +@dataclass(frozen=True) +class PartNumber: + """Vendor SKU. Trimmed; 1-100 chars; case-sensitive (no folding). + + Vendor part numbers like Newport's `RV120CCHL` and `rv120cchl` + are distinct entries in vendor catalogs; case-folding would + collide them. + """ + + value: str + + def __post_init__(self) -> None: + trimmed = validate_bounded_text( + self.value, + max_length=MODEL_PART_NUMBER_MAX_LENGTH, + error_class=InvalidPartNumberError, + ) + object.__setattr__(self, "value", trimmed) + + +@dataclass(frozen=True) +class ManufacturerName: + """Manufacturer display name. Trimmed; 1-200 chars.""" + + value: str + + def __post_init__(self) -> None: + trimmed = validate_bounded_text( + self.value, + max_length=MANUFACTURER_NAME_MAX_LENGTH, + error_class=InvalidManufacturerNameError, + ) + object.__setattr__(self, "value", trimmed) + + +@dataclass(frozen=True) +class ManufacturerIdentifier: + """Optional manufacturer identifier value. Trimmed; 1-200 chars. + + Opaque string; the scheme lives in `ManufacturerIdentifierType`. + See [[project-asset-condition-design]] for the orthogonal-axis + precedent (the scheme is one axis, the identifier value is the + other; coupled through the `Manufacturer` VO's pairing invariant). + """ + + value: str + + def __post_init__(self) -> None: + trimmed = validate_bounded_text( + self.value, + max_length=MANUFACTURER_IDENTIFIER_MAX_LENGTH, + error_class=InvalidManufacturerIdentifierError, + ) + object.__setattr__(self, "value", trimmed) + + +@dataclass(frozen=True) +class Manufacturer: + """A model's manufacturer: required name, optional (identifier, type). + + Pairing invariant: `identifier` and `identifier_type` are both set + or both None. A bare identifier with no scheme cannot be resolved; + a scheme with no identifier is meaningless. Enforced in + `__post_init__`; raises `InvalidManufacturerIdentifierPairingError`. + """ + + name: ManufacturerName + identifier: ManufacturerIdentifier | None = None + identifier_type: ManufacturerIdentifierType | None = None + + def __post_init__(self) -> None: + has_id = self.identifier is not None + has_type = self.identifier_type is not None + if has_id != has_type: + raise InvalidManufacturerIdentifierPairingError( + identifier=self.identifier.value if self.identifier is not None else None, + identifier_type=self.identifier_type, + ) + + +@dataclass(frozen=True) +class ModelVersionTag: + """Operator-supplied revision label. Trimmed; 1-50 chars.""" + + value: str + + def __post_init__(self) -> None: + trimmed = validate_bounded_text( + self.value, + max_length=MODEL_VERSION_TAG_MAX_LENGTH, + error_class=InvalidModelVersionTagError, + ) + object.__setattr__(self, "value", trimmed) + + +@dataclass(frozen=True) +class ModelDeprecationReason: + """Operator-supplied deprecation rationale. Trimmed; 1-500 chars.""" + + value: str + + def __post_init__(self) -> None: + trimmed = validate_bounded_text( + self.value, + max_length=MODEL_DEPRECATION_REASON_MAX_LENGTH, + error_class=InvalidModelDeprecationReasonError, + ) + object.__setattr__(self, "value", trimmed) + + +@dataclass(frozen=True) +class Model: + """Aggregate root: a vendor-catalog entry. + + `version` is the operator-supplied label of the most recent + `version_model` call (None until first version). State always + holds the latest tag; past tags live in the event stream as + `ModelVersioned` events. + + `declared_families` is the frozenset of Family ids the catalog + entry satisfies. Required non-empty at `define_model` time. + Mutated incrementally through `add_model_family` / + `remove_model_family` (targeted-mutation), or wholesale through + `version_model` (replace-on-version). + + Cross-BC subset invariant `Model.declared_families subset-of + Asset.family_ids` evaluated by the Asset BC at `register_asset` and + `add_asset_family`; NOT enforced inside the Model aggregate. + """ + + id: UUID + name: ModelName + manufacturer: Manufacturer + part_number: PartNumber + declared_families: frozenset[UUID] + status: ModelStatus = ModelStatus.DEFINED + version: str | None = None diff --git a/apps/api/src/cora/equipment/features/add_model_family/__init__.py b/apps/api/src/cora/equipment/features/add_model_family/__init__.py new file mode 100644 index 000000000..0b34179e9 --- /dev/null +++ b/apps/api/src/cora/equipment/features/add_model_family/__init__.py @@ -0,0 +1,25 @@ +"""Vertical slice for the `AddModelFamily` command. + +Module-as-namespace surface: + + from cora.equipment.features import add_model_family + + cmd = add_model_family.AddModelFamily(model_id=..., family_id=...) + handler = add_model_family.bind(deps) + await handler(cmd, principal_id=..., correlation_id=...) +""" + +from cora.equipment.features.add_model_family import tool +from cora.equipment.features.add_model_family.command import AddModelFamily +from cora.equipment.features.add_model_family.decider import decide +from cora.equipment.features.add_model_family.handler import Handler, bind +from cora.equipment.features.add_model_family.route import router + +__all__ = [ + "AddModelFamily", + "Handler", + "bind", + "decide", + "router", + "tool", +] diff --git a/apps/api/src/cora/equipment/features/add_model_family/command.py b/apps/api/src/cora/equipment/features/add_model_family/command.py new file mode 100644 index 000000000..07f9f67d6 --- /dev/null +++ b/apps/api/src/cora/equipment/features/add_model_family/command.py @@ -0,0 +1,31 @@ +"""The `AddModelFamily` command, intent dataclass for this slice. + +Targeted-mutation: incremental add of a single Family to the +Model's `declared_families` set. Sibling of `remove_model_family`. + +The operational pattern is "vendor firmware update declares an +additional Family" rather than a wholesale re-author; `version_model` +remains the path when a revision genuinely re-authors the catalog +entry (matches the Family/Method/Plan/Practice replace-on-version +precedent). + +`model_id` is the target Model aggregate. `family_id` is the Family +being declared; the handler resolves it against the Family registry +(cross-BC lookup mirrors `define_model`'s `list_family_ids` pattern) +and raises `FamilyNotFoundError` if it does not resolve. + +Strict-not-idempotent: re-adding a Family already in +`declared_families` raises `ModelFamilyAlreadyPresentError` (same +precedent as `add_asset_family` and `activate_asset`). +""" + +from dataclasses import dataclass +from uuid import UUID + + +@dataclass(frozen=True) +class AddModelFamily: + """Add a Family to an existing model's `declared_families` set.""" + + model_id: UUID + family_id: UUID diff --git a/apps/api/src/cora/equipment/features/add_model_family/decider.py b/apps/api/src/cora/equipment/features/add_model_family/decider.py new file mode 100644 index 000000000..23351ec71 --- /dev/null +++ b/apps/api/src/cora/equipment/features/add_model_family/decider.py @@ -0,0 +1,62 @@ +"""Pure decider for the `AddModelFamily` command. + +Targeted mutation of `Model.declared_families`, not a lifecycle +transition. Status is preserved (`Defined` stays `Defined`, +`Versioned` stays `Versioned`); only `Deprecated` is rejected, on +the same "deprecated catalog entry is frozen" rationale that drives +`ModelVersioned` and `ModelFamilyRemoved` rejection from +`Deprecated` in the events module. + +The Deprecated gate raises a per-verb `ModelCannotAddFamilyError` +mirroring `AssetCannotAddFamilyError`. The diagnostic message names +the actual verb so operators see "cannot add family" instead of +the older shared "cannot be versioned" wording. + +The decider does NOT verify the referenced Family id resolves to a +real Family stream; the handler performs that cross-BC lookup +upstream (mirroring `define_model`) and raises `FamilyNotFoundError` +before the command reaches the decider. + +Strict-not-idempotent: re-adding a present family raises +`ModelFamilyAlreadyPresentError` (mirrors `add_asset_family`). + +Invariants: + - State must not be None -> ModelNotFoundError + - State.status must not be Deprecated -> ModelCannotAddFamilyError + - family_id must not already be in state.declared_families + (strict-not-idempotent) -> ModelFamilyAlreadyPresentError +""" + +from datetime import datetime + +from cora.equipment.aggregates.model import ( + Model, + ModelCannotAddFamilyError, + ModelFamilyAdded, + ModelFamilyAlreadyPresentError, + ModelNotFoundError, + ModelStatus, +) +from cora.equipment.features.add_model_family.command import AddModelFamily + + +def decide( + state: Model | None, + command: AddModelFamily, + *, + now: datetime, +) -> list[ModelFamilyAdded]: + """Decide the events produced by adding a family to an existing model.""" + if state is None: + raise ModelNotFoundError(command.model_id) + if state.status is ModelStatus.DEPRECATED: + raise ModelCannotAddFamilyError(state.id, current_status=state.status) + if command.family_id in state.declared_families: + raise ModelFamilyAlreadyPresentError(state.id, command.family_id) + return [ + ModelFamilyAdded( + model_id=state.id, + family_id=command.family_id, + occurred_at=now, + ) + ] diff --git a/apps/api/src/cora/equipment/features/add_model_family/handler.py b/apps/api/src/cora/equipment/features/add_model_family/handler.py new file mode 100644 index 000000000..ce2a28963 --- /dev/null +++ b/apps/api/src/cora/equipment/features/add_model_family/handler.py @@ -0,0 +1,164 @@ +"""Application handler for the `add_model_family` slice. + +Update-style handler shape: load + fold + decide + append. Mirrors +the `add_asset_family` and `version_model` precedents for the +stream load + fold + decide + append spine, and the `define_model` +precedent for the cross-BC `list_all_family_ids` lookup that resolves +`command.family_id` against the Family registry before the decider +runs. + +Not idempotency-wrapped: domain-idempotent via +`ModelFamilyAlreadyPresentError` on retry (mirrors +`add_asset_family`). + +Cross-BC concern: the referenced `family_id` must resolve to a +registered Family stream (including Deprecated). On miss the +handler raises `FamilyNotFoundError(command.family_id)` (404) before +the decider sees the command, matching the `define_model` +operational pattern of surfacing missing-Family errors at the +application boundary. Family.deprecation is an authoring signal NOT +a runtime gate per the Model aggregate's design memo; adding a +Deprecated Family to a Model's declared set is permitted. +""" + +from typing import Protocol +from uuid import UUID + +from cora.equipment.aggregates.family import FamilyNotFoundError, list_all_family_ids +from cora.equipment.aggregates.model import ( + ModelEvent, + event_type_name, + fold, + from_stored, + to_payload, +) +from cora.equipment.errors import UnauthorizedError +from cora.equipment.features.add_model_family.command import AddModelFamily +from cora.equipment.features.add_model_family.decider import decide +from cora.infrastructure.event_envelope import to_new_event +from cora.infrastructure.kernel import Kernel +from cora.infrastructure.logging import get_logger +from cora.infrastructure.ports import Deny +from cora.infrastructure.routing import NIL_SENTINEL_ID + +_STREAM_TYPE = "Model" +_COMMAND_NAME = "AddModelFamily" + +_log = get_logger(__name__) + + +class Handler(Protocol): + """Callable interface every add_model_family handler implements.""" + + async def __call__( + self, + command: AddModelFamily, + *, + 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_model_family handler closed over the shared deps.""" + + async def handler( + command: AddModelFamily, + *, + principal_id: UUID, + correlation_id: UUID, + causation_id: UUID | None = None, + surface_id: UUID = NIL_SENTINEL_ID, + ) -> None: + _log.info( + "add_model_family.start", + command_name=_COMMAND_NAME, + model_id=str(command.model_id), + family_id=str(command.family_id), + principal_id=str(principal_id), + correlation_id=str(correlation_id), + causation_id=str(causation_id) if causation_id is not None else None, + ) + + decision = await deps.authz.authorize( + principal_id=principal_id, + command_name=_COMMAND_NAME, + conduit_id=NIL_SENTINEL_ID, + surface_id=surface_id, + ) + if isinstance(decision, Deny): + _log.info( + "add_model_family.denied", + command_name=_COMMAND_NAME, + model_id=str(command.model_id), + family_id=str(command.family_id), + principal_id=str(principal_id), + correlation_id=str(correlation_id), + causation_id=str(causation_id) if causation_id is not None else None, + reason=decision.reason, + ) + raise UnauthorizedError(decision.reason) + + # Cross-BC family lookup: the referenced Family must resolve. + # Bulk single-query approach (cheap at pilot scale, <50 Families). + # Trigger to switch to per-id load: facility Family count crosses + # ~500 OR p95 of add_model_family crosses 200ms. + known_family_ids = set(await list_all_family_ids(deps.pool)) + if command.family_id not in known_family_ids: + _log.info( + "add_model_family.family_not_found", + command_name=_COMMAND_NAME, + model_id=str(command.model_id), + family_id=str(command.family_id), + principal_id=str(principal_id), + correlation_id=str(correlation_id), + causation_id=str(causation_id) if causation_id is not None else None, + ) + raise FamilyNotFoundError(command.family_id) + + now = deps.clock.now() + + stored, current_version = await deps.event_store.load( + stream_type=_STREAM_TYPE, + stream_id=command.model_id, + ) + history: list[ModelEvent] = [from_stored(s) for s in stored] + state = fold(history) + + domain_events = decide(state=state, command=command, now=now) + + new_events = [ + to_new_event( + event_type=event_type_name(event), + payload=to_payload(event), + occurred_at=event.occurred_at, + event_id=deps.id_generator.new_id(), + command_name=_COMMAND_NAME, + correlation_id=correlation_id, + causation_id=causation_id, + principal_id=principal_id, + ) + for event in domain_events + ] + await deps.event_store.append( + stream_type=_STREAM_TYPE, + stream_id=command.model_id, + expected_version=current_version, + events=new_events, + ) + + _log.info( + "add_model_family.success", + command_name=_COMMAND_NAME, + model_id=str(command.model_id), + family_id=str(command.family_id), + principal_id=str(principal_id), + correlation_id=str(correlation_id), + causation_id=str(causation_id) if causation_id is not None else None, + event_count=len(new_events), + new_version=current_version + len(new_events), + ) + + return handler diff --git a/apps/api/src/cora/equipment/features/add_model_family/route.py b/apps/api/src/cora/equipment/features/add_model_family/route.py new file mode 100644 index 000000000..089f69c47 --- /dev/null +++ b/apps/api/src/cora/equipment/features/add_model_family/route.py @@ -0,0 +1,91 @@ +"""HTTP route for the `add_model_family` slice. + +Targeted-mutation endpoint at `POST /models/{model_id}/families`. Body +carries a single `family_id` that is added to the Model's +`declared_families` set. 204 No Content on success. Status (`Defined` +or `Versioned`) is preserved; `Deprecated` rejects the mutation. No +`Idempotency-Key` (update-style, mirrors `version_model`). +""" + +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, Path, Request, status +from pydantic import BaseModel, Field + +from cora.equipment.features.add_model_family.command import AddModelFamily +from cora.equipment.features.add_model_family.handler import Handler +from cora.infrastructure.routing import ( + ErrorResponse, + get_correlation_id, + get_principal_id, + get_surface_id, +) + + +class AddModelFamilyRequest(BaseModel): + """Body for `POST /models/{model_id}/families`.""" + + family_id: UUID = Field( + ..., + description="Family id to add to the Model.declared_families set.", + ) + + +def _get_handler(request: Request) -> Handler: + handler: Handler = request.app.state.equipment.add_model_family + return handler + + +router = APIRouter(tags=["equipment"]) + + +@router.post( + "/models/{model_id}/families", + status_code=status.HTTP_204_NO_CONTENT, + responses={ + status.HTTP_400_BAD_REQUEST: { + "model": ErrorResponse, + "description": "Domain invariant violated.", + }, + status.HTTP_403_FORBIDDEN: { + "model": ErrorResponse, + "description": "Authorize port denied the command.", + }, + status.HTTP_404_NOT_FOUND: { + "model": ErrorResponse, + "description": ( + "No model exists with the given id, OR the supplied " + "family_id does not resolve to a registered Family." + ), + }, + status.HTTP_409_CONFLICT: { + "model": ErrorResponse, + "description": ( + "Model is in `Deprecated` status (mutation requires " + "`Defined` or `Versioned`), OR the family_id is already " + "in the Model's declared_families set, OR a concurrent " + "write to the same model stream conflicted (optimistic " + "concurrency)." + ), + }, + status.HTTP_422_UNPROCESSABLE_CONTENT: { + "description": "Path parameter or request body failed schema validation.", + }, + }, + summary="Add a Family to an existing Model's declared_families set", +) +async def post_models_add_family( + model_id: Annotated[UUID, Path(description="Target model's id.")], + body: AddModelFamilyRequest, + 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( + AddModelFamily(model_id=model_id, family_id=body.family_id), + principal_id=principal_id, + correlation_id=cid, + surface_id=surface_id, + ) diff --git a/apps/api/src/cora/equipment/features/add_model_family/tool.py b/apps/api/src/cora/equipment/features/add_model_family/tool.py new file mode 100644 index 000000000..6e30adb43 --- /dev/null +++ b/apps/api/src/cora/equipment/features/add_model_family/tool.py @@ -0,0 +1,51 @@ +"""MCP tool for the `add_model_family` slice. + +Mirror of `add_asset_family` MCP tool: single model_id arg plus an +extra UUID arg (family_id). Domain / application errors propagate +to FastMCP, which wraps them as `isError: true`. +""" + +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.features.add_model_family.command import AddModelFamily +from cora.equipment.features.add_model_family.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_model_family` tool on the given MCP server.""" + + @mcp.tool( + name="add_model_family", + description=( + "Add a Family to a vendor-catalog Model declared_families set. " + "Strict-not-idempotent: re-adding a present family raises an error." + ), + ) + async def add_model_family_tool( # pyright: ignore[reportUnusedFunction] + ctx: Context[Any, Any, Any], + model_id: Annotated[ + UUID, + Field(description="Target Model's id."), + ], + family_id: Annotated[ + UUID, + Field( + description=("Family id to add. Cross-BC existence is verified at the handler."), + ), + ], + ) -> None: + handler = get_handler() + await handler( + AddModelFamily(model_id=model_id, family_id=family_id), + 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/define_model/__init__.py b/apps/api/src/cora/equipment/features/define_model/__init__.py new file mode 100644 index 000000000..de936d2c2 --- /dev/null +++ b/apps/api/src/cora/equipment/features/define_model/__init__.py @@ -0,0 +1,36 @@ +"""Vertical slice for the `DefineModel` command. + +Module-as-namespace surface, symmetric with the other create-style +command slices: + + from cora.equipment.features import define_model + + cmd = define_model.DefineModel( + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({rotary_stage_family_id}), + ) + handler = define_model.bind(deps) + model_id = await handler(cmd, principal_id=..., correlation_id=...) +""" + +from cora.equipment.features.define_model import tool +from cora.equipment.features.define_model.command import DefineModel +from cora.equipment.features.define_model.decider import decide +from cora.equipment.features.define_model.handler import ( + Handler, + IdempotentHandler, + bind, +) +from cora.equipment.features.define_model.route import router + +__all__ = [ + "DefineModel", + "Handler", + "IdempotentHandler", + "bind", + "decide", + "router", + "tool", +] diff --git a/apps/api/src/cora/equipment/features/define_model/command.py b/apps/api/src/cora/equipment/features/define_model/command.py new file mode 100644 index 000000000..6bcee9cff --- /dev/null +++ b/apps/api/src/cora/equipment/features/define_model/command.py @@ -0,0 +1,33 @@ +"""The `DefineModel` command, intent dataclass for this slice. + +Carries only what the caller controls (display name, manufacturer, +part_number, declared_families, optional initial version_tag). +Server-side concerns (new aggregate id, wall-clock timestamp, +correlation id, per-event ids) are injected by the handler from +infrastructure ports. + +Status is implicit at definition (`Defined`) and not part of the +command; see the Model aggregate's `state.py` docstring for the +enum-in-state, str-in-event convention. + +`declared_families` is REQUIRED at definition time with cardinality +at least one. Empty `frozenset()` is rejected by the decider with +`InvalidDeclaredFamiliesError`; the catalog tier without any Family +declaration has no instantiation contract. +""" + +from dataclasses import dataclass +from uuid import UUID + +from cora.equipment.aggregates.model import Manufacturer + + +@dataclass(frozen=True) +class DefineModel: + """Define a new vendor-catalog Model with manufacturer, part_number, families.""" + + name: str + manufacturer: Manufacturer + part_number: str + declared_families: frozenset[UUID] + version_tag: str | None = None diff --git a/apps/api/src/cora/equipment/features/define_model/decider.py b/apps/api/src/cora/equipment/features/define_model/decider.py new file mode 100644 index 000000000..e79813274 --- /dev/null +++ b/apps/api/src/cora/equipment/features/define_model/decider.py @@ -0,0 +1,75 @@ +"""Pure decider for the `DefineModel` command. + +Pure function: given the current Model state (None for a fresh +stream) and a `DefineModel` command, returns the events to append. +No I/O, no awaits, no side effects. + +`now` and `new_id` are injected by the application handler from the +Clock and IdGenerator ports. The handler is also responsible for +the cross-BC `family_lookup` validation (every element of +`command.declared_families` must resolve to a registered Family); +that lookup happens before the decider is called, since the decider +is pure and the Family lookup is impure. + +The `version_tag` VO validation is performed here (defensively) when +the caller supplies one; an empty initial tag is rejected with +`InvalidModelVersionTagError` just like Family's version_tag. +""" + +from datetime import datetime +from uuid import UUID + +from cora.equipment.aggregates.model import ( + InvalidDeclaredFamiliesError, + Model, + ModelAlreadyExistsError, + ModelDefined, + ModelName, + ModelVersionTag, + PartNumber, +) +from cora.equipment.features.define_model.command import DefineModel + + +def decide( + state: Model | None, + command: DefineModel, + *, + now: datetime, + new_id: UUID, +) -> list[ModelDefined]: + """Decide the events produced by defining a new model. + + Invariants: + - State must be None (genesis-only) -> ModelAlreadyExistsError + - declared_families must be non-empty -> InvalidDeclaredFamiliesError + - Name must be valid -> InvalidModelNameError (via ModelName VO) + - Part number must be valid -> InvalidPartNumberError + (via PartNumber VO) + - version_tag, if supplied, must be valid + -> InvalidModelVersionTagError (via ModelVersionTag VO) + + The Manufacturer VO's own pairing invariant is enforced by the + Manufacturer dataclass itself before the command reaches the + decider (raises InvalidManufacturerIdentifierPairingError). + """ + if state is not None: + raise ModelAlreadyExistsError(state.id) + if not command.declared_families: + raise InvalidDeclaredFamiliesError + name = ModelName(command.name) + part_number = PartNumber(command.part_number) + if command.version_tag is not None: + # Validate but discard the VO; the event carries the raw str. + ModelVersionTag(command.version_tag) + return [ + ModelDefined( + model_id=new_id, + name=name.value, + manufacturer=command.manufacturer, + part_number=part_number.value, + declared_families=command.declared_families, + occurred_at=now, + version_tag=command.version_tag, + ) + ] diff --git a/apps/api/src/cora/equipment/features/define_model/handler.py b/apps/api/src/cora/equipment/features/define_model/handler.py new file mode 100644 index 000000000..5175824ef --- /dev/null +++ b/apps/api/src/cora/equipment/features/define_model/handler.py @@ -0,0 +1,180 @@ +"""Application handler for the `define_model` slice. + +Same shape as the locked cross-BC create-style command pattern +(register_actor / register_subject / define_zone / define_conduit +/ define_policy / define_family). Module-as-namespace: callers use +`from cora.equipment.features import define_model` then +`define_model.bind(deps)` returning a `define_model.Handler`. + +Cross-BC concern: this handler loads `list_all_family_ids` from the +Family read repo before invoking the decider, and verifies every +element of `command.declared_families` resolves to a registered +Family (including Deprecated). On miss, raises `FamilyNotFoundError` +(404) carrying the FIRST missing Family id. Operators iterating +through a multi-family catalog entry get a single missing id at +a time, matching the operational pattern. + +Family.deprecation is an authoring signal NOT a runtime gate per +the Model aggregate's design memo; binding a Model to a Deprecated +Family is permitted, mirroring the Asset-to-Deprecated-Family +posture. The discovery-side filter (`list_family_ids`) is the wrong +helper here. +""" + +from typing import Protocol +from uuid import UUID + +from cora.equipment.aggregates.family import FamilyNotFoundError, list_all_family_ids +from cora.equipment.aggregates.model import event_type_name, to_payload +from cora.equipment.errors import UnauthorizedError +from cora.equipment.features.define_model.command import DefineModel +from cora.equipment.features.define_model.decider import decide +from cora.infrastructure.event_envelope import to_new_event +from cora.infrastructure.kernel import Kernel +from cora.infrastructure.logging import get_logger +from cora.infrastructure.ports import Deny +from cora.infrastructure.routing import NIL_SENTINEL_ID + +_STREAM_TYPE = "Model" +_COMMAND_NAME = "DefineModel" + +_log = get_logger(__name__) + + +class Handler(Protocol): + """Bare define_model handler, the type returned by `bind()`. + + Has no idempotency_key kwarg. The cross-BC `with_idempotency` + decorator wraps a bare Handler into an `IdempotentHandler`; + production wiring in `wire.py` always wraps. Tests can use bare + Handler directly when they don't need idempotency semantics. + + `causation_id` is the id of the event/message that triggered + this command (None for HTTP / MCP root calls; sagas / process + managers pass the upstream event's id). + """ + + async def __call__( + self, + command: DefineModel, + *, + principal_id: UUID, + correlation_id: UUID, + causation_id: UUID | None = None, + surface_id: UUID = NIL_SENTINEL_ID, + ) -> UUID: ... + + +class IdempotentHandler(Protocol): + """define_model handler with Idempotency-Key support.""" + + async def __call__( + self, + command: DefineModel, + *, + principal_id: UUID, + correlation_id: UUID, + causation_id: UUID | None = None, + surface_id: UUID = NIL_SENTINEL_ID, + idempotency_key: str | None = None, + ) -> UUID: ... + + +def bind(deps: Kernel) -> Handler: + """Build a define_model handler closed over the shared deps.""" + + async def handler( + command: DefineModel, + *, + principal_id: UUID, + correlation_id: UUID, + causation_id: UUID | None = None, + surface_id: UUID = NIL_SENTINEL_ID, + ) -> UUID: + _log.info( + "define_model.start", + command_name=_COMMAND_NAME, + principal_id=str(principal_id), + correlation_id=str(correlation_id), + causation_id=str(causation_id) if causation_id is not None else None, + ) + + decision = await deps.authz.authorize( + principal_id=principal_id, + command_name=_COMMAND_NAME, + conduit_id=NIL_SENTINEL_ID, + surface_id=surface_id, + ) + if isinstance(decision, Deny): + _log.info( + "define_model.denied", + command_name=_COMMAND_NAME, + principal_id=str(principal_id), + correlation_id=str(correlation_id), + causation_id=str(causation_id) if causation_id is not None else None, + reason=decision.reason, + ) + raise UnauthorizedError(decision.reason) + + # Cross-BC family_lookup: every declared family must resolve. + # Bulk single-query approach (cheap at pilot scale, <50 Families). + # Trigger to switch to per-id load: facility Family count crosses + # ~500 OR p95 of define_model crosses 200ms. + known_family_ids = set(await list_all_family_ids(deps.pool)) + missing = command.declared_families - known_family_ids + if missing: + # Sorted for deterministic error ordering across runs; surface + # the first missing id (operators get one at a time). + first_missing = sorted(missing, key=str)[0] + _log.info( + "define_model.family_not_found", + command_name=_COMMAND_NAME, + principal_id=str(principal_id), + correlation_id=str(correlation_id), + first_missing_family_id=str(first_missing), + missing_count=len(missing), + ) + raise FamilyNotFoundError(first_missing) + + new_id = deps.id_generator.new_id() + now = deps.clock.now() + + domain_events = decide( + state=None, + command=command, + now=now, + new_id=new_id, + ) + + new_events = [ + to_new_event( + event_type=event_type_name(event), + payload=to_payload(event), + occurred_at=event.occurred_at, + event_id=deps.id_generator.new_id(), + command_name=_COMMAND_NAME, + correlation_id=correlation_id, + causation_id=causation_id, + principal_id=principal_id, + ) + for event in domain_events + ] + await deps.event_store.append( + stream_type=_STREAM_TYPE, + stream_id=new_id, + expected_version=0, + events=new_events, + ) + + _log.info( + "define_model.success", + command_name=_COMMAND_NAME, + model_id=str(new_id), + principal_id=str(principal_id), + correlation_id=str(correlation_id), + causation_id=str(causation_id) if causation_id is not None else None, + event_count=len(new_events), + ) + return new_id + + return handler diff --git a/apps/api/src/cora/equipment/features/define_model/route.py b/apps/api/src/cora/equipment/features/define_model/route.py new file mode 100644 index 000000000..cf5585d29 --- /dev/null +++ b/apps/api/src/cora/equipment/features/define_model/route.py @@ -0,0 +1,183 @@ +"""HTTP route for the `define_model` slice. + +Pydantic request/response schemas + APIRouter for `POST /models`. +The slice's BC-level wiring (`cora.equipment.routes.register_equipment_routes`) +includes this router on the FastAPI app. +""" + +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, Header, Request, status +from pydantic import BaseModel, Field + +from cora.equipment.aggregates.model import ( + MANUFACTURER_IDENTIFIER_MAX_LENGTH, + MANUFACTURER_NAME_MAX_LENGTH, + MODEL_NAME_MAX_LENGTH, + MODEL_PART_NUMBER_MAX_LENGTH, + MODEL_VERSION_TAG_MAX_LENGTH, + Manufacturer, + ManufacturerIdentifier, + ManufacturerIdentifierType, + ManufacturerName, +) +from cora.equipment.features.define_model.command import DefineModel +from cora.equipment.features.define_model.handler import IdempotentHandler +from cora.infrastructure.routing import ( + ErrorResponse, + get_correlation_id, + get_principal_id, + get_surface_id, +) + + +class ManufacturerBody(BaseModel): + """Pydantic mirror of the Manufacturer VO for the request body. + + `identifier` and `identifier_type` are both optional but must be + supplied together or both omitted (the pairing invariant is + enforced at the VO constructor; a bare identifier with no scheme + cannot be resolved). + """ + + name: str = Field( + ..., + min_length=1, + max_length=MANUFACTURER_NAME_MAX_LENGTH, + description="Display name of the manufacturer.", + ) + identifier: str | None = Field( + default=None, + min_length=1, + max_length=MANUFACTURER_IDENTIFIER_MAX_LENGTH, + description=( + "Optional opaque identifier value. If supplied, `identifier_type` " + "is required (and vice versa)." + ), + ) + identifier_type: ManufacturerIdentifierType | None = Field( + default=None, + description="Closed scheme for the optional manufacturer identifier.", + ) + + def to_vo(self) -> Manufacturer: + identifier = ( + ManufacturerIdentifier(self.identifier) if self.identifier is not None else None + ) + return Manufacturer( + name=ManufacturerName(self.name), + identifier=identifier, + identifier_type=self.identifier_type, + ) + + +class DefineModelRequest(BaseModel): + """Body for `POST /models`.""" + + name: str = Field( + ..., + min_length=1, + max_length=MODEL_NAME_MAX_LENGTH, + description="Display name for the new Model.", + ) + manufacturer: ManufacturerBody = Field( + ..., + description="Vendor identity (name plus optional ROR/GRID/ISNI identifier).", + ) + part_number: str = Field( + ..., + min_length=1, + max_length=MODEL_PART_NUMBER_MAX_LENGTH, + description=( + "Vendor SKU; case-sensitive (RV120CCHL and rv120cchl are different Newport entries)." + ), + ) + declared_families: list[UUID] = Field( + ..., + min_length=1, + description=( + "Family ids the catalog entry satisfies. At least one required; " + "deduplicated server-side." + ), + ) + version_tag: str | None = Field( + default=None, + min_length=1, + max_length=MODEL_VERSION_TAG_MAX_LENGTH, + description="Optional initial revision label (e.g., 'rev-A').", + ) + + +class DefineModelResponse(BaseModel): + """Response body for `POST /models`.""" + + model_id: UUID + + +def _get_handler(request: Request) -> IdempotentHandler: + handler: IdempotentHandler = request.app.state.equipment.define_model + return handler + + +router = APIRouter(tags=["equipment"]) + + +@router.post( + "/models", + status_code=status.HTTP_201_CREATED, + response_model=DefineModelResponse, + responses={ + status.HTTP_400_BAD_REQUEST: { + "model": ErrorResponse, + "description": "Domain invariant violated (for example whitespace-only name).", + }, + status.HTTP_403_FORBIDDEN: { + "model": ErrorResponse, + "description": "Authorize port denied the command.", + }, + status.HTTP_404_NOT_FOUND: { + "model": ErrorResponse, + "description": "One or more declared families does not resolve to a registered Family.", + }, + status.HTTP_422_UNPROCESSABLE_CONTENT: { + "description": ( + "Request body failed schema validation OR Idempotency-Key " + "was reused with a different request body." + ), + }, + }, + summary="Define a new vendor-catalog Model", +) +async def post_models( + body: DefineModelRequest, + handler: Annotated[IdempotentHandler, 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)], + idempotency_key: Annotated[ + str | None, + Header( + alias="Idempotency-Key", + description=( + "Optional client-supplied unique key per logical request. " + "Retries with the same key + same body return the cached " + "response instead of re-creating the model." + ), + ), + ] = None, +) -> DefineModelResponse: + model_id = await handler( + DefineModel( + name=body.name, + manufacturer=body.manufacturer.to_vo(), + part_number=body.part_number, + declared_families=frozenset(body.declared_families), + version_tag=body.version_tag, + ), + principal_id=principal_id, + correlation_id=cid, + surface_id=surface_id, + idempotency_key=idempotency_key, + ) + return DefineModelResponse(model_id=model_id) diff --git a/apps/api/src/cora/equipment/features/define_model/tool.py b/apps/api/src/cora/equipment/features/define_model/tool.py new file mode 100644 index 000000000..38bf87fa2 --- /dev/null +++ b/apps/api/src/cora/equipment/features/define_model/tool.py @@ -0,0 +1,144 @@ +"""MCP tool for the `define_model` slice. + +Surfaces the same handler the REST route uses, exposed as a Model +Context Protocol tool. +""" + +from collections.abc import Callable +from typing import Annotated, Any +from uuid import UUID + +from mcp.server.fastmcp import Context, FastMCP +from pydantic import BaseModel, Field + +from cora.equipment.aggregates.model import ( + MANUFACTURER_IDENTIFIER_MAX_LENGTH, + MANUFACTURER_NAME_MAX_LENGTH, + MODEL_NAME_MAX_LENGTH, + MODEL_PART_NUMBER_MAX_LENGTH, + MODEL_VERSION_TAG_MAX_LENGTH, + Manufacturer, + ManufacturerIdentifier, + ManufacturerIdentifierType, + ManufacturerName, +) +from cora.equipment.features.define_model.command import DefineModel +from cora.equipment.features.define_model.handler import IdempotentHandler +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 + + +class ManufacturerInput(BaseModel): + """MCP tool input mirror of the Manufacturer VO. + + `identifier` and `identifier_type` are both optional but must be + supplied together (pairing invariant; enforced at the VO). + """ + + name: str = Field( + ..., + min_length=1, + max_length=MANUFACTURER_NAME_MAX_LENGTH, + description="Display name of the manufacturer.", + ) + identifier: str | None = Field( + default=None, + min_length=1, + max_length=MANUFACTURER_IDENTIFIER_MAX_LENGTH, + description=( + "Optional opaque identifier value. If supplied, identifier_type " + "is required (and vice versa)." + ), + ) + identifier_type: ManufacturerIdentifierType | None = Field( + default=None, + description="Closed scheme for the optional manufacturer identifier.", + ) + + def to_vo(self) -> Manufacturer: + identifier = ( + ManufacturerIdentifier(self.identifier) if self.identifier is not None else None + ) + return Manufacturer( + name=ManufacturerName(self.name), + identifier=identifier, + identifier_type=self.identifier_type, + ) + + +class DefineModelOutput(BaseModel): + """Structured output of the `define_model` MCP tool.""" + + model_id: UUID + + +def register(mcp: FastMCP, *, get_handler: Callable[[], IdempotentHandler]) -> None: + """Register the `define_model` tool on the given MCP server.""" + + @mcp.tool( + name="define_model", + description=( + "Define a new vendor-catalog Model with manufacturer, part number, " + "and the set of Family ids it satisfies." + ), + ) + async def define_model_tool( # pyright: ignore[reportUnusedFunction] + ctx: Context[Any, Any, Any], + name: Annotated[ + str, + Field( + min_length=1, + max_length=MODEL_NAME_MAX_LENGTH, + description="Display name for the new Model.", + ), + ], + manufacturer: Annotated[ + ManufacturerInput, + Field(description="Vendor identity (name plus optional identifier)."), + ], + part_number: Annotated[ + str, + Field( + min_length=1, + max_length=MODEL_PART_NUMBER_MAX_LENGTH, + description=( + "Vendor SKU; case-sensitive (RV120CCHL and rv120cchl are " + "different Newport entries)." + ), + ), + ], + declared_families: Annotated[ + list[UUID], + Field( + min_length=1, + description=( + "Family ids the catalog entry satisfies. At least one required; " + "deduplicated server-side." + ), + ), + ], + version_tag: Annotated[ + str | None, + Field( + default=None, + min_length=1, + max_length=MODEL_VERSION_TAG_MAX_LENGTH, + description="Optional initial revision label (e.g., 'rev-A').", + ), + ] = None, + ) -> DefineModelOutput: + handler = get_handler() + model_id = await handler( + DefineModel( + name=name, + manufacturer=manufacturer.to_vo(), + part_number=part_number, + declared_families=frozenset(declared_families), + version_tag=version_tag, + ), + principal_id=get_mcp_principal_id(ctx), + correlation_id=current_correlation_id(), + surface_id=get_mcp_surface_id(), + ) + return DefineModelOutput(model_id=model_id) diff --git a/apps/api/src/cora/equipment/features/deprecate_model/__init__.py b/apps/api/src/cora/equipment/features/deprecate_model/__init__.py new file mode 100644 index 000000000..08d1ba1da --- /dev/null +++ b/apps/api/src/cora/equipment/features/deprecate_model/__init__.py @@ -0,0 +1,28 @@ +"""Vertical slice for the `DeprecateModel` command. + +Module-as-namespace surface: + + from cora.equipment.features import deprecate_model + + cmd = deprecate_model.DeprecateModel( + model_id=..., + reason="Vendor end-of-life 2026-Q3; replaced by ANT130-LZS", + ) + handler = deprecate_model.bind(deps) + await handler(cmd, principal_id=..., correlation_id=...) +""" + +from cora.equipment.features.deprecate_model import tool +from cora.equipment.features.deprecate_model.command import DeprecateModel +from cora.equipment.features.deprecate_model.decider import decide +from cora.equipment.features.deprecate_model.handler import Handler, bind +from cora.equipment.features.deprecate_model.route import router + +__all__ = [ + "DeprecateModel", + "Handler", + "bind", + "decide", + "router", + "tool", +] diff --git a/apps/api/src/cora/equipment/features/deprecate_model/command.py b/apps/api/src/cora/equipment/features/deprecate_model/command.py new file mode 100644 index 000000000..60be7aa53 --- /dev/null +++ b/apps/api/src/cora/equipment/features/deprecate_model/command.py @@ -0,0 +1,23 @@ +"""The `DeprecateModel` command, intent dataclass for this slice. + +Multi-source transition: `Defined | Versioned -> Deprecated`. Carries +the target `model_id` plus an operator-supplied `reason` (1-500 chars +after trimming, validated via `ModelDeprecationReason` at the decider). + +`reason` is REQUIRED. Deprecation is an authoring signal that informs +later operators why the catalog entry should not be reused for new +Assets; recording a rationale keeps that signal actionable. Existing +Assets bound to the Model continue to function (deprecation is not a +runtime gate). +""" + +from dataclasses import dataclass +from uuid import UUID + + +@dataclass(frozen=True) +class DeprecateModel: + """Mark an existing model as no longer recommended for new Assets.""" + + model_id: UUID + reason: str diff --git a/apps/api/src/cora/equipment/features/deprecate_model/decider.py b/apps/api/src/cora/equipment/features/deprecate_model/decider.py new file mode 100644 index 000000000..0b9f05318 --- /dev/null +++ b/apps/api/src/cora/equipment/features/deprecate_model/decider.py @@ -0,0 +1,64 @@ +"""Pure decider for the `DeprecateModel` command. + +Multi-source-state transition: `Defined | Versioned -> Deprecated`. +Same source-set as version_model but the target is terminal. +Re-deprecating an already-Deprecated model raises (strict-not- +idempotent; mirrors deprecate_family). + +Source-state guard uses tuple-membership (same precedent as +deprecate_family and version_model). The decider validates the +bounded-text `reason` defensively via `ModelDeprecationReason` so +direct in-process callers get the same protection as API-boundary +callers. + +Once Deprecated, no further `ModelVersioned`, `ModelFamilyAdded`, or +`ModelFamilyRemoved` events are accepted (enforced by the relevant +deciders via their own source-state guards). Existing Assets bound to +the Model continue to function; deprecation is an authoring signal, +not a runtime gate. + +Invariants: + - State must not be None -> ModelNotFoundError + - State.status must be in {Defined, Versioned} + -> ModelCannotDeprecateError(current_status=...) + - reason must be valid -> InvalidModelDeprecationReasonError + (via ModelDeprecationReason VO) +""" + +from datetime import datetime + +from cora.equipment.aggregates.model import ( + Model, + ModelCannotDeprecateError, + ModelDeprecated, + ModelDeprecationReason, + ModelNotFoundError, + ModelStatus, +) +from cora.equipment.features.deprecate_model.command import DeprecateModel + +_DEPRECATABLE_STATUSES: tuple[ModelStatus, ...] = ( + ModelStatus.DEFINED, + ModelStatus.VERSIONED, +) + + +def decide( + state: Model | None, + command: DeprecateModel, + *, + now: datetime, +) -> list[ModelDeprecated]: + """Decide the events produced by deprecating an existing model.""" + if state is None: + raise ModelNotFoundError(command.model_id) + if state.status not in _DEPRECATABLE_STATUSES: + raise ModelCannotDeprecateError(state.id, current_status=state.status) + reason = ModelDeprecationReason(command.reason) + return [ + ModelDeprecated( + model_id=state.id, + reason=reason.value, + occurred_at=now, + ) + ] diff --git a/apps/api/src/cora/equipment/features/deprecate_model/handler.py b/apps/api/src/cora/equipment/features/deprecate_model/handler.py new file mode 100644 index 000000000..c3aedb561 --- /dev/null +++ b/apps/api/src/cora/equipment/features/deprecate_model/handler.py @@ -0,0 +1,137 @@ +"""Application handler for the `deprecate_model` slice. + +Update-style handler shape: load + fold + decide + append. Mirrors +the version_model precedent for Model-aggregate transitions and the +deprecate_family precedent for the deprecation command shape. + +Not idempotency-wrapped: domain-idempotent via +`ModelCannotDeprecateError` on retry from `Deprecated` (matches the +deprecate_family stance). The reason field is treated as authoring +intent; a fresh attempt against an already-Deprecated Model is a +real conflict the operator should see, not a silent no-op. + +NO cross-BC lookup here: deprecation is an authoring signal on the +Model stream itself. Existing Assets bound to this Model continue +to function; the runtime gate is elsewhere. +""" + +from typing import Protocol +from uuid import UUID + +from cora.equipment.aggregates.model import ( + ModelEvent, + event_type_name, + fold, + from_stored, + to_payload, +) +from cora.equipment.errors import UnauthorizedError +from cora.equipment.features.deprecate_model.command import DeprecateModel +from cora.equipment.features.deprecate_model.decider import decide +from cora.infrastructure.event_envelope import to_new_event +from cora.infrastructure.kernel import Kernel +from cora.infrastructure.logging import get_logger +from cora.infrastructure.ports import Deny +from cora.infrastructure.routing import NIL_SENTINEL_ID + +_STREAM_TYPE = "Model" +_COMMAND_NAME = "DeprecateModel" + +_log = get_logger(__name__) + + +class Handler(Protocol): + """Callable interface every deprecate_model handler implements.""" + + async def __call__( + self, + command: DeprecateModel, + *, + 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 deprecate_model handler closed over the shared deps.""" + + async def handler( + command: DeprecateModel, + *, + principal_id: UUID, + correlation_id: UUID, + causation_id: UUID | None = None, + surface_id: UUID = NIL_SENTINEL_ID, + ) -> None: + _log.info( + "deprecate_model.start", + command_name=_COMMAND_NAME, + model_id=str(command.model_id), + principal_id=str(principal_id), + correlation_id=str(correlation_id), + causation_id=str(causation_id) if causation_id is not None else None, + ) + + decision = await deps.authz.authorize( + principal_id=principal_id, + command_name=_COMMAND_NAME, + conduit_id=NIL_SENTINEL_ID, + surface_id=surface_id, + ) + if isinstance(decision, Deny): + _log.info( + "deprecate_model.denied", + command_name=_COMMAND_NAME, + model_id=str(command.model_id), + principal_id=str(principal_id), + correlation_id=str(correlation_id), + causation_id=str(causation_id) if causation_id is not None else None, + reason=decision.reason, + ) + raise UnauthorizedError(decision.reason) + + now = deps.clock.now() + + stored, current_version = await deps.event_store.load( + stream_type=_STREAM_TYPE, + stream_id=command.model_id, + ) + history: list[ModelEvent] = [from_stored(s) for s in stored] + state = fold(history) + + domain_events = decide(state=state, command=command, now=now) + + new_events = [ + to_new_event( + event_type=event_type_name(event), + payload=to_payload(event), + occurred_at=event.occurred_at, + event_id=deps.id_generator.new_id(), + command_name=_COMMAND_NAME, + correlation_id=correlation_id, + causation_id=causation_id, + principal_id=principal_id, + ) + for event in domain_events + ] + await deps.event_store.append( + stream_type=_STREAM_TYPE, + stream_id=command.model_id, + expected_version=current_version, + events=new_events, + ) + + _log.info( + "deprecate_model.success", + command_name=_COMMAND_NAME, + model_id=str(command.model_id), + principal_id=str(principal_id), + correlation_id=str(correlation_id), + causation_id=str(causation_id) if causation_id is not None else None, + event_count=len(new_events), + new_version=current_version + len(new_events), + ) + + return handler diff --git a/apps/api/src/cora/equipment/features/deprecate_model/route.py b/apps/api/src/cora/equipment/features/deprecate_model/route.py new file mode 100644 index 000000000..6d7c80fcd --- /dev/null +++ b/apps/api/src/cora/equipment/features/deprecate_model/route.py @@ -0,0 +1,107 @@ +"""HTTP route for the `deprecate_model` slice. + +Action endpoint at `POST /models/{model_id}/deprecation`. Body carries +the operator-supplied `reason` (1-500 chars, trimmed at the VO). +204 No Content on success. Once deprecated the Model rejects further +versioning or family edits at the decider; existing Assets bound to +the Model continue to function (deprecation is an authoring signal, +not a runtime gate). +""" + +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, Path, Request, status +from pydantic import BaseModel, Field + +from cora.equipment.aggregates.model import MODEL_DEPRECATION_REASON_MAX_LENGTH +from cora.equipment.features.deprecate_model.command import DeprecateModel +from cora.equipment.features.deprecate_model.handler import Handler +from cora.infrastructure.routing import ( + ErrorResponse, + get_correlation_id, + get_principal_id, + get_surface_id, +) + + +class DeprecateModelRequest(BaseModel): + """Body for `POST /models/{model_id}/deprecation`. + + `reason` is operator free text recording why the catalog entry is + being retired (for example "superseded by part RV120CCHL", "vendor + EOL 2026"). Trimmed and length-validated at the + `ModelDeprecationReason` VO; whitespace-only is rejected as a + domain invariant violation (400). + """ + + reason: str = Field( + ..., + min_length=1, + max_length=MODEL_DEPRECATION_REASON_MAX_LENGTH, + description=( + "Operator-supplied rationale for retiring this Model " + "(for example 'superseded by RV120CCHL', 'vendor EOL 2026'). " + "Free text; trimmed server-side." + ), + ) + + +def _get_handler(request: Request) -> Handler: + handler: Handler = request.app.state.equipment.deprecate_model + return handler + + +router = APIRouter(tags=["equipment"]) + + +@router.post( + "/models/{model_id}/deprecation", + status_code=status.HTTP_204_NO_CONTENT, + responses={ + status.HTTP_400_BAD_REQUEST: { + "model": ErrorResponse, + "description": ( + "Domain invariant violated (for example whitespace-only reason after trimming)." + ), + }, + status.HTTP_403_FORBIDDEN: { + "model": ErrorResponse, + "description": "Authorize port denied the command.", + }, + status.HTTP_404_NOT_FOUND: { + "model": ErrorResponse, + "description": "No model exists with the given id.", + }, + status.HTTP_409_CONFLICT: { + "model": ErrorResponse, + "description": ( + "Model is already in `Deprecated` status (deprecate " + "requires `Defined` or `Versioned`), OR a concurrent " + "write to the same model stream conflicted (optimistic " + "concurrency)." + ), + }, + status.HTTP_422_UNPROCESSABLE_CONTENT: { + "description": "Path parameter or request body failed schema validation.", + }, + }, + summary="Mark an existing Model as deprecated", +) +async def post_models_deprecation( + model_id: Annotated[UUID, Path(description="Target model's id.")], + body: DeprecateModelRequest, + 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( + DeprecateModel( + model_id=model_id, + reason=body.reason, + ), + principal_id=principal_id, + correlation_id=cid, + surface_id=surface_id, + ) diff --git a/apps/api/src/cora/equipment/features/deprecate_model/tool.py b/apps/api/src/cora/equipment/features/deprecate_model/tool.py new file mode 100644 index 000000000..5437a267a --- /dev/null +++ b/apps/api/src/cora/equipment/features/deprecate_model/tool.py @@ -0,0 +1,57 @@ +"""MCP tool for the `deprecate_model` slice.""" + +from collections.abc import Callable +from typing import Annotated, Any +from uuid import UUID + +from mcp.server.fastmcp import Context, FastMCP +from pydantic import Field + +from cora.equipment.aggregates.model import MODEL_DEPRECATION_REASON_MAX_LENGTH +from cora.equipment.features.deprecate_model.command import DeprecateModel +from cora.equipment.features.deprecate_model.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 `deprecate_model` tool on the given MCP server.""" + + @mcp.tool( + name="deprecate_model", + description=( + "Deprecate a vendor-catalog Model with a reason. Existing " + "Assets bound to this Model continue to function; " + "deprecation is an authoring signal." + ), + ) + async def deprecate_model_tool( # pyright: ignore[reportUnusedFunction] + ctx: Context[Any, Any, Any], + model_id: Annotated[ + UUID, + Field(description="Target Model's id."), + ], + reason: Annotated[ + str, + Field( + min_length=1, + max_length=MODEL_DEPRECATION_REASON_MAX_LENGTH, + description=( + "Operator-supplied rationale for retiring this Model " + "(for example 'superseded by RV120CCHL', 'vendor EOL 2026'). " + "Free text; trimmed server-side." + ), + ), + ], + ) -> None: + handler = get_handler() + await handler( + DeprecateModel( + model_id=model_id, + reason=reason, + ), + 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/get_model/__init__.py b/apps/api/src/cora/equipment/features/get_model/__init__.py new file mode 100644 index 000000000..5542b2ec9 --- /dev/null +++ b/apps/api/src/cora/equipment/features/get_model/__init__.py @@ -0,0 +1,27 @@ +"""Vertical slice for the `GetModel` query. + +Module-as-namespace surface, symmetric with command slices: + + from cora.equipment.features import get_model + + q = get_model.GetModel(model_id=...) + handler = get_model.bind(deps) + model = await handler(q, principal_id=..., correlation_id=...) + +Read slices have no decider (queries don't emit events); the handler +is a thin wrapper around `load_model`. The HTTP `router` and MCP +`tool` modules follow the `get_family` precedent. +""" + +from cora.equipment.features.get_model import tool +from cora.equipment.features.get_model.handler import Handler, bind +from cora.equipment.features.get_model.query import GetModel +from cora.equipment.features.get_model.route import router + +__all__ = [ + "GetModel", + "Handler", + "bind", + "router", + "tool", +] diff --git a/apps/api/src/cora/equipment/features/get_model/handler.py b/apps/api/src/cora/equipment/features/get_model/handler.py new file mode 100644 index 000000000..e3a88898f --- /dev/null +++ b/apps/api/src/cora/equipment/features/get_model/handler.py @@ -0,0 +1,99 @@ +"""Application handler for the `get_model` query slice. + +Cross-BC query-handler shape, mirrored from `get_family` / +`get_actor` / `get_subject`: + + 1. authorize(principal_id, query_name, conduit_id) -> Allow | Deny + 2. load_model(...) -> Model | None (fold-on-read) + 3. return Model | None -> caller maps None to 404 / isError + +Unlike `get_family`, no `ModelView` wrapper is needed: the Model +summary projection does NOT carry per-FSM-transition timestamps +(versioned_at, deprecated_at), so there is no projection-sourced +metadata to fold into the response. The route / tool layer reads +`Model` directly. If a future projection-schema addition lands +(transition timestamps), the same `FamilyView`-style bundle would +be introduced here without changing the slice contract. + +Query handlers do NOT emit `causation_id` log fields, since queries +have no causation chain (they don't emit events that downstream +commands react to). Same convention as `get_family` / +`get_actor` / `get_subject`. +""" + +from typing import Protocol +from uuid import UUID + +from cora.equipment.aggregates.model import Model, load_model +from cora.equipment.errors import UnauthorizedError +from cora.equipment.features.get_model.query import GetModel +from cora.infrastructure.kernel import Kernel +from cora.infrastructure.logging import get_logger +from cora.infrastructure.ports import Deny +from cora.infrastructure.routing import NIL_SENTINEL_ID + +_QUERY_NAME = "GetModel" + +_log = get_logger(__name__) + + +class Handler(Protocol): + """Callable interface every get_model handler implements.""" + + async def __call__( + self, + query: GetModel, + *, + principal_id: UUID, + correlation_id: UUID, + surface_id: UUID = NIL_SENTINEL_ID, + ) -> Model | None: ... + + +def bind(deps: Kernel) -> Handler: + """Build a get_model handler closed over the shared deps.""" + + async def handler( + query: GetModel, + *, + principal_id: UUID, + correlation_id: UUID, + surface_id: UUID = NIL_SENTINEL_ID, + ) -> Model | None: + _log.info( + "get_model.start", + query_name=_QUERY_NAME, + model_id=str(query.model_id), + principal_id=str(principal_id), + correlation_id=str(correlation_id), + ) + + decision = await deps.authz.authorize( + principal_id=principal_id, + command_name=_QUERY_NAME, + conduit_id=NIL_SENTINEL_ID, + surface_id=surface_id, + ) + if isinstance(decision, Deny): + _log.info( + "get_model.denied", + query_name=_QUERY_NAME, + model_id=str(query.model_id), + principal_id=str(principal_id), + correlation_id=str(correlation_id), + reason=decision.reason, + ) + raise UnauthorizedError(decision.reason) + + model = await load_model(deps.event_store, query.model_id) + _log.info( + "get_model.success", + query_name=_QUERY_NAME, + model_id=str(query.model_id), + principal_id=str(principal_id), + correlation_id=str(correlation_id), + found=model is not None, + ) + return model + + return handler diff --git a/apps/api/src/cora/equipment/features/get_model/query.py b/apps/api/src/cora/equipment/features/get_model/query.py new file mode 100644 index 000000000..1c502b556 --- /dev/null +++ b/apps/api/src/cora/equipment/features/get_model/query.py @@ -0,0 +1,22 @@ +"""The `GetModel` query: intent dataclass for this read slice. + +Queries are dataclasses just like commands. Mirrors `GetFamily` / +`GetSubject` / `GetActor`: they name the read intent and carry only +the input the caller controls; the application handler adds context +(correlation_id, principal_id) at call time. + +Cross-BC pattern: queries are full vertical slices symmetric with +commands but without a decider (queries don't emit events). The +handler is essentially a thin wrapper around the aggregate's read +repository (`load_model`). +""" + +from dataclasses import dataclass +from uuid import UUID + + +@dataclass(frozen=True) +class GetModel: + """Read the current state of an existing model by id.""" + + model_id: UUID diff --git a/apps/api/src/cora/equipment/features/get_model/route.py b/apps/api/src/cora/equipment/features/get_model/route.py new file mode 100644 index 000000000..a2ecbff4e --- /dev/null +++ b/apps/api/src/cora/equipment/features/get_model/route.py @@ -0,0 +1,140 @@ +"""HTTP route for the `get_model` query slice. + +`GET /models/{model_id}` returns 200 + ModelResponse on hit, 404 on +miss. The handler returns `Model | None`; the route maps None to 404 +via HTTPException (idiomatic in routes; the BC's exception-handler +infrastructure stays focused on domain / application errors raised +deeper in the stack). + +Response carries the vendor-catalog state: the `manufacturer` is a +nested `ManufacturerResponse` (required name plus optional opaque +identifier and closed-enum scheme), `declared_families` is the +sorted list of Family ids the catalog entry satisfies, `status` is +the StrEnum string value (Defined / Versioned / Deprecated), and +`version_tag` is the operator-supplied label of the most recent +`version_model` call (null until first version). +""" + +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, Path, Request, status +from pydantic import BaseModel, Field + +from cora.equipment.aggregates.model import ( + MANUFACTURER_IDENTIFIER_MAX_LENGTH, + MANUFACTURER_NAME_MAX_LENGTH, + MODEL_NAME_MAX_LENGTH, + MODEL_PART_NUMBER_MAX_LENGTH, +) +from cora.equipment.features.get_model.handler import Handler +from cora.equipment.features.get_model.query import GetModel +from cora.infrastructure.routing import ( + ErrorResponse, + get_correlation_id, + get_principal_id, + get_surface_id, +) + + +class ManufacturerResponse(BaseModel): + """Nested DTO for a model's manufacturer. + + `name` is required; `identifier` and `identifier_type` are both + set or both null (pairing invariant enforced by the domain VO). + `identifier_type` is the closed-StrEnum scheme string value + (ROR / GRID / ISNI) when present. + """ + + name: str = Field(..., max_length=MANUFACTURER_NAME_MAX_LENGTH) + identifier: str | None = Field(default=None, max_length=MANUFACTURER_IDENTIFIER_MAX_LENGTH) + identifier_type: str | None = None + + +class ModelResponse(BaseModel): + """Read-side DTO at the API boundary. + + Carries primitives, not domain VOs. Decouples the wire format + from the domain model so the two can evolve independently. + `status` is the StrEnum's string value (Defined / Versioned / + Deprecated). `version_tag` is the operator-supplied label of the + most recent version_model call (null until first version). + `declared_families` serializes as a sorted list of Family UUIDs + (frozenset semantics in domain state, list at the JSON boundary; + sorted by UUID string form for response determinism). + """ + + model_id: UUID + name: str = Field(..., max_length=MODEL_NAME_MAX_LENGTH) + manufacturer: ManufacturerResponse + part_number: str = Field(..., max_length=MODEL_PART_NUMBER_MAX_LENGTH) + declared_families: list[UUID] + status: str + version_tag: str | None + + +def _get_handler(request: Request) -> Handler: + handler: Handler = request.app.state.equipment.get_model + return handler + + +router = APIRouter(tags=["equipment"]) + + +@router.get( + "/models/{model_id}", + status_code=status.HTTP_200_OK, + response_model=ModelResponse, + responses={ + status.HTTP_403_FORBIDDEN: { + "model": ErrorResponse, + "description": "Authorize port denied the query.", + }, + status.HTTP_404_NOT_FOUND: { + "model": ErrorResponse, + "description": "No model exists with the given id.", + }, + status.HTTP_422_UNPROCESSABLE_CONTENT: { + "description": "Path parameter failed schema validation.", + }, + }, + summary="Get a model by id", +) +async def get_models( + model_id: Annotated[UUID, Path(description="Target model's id.")], + 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)], +) -> ModelResponse: + model = await handler( + GetModel(model_id=model_id), + principal_id=principal_id, + correlation_id=cid, + surface_id=surface_id, + ) + if model is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Model {model_id} not found", + ) + manufacturer = model.manufacturer + return ModelResponse( + model_id=model.id, + name=model.name.value, + manufacturer=ManufacturerResponse( + name=manufacturer.name.value, + identifier=( + manufacturer.identifier.value if manufacturer.identifier is not None else None + ), + identifier_type=( + manufacturer.identifier_type.value + if manufacturer.identifier_type is not None + else None + ), + ), + part_number=model.part_number.value, + declared_families=sorted(model.declared_families, key=str), + status=model.status.value, + version_tag=model.version, + ) diff --git a/apps/api/src/cora/equipment/features/get_model/tool.py b/apps/api/src/cora/equipment/features/get_model/tool.py new file mode 100644 index 000000000..c89c7bfd8 --- /dev/null +++ b/apps/api/src/cora/equipment/features/get_model/tool.py @@ -0,0 +1,105 @@ +"""MCP tool for the `get_model` query slice. + +Surfaces the same handler the REST route uses. Returns a structured +GetModelOutput on hit. On miss raises an exception that FastMCP +wraps as `isError: true` with a text diagnostic, matching the REST +404 behaviour in MCP's error idiom (LLM consumers get a clear +"model not found" message rather than null structuredContent they +have to interpret). +""" + +from collections.abc import Callable +from typing import Annotated, Any +from uuid import UUID + +from mcp.server.fastmcp import Context, FastMCP +from pydantic import BaseModel, Field + +from cora.equipment.aggregates.model import ( + MANUFACTURER_IDENTIFIER_MAX_LENGTH, + MANUFACTURER_NAME_MAX_LENGTH, + MODEL_NAME_MAX_LENGTH, + MODEL_PART_NUMBER_MAX_LENGTH, +) +from cora.equipment.features.get_model.handler import Handler +from cora.equipment.features.get_model.query import GetModel +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 + + +class ManufacturerOutput(BaseModel): + """Structured output for a model's manufacturer. + + `name` is required; `identifier` and `identifier_type` are both + set or both null (pairing invariant enforced by the domain VO). + `identifier_type` is the closed-StrEnum scheme string value + (ROR / GRID / ISNI) when present. + """ + + name: str = Field(..., max_length=MANUFACTURER_NAME_MAX_LENGTH) + identifier: str | None = Field(default=None, max_length=MANUFACTURER_IDENTIFIER_MAX_LENGTH) + identifier_type: str | None = None + + +class GetModelOutput(BaseModel): + """Structured output of the `get_model` MCP tool. + + Mirrors the REST `ModelResponse` shape: `model_id`, `name`, + nested `manufacturer`, `part_number`, sorted `declared_families` + list, `status` enum string, and optional `version_tag`. + """ + + model_id: UUID + name: str = Field(..., max_length=MODEL_NAME_MAX_LENGTH) + manufacturer: ManufacturerOutput + part_number: str = Field(..., max_length=MODEL_PART_NUMBER_MAX_LENGTH) + declared_families: list[UUID] + status: str + version_tag: str | None + + +def register(mcp: FastMCP, *, get_handler: Callable[[], Handler]) -> None: + """Register the `get_model` tool on the given MCP server.""" + + @mcp.tool( + name="get_model", + description="Fetch a vendor-catalog Model by id.", + ) + async def get_model_tool( # pyright: ignore[reportUnusedFunction] + ctx: Context[Any, Any, Any], + model_id: Annotated[ + UUID, + Field(description="Target model's id."), + ], + ) -> GetModelOutput: + handler = get_handler() + model = await handler( + GetModel(model_id=model_id), + principal_id=get_mcp_principal_id(ctx), + correlation_id=current_correlation_id(), + surface_id=get_mcp_surface_id(), + ) + if model is None: + msg = f"Model {model_id} not found" + raise ValueError(msg) + manufacturer = model.manufacturer + return GetModelOutput( + model_id=model.id, + name=model.name.value, + manufacturer=ManufacturerOutput( + name=manufacturer.name.value, + identifier=( + manufacturer.identifier.value if manufacturer.identifier is not None else None + ), + identifier_type=( + manufacturer.identifier_type.value + if manufacturer.identifier_type is not None + else None + ), + ), + part_number=model.part_number.value, + declared_families=sorted(model.declared_families, key=str), + status=model.status.value, + version_tag=model.version, + ) diff --git a/apps/api/src/cora/equipment/features/remove_model_family/__init__.py b/apps/api/src/cora/equipment/features/remove_model_family/__init__.py new file mode 100644 index 000000000..d8e349bb8 --- /dev/null +++ b/apps/api/src/cora/equipment/features/remove_model_family/__init__.py @@ -0,0 +1,25 @@ +"""Vertical slice for the `RemoveModelFamily` command. + +Module-as-namespace surface: + + from cora.equipment.features import remove_model_family + + cmd = remove_model_family.RemoveModelFamily(model_id=..., family_id=...) + handler = remove_model_family.bind(deps) + await handler(cmd, principal_id=..., correlation_id=...) +""" + +from cora.equipment.features.remove_model_family import tool +from cora.equipment.features.remove_model_family.command import RemoveModelFamily +from cora.equipment.features.remove_model_family.decider import decide +from cora.equipment.features.remove_model_family.handler import Handler, bind +from cora.equipment.features.remove_model_family.route import router + +__all__ = [ + "Handler", + "RemoveModelFamily", + "bind", + "decide", + "router", + "tool", +] diff --git a/apps/api/src/cora/equipment/features/remove_model_family/command.py b/apps/api/src/cora/equipment/features/remove_model_family/command.py new file mode 100644 index 000000000..303366630 --- /dev/null +++ b/apps/api/src/cora/equipment/features/remove_model_family/command.py @@ -0,0 +1,33 @@ +"""The `RemoveModelFamily` command, intent dataclass for this slice. + +Targeted-mutation: incremental removal of a single Family from the +Model's `declared_families` set. Sibling of `add_model_family`. + +The operational pattern is "vendor firmware update drops support for +a technique" or "catalog entry no longer advertises a Family"; +`version_model` remains the path when a revision genuinely re-authors +the catalog entry (matches the Family/Method/Plan/Practice +replace-on-version precedent). + +`model_id` is the target Model aggregate. `family_id` is the Family +being undeclared. Unlike `add_model_family`, the handler does NOT +resolve the Family against the Family registry: removal only +requires that `family_id` already sits in `declared_families` (the +Family may have been deprecated or deleted, and removal still +proceeds). + +Strict-not-idempotent: removing a Family not in `declared_families` +raises `ModelFamilyNotPresentError` (same precedent as +`add_model_family` and `remove_asset_family`). +""" + +from dataclasses import dataclass +from uuid import UUID + + +@dataclass(frozen=True) +class RemoveModelFamily: + """Remove a Family from an existing model's `declared_families` set.""" + + model_id: UUID + family_id: UUID diff --git a/apps/api/src/cora/equipment/features/remove_model_family/decider.py b/apps/api/src/cora/equipment/features/remove_model_family/decider.py new file mode 100644 index 000000000..1ad5406e4 --- /dev/null +++ b/apps/api/src/cora/equipment/features/remove_model_family/decider.py @@ -0,0 +1,63 @@ +"""Pure decider for the `RemoveModelFamily` command. + +Targeted mutation of `Model.declared_families`, not a lifecycle +transition. Status is preserved (`Defined` stays `Defined`, +`Versioned` stays `Versioned`); only `Deprecated` is rejected, on +the same "deprecated catalog entry is frozen" rationale that drives +`ModelVersioned` and `ModelFamilyAdded` rejection from `Deprecated` +in the events module. + +The Deprecated gate raises a per-verb `ModelCannotRemoveFamilyError` +mirroring `AssetCannotRemoveFamilyError`. The diagnostic message +names the actual verb so operators see "cannot remove family" +instead of the older shared "cannot be versioned" wording. + +The decider does NOT verify the referenced Family id resolves to a +real Family stream; removal only requires that the id already sits +in `declared_families`. The Family may have been deprecated or +deleted in the Family registry, and removal still proceeds. + +Strict-not-idempotent: removing an absent family raises +`ModelFamilyNotPresentError` (mirrors `add_model_family` and +`remove_asset_family`). + +Invariants: + - State must not be None -> ModelNotFoundError + - State.status must not be Deprecated -> ModelCannotRemoveFamilyError + - family_id must already be in state.declared_families + (strict-not-idempotent) -> ModelFamilyNotPresentError +""" + +from datetime import datetime + +from cora.equipment.aggregates.model import ( + Model, + ModelCannotRemoveFamilyError, + ModelFamilyNotPresentError, + ModelFamilyRemoved, + ModelNotFoundError, + ModelStatus, +) +from cora.equipment.features.remove_model_family.command import RemoveModelFamily + + +def decide( + state: Model | None, + command: RemoveModelFamily, + *, + now: datetime, +) -> list[ModelFamilyRemoved]: + """Decide the events produced by removing a family from an existing model.""" + if state is None: + raise ModelNotFoundError(command.model_id) + if state.status is ModelStatus.DEPRECATED: + raise ModelCannotRemoveFamilyError(state.id, current_status=state.status) + if command.family_id not in state.declared_families: + raise ModelFamilyNotPresentError(state.id, command.family_id) + return [ + ModelFamilyRemoved( + model_id=state.id, + family_id=command.family_id, + occurred_at=now, + ) + ] diff --git a/apps/api/src/cora/equipment/features/remove_model_family/handler.py b/apps/api/src/cora/equipment/features/remove_model_family/handler.py new file mode 100644 index 000000000..74706cb25 --- /dev/null +++ b/apps/api/src/cora/equipment/features/remove_model_family/handler.py @@ -0,0 +1,137 @@ +"""Application handler for the `remove_model_family` slice. + +Update-style handler shape: load + fold + decide + append. Mirrors +the `add_model_family` precedent for the stream load + fold + decide ++ append spine, minus the cross-BC Family lookup: removal only needs +`family_id` to be present in the Model's `declared_families`, and it +proceeds even if the referenced Family has since been +deprecated or deleted from the Family registry. + +Not idempotency-wrapped: domain-strict via +`ModelFamilyNotPresentError` on retry (mirrors +`remove_asset_family`). +""" + +from typing import Protocol +from uuid import UUID + +from cora.equipment.aggregates.model import ( + ModelEvent, + event_type_name, + fold, + from_stored, + to_payload, +) +from cora.equipment.errors import UnauthorizedError +from cora.equipment.features.remove_model_family.command import RemoveModelFamily +from cora.equipment.features.remove_model_family.decider import decide +from cora.infrastructure.event_envelope import to_new_event +from cora.infrastructure.kernel import Kernel +from cora.infrastructure.logging import get_logger +from cora.infrastructure.ports import Deny +from cora.infrastructure.routing import NIL_SENTINEL_ID + +_STREAM_TYPE = "Model" +_COMMAND_NAME = "RemoveModelFamily" + +_log = get_logger(__name__) + + +class Handler(Protocol): + """Callable interface every remove_model_family handler implements.""" + + async def __call__( + self, + command: RemoveModelFamily, + *, + 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_model_family handler closed over the shared deps.""" + + async def handler( + command: RemoveModelFamily, + *, + principal_id: UUID, + correlation_id: UUID, + causation_id: UUID | None = None, + surface_id: UUID = NIL_SENTINEL_ID, + ) -> None: + _log.info( + "remove_model_family.start", + command_name=_COMMAND_NAME, + model_id=str(command.model_id), + family_id=str(command.family_id), + principal_id=str(principal_id), + correlation_id=str(correlation_id), + causation_id=str(causation_id) if causation_id is not None else None, + ) + + decision = await deps.authz.authorize( + principal_id=principal_id, + command_name=_COMMAND_NAME, + conduit_id=NIL_SENTINEL_ID, + surface_id=surface_id, + ) + if isinstance(decision, Deny): + _log.info( + "remove_model_family.denied", + command_name=_COMMAND_NAME, + model_id=str(command.model_id), + family_id=str(command.family_id), + principal_id=str(principal_id), + correlation_id=str(correlation_id), + causation_id=str(causation_id) if causation_id is not None else None, + reason=decision.reason, + ) + raise UnauthorizedError(decision.reason) + + now = deps.clock.now() + + stored, current_version = await deps.event_store.load( + stream_type=_STREAM_TYPE, + stream_id=command.model_id, + ) + history: list[ModelEvent] = [from_stored(s) for s in stored] + state = fold(history) + + domain_events = decide(state=state, command=command, now=now) + + new_events = [ + to_new_event( + event_type=event_type_name(event), + payload=to_payload(event), + occurred_at=event.occurred_at, + event_id=deps.id_generator.new_id(), + command_name=_COMMAND_NAME, + correlation_id=correlation_id, + causation_id=causation_id, + principal_id=principal_id, + ) + for event in domain_events + ] + await deps.event_store.append( + stream_type=_STREAM_TYPE, + stream_id=command.model_id, + expected_version=current_version, + events=new_events, + ) + + _log.info( + "remove_model_family.success", + command_name=_COMMAND_NAME, + model_id=str(command.model_id), + family_id=str(command.family_id), + principal_id=str(principal_id), + correlation_id=str(correlation_id), + causation_id=str(causation_id) if causation_id is not None else None, + event_count=len(new_events), + new_version=current_version + len(new_events), + ) + + return handler diff --git a/apps/api/src/cora/equipment/features/remove_model_family/route.py b/apps/api/src/cora/equipment/features/remove_model_family/route.py new file mode 100644 index 000000000..5ea306d72 --- /dev/null +++ b/apps/api/src/cora/equipment/features/remove_model_family/route.py @@ -0,0 +1,76 @@ +"""HTTP route for the `remove_model_family` slice. + +Targeted-mutation endpoint at `DELETE /models/{model_id}/families/{family_id}`. +Both ids travel as path parameters; there is no request body. 204 No +Content on success. Status (`Defined` or `Versioned`) is preserved; +`Deprecated` rejects the mutation. No `Idempotency-Key` (update-style, +mirrors `add_model_family`). +""" + +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, Path, Request, status + +from cora.equipment.features.remove_model_family.command import RemoveModelFamily +from cora.equipment.features.remove_model_family.handler import Handler +from cora.infrastructure.routing import ( + ErrorResponse, + get_correlation_id, + get_principal_id, + get_surface_id, +) + + +def _get_handler(request: Request) -> Handler: + handler: Handler = request.app.state.equipment.remove_model_family + return handler + + +router = APIRouter(tags=["equipment"]) + + +@router.delete( + "/models/{model_id}/families/{family_id}", + status_code=status.HTTP_204_NO_CONTENT, + responses={ + status.HTTP_403_FORBIDDEN: { + "model": ErrorResponse, + "description": "Authorize port denied the command.", + }, + status.HTTP_404_NOT_FOUND: { + "model": ErrorResponse, + "description": "No model exists with the given id.", + }, + status.HTTP_409_CONFLICT: { + "model": ErrorResponse, + "description": ( + "Model is in `Deprecated` status (mutation requires " + "`Defined` or `Versioned`), OR the family_id is not in " + "the Model's declared_families set, OR a concurrent " + "write to the same model stream conflicted (optimistic " + "concurrency)." + ), + }, + status.HTTP_422_UNPROCESSABLE_CONTENT: { + "description": "Path parameter failed schema validation.", + }, + }, + summary="Remove a Family from an existing Model's declared_families set", +) +async def delete_models_family( + model_id: Annotated[UUID, Path(description="Target model's id.")], + family_id: Annotated[ + UUID, Path(description="Family id to remove from the Model.declared_families set.") + ], + 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( + RemoveModelFamily(model_id=model_id, family_id=family_id), + principal_id=principal_id, + correlation_id=cid, + surface_id=surface_id, + ) diff --git a/apps/api/src/cora/equipment/features/remove_model_family/tool.py b/apps/api/src/cora/equipment/features/remove_model_family/tool.py new file mode 100644 index 000000000..14fd2c00e --- /dev/null +++ b/apps/api/src/cora/equipment/features/remove_model_family/tool.py @@ -0,0 +1,50 @@ +"""MCP tool for the `remove_model_family` slice. + +Mirror of `add_model_family` MCP tool: single model_id arg plus an +extra UUID arg (family_id). Domain / application errors propagate +to FastMCP, which wraps them as `isError: true`. +""" + +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.features.remove_model_family.command import RemoveModelFamily +from cora.equipment.features.remove_model_family.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_model_family` tool on the given MCP server.""" + + @mcp.tool( + name="remove_model_family", + description=( + "Remove a Family from a vendor-catalog Model declared_families set. " + "Strict-not-idempotent: removing an absent family raises an error. " + "Does not cascade through existing Assets bound to the Model." + ), + ) + async def remove_model_family_tool( # pyright: ignore[reportUnusedFunction] + ctx: Context[Any, Any, Any], + model_id: Annotated[ + UUID, + Field(description="Target Model's id."), + ], + family_id: Annotated[ + UUID, + Field(description="Family id to remove from the Model.declared_families set."), + ], + ) -> None: + handler = get_handler() + await handler( + RemoveModelFamily(model_id=model_id, family_id=family_id), + 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/version_model/__init__.py b/apps/api/src/cora/equipment/features/version_model/__init__.py new file mode 100644 index 000000000..22f652e83 --- /dev/null +++ b/apps/api/src/cora/equipment/features/version_model/__init__.py @@ -0,0 +1,32 @@ +"""Vertical slice for the `VersionModel` command. + +Module-as-namespace surface: + + from cora.equipment.features import version_model + + cmd = version_model.VersionModel( + model_id=..., + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({rotary_stage_family_id}), + version_tag="v2", + ) + handler = version_model.bind(deps) + await handler(cmd, principal_id=..., correlation_id=...) +""" + +from cora.equipment.features.version_model import tool +from cora.equipment.features.version_model.command import VersionModel +from cora.equipment.features.version_model.decider import decide +from cora.equipment.features.version_model.handler import Handler, bind +from cora.equipment.features.version_model.route import router + +__all__ = [ + "Handler", + "VersionModel", + "bind", + "decide", + "router", + "tool", +] diff --git a/apps/api/src/cora/equipment/features/version_model/command.py b/apps/api/src/cora/equipment/features/version_model/command.py new file mode 100644 index 000000000..5560d387d --- /dev/null +++ b/apps/api/src/cora/equipment/features/version_model/command.py @@ -0,0 +1,38 @@ +"""The `VersionModel` command, intent dataclass for this slice. + +Multi-source transition: `Defined | Versioned -> Versioned`. Operators +issue a new `version_tag` (free text like "v2", "2026-Q3") to mark a +revision of the vendor-catalog entry. + +A new version IS a new declaration: `name`, `manufacturer`, +`part_number`, `declared_families`, and `version_tag` are ALL required +at version time. The supplied values REPLACE the prior catalog entry +wholesale (no diff/merge semantics). Matches Family/Method/Plan/ +Practice replace-on-version precedent. + +`declared_families` must be non-empty: a catalog entry without any +Family declaration has no instantiation contract, and a re-authored +revision is no exception. Empty `frozenset()` is rejected by the +decider with `InvalidDeclaredFamiliesError`. + +`version_tag` is REQUIRED for `version_model` (unlike `define_model` +where the initial label is optional). The whole point of the slice is +to issue a new label. +""" + +from dataclasses import dataclass +from uuid import UUID + +from cora.equipment.aggregates.model import Manufacturer + + +@dataclass(frozen=True) +class VersionModel: + """Issue a new version label plus replacement catalog body for a Model.""" + + model_id: UUID + name: str + manufacturer: Manufacturer + part_number: str + declared_families: frozenset[UUID] + version_tag: str diff --git a/apps/api/src/cora/equipment/features/version_model/decider.py b/apps/api/src/cora/equipment/features/version_model/decider.py new file mode 100644 index 000000000..967ca24d9 --- /dev/null +++ b/apps/api/src/cora/equipment/features/version_model/decider.py @@ -0,0 +1,95 @@ +"""Pure decider for the `VersionModel` command. + +Multi-source-state transition: `Defined | Versioned -> Versioned`. +Both Defined (first revision) and Versioned (subsequent revisions) +are valid sources; only Deprecated is rejected. + +Source-state guard uses tuple-membership (same precedent as +version_family and decommission_asset). The decider validates the +bounded-text VOs defensively (`ModelName`, `PartNumber`, +`ModelVersionTag`) so direct in-process callers get the same +protection as API-boundary callers. `declared_families` cardinality +is checked here (must be non-empty); the `Manufacturer` pairing +invariant is enforced by the `Manufacturer` dataclass itself before +the command reaches the decider (raises +`InvalidManufacturerIdentifierPairingError`). + +The handler does NOT cross-BC-validate `declared_families` here: +per the design memo Lock, full-set re-validation at version time is +deferred to incremental `add_model_family` edits. `version_model` +accepts whatever `declared_families` the caller passes; downstream +slices catch stale family references at their own boundaries. + +## Deliberate divergence from strict-not-idempotent + +Most update-style transitions in the codebase are strict-not- +idempotent: re-mounting / re-activating / re-decommissioning raises. +version_model is the EXCEPTION (mirroring version_family). Calling +`version_model("v2")` twice in a row both succeed, producing two +`ModelVersioned` events with the same tag. This is intentional: +re-attestation is a legitimate audit moment ("the operator confirmed +v2 again on date X"), and the multi-source Versioned to Versioned +transition already permits the operation structurally. Tightening to +"must use a different tag" would couple the decider to history- +walking, which the eventual-consistency stance avoids. + +Invariants: + - State must not be None -> ModelNotFoundError + - State.status must be in {Defined, Versioned}, i.e., not Deprecated + -> ModelCannotVersionError(current_status=...) + - declared_families must be non-empty -> InvalidDeclaredFamiliesError + - Name must be valid -> InvalidModelNameError (via ModelName VO) + - Part number must be valid -> InvalidPartNumberError + (via PartNumber VO) + - version_tag must be valid -> InvalidModelVersionTagError + (via ModelVersionTag VO) +""" + +from datetime import datetime + +from cora.equipment.aggregates.model import ( + InvalidDeclaredFamiliesError, + Model, + ModelCannotVersionError, + ModelName, + ModelNotFoundError, + ModelStatus, + ModelVersioned, + ModelVersionTag, + PartNumber, +) +from cora.equipment.features.version_model.command import VersionModel + +_VERSIONABLE_STATUSES: tuple[ModelStatus, ...] = ( + ModelStatus.DEFINED, + ModelStatus.VERSIONED, +) + + +def decide( + state: Model | None, + command: VersionModel, + *, + now: datetime, +) -> list[ModelVersioned]: + """Decide the events produced by versioning an existing model.""" + if state is None: + raise ModelNotFoundError(command.model_id) + if state.status not in _VERSIONABLE_STATUSES: + raise ModelCannotVersionError(state.id, current_status=state.status) + if not command.declared_families: + raise InvalidDeclaredFamiliesError + name = ModelName(command.name) + part_number = PartNumber(command.part_number) + version_tag = ModelVersionTag(command.version_tag) + return [ + ModelVersioned( + model_id=state.id, + name=name.value, + manufacturer=command.manufacturer, + part_number=part_number.value, + declared_families=command.declared_families, + version_tag=version_tag.value, + occurred_at=now, + ) + ] diff --git a/apps/api/src/cora/equipment/features/version_model/handler.py b/apps/api/src/cora/equipment/features/version_model/handler.py new file mode 100644 index 000000000..ab6d66e13 --- /dev/null +++ b/apps/api/src/cora/equipment/features/version_model/handler.py @@ -0,0 +1,144 @@ +"""Application handler for the `version_model` slice. + +Update-style handler shape: load + fold + decide + append. Mirrors +the version_family precedent (no idempotency wrapping; the command +carries `model_id` and `version_tag` and the handler logs both for +diagnostic visibility). + +Not idempotency-wrapped: re-versioning with the same tag is still a +real revision the operator wanted (matches version_family's +re-attestation stance). Domain-idempotent via `ModelCannotVersionError` +on retry from `Deprecated`. + +NO cross-BC family lookup here: per the Model design memo Lock, +`version_model` accepts whatever `declared_families` the caller +supplies without round-tripping the Family read repo. Incremental +declared-family edits use `add_model_family`, which is where the +cross-BC `list_family_ids` check lives. The wholesale replacement +that `version_model` performs is treated as authoritative operator +intent at version time. +""" + +from typing import Protocol +from uuid import UUID + +from cora.equipment.aggregates.model import ( + ModelEvent, + event_type_name, + fold, + from_stored, + to_payload, +) +from cora.equipment.errors import UnauthorizedError +from cora.equipment.features.version_model.command import VersionModel +from cora.equipment.features.version_model.decider import decide +from cora.infrastructure.event_envelope import to_new_event +from cora.infrastructure.kernel import Kernel +from cora.infrastructure.logging import get_logger +from cora.infrastructure.ports import Deny +from cora.infrastructure.routing import NIL_SENTINEL_ID + +_STREAM_TYPE = "Model" +_COMMAND_NAME = "VersionModel" + +_log = get_logger(__name__) + + +class Handler(Protocol): + """Callable interface every version_model handler implements.""" + + async def __call__( + self, + command: VersionModel, + *, + 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 version_model handler closed over the shared deps.""" + + async def handler( + command: VersionModel, + *, + principal_id: UUID, + correlation_id: UUID, + causation_id: UUID | None = None, + surface_id: UUID = NIL_SENTINEL_ID, + ) -> None: + _log.info( + "version_model.start", + command_name=_COMMAND_NAME, + model_id=str(command.model_id), + version_tag=command.version_tag, + principal_id=str(principal_id), + correlation_id=str(correlation_id), + causation_id=str(causation_id) if causation_id is not None else None, + ) + + decision = await deps.authz.authorize( + principal_id=principal_id, + command_name=_COMMAND_NAME, + conduit_id=NIL_SENTINEL_ID, + surface_id=surface_id, + ) + if isinstance(decision, Deny): + _log.info( + "version_model.denied", + command_name=_COMMAND_NAME, + model_id=str(command.model_id), + version_tag=command.version_tag, + principal_id=str(principal_id), + correlation_id=str(correlation_id), + causation_id=str(causation_id) if causation_id is not None else None, + reason=decision.reason, + ) + raise UnauthorizedError(decision.reason) + + now = deps.clock.now() + + stored, current_version = await deps.event_store.load( + stream_type=_STREAM_TYPE, + stream_id=command.model_id, + ) + history: list[ModelEvent] = [from_stored(s) for s in stored] + state = fold(history) + + domain_events = decide(state=state, command=command, now=now) + + new_events = [ + to_new_event( + event_type=event_type_name(event), + payload=to_payload(event), + occurred_at=event.occurred_at, + event_id=deps.id_generator.new_id(), + command_name=_COMMAND_NAME, + correlation_id=correlation_id, + causation_id=causation_id, + principal_id=principal_id, + ) + for event in domain_events + ] + await deps.event_store.append( + stream_type=_STREAM_TYPE, + stream_id=command.model_id, + expected_version=current_version, + events=new_events, + ) + + _log.info( + "version_model.success", + command_name=_COMMAND_NAME, + model_id=str(command.model_id), + version_tag=command.version_tag, + principal_id=str(principal_id), + correlation_id=str(correlation_id), + causation_id=str(causation_id) if causation_id is not None else None, + event_count=len(new_events), + new_version=current_version + len(new_events), + ) + + return handler diff --git a/apps/api/src/cora/equipment/features/version_model/route.py b/apps/api/src/cora/equipment/features/version_model/route.py new file mode 100644 index 000000000..5b62e3435 --- /dev/null +++ b/apps/api/src/cora/equipment/features/version_model/route.py @@ -0,0 +1,185 @@ +"""HTTP route for the `version_model` slice. + +Action endpoint at `POST /models/{model_id}/versions`. Body carries +the full replacement declaration (name, manufacturer, part_number, +declared_families, version_tag). 204 No Content on success. A new +version IS a new declaration, so the supplied fields REPLACE the +prior values wholesale. +""" + +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, Path, Request, status +from pydantic import BaseModel, Field + +from cora.equipment.aggregates.model import ( + MANUFACTURER_IDENTIFIER_MAX_LENGTH, + MANUFACTURER_NAME_MAX_LENGTH, + MODEL_NAME_MAX_LENGTH, + MODEL_PART_NUMBER_MAX_LENGTH, + MODEL_VERSION_TAG_MAX_LENGTH, + Manufacturer, + ManufacturerIdentifier, + ManufacturerIdentifierType, + ManufacturerName, +) +from cora.equipment.features.version_model.command import VersionModel +from cora.equipment.features.version_model.handler import Handler +from cora.infrastructure.routing import ( + ErrorResponse, + get_correlation_id, + get_principal_id, + get_surface_id, +) + + +class ManufacturerBody(BaseModel): + """Pydantic mirror of the Manufacturer VO for the request body. + + `identifier` and `identifier_type` are both optional but must be + supplied together or both omitted (the pairing invariant is + enforced at the VO constructor; a bare identifier with no scheme + cannot be resolved). + """ + + name: str = Field( + ..., + min_length=1, + max_length=MANUFACTURER_NAME_MAX_LENGTH, + description="Display name of the manufacturer.", + ) + identifier: str | None = Field( + default=None, + min_length=1, + max_length=MANUFACTURER_IDENTIFIER_MAX_LENGTH, + description=( + "Optional opaque identifier value. If supplied, `identifier_type` " + "is required (and vice versa)." + ), + ) + identifier_type: ManufacturerIdentifierType | None = Field( + default=None, + description="Closed scheme for the optional manufacturer identifier.", + ) + + def to_vo(self) -> Manufacturer: + identifier = ( + ManufacturerIdentifier(self.identifier) if self.identifier is not None else None + ) + return Manufacturer( + name=ManufacturerName(self.name), + identifier=identifier, + identifier_type=self.identifier_type, + ) + + +class VersionModelRequest(BaseModel): + """Body for `POST /models/{model_id}/versions`. + + A new version IS a new declaration: every field REPLACES the prior + value wholesale (no diff/merge semantics). `version_tag` is + REQUIRED here, unlike `define_model` where it is optional. + """ + + name: str = Field( + ..., + min_length=1, + max_length=MODEL_NAME_MAX_LENGTH, + description="Replacement display name for the Model at this version.", + ) + manufacturer: ManufacturerBody = Field( + ..., + description="Replacement vendor identity (name plus optional ROR/GRID/ISNI identifier).", + ) + part_number: str = Field( + ..., + min_length=1, + max_length=MODEL_PART_NUMBER_MAX_LENGTH, + description=( + "Replacement vendor SKU; case-sensitive (RV120CCHL and rv120cchl " + "are different Newport entries)." + ), + ) + declared_families: list[UUID] = Field( + ..., + min_length=1, + description=( + "Replacement Family ids the catalog entry satisfies at this " + "version. At least one required; deduplicated server-side." + ), + ) + version_tag: str = Field( + ..., + min_length=1, + max_length=MODEL_VERSION_TAG_MAX_LENGTH, + description=( + "Operator-supplied label for this revision (for example " + "'rev-B', '2026-Q3'). Free text; institution-specific." + ), + ) + + +def _get_handler(request: Request) -> Handler: + handler: Handler = request.app.state.equipment.version_model + return handler + + +router = APIRouter(tags=["equipment"]) + + +@router.post( + "/models/{model_id}/versions", + status_code=status.HTTP_204_NO_CONTENT, + responses={ + status.HTTP_400_BAD_REQUEST: { + "model": ErrorResponse, + "description": ( + "Domain invariant violated (for example whitespace-only " + "name, whitespace-only part_number, whitespace-only " + "version_tag, or empty declared_families)." + ), + }, + status.HTTP_403_FORBIDDEN: { + "model": ErrorResponse, + "description": "Authorize port denied the command.", + }, + status.HTTP_404_NOT_FOUND: { + "model": ErrorResponse, + "description": "No model exists with the given id.", + }, + status.HTTP_409_CONFLICT: { + "model": ErrorResponse, + "description": ( + "Model is in `Deprecated` status (version requires " + "`Defined` or `Versioned`), OR a concurrent write to the " + "same model stream conflicted (optimistic concurrency)." + ), + }, + status.HTTP_422_UNPROCESSABLE_CONTENT: { + "description": "Path parameter or request body failed schema validation.", + }, + }, + summary="Issue a new version declaration for an existing Model", +) +async def post_models_versions( + model_id: Annotated[UUID, Path(description="Target model's id.")], + body: VersionModelRequest, + 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( + VersionModel( + model_id=model_id, + name=body.name, + manufacturer=body.manufacturer.to_vo(), + part_number=body.part_number, + declared_families=frozenset(body.declared_families), + version_tag=body.version_tag, + ), + principal_id=principal_id, + correlation_id=cid, + surface_id=surface_id, + ) diff --git a/apps/api/src/cora/equipment/features/version_model/tool.py b/apps/api/src/cora/equipment/features/version_model/tool.py new file mode 100644 index 000000000..61527f97a --- /dev/null +++ b/apps/api/src/cora/equipment/features/version_model/tool.py @@ -0,0 +1,143 @@ +"""MCP tool for the `version_model` 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 BaseModel, Field + +from cora.equipment.aggregates.model import ( + MANUFACTURER_IDENTIFIER_MAX_LENGTH, + MANUFACTURER_NAME_MAX_LENGTH, + MODEL_NAME_MAX_LENGTH, + MODEL_PART_NUMBER_MAX_LENGTH, + MODEL_VERSION_TAG_MAX_LENGTH, + Manufacturer, + ManufacturerIdentifier, + ManufacturerIdentifierType, + ManufacturerName, +) +from cora.equipment.features.version_model.command import VersionModel +from cora.equipment.features.version_model.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 + + +class ManufacturerInput(BaseModel): + """MCP tool input mirror of the Manufacturer VO. + + `identifier` and `identifier_type` are both optional but must be + supplied together (pairing invariant; enforced at the VO). + """ + + name: str = Field( + ..., + min_length=1, + max_length=MANUFACTURER_NAME_MAX_LENGTH, + description="Display name of the manufacturer.", + ) + identifier: str | None = Field( + default=None, + min_length=1, + max_length=MANUFACTURER_IDENTIFIER_MAX_LENGTH, + description=( + "Optional opaque identifier value. If supplied, identifier_type " + "is required (and vice versa)." + ), + ) + identifier_type: ManufacturerIdentifierType | None = Field( + default=None, + description="Closed scheme for the optional manufacturer identifier.", + ) + + def to_vo(self) -> Manufacturer: + identifier = ( + ManufacturerIdentifier(self.identifier) if self.identifier is not None else None + ) + return Manufacturer( + name=ManufacturerName(self.name), + identifier=identifier, + identifier_type=self.identifier_type, + ) + + +def register(mcp: FastMCP, *, get_handler: Callable[[], Handler]) -> None: + """Register the `version_model` tool on the given MCP server.""" + + @mcp.tool( + name="version_model", + description=( + "Issue a new version of a vendor-catalog Model with updated name, " + "manufacturer, part number, family set, and version tag. Accepts " + "both Defined and Versioned source states (subsequent revisions " + "allowed). Deprecated Models cannot be re-versioned. A new version " + "IS a new declaration; the supplied fields REPLACE the prior values " + "wholesale." + ), + ) + async def version_model_tool( # pyright: ignore[reportUnusedFunction] + ctx: Context[Any, Any, Any], + model_id: Annotated[ + UUID, + Field(description="Target Model's id."), + ], + name: Annotated[ + str, + Field( + min_length=1, + max_length=MODEL_NAME_MAX_LENGTH, + description="Replacement display name for the new version.", + ), + ], + manufacturer: Annotated[ + ManufacturerInput, + Field(description="Replacement vendor identity (name plus optional identifier)."), + ], + part_number: Annotated[ + str, + Field( + min_length=1, + max_length=MODEL_PART_NUMBER_MAX_LENGTH, + description=( + "Replacement vendor SKU; case-sensitive (RV120CCHL and rv120cchl " + "are different Newport entries)." + ), + ), + ], + declared_families: Annotated[ + list[UUID], + Field( + min_length=1, + description=( + "Replacement Family id set the catalog entry satisfies. At least " + "one required; deduplicated server-side." + ), + ), + ], + version_tag: Annotated[ + str, + Field( + min_length=1, + max_length=MODEL_VERSION_TAG_MAX_LENGTH, + description=( + "Operator-supplied label for this revision (for example 'v2', '2026-Q3')." + ), + ), + ], + ) -> None: + handler = get_handler() + await handler( + VersionModel( + model_id=model_id, + name=name, + manufacturer=manufacturer.to_vo(), + part_number=part_number, + declared_families=frozenset(declared_families), + version_tag=version_tag, + ), + 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/__init__.py b/apps/api/src/cora/equipment/projections/__init__.py index a7e146e0f..53c02c342 100644 --- a/apps/api/src/cora/equipment/projections/__init__.py +++ b/apps/api/src/cora/equipment/projections/__init__.py @@ -14,6 +14,7 @@ from cora.equipment.projections.frame_children import FrameChildrenProjection from cora.equipment.projections.frame_consumers import FrameConsumersProjection from cora.equipment.projections.frame_summary import FrameSummaryProjection +from cora.equipment.projections.model import ModelSummaryProjection from cora.equipment.projections.mount_children import MountChildrenProjection from cora.equipment.projections.mount_lookup import MountLookupProjection from cora.equipment.projections.mount_summary import MountSummaryProjection @@ -26,6 +27,7 @@ "FrameChildrenProjection", "FrameConsumersProjection", "FrameSummaryProjection", + "ModelSummaryProjection", "MountChildrenProjection", "MountLookupProjection", "MountSummaryProjection", diff --git a/apps/api/src/cora/equipment/projections/model.py b/apps/api/src/cora/equipment/projections/model.py new file mode 100644 index 000000000..5a301f22a --- /dev/null +++ b/apps/api/src/cora/equipment/projections/model.py @@ -0,0 +1,223 @@ +"""ModelSummaryProjection: folds the Model aggregate's 5 events into +the `proj_equipment_model_summary` read model that backs +`GET /models/{id}` and the future `list_models` slice, and that +materializes the Lock-4 vendor-key uniqueness guard +`(manufacturer_name, part_number)` for command-time precondition +checks. + + - ModelDefined -> INSERT (status=Defined; version_tag from + payload when present, NULL otherwise; manufacturer flat + columns; declared_families JSONB array sorted as carried + in the event payload) + - ModelVersioned -> UPDATE status=Versioned and REPLACE + name / manufacturer_name / manufacturer_identifier / + manufacturer_identifier_type / part_number / + declared_families / version_tag wholesale (a new revision + re-authors the catalog entry's identity block) + - ModelDeprecated -> UPDATE status=Deprecated and set + deprecation_reason; vendor-key columns + (manufacturer_name, part_number) and declared_families + preserved so the audit answer to "what was deprecated" + stays queryable + - ModelFamilyAdded -> UPDATE declared_families to append the + single family_id and re-sort, matching the canonical + sorted-string-array ordering used in event payloads + - ModelFamilyRemoved -> UPDATE declared_families to drop the + single family_id while preserving sort order + +All branches idempotent. `version_tag` lands in the projection on +Defined (when carried) and on Versioned, and is replaced wholesale +on Versioned; the Deprecated UPDATE does not touch it. The flat +manufacturer columns (rather than a single JSONB blob) keep the +vendor-key uniqueness index and the manufacturer-keyed filter path +queryable without JSONB expression indexes. + +The targeted-mutation events fold via pure-SQL re-aggregation +(`jsonb_array_elements_text` + `UNION` / filter + `jsonb_agg(... +ORDER BY ...)`) rather than read-then-rewrite in Python; this keeps +the apply step in one round trip and reproduces the canonical +sorted-array shape the event payloads carry for the wholesale +events. +""" + +# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false + +from datetime import datetime +from uuid import UUID + +from cora.infrastructure.ports.event_store import StoredEvent +from cora.infrastructure.projection.handler import ConnectionLike + +# `declared_families` is bound as a Python list and lands as a real JSONB +# array (the pool-wide asyncpg jsonb codec encodes it via `json.dumps` +# exactly once before sending). Pre-encoding the list with `json.dumps` +# in Python first would land a JSONB scalar string instead, which +# `jsonb_array_elements_text` rejects with "cannot extract elements from +# a scalar" on every ModelFamilyAdded / ModelFamilyRemoved replay. + + +def _id(payload: dict[str, object]) -> UUID: + return UUID(str(payload["model_id"])) + + +def _manufacturer_columns( + payload: dict[str, object], +) -> tuple[str, str | None, str | None]: + """Extract flat manufacturer columns from a payload's `manufacturer` sub-dict. + + The pairing invariant (`identifier` and `identifier_type` both set + or both None) is preserved end-to-end: the event payload omits the + pair together, so .get() returning None for both is the correct + shape and the projection table's paired CHECK constraint allows it. + """ + manufacturer = payload["manufacturer"] + assert isinstance(manufacturer, dict) + name = str(manufacturer["name"]) + identifier_raw = manufacturer.get("identifier") + identifier_type_raw = manufacturer.get("identifier_type") + identifier = str(identifier_raw) if identifier_raw is not None else None + identifier_type = str(identifier_type_raw) if identifier_type_raw is not None else None + return name, identifier, identifier_type + + +_INSERT_MODEL_SQL = """ +INSERT INTO proj_equipment_model_summary + (model_id, name, + manufacturer_name, manufacturer_identifier, manufacturer_identifier_type, + part_number, declared_families, + status, version_tag, created_at) +VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, 'Defined', $8, $9) +ON CONFLICT (model_id) DO NOTHING +""" + +_UPDATE_VERSIONED_SQL = """ +UPDATE proj_equipment_model_summary +SET status = 'Versioned', + name = $2, + manufacturer_name = $3, + manufacturer_identifier = $4, + manufacturer_identifier_type = $5, + part_number = $6, + declared_families = $7::jsonb, + version_tag = $8, + updated_at = now() +WHERE model_id = $1 +""" + +_UPDATE_DEPRECATED_SQL = """ +UPDATE proj_equipment_model_summary +SET status = 'Deprecated', + deprecation_reason = $2, + updated_at = now() +WHERE model_id = $1 +""" + +# Append a single family_id to the JSONB array and re-sort. The UNION +# de-duplicates so re-applying ModelFamilyAdded is a no-op (the +# aggregate already rejected the duplicate at command time; this is +# the replay-safety layer). +_UPDATE_FAMILY_ADDED_SQL = """ +UPDATE proj_equipment_model_summary +SET declared_families = COALESCE(( + SELECT jsonb_agg(elem ORDER BY elem) + FROM ( + SELECT jsonb_array_elements_text(declared_families) AS elem + UNION + SELECT $2::text + ) sub + ), '[]'::jsonb), + updated_at = now() +WHERE model_id = $1 +""" + +# Drop a single family_id from the JSONB array while preserving sort +# order. The WHERE clause inside the subquery skips the removed id; +# the outer COALESCE handles the all-removed degenerate case (which +# the aggregate's empty-set guard makes unreachable in practice but +# keeps the projection robust under replay of historical streams). +_UPDATE_FAMILY_REMOVED_SQL = """ +UPDATE proj_equipment_model_summary +SET declared_families = COALESCE(( + SELECT jsonb_agg(elem ORDER BY elem) + FROM ( + SELECT jsonb_array_elements_text(declared_families) AS elem + ) sub + WHERE elem <> $2::text + ), '[]'::jsonb), + updated_at = now() +WHERE model_id = $1 +""" + + +class ModelSummaryProjection: + """Maintains the `proj_equipment_model_summary` read model.""" + + name = "proj_equipment_model_summary" + subscribed_event_types = frozenset( + { + "ModelDefined", + "ModelVersioned", + "ModelDeprecated", + "ModelFamilyAdded", + "ModelFamilyRemoved", + } + ) + + async def apply( + self, + event: StoredEvent, + conn: ConnectionLike, + ) -> None: + match event.event_type: + case "ModelDefined": + name, identifier, identifier_type = _manufacturer_columns(event.payload) + declared_families = event.payload.get("declared_families", []) + await conn.execute( + _INSERT_MODEL_SQL, + _id(event.payload), + event.payload["name"], + name, + identifier, + identifier_type, + event.payload["part_number"], + declared_families, + event.payload.get("version_tag"), + datetime.fromisoformat(event.payload["occurred_at"]), + ) + case "ModelVersioned": + name, identifier, identifier_type = _manufacturer_columns(event.payload) + declared_families = event.payload.get("declared_families", []) + await conn.execute( + _UPDATE_VERSIONED_SQL, + _id(event.payload), + event.payload["name"], + name, + identifier, + identifier_type, + event.payload["part_number"], + declared_families, + event.payload["version_tag"], + ) + case "ModelDeprecated": + await conn.execute( + _UPDATE_DEPRECATED_SQL, + _id(event.payload), + event.payload["reason"], + ) + case "ModelFamilyAdded": + await conn.execute( + _UPDATE_FAMILY_ADDED_SQL, + _id(event.payload), + str(event.payload["family_id"]), + ) + case "ModelFamilyRemoved": + await conn.execute( + _UPDATE_FAMILY_REMOVED_SQL, + _id(event.payload), + str(event.payload["family_id"]), + ) + case _: + pass + + +__all__ = ["ModelSummaryProjection"] diff --git a/apps/api/src/cora/equipment/routes.py b/apps/api/src/cora/equipment/routes.py index 00764ccef..d418a7e24 100644 --- a/apps/api/src/cora/equipment/routes.py +++ b/apps/api/src/cora/equipment/routes.py @@ -16,10 +16,9 @@ ## Loop-collapse pattern -Equipment owns multiple aggregates (Family + Asset, with more -slices to come). Three error families share the same response -shape and get collapsed via Trust's `_handle_invalid_name`-style -loop pattern: +Equipment owns five aggregates (Family, Model, Asset, Frame, Mount). +Three error families share the same response shape and get collapsed +via Trust's `_handle_invalid_name`-style loop pattern: - 400 (validation): InvalidFamilyName, InvalidAssetName, InvalidAssetParent @@ -77,6 +76,24 @@ InvalidFrameRevisionError, InvalidFrameRootError, ) +from cora.equipment.aggregates.model import ( + InvalidDeclaredFamiliesError, + InvalidManufacturerIdentifierError, + InvalidManufacturerIdentifierPairingError, + InvalidManufacturerNameError, + InvalidModelDeprecationReasonError, + InvalidModelNameError, + InvalidModelVersionTagError, + InvalidPartNumberError, + ModelAlreadyExistsError, + ModelCannotAddFamilyError, + ModelCannotDeprecateError, + ModelCannotRemoveFamilyError, + ModelCannotVersionError, + ModelFamilyAlreadyPresentError, + ModelFamilyNotPresentError, + ModelNotFoundError, +) from cora.equipment.aggregates.mount import ( AssetAlreadyInstalledElsewhereError, AssetNotFoundForMountError, @@ -96,18 +113,22 @@ activate_asset, add_asset_family, add_asset_port, + add_model_family, decommission_asset, decommission_frame, decommission_mount, define_family, + define_model, degrade_asset, deprecate_family, + deprecate_model, enter_maintenance, exit_maintenance, fault_asset, get_asset, get_asset_integration_view, get_family, + get_model, install_asset, list_assets, list_families, @@ -117,6 +138,7 @@ relocate_asset, remove_asset_family, remove_asset_port, + remove_model_family, restore_asset, uninstall_asset, update_asset_settings, @@ -124,6 +146,7 @@ update_frame_placement, update_mount_placement, version_family, + version_model, ) @@ -191,12 +214,21 @@ async def _handle_cannot_transition(request: Request, exc: Exception) -> JSONRes def register_equipment_routes(app: FastAPI) -> None: """Attach Equipment slice routers and exception handlers to the FastAPI app.""" + # Family aggregate app.include_router(define_family.router) - app.include_router(get_family.router) app.include_router(version_family.router) app.include_router(deprecate_family.router) app.include_router(update_family_settings_schema.router) + app.include_router(get_family.router) app.include_router(list_families.router) + # Model aggregate + app.include_router(define_model.router) + app.include_router(version_model.router) + app.include_router(deprecate_model.router) + app.include_router(add_model_family.router) + app.include_router(remove_model_family.router) + app.include_router(get_model.router) + # Asset aggregate app.include_router(register_asset.router) app.include_router(activate_asset.router) app.include_router(decommission_asset.router) @@ -214,9 +246,11 @@ def register_equipment_routes(app: FastAPI) -> None: app.include_router(get_asset.router) app.include_router(get_asset_integration_view.router) app.include_router(list_assets.router) + # Frame aggregate app.include_router(register_frame.router) app.include_router(update_frame_placement.router) app.include_router(decommission_frame.router) + # Mount aggregate app.include_router(register_mount.router) app.include_router(update_mount_placement.router) app.include_router(decommission_mount.router) @@ -238,6 +272,14 @@ def register_equipment_routes(app: FastAPI) -> None: InvalidPlacementError, InvalidDrawingError, InvalidSlotCodeError, + InvalidModelNameError, + InvalidPartNumberError, + InvalidManufacturerNameError, + InvalidManufacturerIdentifierError, + InvalidManufacturerIdentifierPairingError, + InvalidModelVersionTagError, + InvalidModelDeprecationReasonError, + InvalidDeclaredFamiliesError, ): app.add_exception_handler(validation_cls, _handle_validation_error) for not_found_cls in ( @@ -246,6 +288,7 @@ def register_equipment_routes(app: FastAPI) -> None: FrameNotFoundError, MountNotFoundError, AssetNotFoundForMountError, + ModelNotFoundError, ): app.add_exception_handler(not_found_cls, _handle_not_found) for already_exists_cls in ( @@ -253,6 +296,7 @@ def register_equipment_routes(app: FastAPI) -> None: AssetAlreadyExistsError, FrameAlreadyExistsError, MountAlreadyExistsError, + ModelAlreadyExistsError, ): app.add_exception_handler(already_exists_cls, _handle_already_exists) for cannot_transition_cls in ( @@ -279,6 +323,12 @@ def register_equipment_routes(app: FastAPI) -> None: MountIsEmptyError, AssetNotInstallableError, AssetAlreadyInstalledElsewhereError, + ModelCannotVersionError, + ModelCannotDeprecateError, + ModelCannotAddFamilyError, + ModelCannotRemoveFamilyError, + ModelFamilyAlreadyPresentError, + ModelFamilyNotPresentError, ): app.add_exception_handler(cannot_transition_cls, _handle_cannot_transition) app.add_exception_handler(UnauthorizedError, _handle_unauthorized) diff --git a/apps/api/src/cora/equipment/tools.py b/apps/api/src/cora/equipment/tools.py index a79ba7831..d94d0030c 100644 --- a/apps/api/src/cora/equipment/tools.py +++ b/apps/api/src/cora/equipment/tools.py @@ -16,14 +16,17 @@ tool as add_asset_family_tool, ) from cora.equipment.features.add_asset_port import tool as add_asset_port_tool +from cora.equipment.features.add_model_family import tool as add_model_family_tool from cora.equipment.features.decommission_asset import tool as decommission_asset_tool from cora.equipment.features.decommission_frame import tool as decommission_frame_tool from cora.equipment.features.decommission_mount import tool as decommission_mount_tool from cora.equipment.features.define_family import tool as define_family_tool +from cora.equipment.features.define_model import tool as define_model_tool from cora.equipment.features.degrade_asset import tool as degrade_asset_tool from cora.equipment.features.deprecate_family import ( tool as deprecate_family_tool, ) +from cora.equipment.features.deprecate_model import tool as deprecate_model_tool from cora.equipment.features.enter_maintenance import tool as enter_maintenance_tool from cora.equipment.features.exit_maintenance import ( tool as exit_maintenance_tool, @@ -34,6 +37,7 @@ tool as get_asset_integration_view_tool, ) from cora.equipment.features.get_family import tool as get_family_tool +from cora.equipment.features.get_model import tool as get_model_tool from cora.equipment.features.install_asset import tool as install_asset_tool from cora.equipment.features.list_assets import tool as list_assets_tool from cora.equipment.features.list_families import tool as list_families_tool @@ -45,6 +49,7 @@ tool as remove_asset_family_tool, ) from cora.equipment.features.remove_asset_port import tool as remove_asset_port_tool +from cora.equipment.features.remove_model_family import tool as remove_model_family_tool from cora.equipment.features.restore_asset import tool as restore_asset_tool from cora.equipment.features.uninstall_asset import tool as uninstall_asset_tool from cora.equipment.features.update_asset_settings import ( @@ -56,6 +61,7 @@ from cora.equipment.features.update_frame_placement import tool as update_frame_placement_tool from cora.equipment.features.update_mount_placement import tool as update_mount_placement_tool from cora.equipment.features.version_family import tool as version_family_tool +from cora.equipment.features.version_model import tool as version_model_tool from cora.equipment.wire import EquipmentHandlers @@ -65,14 +71,11 @@ def register_equipment_tools( get_handlers: Callable[[], EquipmentHandlers], ) -> None: """Register every Equipment slice's MCP tool on the FastMCP server.""" + # Family aggregate define_family_tool.register( mcp, get_handler=lambda: get_handlers().define_family, ) - get_family_tool.register( - mcp, - get_handler=lambda: get_handlers().get_family, - ) version_family_tool.register( mcp, get_handler=lambda: get_handlers().version_family, @@ -85,6 +88,40 @@ def register_equipment_tools( mcp, get_handler=lambda: get_handlers().update_family_settings_schema, ) + get_family_tool.register( + mcp, + get_handler=lambda: get_handlers().get_family, + ) + list_families_tool.register( + mcp, + get_handler=lambda: get_handlers().list_families, + ) + # Model aggregate + define_model_tool.register( + mcp, + get_handler=lambda: get_handlers().define_model, + ) + version_model_tool.register( + mcp, + get_handler=lambda: get_handlers().version_model, + ) + deprecate_model_tool.register( + mcp, + get_handler=lambda: get_handlers().deprecate_model, + ) + add_model_family_tool.register( + mcp, + get_handler=lambda: get_handlers().add_model_family, + ) + remove_model_family_tool.register( + mcp, + get_handler=lambda: get_handlers().remove_model_family, + ) + get_model_tool.register( + mcp, + get_handler=lambda: get_handlers().get_model, + ) + # Asset aggregate register_asset_tool.register( mcp, get_handler=lambda: get_handlers().register_asset, @@ -153,10 +190,7 @@ def register_equipment_tools( mcp, get_handler=lambda: get_handlers().list_assets, ) - list_families_tool.register( - mcp, - get_handler=lambda: get_handlers().list_families, - ) + # Frame aggregate register_frame_tool.register( mcp, get_handler=lambda: get_handlers().register_frame, @@ -169,6 +203,7 @@ def register_equipment_tools( mcp, get_handler=lambda: get_handlers().decommission_frame, ) + # Mount aggregate register_mount_tool.register( mcp, get_handler=lambda: get_handlers().register_mount, diff --git a/apps/api/src/cora/equipment/wire.py b/apps/api/src/cora/equipment/wire.py index 964c2da25..a01cdb987 100644 --- a/apps/api/src/cora/equipment/wire.py +++ b/apps/api/src/cora/equipment/wire.py @@ -7,13 +7,13 @@ on `EquipmentHandlers` and a single line in this factory. Cross-cutting decorators applied here mirror Access / Trust / -Subject (composition order matters — innermost first): +Subject (composition order matters, innermost first): -1. `bind(deps)` — bare handler. -2. `with_idempotency` (create-style commands only) — Idempotency-Key +1. `bind(deps)` bare handler. +2. `with_idempotency` (create-style commands only) Idempotency-Key support. Wrapped before tracing so cache-hits and cache-misses both attribute to the tracing span. -3. `with_tracing` — OTel span around every handler call. Records +3. `with_tracing` OTel span around every handler call. Records `cora.bc`, `cora.command` / `cora.query` attributes. Update-style transitions are not idempotency-wrapped: they're @@ -35,18 +35,22 @@ activate_asset, add_asset_family, add_asset_port, + add_model_family, decommission_asset, decommission_frame, decommission_mount, define_family, + define_model, degrade_asset, deprecate_family, + deprecate_model, enter_maintenance, exit_maintenance, fault_asset, get_asset, get_asset_integration_view, get_family, + get_model, install_asset, list_assets, list_families, @@ -56,6 +60,7 @@ relocate_asset, remove_asset_family, remove_asset_port, + remove_model_family, restore_asset, uninstall_asset, update_asset_settings, @@ -63,6 +68,7 @@ update_frame_placement, update_mount_placement, version_family, + version_model, ) from cora.infrastructure.idempotency import with_idempotency from cora.infrastructure.kernel import Kernel @@ -75,19 +81,41 @@ class EquipmentHandlers: """The Equipment BC's handler bundle, each closed over Kernel. - Two aggregates: `Family` (technique-class catalog; lifecycle - Defined → Versioned → Deprecated) and `Asset` (instance with - hierarchy + lifecycle + family-set + condition + settings + ports). - Genesis commands (`define_family`, `register_asset`) are - idempotency-wrapped; everything else is update-style with bare - Handler protocols. + Five aggregates: + + - `Family`: technique-class catalog (lifecycle Defined, + Versioned, Deprecated) declaring Affordances + settings schema. + - `Model`: manufacturer-specific catalog entry under one or more + Families (lifecycle Defined, Versioned, Deprecated). + - `Asset`: physical or logical instance with hierarchy, lifecycle, + family-set, condition, settings, and typed ports. + - `Frame`: spatial reference frame anchored to a root surface with + a 6-DoF Placement. + - `Mount`: a slot on a Frame that can receive at most one Asset + via install / uninstall. + + Genesis commands (`define_family`, `define_model`, `register_asset`, + `register_frame`, `register_mount`) are idempotency-wrapped; + everything else is update-style with bare Handler protocols. """ + # Family aggregate define_family: define_family.IdempotentHandler - get_family: get_family.Handler version_family: version_family.Handler deprecate_family: deprecate_family.Handler update_family_settings_schema: update_family_settings_schema.Handler + get_family: get_family.Handler + list_families: list_families.Handler + + # Model aggregate + define_model: define_model.IdempotentHandler + version_model: version_model.Handler + deprecate_model: deprecate_model.Handler + add_model_family: add_model_family.Handler + remove_model_family: remove_model_family.Handler + get_model: get_model.Handler + + # Asset aggregate register_asset: register_asset.IdempotentHandler activate_asset: activate_asset.Handler decommission_asset: decommission_asset.Handler @@ -105,10 +133,13 @@ class EquipmentHandlers: get_asset: get_asset.Handler get_asset_integration_view: get_asset_integration_view.Handler list_assets: list_assets.Handler - list_families: list_families.Handler + + # Frame aggregate register_frame: register_frame.IdempotentHandler update_frame_placement: update_frame_placement.Handler decommission_frame: decommission_frame.Handler + + # Mount aggregate register_mount: register_mount.IdempotentHandler update_mount_placement: update_mount_placement.Handler decommission_mount: decommission_mount.Handler @@ -119,6 +150,7 @@ class EquipmentHandlers: def wire_equipment(deps: Kernel) -> EquipmentHandlers: """Build the Equipment BC handlers from shared dependencies.""" return EquipmentHandlers( + # Family aggregate define_family=with_tracing( with_idempotency( define_family.bind(deps), @@ -133,12 +165,6 @@ def wire_equipment(deps: Kernel) -> EquipmentHandlers: command_name="DefineFamily", bc=_BC, ), - get_family=with_tracing( - get_family.bind(deps), - command_name="GetFamily", - bc=_BC, - kind="query", - ), version_family=with_tracing( version_family.bind(deps), command_name="VersionFamily", @@ -154,6 +180,58 @@ def wire_equipment(deps: Kernel) -> EquipmentHandlers: command_name="UpdateFamilySettingsSchema", bc=_BC, ), + get_family=with_tracing( + get_family.bind(deps), + command_name="GetFamily", + bc=_BC, + kind="query", + ), + list_families=with_tracing( + list_families.bind(deps), + command_name="ListFamilies", + bc=_BC, + kind="query", + ), + # Model aggregate + define_model=with_tracing( + with_idempotency( + define_model.bind(deps), + deps.idempotency_store, + command_name="DefineModel", + serialize_result=str, + deserialize_result=UUID, + lock_stale_seconds=deps.settings.idempotency_lock_stale_seconds, + ), + command_name="DefineModel", + bc=_BC, + ), + version_model=with_tracing( + version_model.bind(deps), + command_name="VersionModel", + bc=_BC, + ), + deprecate_model=with_tracing( + deprecate_model.bind(deps), + command_name="DeprecateModel", + bc=_BC, + ), + add_model_family=with_tracing( + add_model_family.bind(deps), + command_name="AddModelFamily", + bc=_BC, + ), + remove_model_family=with_tracing( + remove_model_family.bind(deps), + command_name="RemoveModelFamily", + bc=_BC, + ), + get_model=with_tracing( + get_model.bind(deps), + command_name="GetModel", + bc=_BC, + kind="query", + ), + # Asset aggregate register_asset=with_tracing( with_idempotency( register_asset.bind(deps), @@ -249,12 +327,7 @@ def wire_equipment(deps: Kernel) -> EquipmentHandlers: bc=_BC, kind="query", ), - list_families=with_tracing( - list_families.bind(deps), - command_name="ListFamilies", - bc=_BC, - kind="query", - ), + # Frame aggregate register_frame=with_tracing( with_idempotency( register_frame.bind(deps), @@ -277,6 +350,7 @@ def wire_equipment(deps: Kernel) -> EquipmentHandlers: command_name="DecommissionFrame", bc=_BC, ), + # Mount aggregate register_mount=with_tracing( with_idempotency( register_mount.bind(deps), diff --git a/apps/api/tests/architecture/test_equipment_aggregate_shape.py b/apps/api/tests/architecture/test_equipment_aggregate_shape.py new file mode 100644 index 000000000..a19ca42b4 --- /dev/null +++ b/apps/api/tests/architecture/test_equipment_aggregate_shape.py @@ -0,0 +1,104 @@ +"""Pin: every Equipment BC aggregate directory carries the same core file set. + +Background: the Equipment BC now ships five aggregates (Family, +Asset, Frame, Mount, Model). Earlier aggregates accreted siblings +unevenly (`affordance.py` on Family, `settings_validation.py` on +Family + Asset, `read.py` added late to Model in Commit B). Each +clone reopened the same questions about which files are +load-bearing versus optional. This fitness locks the load-bearing +set so the next clone starts from a checklist rather than a survey. + +Rule: every non-private aggregate directory under +`apps/api/src/cora/equipment/aggregates/` MUST track these five +files: + + - __init__.py + - state.py + - events.py + - evolver.py + - read.py + +Optional siblings (allowed but not required) include +`affordance.py`, `settings_validation.py`, or other helpers that +arise from a single aggregate's needs. The fitness deliberately +does not enumerate optional files: the goal is to lock the +*minimum* shared shape, not to forbid divergence above it. + +`_drawing.py` and `_placement.py` (private modules at the +`aggregates/` package root) are not aggregate directories and are +ignored. Anything starting with `_` or named `__pycache__` is +skipped , the same convention `BC root layout (flat)` uses for +private helpers. + +Enumeration is git-aware via `tracked_python_files()` per the +worktree pre-commit-stash rationale in `conftest.py`: untracked +half-staged files must stay invisible to this scan, otherwise +in-flight aggregate skeletons would false-fail before the author +finishes wiring them up. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from tests.architecture.conftest import CORA_ROOT, tracked_python_files + +if TYPE_CHECKING: + from pathlib import Path + +_AGGREGATES_ROOT = CORA_ROOT / "equipment" / "aggregates" + +_REQUIRED_FILES: tuple[str, ...] = ( + "__init__.py", + "state.py", + "events.py", + "evolver.py", + "read.py", +) + + +def _aggregate_dirs() -> list[Path]: + """Aggregate directories under `equipment/aggregates/`. + + A directory qualifies when: + - it sits directly under `aggregates/`, + - its name does not start with `_` (private modules like + `_placement.py` / `_drawing.py` are not aggregates), and + - it tracks an `__init__.py` (so the scan stays git-aware). + + Returns directories sorted by name for stable parametrize ids. + """ + tracked = tracked_python_files() + dirs: set[Path] = set() + for path in tracked: + try: + rel = path.relative_to(_AGGREGATES_ROOT) + except ValueError: + continue + if len(rel.parts) < 2: + continue + aggregate_name = rel.parts[0] + if aggregate_name.startswith("_") or aggregate_name == "__pycache__": + continue + if path.name == "__init__.py" and path.parent.parent == _AGGREGATES_ROOT: + dirs.add(path.parent) + return sorted(dirs) + + +@pytest.mark.architecture +@pytest.mark.parametrize("aggregate_dir", _aggregate_dirs(), ids=lambda p: p.name) +def test_equipment_aggregate_carries_required_files(aggregate_dir: Path) -> None: + """Equipment aggregate must track every file in `_REQUIRED_FILES`.""" + tracked = tracked_python_files() + missing = [name for name in _REQUIRED_FILES if (aggregate_dir / name) not in tracked] + assert not missing, ( + f"equipment aggregate `{aggregate_dir.name}` is missing required " + f"file(s): {', '.join(missing)}.\n" + f"Every aggregate under {_AGGREGATES_ROOT.relative_to(CORA_ROOT.parent.parent)} " + f"must track: {', '.join(_REQUIRED_FILES)}.\n" + "Add the missing module(s) before merging, or, if the aggregate is " + "genuinely a different shape, justify the divergence in the BC " + "module doc and update this fitness." + ) diff --git a/apps/api/tests/architecture/test_no_em_dashes.py b/apps/api/tests/architecture/test_no_em_dashes.py index 2aed5d1bd..e031e2a50 100644 --- a/apps/api/tests/architecture/test_no_em_dashes.py +++ b/apps/api/tests/architecture/test_no_em_dashes.py @@ -164,7 +164,6 @@ "src/cora/equipment/features/version_family/decider.py", "src/cora/equipment/features/version_family/handler.py", "src/cora/equipment/routes.py", - "src/cora/equipment/wire.py", "src/cora/infrastructure/adapters/introspection_token_verifier.py", "src/cora/infrastructure/adapters/jwt_token_verifier.py", "src/cora/infrastructure/adapters/postgres_event_store.py", diff --git a/apps/api/tests/contract/test_add_model_family_endpoint.py b/apps/api/tests/contract/test_add_model_family_endpoint.py new file mode 100644 index 000000000..9a8e1449c --- /dev/null +++ b/apps/api/tests/contract/test_add_model_family_endpoint.py @@ -0,0 +1,192 @@ +"""Contract tests for `POST /models/{model_id}/families`. + +Targeted-mutation endpoint adding a single Family to the Model's +`declared_families` set. 204 No Content on success. + +In-memory contract harness has no Postgres pool, so the cross-BC +`list_all_family_ids` lookup performed by both `define_model` (during +seeding) and `add_model_family` (under test) returns `[]` by default. +We stub the symbol in BOTH handler modules to a fixed accept-all set so +we can seed a Model via `POST /models` and exercise +`POST /models/{model_id}/families` end-to-end. The 404-on-unknown- +family branch removes a chosen id from the stub before invoking. + +The 409-on-Deprecated path appends a `ModelDeprecated` event directly +to the in-memory event store (no `deprecate_model` slice exercised), +then exercises the route and expects 409. +""" + +from collections.abc import Iterator +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from cora.api.main import create_app +from cora.equipment.aggregates.model import ( + ModelDeprecated, + event_type_name, + to_payload, +) +from cora.infrastructure.event_envelope import to_new_event + +_FIXED_FAMILY_ID = UUID("01900000-0000-7000-8000-00000000fc01") +_OTHER_FAMILY_ID = UUID("01900000-0000-7000-8000-00000000fc02") +_PRINCIPAL_ID = UUID("01900000-0000-7000-8000-000000000099") +_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000aa") +_NOW = datetime(2026, 6, 1, 12, 0, 0, tzinfo=UTC) + + +@pytest.fixture +def accept_families(monkeypatch: pytest.MonkeyPatch) -> Iterator[list[UUID]]: + """Stub `list_all_family_ids` in both handler modules so the seeding + `define_model` call and the `add_model_family` call under test each + accept the fixed family-id set.""" + known: list[UUID] = [_FIXED_FAMILY_ID, _OTHER_FAMILY_ID] + + async def _stub(_pool: object) -> list[UUID]: + return list(known) + + monkeypatch.setattr( + "cora.equipment.features.define_model.handler.list_all_family_ids", + _stub, + ) + monkeypatch.setattr( + "cora.equipment.features.add_model_family.handler.list_all_family_ids", + _stub, + ) + yield known + + +def _define_body() -> dict[str, object]: + return { + "name": "ANT130-L", + "manufacturer": {"name": "Aerotech"}, + "part_number": "ANT130-L", + "declared_families": [str(_FIXED_FAMILY_ID)], + } + + +def _seed_model(client: TestClient) -> UUID: + response = client.post("/models", json=_define_body()) + assert response.status_code == 201 + return UUID(response.json()["model_id"]) + + +async def _append_deprecated_event(app: FastAPI, model_id: UUID) -> None: + deps = app.state.deps + deprecated = ModelDeprecated( + model_id=model_id, + reason="superseded", + occurred_at=_NOW, + ) + new_event = to_new_event( + event_type=event_type_name(deprecated), + payload=to_payload(deprecated), + occurred_at=deprecated.occurred_at, + event_id=uuid4(), + command_name="DeprecateModel", + correlation_id=_CORRELATION_ID, + causation_id=None, + principal_id=_PRINCIPAL_ID, + ) + _, current_version = await deps.event_store.load("Model", model_id) + await deps.event_store.append( + stream_type="Model", + stream_id=model_id, + expected_version=current_version, + events=[new_event], + ) + + +@pytest.mark.contract +def test_post_add_model_family_returns_204_on_success(accept_families: list[UUID]) -> None: + _ = accept_families + with TestClient(create_app()) as client: + model_id = _seed_model(client) + response = client.post( + f"/models/{model_id}/families", + json={"family_id": str(_OTHER_FAMILY_ID)}, + ) + assert response.status_code == 204 + assert response.content == b"" + + +@pytest.mark.contract +def test_post_add_model_family_missing_family_id_returns_422( + accept_families: list[UUID], +) -> None: + """Pydantic schema validation: missing required `family_id`.""" + _ = accept_families + with TestClient(create_app()) as client: + model_id = _seed_model(client) + response = client.post(f"/models/{model_id}/families", json={}) + assert response.status_code == 422 + + +@pytest.mark.contract +def test_post_add_model_family_returns_404_when_model_does_not_exist( + accept_families: list[UUID], +) -> None: + _ = accept_families + missing_id = str(uuid4()) + with TestClient(create_app()) as client: + response = client.post( + f"/models/{missing_id}/families", + json={"family_id": str(_OTHER_FAMILY_ID)}, + ) + assert response.status_code == 404 + + +@pytest.mark.contract +def test_post_add_model_family_returns_404_when_family_unregistered( + accept_families: list[UUID], +) -> None: + """Cross-BC precondition surfaces as 404 when the supplied family_id + does not resolve via `list_all_family_ids`.""" + _ = accept_families + unknown_family_id = str(uuid4()) + with TestClient(create_app()) as client: + model_id = _seed_model(client) + response = client.post( + f"/models/{model_id}/families", + json={"family_id": unknown_family_id}, + ) + assert response.status_code == 404 + + +@pytest.mark.contract +def test_post_add_model_family_returns_409_on_duplicate_family( + accept_families: list[UUID], +) -> None: + """Strict-not-idempotent: re-adding a family already in + declared_families surfaces as 409.""" + _ = accept_families + with TestClient(create_app()) as client: + model_id = _seed_model(client) + # `_FIXED_FAMILY_ID` is already in declared_families per the + # seed body; re-adding it must reject. + response = client.post( + f"/models/{model_id}/families", + json={"family_id": str(_FIXED_FAMILY_ID)}, + ) + assert response.status_code == 409 + + +@pytest.mark.contract +async def test_post_add_model_family_returns_409_when_deprecated( + accept_families: list[UUID], +) -> None: + """Deprecated Models cannot accept new family declarations; 409.""" + _ = accept_families + with TestClient(create_app()) as client: + model_id = _seed_model(client) + await _append_deprecated_event(client.app, model_id) # type: ignore[arg-type] + response = client.post( + f"/models/{model_id}/families", + json={"family_id": str(_OTHER_FAMILY_ID)}, + ) + assert response.status_code == 409 + assert "Deprecated" in response.json()["detail"] diff --git a/apps/api/tests/contract/test_add_model_family_mcp_tool.py b/apps/api/tests/contract/test_add_model_family_mcp_tool.py new file mode 100644 index 000000000..637003a83 --- /dev/null +++ b/apps/api/tests/contract/test_add_model_family_mcp_tool.py @@ -0,0 +1,148 @@ +"""Contract tests for the `add_model_family` MCP tool. + +Shared MCP helpers live in `tests/contract/_mcp_helpers.py`. + +In-memory contract harness has no Postgres pool, so the cross-BC +`list_all_family_ids` lookup returns `[]` and every `add_model_family` +call surfaces `FamilyNotFoundError` before the decider runs. The +happy path is pinned at the integration tier; this file pins the +MCP-wire shape: tool registration, description spec, and the failure +branches reachable without a database. +""" + +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 + + +@pytest.mark.contract +def test_mcp_lists_add_model_family_tool() -> None: + with TestClient(create_app()) as client: + session_headers = open_session(client) + response = client.post( + "/mcp", + json={"jsonrpc": "2.0", "id": 2, "method": "tools/list"}, + headers=session_headers, + ) + assert response.status_code == 200 + body = parse_sse_data(response.text) + tool_names = [t["name"] for t in body["result"]["tools"]] + assert "add_model_family" in tool_names + + +@pytest.mark.contract +def test_mcp_add_model_family_tool_description_matches_spec() -> None: + with TestClient(create_app()) as client: + session_headers = open_session(client) + response = client.post( + "/mcp", + json={"jsonrpc": "2.0", "id": 3, "method": "tools/list"}, + headers=session_headers, + ) + body = parse_sse_data(response.text) + tools_by_name = {t["name"]: t for t in body["result"]["tools"]} + add_model_family = tools_by_name["add_model_family"] + description = add_model_family["description"] + assert "Family" in description + assert "vendor-catalog Model" in description + assert "declared_families" in description + assert "Strict-not-idempotent" in description + + +@pytest.mark.contract +def test_mcp_add_model_family_tool_rejects_missing_argument() -> None: + with TestClient(create_app()) as client: + session_headers = open_session(client) + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 4, + "method": "tools/call", + "params": { + "name": "add_model_family", + "arguments": {"model_id": str(uuid4())}, + }, + }, + headers=session_headers, + ) + assert response.status_code == 200 + body = parse_sse_data(response.text) + assert body["result"]["isError"] is True + + +@pytest.mark.contract +def test_mcp_add_model_family_tool_returns_iserror_on_unregistered_family() -> None: + """Cross-BC check: family_id must resolve to a registered Family. + In-memory harness has no Family registry, so any family_id surfaces + FamilyNotFoundError, which FastMCP wraps as isError: true with a + 'not found' diagnostic (same shape as the REST 404).""" + with TestClient(create_app()) as client: + session_headers = open_session(client) + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 5, + "method": "tools/call", + "params": { + "name": "add_model_family", + "arguments": { + "model_id": str(uuid4()), + "family_id": str(uuid4()), + }, + }, + }, + headers=session_headers, + ) + assert response.status_code == 200 + body = parse_sse_data(response.text) + result = body["result"] + assert result["isError"] is True + assert "not found" in result["content"][0]["text"].lower() + + +@pytest.mark.contract +def test_mcp_add_model_family_tool_returns_iserror_on_unknown_model( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Family is registered but the model stream is missing; the handler + raises ModelNotFoundError after the cross-BC lookup succeeds. FastMCP + wraps that as isError: true with a 'not found' diagnostic.""" + fake_family_id = uuid4() + + async def _stub(_pool: object) -> list[UUID]: + return [fake_family_id] + + monkeypatch.setattr( + "cora.equipment.features.add_model_family.handler.list_all_family_ids", + _stub, + ) + missing_model_id = uuid4() + with TestClient(create_app()) as client: + session_headers = open_session(client) + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 6, + "method": "tools/call", + "params": { + "name": "add_model_family", + "arguments": { + "model_id": str(missing_model_id), + "family_id": str(fake_family_id), + }, + }, + }, + headers=session_headers, + ) + assert response.status_code == 200 + body = parse_sse_data(response.text) + result = body["result"] + assert result["isError"] is True + assert "not found" in result["content"][0]["text"].lower() diff --git a/apps/api/tests/contract/test_define_model_contract.py b/apps/api/tests/contract/test_define_model_contract.py new file mode 100644 index 000000000..20e2fee8d --- /dev/null +++ b/apps/api/tests/contract/test_define_model_contract.py @@ -0,0 +1,192 @@ +"""Contract tests for `POST /models`. + +Covers the create-style slice surface: happy-path 201 + UUID, Pydantic +422 on schema misses (missing required, empty `declared_families`), +domain 400 on whitespace-only name, and the cross-BC +`with_idempotency` decorator semantics (same key + same body returns +the cached id; same key + different body returns 422). Test keys are +short to stay below the gitleaks generic-API-key entropy threshold. + +The Model handler enforces a cross-BC precondition: every entry in +`declared_families` must resolve via the Family read repo's +`list_all_family_ids`, which is pool-backed and returns `[]` in the +in-memory TestClient harness. We monkeypatch the symbol imported into +the handler module to a fixed accept-all stub so the contract surface +under test stays focused on HTTP shape + idempotency semantics (the +cross-BC lookup is exercised at the unit and integration tiers). +""" + +from collections.abc import Iterator +from uuid import UUID, uuid4 + +import pytest +from fastapi.testclient import TestClient + +from cora.api.main import create_app + +_FIXED_FAMILY_ID = UUID("01900000-0000-7000-8000-00000000fa01") + + +@pytest.fixture +def accept_family(monkeypatch: pytest.MonkeyPatch) -> Iterator[UUID]: + """Stub `list_all_family_ids` so `_FIXED_FAMILY_ID` always resolves. + + The handler imports `list_all_family_ids` by name at module load, so we + patch the binding in the handler's namespace (the one it actually + calls), mirroring the unit-test pattern in + `tests/unit/equipment/test_define_model_handler.py`. + """ + + async def _stub(_pool: object) -> list[UUID]: + return [_FIXED_FAMILY_ID] + + monkeypatch.setattr( + "cora.equipment.features.define_model.handler.list_all_family_ids", + _stub, + ) + yield _FIXED_FAMILY_ID + + +def _body( + *, + name: str = "ANT130-L", + part_number: str = "ANT130-L-150", + manufacturer_name: str = "Aerotech", +) -> dict[str, object]: + return { + "name": name, + "manufacturer": {"name": manufacturer_name}, + "part_number": part_number, + "declared_families": [str(_FIXED_FAMILY_ID)], + } + + +@pytest.mark.contract +def test_post_models_happy_path_returns_201_and_uuid(accept_family: UUID) -> None: + _ = accept_family + with TestClient(create_app()) as client: + response = client.post("/models", json=_body()) + + assert response.status_code == 201 + UUID(response.json()["model_id"]) # parses + + +@pytest.mark.contract +def test_post_models_missing_required_field_returns_422(accept_family: UUID) -> None: + """Pydantic schema validation: missing `part_number`.""" + _ = accept_family + with TestClient(create_app()) as client: + body = _body() + del body["part_number"] + response = client.post("/models", json=body) + + assert response.status_code == 422 + + +@pytest.mark.contract +def test_post_models_empty_declared_families_returns_422(accept_family: UUID) -> None: + """Pydantic `min_length=1` on `declared_families`.""" + _ = accept_family + with TestClient(create_app()) as client: + body = _body() + body["declared_families"] = [] + response = client.post("/models", json=body) + + assert response.status_code == 422 + + +@pytest.mark.contract +def test_post_models_whitespace_only_name_returns_400(accept_family: UUID) -> None: + """Domain `InvalidModelNameError` after Pydantic min_length=1 passes.""" + _ = accept_family + with TestClient(create_app()) as client: + body = _body(name=" ") + response = client.post("/models", json=body) + + assert response.status_code == 400 + detail = response.json()["detail"] + assert "name" in detail.lower() + + +@pytest.mark.contract +def test_post_models_unknown_declared_family_returns_404(accept_family: UUID) -> None: + """Cross-BC precondition surfaces as 404 when a declared family does + not resolve against `list_all_family_ids`.""" + _ = accept_family + with TestClient(create_app()) as client: + body = _body() + body["declared_families"] = [str(uuid4())] + response = client.post("/models", json=body) + + assert response.status_code == 404 + + +@pytest.mark.contract +def test_post_models_without_key_creates_distinct_models_on_each_call( + accept_family: UUID, +) -> None: + _ = accept_family + with TestClient(create_app()) as client: + body = _body() + r1 = client.post("/models", json=body) + r2 = client.post("/models", json=body) + + assert r1.status_code == 201 + assert r2.status_code == 201 + assert r1.json()["model_id"] != r2.json()["model_id"] + + +@pytest.mark.contract +def test_post_models_same_key_and_body_returns_same_model_id(accept_family: UUID) -> None: + _ = accept_family + with TestClient(create_app()) as client: + headers = {"Idempotency-Key": "mk-1"} + body = _body() + r1 = client.post("/models", json=body, headers=headers) + r2 = client.post("/models", json=body, headers=headers) + + assert r1.status_code == 201 + assert r2.status_code == 201 + assert r1.json()["model_id"] == r2.json()["model_id"] + + +@pytest.mark.contract +def test_post_models_same_key_different_body_returns_422(accept_family: UUID) -> None: + _ = accept_family + with TestClient(create_app()) as client: + headers = {"Idempotency-Key": "mk-2"} + r1 = client.post("/models", json=_body(name="ANT130-L"), headers=headers) + r2 = client.post("/models", json=_body(name="Other"), headers=headers) + + assert r1.status_code == 201 + assert r2.status_code == 422 + body = r2.json() + assert "detail" in body + assert "idempotency-key" in body["detail"].lower() + + +@pytest.mark.contract +def test_post_models_different_keys_create_distinct_models(accept_family: UUID) -> None: + _ = accept_family + with TestClient(create_app()) as client: + body = _body() + r1 = client.post("/models", json=body, headers={"Idempotency-Key": "mk-A"}) + r2 = client.post("/models", json=body, headers={"Idempotency-Key": "mk-B"}) + + assert r1.status_code == 201 + assert r2.status_code == 201 + assert r1.json()["model_id"] != r2.json()["model_id"] + + +@pytest.mark.contract +def test_post_models_cached_response_returns_valid_uuid(accept_family: UUID) -> None: + _ = accept_family + with TestClient(create_app()) as client: + headers = {"Idempotency-Key": "mk-uuid"} + body = _body() + r1 = client.post("/models", json=body, headers=headers) + r2 = client.post("/models", json=body, headers=headers) + + UUID(r1.json()["model_id"]) # parses + UUID(r2.json()["model_id"]) # parses + assert r1.json()["model_id"] == r2.json()["model_id"] diff --git a/apps/api/tests/contract/test_define_model_mcp_tool.py b/apps/api/tests/contract/test_define_model_mcp_tool.py new file mode 100644 index 000000000..ba4541692 --- /dev/null +++ b/apps/api/tests/contract/test_define_model_mcp_tool.py @@ -0,0 +1,132 @@ +"""Contract tests for the `define_model` MCP tool. + +Shared MCP helpers live in `tests/contract/_mcp_helpers.py`. + +In-memory contract harness has no Postgres pool, so the cross-BC +`list_family_ids` lookup returns `[]` and every `define_model` call +surfaces `FamilyNotFoundError`. The structured-output happy path is +pinned at the integration tier (see `tests/integration/equipment/`); +this file pins the MCP-wire shape: tool registration, description, +structured-output schema (declares `model_id`), and the failure +branches reachable without a database. +""" + +from uuid import 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 + + +@pytest.mark.contract +def test_mcp_lists_define_model_tool() -> None: + with TestClient(create_app()) as client: + session_headers = open_session(client) + response = client.post( + "/mcp", + json={"jsonrpc": "2.0", "id": 2, "method": "tools/list"}, + headers=session_headers, + ) + assert response.status_code == 200 + body = parse_sse_data(response.text) + tool_names = [t["name"] for t in body["result"]["tools"]] + assert "define_model" in tool_names + + +@pytest.mark.contract +def test_mcp_define_model_tool_description_matches_vendor_catalog_spec() -> None: + with TestClient(create_app()) as client: + session_headers = open_session(client) + response = client.post( + "/mcp", + json={"jsonrpc": "2.0", "id": 3, "method": "tools/list"}, + headers=session_headers, + ) + body = parse_sse_data(response.text) + tools_by_name = {t["name"]: t for t in body["result"]["tools"]} + define_model = tools_by_name["define_model"] + description = define_model["description"] + assert "vendor-catalog Model" in description + assert "manufacturer" in description + assert "part number" in description + assert "Family" in description + + +@pytest.mark.contract +def test_mcp_define_model_tool_advertises_model_id_in_output_schema() -> None: + """Pin the structured-output schema: DefineModelOutput.model_id is on the + wire. The actual happy-path emission of a model_id value requires a + Postgres pool (cross-BC family_lookup), so it is covered at the + integration tier; here we verify the schema contract.""" + with TestClient(create_app()) as client: + session_headers = open_session(client) + response = client.post( + "/mcp", + json={"jsonrpc": "2.0", "id": 4, "method": "tools/list"}, + headers=session_headers, + ) + body = parse_sse_data(response.text) + tools_by_name = {t["name"]: t for t in body["result"]["tools"]} + define_model = tools_by_name["define_model"] + output_schema = define_model["outputSchema"] + assert "model_id" in output_schema["properties"] + assert output_schema["properties"]["model_id"]["format"] == "uuid" + assert "model_id" in output_schema["required"] + + +@pytest.mark.contract +def test_mcp_define_model_tool_rejects_missing_argument() -> None: + with TestClient(create_app()) as client: + session_headers = open_session(client) + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 5, + "method": "tools/call", + "params": { + "name": "define_model", + "arguments": {}, + }, + }, + headers=session_headers, + ) + assert response.status_code == 200 + body = parse_sse_data(response.text) + assert body["result"]["isError"] is True + + +@pytest.mark.contract +def test_mcp_define_model_tool_returns_iserror_on_unregistered_family() -> None: + """Cross-BC check: declared_families must resolve to a registered Family. + An unknown family id surfaces FamilyNotFoundError, which FastMCP wraps as + isError: true with a 'not found' diagnostic (same shape as the REST 404). + """ + unknown_family_id = str(uuid4()) + with TestClient(create_app()) as client: + session_headers = open_session(client) + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 6, + "method": "tools/call", + "params": { + "name": "define_model", + "arguments": { + "name": "ANT130-L", + "manufacturer": {"name": "Aerotech"}, + "part_number": "ANT130-L", + "declared_families": [unknown_family_id], + }, + }, + }, + headers=session_headers, + ) + assert response.status_code == 200 + body = parse_sse_data(response.text) + result = body["result"] + assert result["isError"] is True + assert "not found" in result["content"][0]["text"].lower() diff --git a/apps/api/tests/contract/test_deprecate_model_endpoint.py b/apps/api/tests/contract/test_deprecate_model_endpoint.py new file mode 100644 index 000000000..42c32ba4d --- /dev/null +++ b/apps/api/tests/contract/test_deprecate_model_endpoint.py @@ -0,0 +1,146 @@ +"""Contract tests for `POST /models/{model_id}/deprecation`. + +Action endpoint carrying a `reason` body. Multi-source guard +(Defined | Versioned -> Deprecated). Strict-not-idempotent. + +In-memory contract harness has no Postgres pool, so the cross-BC +`list_all_family_ids` lookup performed by `define_model` returns `[]` and +every model-seeding call would fail. We stub the symbol to a fixed +accept-all set so we can seed a Model via `POST /models` and exercise +`POST /models/{model_id}/deprecation` end-to-end. +""" + +from collections.abc import Iterator +from uuid import UUID, uuid4 + +import pytest +from fastapi.testclient import TestClient + +from cora.api.main import create_app + +_FIXED_FAMILY_ID = UUID("01900000-0000-7000-8000-00000000fad1") +_REASON = "Vendor end-of-life 2026-Q3; replaced by ANT130-LZS" + + +@pytest.fixture +def accept_family(monkeypatch: pytest.MonkeyPatch) -> Iterator[UUID]: + """Stub `list_all_family_ids` so the seeding `define_model` succeeds.""" + + async def _stub(_pool: object) -> list[UUID]: + return [_FIXED_FAMILY_ID] + + monkeypatch.setattr( + "cora.equipment.features.define_model.handler.list_all_family_ids", + _stub, + ) + yield _FIXED_FAMILY_ID + + +def _define_body(*, name: str = "ANT130-L") -> dict[str, object]: + return { + "name": name, + "manufacturer": {"name": "Aerotech"}, + "part_number": "ANT130-L", + "declared_families": [str(_FIXED_FAMILY_ID)], + } + + +def _deprecate_body(*, reason: str = _REASON) -> dict[str, object]: + return {"reason": reason} + + +def _seed_model(client: TestClient) -> UUID: + response = client.post("/models", json=_define_body()) + assert response.status_code == 201 + return UUID(response.json()["model_id"]) + + +@pytest.mark.contract +def test_post_deprecate_model_returns_204_on_success(accept_family: UUID) -> None: + """Defined -> Deprecated.""" + _ = accept_family + with TestClient(create_app()) as client: + model_id = _seed_model(client) + response = client.post( + f"/models/{model_id}/deprecation", + json=_deprecate_body(), + ) + assert response.status_code == 204 + assert response.content == b"" + + +@pytest.mark.contract +def test_post_deprecate_model_missing_reason_returns_422(accept_family: UUID) -> None: + """Pydantic schema validation: `reason` is required.""" + _ = accept_family + with TestClient(create_app()) as client: + model_id = _seed_model(client) + response = client.post( + f"/models/{model_id}/deprecation", + json={}, + ) + assert response.status_code == 422 + + +@pytest.mark.contract +def test_post_deprecate_model_whitespace_only_reason_returns_400(accept_family: UUID) -> None: + """Domain `InvalidModelDeprecationReasonError` after Pydantic min_length=1 passes.""" + _ = accept_family + with TestClient(create_app()) as client: + model_id = _seed_model(client) + response = client.post( + f"/models/{model_id}/deprecation", + json=_deprecate_body(reason=" "), + ) + assert response.status_code == 400 + assert "reason" in response.json()["detail"].lower() + + +@pytest.mark.contract +def test_post_deprecate_model_returns_404_when_model_does_not_exist( + accept_family: UUID, +) -> None: + _ = accept_family + missing_id = str(uuid4()) + with TestClient(create_app()) as client: + response = client.post( + f"/models/{missing_id}/deprecation", + json=_deprecate_body(), + ) + assert response.status_code == 404 + + +@pytest.mark.contract +def test_post_deprecate_model_returns_409_when_already_deprecated( + accept_family: UUID, +) -> None: + """Strict-not-idempotent: re-deprecating raises 409.""" + _ = accept_family + with TestClient(create_app()) as client: + model_id = _seed_model(client) + first = client.post( + f"/models/{model_id}/deprecation", + json=_deprecate_body(), + ) + assert first.status_code == 204 + second = client.post( + f"/models/{model_id}/deprecation", + json=_deprecate_body(), + ) + assert second.status_code == 409 + body = second.json() + assert "Defined" in body["detail"] + assert "Versioned" in body["detail"] + + +@pytest.mark.contract +def test_post_deprecate_model_rejects_invalid_path_uuid_with_422( + accept_family: UUID, +) -> None: + _ = accept_family + with TestClient(create_app()) as client: + response = client.post( + "/models/not-a-uuid/deprecation", + json=_deprecate_body(), + ) + assert response.status_code == 422 diff --git a/apps/api/tests/contract/test_deprecate_model_mcp_tool.py b/apps/api/tests/contract/test_deprecate_model_mcp_tool.py new file mode 100644 index 000000000..639a73b1e --- /dev/null +++ b/apps/api/tests/contract/test_deprecate_model_mcp_tool.py @@ -0,0 +1,100 @@ +"""Contract tests for the `deprecate_model` MCP tool. + +In-memory contract harness has no Postgres pool, so happy-path +deprecation end-to-end requires seeding a Model first. We exercise: +- tool registration (the tool appears in `tools/list`) +- description matches the authoring-signal spec +- missing-argument call surfaces `isError: true` +- unknown `model_id` surfaces `ModelNotFoundError` as `isError: true` +""" + +from uuid import 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 + + +@pytest.mark.contract +def test_mcp_lists_deprecate_model_tool() -> None: + with TestClient(create_app()) as client: + session_headers = open_session(client) + response = client.post( + "/mcp", + json={"jsonrpc": "2.0", "id": 2, "method": "tools/list"}, + headers=session_headers, + ) + assert response.status_code == 200 + body = parse_sse_data(response.text) + tool_names = [t["name"] for t in body["result"]["tools"]] + assert "deprecate_model" in tool_names + + +@pytest.mark.contract +def test_mcp_deprecate_model_tool_description_matches_authoring_spec() -> None: + with TestClient(create_app()) as client: + session_headers = open_session(client) + response = client.post( + "/mcp", + json={"jsonrpc": "2.0", "id": 3, "method": "tools/list"}, + headers=session_headers, + ) + body = parse_sse_data(response.text) + tools_by_name = {t["name"]: t for t in body["result"]["tools"]} + deprecate_model = tools_by_name["deprecate_model"] + description = deprecate_model["description"] + assert "vendor-catalog Model" in description + assert "authoring signal" in description + assert "Assets" in description + + +@pytest.mark.contract +def test_mcp_deprecate_model_tool_rejects_missing_argument() -> None: + with TestClient(create_app()) as client: + session_headers = open_session(client) + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 5, + "method": "tools/call", + "params": { + "name": "deprecate_model", + "arguments": {}, + }, + }, + headers=session_headers, + ) + assert response.status_code == 200 + body = parse_sse_data(response.text) + assert body["result"]["isError"] is True + + +@pytest.mark.contract +def test_mcp_deprecate_model_tool_returns_iserror_for_unknown_model() -> None: + unknown_id = str(uuid4()) + with TestClient(create_app()) as client: + session_headers = open_session(client) + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 6, + "method": "tools/call", + "params": { + "name": "deprecate_model", + "arguments": { + "model_id": unknown_id, + "reason": "Vendor EOL 2026-Q3", + }, + }, + }, + headers=session_headers, + ) + assert response.status_code == 200 + body = parse_sse_data(response.text) + result = body["result"] + assert result["isError"] is True + assert "not found" in result["content"][0]["text"].lower() diff --git a/apps/api/tests/contract/test_get_model_endpoint.py b/apps/api/tests/contract/test_get_model_endpoint.py new file mode 100644 index 000000000..df021d8d0 --- /dev/null +++ b/apps/api/tests/contract/test_get_model_endpoint.py @@ -0,0 +1,136 @@ +# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false, reportFunctionMemberAccess=false, reportAttributeAccessIssue=false + +"""Contract tests for `GET /models/{model_id}`. + +Mirrors `test_get_family_endpoint.py`. Pinned response shape: +`{model_id, name, manufacturer, part_number, declared_families, +status, version_tag}` where `manufacturer` is the nested +`ManufacturerResponse` and `status` is the StrEnum's string value +(Defined / Versioned / Deprecated). + +The Model upstream `define_model` slice enforces a cross-BC precondition: +every entry in `declared_families` must resolve via the Family read +repo's `list_all_family_ids`, which is pool-backed and returns `[]` in the +in-memory TestClient harness. We monkeypatch the symbol imported into +the upstream handler module so the seed `POST /models` call succeeds +and we can exercise the read surface here. +""" + +from collections.abc import Iterator +from uuid import UUID, uuid4 + +import pytest +from fastapi.testclient import TestClient + +from cora.api.main import create_app + +_FIXED_FAMILY_ID = UUID("01900000-0000-7000-8000-00000000fa01") + + +@pytest.fixture +def accept_family(monkeypatch: pytest.MonkeyPatch) -> Iterator[UUID]: + """Stub `list_all_family_ids` so `_FIXED_FAMILY_ID` always resolves.""" + + async def _stub(_pool: object) -> list[UUID]: + return [_FIXED_FAMILY_ID] + + monkeypatch.setattr( + "cora.equipment.features.define_model.handler.list_all_family_ids", + _stub, + ) + yield _FIXED_FAMILY_ID + + +def _define_model(client: TestClient) -> UUID: + response = client.post( + "/models", + json={ + "name": "Aerotech ANT130-L", + "manufacturer": {"name": "Aerotech"}, + "part_number": "ANT130-L", + "declared_families": [str(_FIXED_FAMILY_ID)], + }, + ) + assert response.status_code == 201, response.text + return UUID(response.json()["model_id"]) + + +@pytest.mark.contract +def test_get_model_returns_200_with_defined_status_for_new_model( + accept_family: UUID, +) -> None: + _ = accept_family + with TestClient(create_app()) as client: + model_id = _define_model(client) + response = client.get(f"/models/{model_id}") + + assert response.status_code == 200 + body = response.json() + assert body == { + "model_id": str(model_id), + "name": "Aerotech ANT130-L", + "manufacturer": { + "name": "Aerotech", + "identifier": None, + "identifier_type": None, + }, + "part_number": "ANT130-L", + "declared_families": [str(_FIXED_FAMILY_ID)], + "status": "Defined", + # Null until version_model runs (no initial version_tag supplied). + "version_tag": None, + } + + +@pytest.mark.contract +def test_get_model_returns_404_for_unknown_id() -> None: + with TestClient(create_app()) as client: + response = client.get(f"/models/{uuid4()}") + assert response.status_code == 404 + body = response.json() + assert "detail" in body + assert "not found" in body["detail"].lower() + + +@pytest.mark.contract +def test_get_model_returns_422_for_malformed_model_id() -> None: + with TestClient(create_app()) as client: + response = client.get("/models/not-a-uuid") + assert response.status_code == 422 + + +@pytest.mark.contract +def test_get_model_returns_403_when_authorize_denies( + accept_family: UUID, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Authorize-deny surfaces as 403 via the BC's exception handler. + Patched at the bound-handler tier: the create_app default Authorize + is AllowAll, so we wrap the wired get_model handler to raise + UnauthorizedError on entry, the same way the production + TrustAuthorize would on a Deny.""" + _ = accept_family + from dataclasses import replace + + from cora.equipment.errors import UnauthorizedError + + with TestClient(create_app()) as client: + model_id = _define_model(client) + + async def _denying_handler(*_args: object, **_kwargs: object) -> None: + raise UnauthorizedError("denied for test") + + # Replace the wired get_model handler with one that always + # denies. The FastAPI dep resolves the handler via + # request.app.state.equipment, an EquipmentHandlers dataclass. + original_handlers = client.app.state.equipment + client.app.state.equipment = replace( + original_handlers, + get_model=_denying_handler, + ) + response = client.get(f"/models/{model_id}") + + assert response.status_code == 403 + body = response.json() + assert "detail" in body + assert "denied" in body["detail"].lower() diff --git a/apps/api/tests/contract/test_get_model_mcp_tool.py b/apps/api/tests/contract/test_get_model_mcp_tool.py new file mode 100644 index 000000000..923fe031b --- /dev/null +++ b/apps/api/tests/contract/test_get_model_mcp_tool.py @@ -0,0 +1,156 @@ +"""Contract tests for the `get_model` MCP tool. + +Mirrors `test_get_family_mcp_tool.py`. Shared MCP helpers live in +`tests/contract/_mcp_helpers.py`. The Model upstream `define_model` +tool enforces a cross-BC `list_all_family_ids` precondition that is +pool-backed and returns `[]` in the in-memory harness; we +monkeypatch the upstream handler's binding so the seed tool call +succeeds. +""" + +from collections.abc import Iterator +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 + +_FIXED_FAMILY_ID = UUID("01900000-0000-7000-8000-00000000fa01") + + +@pytest.fixture +def accept_family(monkeypatch: pytest.MonkeyPatch) -> Iterator[UUID]: + async def _stub(_pool: object) -> list[UUID]: + return [_FIXED_FAMILY_ID] + + monkeypatch.setattr( + "cora.equipment.features.define_model.handler.list_all_family_ids", + _stub, + ) + yield _FIXED_FAMILY_ID + + +def _define_model_via_tool(client: TestClient, headers: dict[str, str]) -> UUID: + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "define_model", + "arguments": { + "name": "Aerotech ANT130-L", + "manufacturer": {"name": "Aerotech"}, + "part_number": "ANT130-L", + "declared_families": [str(_FIXED_FAMILY_ID)], + }, + }, + }, + headers=headers, + ) + body = parse_sse_data(response.text) + return UUID(body["result"]["structuredContent"]["model_id"]) + + +@pytest.mark.contract +def test_mcp_lists_get_model_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 "get_model" in tool_names + + +@pytest.mark.contract +def test_mcp_get_model_tool_returns_structured_model_for_known_id( + accept_family: UUID, +) -> None: + _ = accept_family + with TestClient(create_app()) as client: + headers = open_session(client) + model_id = _define_model_via_tool(client, headers) + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": { + "name": "get_model", + "arguments": {"model_id": str(model_id)}, + }, + }, + headers=headers, + ) + + body = parse_sse_data(response.text) + result = body["result"] + assert result["isError"] is False + structured = result["structuredContent"] + assert structured["model_id"] == str(model_id) + assert structured["name"] == "Aerotech ANT130-L" + assert structured["manufacturer"] == { + "name": "Aerotech", + "identifier": None, + "identifier_type": None, + } + assert structured["part_number"] == "ANT130-L" + assert structured["declared_families"] == [str(_FIXED_FAMILY_ID)] + assert structured["status"] == "Defined" + # Null until version_model runs (no initial version_tag supplied). + assert structured["version_tag"] is None + + +@pytest.mark.contract +def test_mcp_get_model_tool_returns_iserror_for_unknown_id() -> None: + with TestClient(create_app()) as client: + headers = open_session(client) + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 4, + "method": "tools/call", + "params": { + "name": "get_model", + "arguments": {"model_id": str(uuid4())}, + }, + }, + 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_get_model_tool_rejects_missing_argument() -> None: + """Calling `get_model` without `model_id` raises Pydantic input + validation, which FastMCP wraps as `isError: true`.""" + 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": "get_model", + "arguments": {}, + }, + }, + headers=headers, + ) + assert response.status_code == 200 + body = parse_sse_data(response.text) + assert body["result"]["isError"] is True diff --git a/apps/api/tests/contract/test_remove_model_family_endpoint.py b/apps/api/tests/contract/test_remove_model_family_endpoint.py new file mode 100644 index 000000000..412d292f7 --- /dev/null +++ b/apps/api/tests/contract/test_remove_model_family_endpoint.py @@ -0,0 +1,150 @@ +"""Contract tests for `DELETE /models/{model_id}/families/{family_id}`. + +Targeted-mutation endpoint removing a single Family from the Model's +`declared_families` set. 204 No Content on success. Both ids travel +as path parameters; no request body. + +Unlike `add_model_family`, the `remove_model_family` slice performs +NO cross-BC Family lookup; removing a Family that has been deprecated +or deleted from the Family registry still succeeds if it sits in +`declared_families`. The seeding `define_model` call DOES still +resolve `list_all_family_ids` cross-BC, so we stub that one symbol on +the `define_model` handler module. + +The 409-on-Deprecated path appends a `ModelDeprecated` event directly +to the in-memory event store (no `deprecate_model` slice exercised), +then exercises the route and expects 409. +""" + +from collections.abc import Iterator +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from cora.api.main import create_app +from cora.equipment.aggregates.model import ( + ModelDeprecated, + event_type_name, + to_payload, +) +from cora.infrastructure.event_envelope import to_new_event + +_FIXED_FAMILY_ID = UUID("01900000-0000-7000-8000-00000000fd01") +_OTHER_FAMILY_ID = UUID("01900000-0000-7000-8000-00000000fd02") +_PRINCIPAL_ID = UUID("01900000-0000-7000-8000-000000000099") +_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000aa") +_NOW = datetime(2026, 6, 1, 12, 0, 0, tzinfo=UTC) + + +@pytest.fixture +def accept_families(monkeypatch: pytest.MonkeyPatch) -> Iterator[list[UUID]]: + """Stub `list_all_family_ids` on the `define_model` handler so the + seeding call accepts the fixed family-id set. The + `remove_model_family` handler does NOT perform this lookup.""" + known: list[UUID] = [_FIXED_FAMILY_ID, _OTHER_FAMILY_ID] + + async def _stub(_pool: object) -> list[UUID]: + return list(known) + + monkeypatch.setattr( + "cora.equipment.features.define_model.handler.list_all_family_ids", + _stub, + ) + yield known + + +def _define_body() -> dict[str, object]: + return { + "name": "ANT130-L", + "manufacturer": {"name": "Aerotech"}, + "part_number": "ANT130-L", + "declared_families": [str(_FIXED_FAMILY_ID)], + } + + +def _seed_model(client: TestClient) -> UUID: + response = client.post("/models", json=_define_body()) + assert response.status_code == 201 + return UUID(response.json()["model_id"]) + + +async def _append_deprecated_event(app: FastAPI, model_id: UUID) -> None: + deps = app.state.deps + deprecated = ModelDeprecated( + model_id=model_id, + reason="superseded", + occurred_at=_NOW, + ) + new_event = to_new_event( + event_type=event_type_name(deprecated), + payload=to_payload(deprecated), + occurred_at=deprecated.occurred_at, + event_id=uuid4(), + command_name="DeprecateModel", + correlation_id=_CORRELATION_ID, + causation_id=None, + principal_id=_PRINCIPAL_ID, + ) + _, current_version = await deps.event_store.load("Model", model_id) + await deps.event_store.append( + stream_type="Model", + stream_id=model_id, + expected_version=current_version, + events=[new_event], + ) + + +@pytest.mark.contract +def test_delete_remove_model_family_returns_204_on_success( + accept_families: list[UUID], +) -> None: + _ = accept_families + with TestClient(create_app()) as client: + model_id = _seed_model(client) + response = client.delete(f"/models/{model_id}/families/{_FIXED_FAMILY_ID}") + assert response.status_code == 204 + assert response.content == b"" + + +@pytest.mark.contract +def test_delete_remove_model_family_returns_404_when_model_does_not_exist( + accept_families: list[UUID], +) -> None: + _ = accept_families + missing_id = str(uuid4()) + with TestClient(create_app()) as client: + response = client.delete(f"/models/{missing_id}/families/{_FIXED_FAMILY_ID}") + assert response.status_code == 404 + + +@pytest.mark.contract +def test_delete_remove_model_family_returns_409_when_family_absent( + accept_families: list[UUID], +) -> None: + """Strict-not-idempotent: removing a family not in + declared_families surfaces as 409.""" + _ = accept_families + with TestClient(create_app()) as client: + model_id = _seed_model(client) + # `_OTHER_FAMILY_ID` was never added to declared_families; the + # seed only declares `_FIXED_FAMILY_ID`. + response = client.delete(f"/models/{model_id}/families/{_OTHER_FAMILY_ID}") + assert response.status_code == 409 + assert "does not declare" in response.json()["detail"] + + +@pytest.mark.contract +async def test_delete_remove_model_family_returns_409_when_deprecated( + accept_families: list[UUID], +) -> None: + """Deprecated Models cannot accept family removals; 409.""" + _ = accept_families + with TestClient(create_app()) as client: + model_id = _seed_model(client) + await _append_deprecated_event(client.app, model_id) # type: ignore[arg-type] + response = client.delete(f"/models/{model_id}/families/{_FIXED_FAMILY_ID}") + assert response.status_code == 409 + assert "Deprecated" in response.json()["detail"] diff --git a/apps/api/tests/contract/test_remove_model_family_mcp_tool.py b/apps/api/tests/contract/test_remove_model_family_mcp_tool.py new file mode 100644 index 000000000..e1e29037a --- /dev/null +++ b/apps/api/tests/contract/test_remove_model_family_mcp_tool.py @@ -0,0 +1,175 @@ +"""Contract tests for the `remove_model_family` MCP tool. + +Shared MCP helpers live in `tests/contract/_mcp_helpers.py`. + +Unlike `add_model_family`, this slice performs NO cross-BC Family +lookup, so the failure shapes pinned at the MCP wire are: + + - missing argument -> isError: true (Pydantic schema validation) + - present model + absent family -> isError: true ("does not declare") + - missing model stream -> isError: true ("not found") + +The seeding `define_model` call still needs `list_all_family_ids` +stubbed so we can seed a real model via REST in the same TestClient +before invoking the 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 + +_FIXED_FAMILY_ID = UUID("01900000-0000-7000-8000-00000000fe01") + + +def _stub_define_model_family_lookup( + monkeypatch: pytest.MonkeyPatch, + family_ids: list[UUID], +) -> None: + async def _stub(_pool: object) -> list[UUID]: + return list(family_ids) + + monkeypatch.setattr( + "cora.equipment.features.define_model.handler.list_all_family_ids", + _stub, + ) + + +def _seed_model_via_rest(client: TestClient) -> UUID: + response = client.post( + "/models", + json={ + "name": "ANT130-L", + "manufacturer": {"name": "Aerotech"}, + "part_number": "ANT130-L", + "declared_families": [str(_FIXED_FAMILY_ID)], + }, + ) + assert response.status_code == 201 + return UUID(response.json()["model_id"]) + + +@pytest.mark.contract +def test_mcp_lists_remove_model_family_tool() -> None: + with TestClient(create_app()) as client: + session_headers = open_session(client) + response = client.post( + "/mcp", + json={"jsonrpc": "2.0", "id": 2, "method": "tools/list"}, + headers=session_headers, + ) + assert response.status_code == 200 + body = parse_sse_data(response.text) + tool_names = [t["name"] for t in body["result"]["tools"]] + assert "remove_model_family" in tool_names + + +@pytest.mark.contract +def test_mcp_remove_model_family_tool_description_matches_spec() -> None: + with TestClient(create_app()) as client: + session_headers = open_session(client) + response = client.post( + "/mcp", + json={"jsonrpc": "2.0", "id": 3, "method": "tools/list"}, + headers=session_headers, + ) + body = parse_sse_data(response.text) + tools_by_name = {t["name"]: t for t in body["result"]["tools"]} + remove_model_family = tools_by_name["remove_model_family"] + description = remove_model_family["description"] + assert "Family" in description + assert "vendor-catalog Model" in description + assert "declared_families" in description + assert "Strict-not-idempotent" in description + + +@pytest.mark.contract +def test_mcp_remove_model_family_tool_rejects_missing_argument() -> None: + with TestClient(create_app()) as client: + session_headers = open_session(client) + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 4, + "method": "tools/call", + "params": { + "name": "remove_model_family", + "arguments": {"model_id": str(uuid4())}, + }, + }, + headers=session_headers, + ) + assert response.status_code == 200 + body = parse_sse_data(response.text) + assert body["result"]["isError"] is True + + +@pytest.mark.contract +def test_mcp_remove_model_family_tool_returns_iserror_on_absent_family( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Strict-not-idempotent: removing a family not in declared_families + raises ModelFamilyNotPresentError, which FastMCP wraps as + isError: true with a 'does not declare' diagnostic.""" + _stub_define_model_family_lookup(monkeypatch, [_FIXED_FAMILY_ID]) + absent_family_id = uuid4() + with TestClient(create_app()) as client: + model_id = _seed_model_via_rest(client) + session_headers = open_session(client) + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 5, + "method": "tools/call", + "params": { + "name": "remove_model_family", + "arguments": { + "model_id": str(model_id), + "family_id": str(absent_family_id), + }, + }, + }, + headers=session_headers, + ) + assert response.status_code == 200 + body = parse_sse_data(response.text) + result = body["result"] + assert result["isError"] is True + assert "does not declare" in result["content"][0]["text"] + + +@pytest.mark.contract +def test_mcp_remove_model_family_tool_returns_iserror_on_unknown_model() -> None: + """Missing model stream surfaces ModelNotFoundError, which FastMCP + wraps as isError: true with a 'not found' diagnostic. No cross-BC + lookup runs, so no stub is needed.""" + missing_model_id = uuid4() + missing_family_id = uuid4() + with TestClient(create_app()) as client: + session_headers = open_session(client) + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 6, + "method": "tools/call", + "params": { + "name": "remove_model_family", + "arguments": { + "model_id": str(missing_model_id), + "family_id": str(missing_family_id), + }, + }, + }, + headers=session_headers, + ) + assert response.status_code == 200 + body = parse_sse_data(response.text) + result = body["result"] + assert result["isError"] is True + assert "not found" in result["content"][0]["text"].lower() diff --git a/apps/api/tests/contract/test_version_model_endpoint.py b/apps/api/tests/contract/test_version_model_endpoint.py new file mode 100644 index 000000000..0eaaea5a4 --- /dev/null +++ b/apps/api/tests/contract/test_version_model_endpoint.py @@ -0,0 +1,203 @@ +"""Contract tests for `POST /models/{model_id}/versions`. + +Action endpoint carrying a full replacement body (name, manufacturer, +part_number, declared_families, version_tag). Multi-source guard +(Defined | Versioned -> Versioned). + +In-memory contract harness has no Postgres pool, so the cross-BC +`list_all_family_ids` lookup performed by `define_model` returns `[]` and +every model-seeding call would fail. We stub the symbol to a +fixed accept-all set so we can seed a Model via `POST /models` and +exercise `POST /models/{model_id}/versions` end-to-end. + +The 409-on-Deprecated path appends a `ModelDeprecated` event directly +to the in-memory event store (no `deprecate_model` slice exists yet), +then exercises the route and expects 409. +""" + +from collections.abc import Iterator +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from cora.api.main import create_app +from cora.equipment.aggregates.model import ( + ModelDeprecated, + event_type_name, + to_payload, +) +from cora.infrastructure.event_envelope import to_new_event + +_FIXED_FAMILY_ID = UUID("01900000-0000-7000-8000-00000000fa01") +_OTHER_FAMILY_ID = UUID("01900000-0000-7000-8000-00000000fa02") +_PRINCIPAL_ID = UUID("01900000-0000-7000-8000-000000000099") +_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000aa") +_NOW = datetime(2026, 6, 1, 12, 0, 0, tzinfo=UTC) + + +@pytest.fixture +def accept_family(monkeypatch: pytest.MonkeyPatch) -> Iterator[UUID]: + """Stub `list_all_family_ids` so the seeding `define_model` succeeds.""" + + async def _stub(_pool: object) -> list[UUID]: + return [_FIXED_FAMILY_ID, _OTHER_FAMILY_ID] + + monkeypatch.setattr( + "cora.equipment.features.define_model.handler.list_all_family_ids", + _stub, + ) + yield _FIXED_FAMILY_ID + + +def _define_body(*, name: str = "ANT130-L") -> dict[str, object]: + return { + "name": name, + "manufacturer": {"name": "Aerotech"}, + "part_number": "ANT130-L", + "declared_families": [str(_FIXED_FAMILY_ID)], + } + + +def _version_body( + *, + name: str = "ANT130-L rev-B", + part_number: str = "ANT130-L-B", + version_tag: str = "v2", + declared_families: list[str] | None = None, +) -> dict[str, object]: + return { + "name": name, + "manufacturer": {"name": "Aerotech"}, + "part_number": part_number, + "declared_families": declared_families + if declared_families is not None + else [str(_FIXED_FAMILY_ID), str(_OTHER_FAMILY_ID)], + "version_tag": version_tag, + } + + +def _seed_model(client: TestClient) -> UUID: + response = client.post("/models", json=_define_body()) + assert response.status_code == 201 + return UUID(response.json()["model_id"]) + + +async def _append_deprecated_event(app: FastAPI, model_id: UUID) -> None: + deps = app.state.deps + deprecated = ModelDeprecated( + model_id=model_id, + reason="superseded", + occurred_at=_NOW, + ) + new_event = to_new_event( + event_type=event_type_name(deprecated), + payload=to_payload(deprecated), + occurred_at=deprecated.occurred_at, + event_id=uuid4(), + command_name="DeprecateModel", + correlation_id=_CORRELATION_ID, + causation_id=None, + principal_id=_PRINCIPAL_ID, + ) + _, current_version = await deps.event_store.load("Model", model_id) + await deps.event_store.append( + stream_type="Model", + stream_id=model_id, + expected_version=current_version, + events=[new_event], + ) + + +@pytest.mark.contract +def test_post_version_model_returns_204_from_defined_state(accept_family: UUID) -> None: + """First revision (Defined -> Versioned).""" + _ = accept_family + with TestClient(create_app()) as client: + model_id = _seed_model(client) + response = client.post( + f"/models/{model_id}/versions", + json=_version_body(), + ) + assert response.status_code == 204 + assert response.content == b"" + + +@pytest.mark.contract +def test_post_version_model_returns_204_from_versioned_state(accept_family: UUID) -> None: + """Subsequent revision (Versioned -> Versioned).""" + _ = accept_family + with TestClient(create_app()) as client: + model_id = _seed_model(client) + first = client.post(f"/models/{model_id}/versions", json=_version_body(version_tag="v1")) + assert first.status_code == 204 + second = client.post(f"/models/{model_id}/versions", json=_version_body(version_tag="v2")) + assert second.status_code == 204 + + +@pytest.mark.contract +def test_post_version_model_returns_404_when_model_does_not_exist(accept_family: UUID) -> None: + _ = accept_family + missing_id = str(uuid4()) + with TestClient(create_app()) as client: + response = client.post(f"/models/{missing_id}/versions", json=_version_body()) + assert response.status_code == 404 + + +@pytest.mark.contract +async def test_post_version_model_returns_409_when_deprecated(accept_family: UUID) -> None: + """Deprecated Models cannot be re-versioned.""" + _ = accept_family + with TestClient(create_app()) as client: + model_id = _seed_model(client) + await _append_deprecated_event(client.app, model_id) # type: ignore[arg-type] + response = client.post(f"/models/{model_id}/versions", json=_version_body()) + assert response.status_code == 409 + assert "Deprecated" in response.json()["detail"] + + +@pytest.mark.contract +def test_post_version_model_missing_required_field_returns_422(accept_family: UUID) -> None: + """Pydantic schema validation: missing `version_tag`.""" + _ = accept_family + with TestClient(create_app()) as client: + model_id = _seed_model(client) + body = _version_body() + del body["version_tag"] + response = client.post(f"/models/{model_id}/versions", json=body) + assert response.status_code == 422 + + +@pytest.mark.contract +def test_post_version_model_empty_declared_families_returns_422(accept_family: UUID) -> None: + """Pydantic `min_length=1` on `declared_families`.""" + _ = accept_family + with TestClient(create_app()) as client: + model_id = _seed_model(client) + body = _version_body(declared_families=[]) + response = client.post(f"/models/{model_id}/versions", json=body) + assert response.status_code == 422 + + +@pytest.mark.contract +def test_post_version_model_whitespace_only_name_returns_400(accept_family: UUID) -> None: + """Domain `InvalidModelNameError` after Pydantic min_length=1 passes.""" + _ = accept_family + with TestClient(create_app()) as client: + model_id = _seed_model(client) + response = client.post( + f"/models/{model_id}/versions", + json=_version_body(name=" "), + ) + assert response.status_code == 400 + assert "name" in response.json()["detail"].lower() + + +@pytest.mark.contract +def test_post_version_model_rejects_invalid_path_uuid_with_422(accept_family: UUID) -> None: + _ = accept_family + with TestClient(create_app()) as client: + response = client.post("/models/not-a-uuid/versions", json=_version_body()) + assert response.status_code == 422 diff --git a/apps/api/tests/contract/test_version_model_mcp_tool.py b/apps/api/tests/contract/test_version_model_mcp_tool.py new file mode 100644 index 000000000..b604ba113 --- /dev/null +++ b/apps/api/tests/contract/test_version_model_mcp_tool.py @@ -0,0 +1,105 @@ +"""Contract tests for the `version_model` MCP tool. + +In-memory contract harness has no Postgres pool, so happy-path +versioning end-to-end requires seeding a Model first. We exercise: +- tool registration (the tool appears in `tools/list`) +- description matches the wholesale-replacement spec +- missing-argument call surfaces `isError: true` +- unknown `model_id` surfaces `ModelNotFoundError` as `isError: true` +""" + +from uuid import 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 + + +@pytest.mark.contract +def test_mcp_lists_version_model_tool() -> None: + with TestClient(create_app()) as client: + session_headers = open_session(client) + response = client.post( + "/mcp", + json={"jsonrpc": "2.0", "id": 2, "method": "tools/list"}, + headers=session_headers, + ) + assert response.status_code == 200 + body = parse_sse_data(response.text) + tool_names = [t["name"] for t in body["result"]["tools"]] + assert "version_model" in tool_names + + +@pytest.mark.contract +def test_mcp_version_model_tool_description_matches_replacement_spec() -> None: + with TestClient(create_app()) as client: + session_headers = open_session(client) + response = client.post( + "/mcp", + json={"jsonrpc": "2.0", "id": 3, "method": "tools/list"}, + headers=session_headers, + ) + body = parse_sse_data(response.text) + tools_by_name = {t["name"]: t for t in body["result"]["tools"]} + version_model = tools_by_name["version_model"] + description = version_model["description"] + assert "vendor-catalog Model" in description + assert "Deprecated" in description + assert "REPLACE" in description + + +@pytest.mark.contract +def test_mcp_version_model_tool_rejects_missing_argument() -> None: + with TestClient(create_app()) as client: + session_headers = open_session(client) + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 5, + "method": "tools/call", + "params": { + "name": "version_model", + "arguments": {}, + }, + }, + headers=session_headers, + ) + assert response.status_code == 200 + body = parse_sse_data(response.text) + assert body["result"]["isError"] is True + + +@pytest.mark.contract +def test_mcp_version_model_tool_returns_iserror_for_unknown_model() -> None: + unknown_id = str(uuid4()) + family_id = str(uuid4()) + with TestClient(create_app()) as client: + session_headers = open_session(client) + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 6, + "method": "tools/call", + "params": { + "name": "version_model", + "arguments": { + "model_id": unknown_id, + "name": "ANT130-L rev-B", + "manufacturer": {"name": "Aerotech"}, + "part_number": "ANT130-L-B", + "declared_families": [family_id], + "version_tag": "v2", + }, + }, + }, + headers=session_headers, + ) + assert response.status_code == 200 + body = parse_sse_data(response.text) + result = body["result"] + assert result["isError"] is True + assert "not found" in result["content"][0]["text"].lower() diff --git a/apps/api/tests/integration/test_add_model_family_handler_postgres.py b/apps/api/tests/integration/test_add_model_family_handler_postgres.py new file mode 100644 index 000000000..0420424ff --- /dev/null +++ b/apps/api/tests/integration/test_add_model_family_handler_postgres.py @@ -0,0 +1,311 @@ +"""End-to-end integration test: add_model_family against real Postgres. + +Round-trip: define Families, define Model, add a second Family to the +Model's declared_families set, and read the events back from the event +store. Verifies the ModelFamilyAdded payload shape, the cross-BC +`list_family_ids` lookup against the real `proj_equipment_family_summary` +projection (404 path on a missing Family id), and the +strict-not-idempotent guard (re-adding a present family raises). +""" + +from datetime import UTC, datetime +from uuid import UUID + +import asyncpg +import pytest + +from cora.equipment._projections import register_equipment_projections +from cora.equipment.aggregates.family import FamilyNotFoundError +from cora.equipment.aggregates.model import ( + Manufacturer, + ManufacturerName, + ModelFamilyAlreadyPresentError, + fold, + from_stored, +) +from cora.equipment.features import ( + add_model_family, + define_family, + define_model, + deprecate_family, +) +from cora.equipment.features.add_model_family import AddModelFamily +from cora.equipment.features.define_family import DefineFamily +from cora.equipment.features.define_model import DefineModel +from cora.equipment.features.deprecate_family import DeprecateFamily +from cora.infrastructure.projection import ProjectionRegistry, drain_projections +from tests.integration._helpers import build_postgres_deps + +_NOW = datetime(2026, 6, 1, 12, 0, 0, tzinfo=UTC) +_PRINCIPAL_ID = UUID("01900000-0000-7000-8000-000000000099") +_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000aa") + + +async def _drain_equipment_projections(db_pool: asyncpg.Pool) -> None: + """Flush FamilyDefined rows into `proj_equipment_family_summary` so + the Family read repo called by `add_model_family.handler` sees the + seed.""" + registry = ProjectionRegistry() + register_equipment_projections(registry) + await drain_projections(db_pool, registry, deadline_seconds=2.0) + + +@pytest.mark.integration +async def test_add_model_family_persists_event_with_payload( + db_pool: asyncpg.Pool, +) -> None: + """Happy path: seed two Families, define a Model declaring one, + add the other via add_model_family. Verify ModelFamilyAdded is + persisted with the expected payload shape and fold reflects the + expanded declared_families set.""" + family_a_id = UUID("01900000-0000-7000-8000-00000061d001") + family_a_event_id = UUID("01900000-0000-7000-8000-00000061d00e") + family_b_id = UUID("01900000-0000-7000-8000-00000061d002") + family_b_event_id = UUID("01900000-0000-7000-8000-00000061d00f") + model_id = UUID("01900000-0000-7000-8000-00000061ca01") + define_event_id = UUID("01900000-0000-7000-8000-00000061ca0e") + added_event_id = UUID("01900000-0000-7000-8000-00000061ca1a") + + deps = build_postgres_deps( + db_pool, + now=_NOW, + ids=[ + family_a_id, + family_a_event_id, + family_b_id, + family_b_event_id, + model_id, + define_event_id, + added_event_id, + ], + ) + await define_family.bind(deps)( + DefineFamily(name="ContinuousRotationTomography", affordances=frozenset()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await define_family.bind(deps)( + DefineFamily(name="StepScanTomography", affordances=frozenset()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await _drain_equipment_projections(db_pool) + + await define_model.bind(deps)( + DefineModel( + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({family_a_id}), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + await add_model_family.bind(deps)( + AddModelFamily(model_id=model_id, family_id=family_b_id), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + events, version = await deps.event_store.load("Model", model_id) + assert version == 2 + assert [e.event_type for e in events] == ["ModelDefined", "ModelFamilyAdded"] + added = events[1] + assert added.event_id == added_event_id + assert added.metadata == {"command": "AddModelFamily"} + assert added.payload == { + "model_id": str(model_id), + "family_id": str(family_b_id), + "occurred_at": _NOW.isoformat(), + } + + # State round-trip via fold confirms the targeted mutation. + history = [from_stored(s) for s in events] + state = fold(history) + assert state is not None + assert state.declared_families == frozenset({family_a_id, family_b_id}) + + +@pytest.mark.integration +async def test_add_model_family_rejects_unregistered_family_id( + db_pool: asyncpg.Pool, +) -> None: + """Cross-BC family_lookup: adding a Family that has never been + registered raises `FamilyNotFoundError` before the decider sees the + command. Real PG lookup against `proj_equipment_family_summary`.""" + family_id = UUID("01900000-0000-7000-8000-00000061e001") + family_event_id = UUID("01900000-0000-7000-8000-00000061e00e") + model_id = UUID("01900000-0000-7000-8000-00000061ca21") + define_event_id = UUID("01900000-0000-7000-8000-00000061ca2e") + # The add_model_family call rejects before consuming any id; queue + # an extra to be safe. + unused_add_event_id = UUID("01900000-0000-7000-8000-00000061ca3a") + missing_family_id = UUID("01900000-0000-7000-8000-0000000bad21") + + deps = build_postgres_deps( + db_pool, + now=_NOW, + ids=[family_id, family_event_id, model_id, define_event_id, unused_add_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="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({family_id}), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + with pytest.raises(FamilyNotFoundError) as exc_info: + await add_model_family.bind(deps)( + AddModelFamily(model_id=model_id, family_id=missing_family_id), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert exc_info.value.family_id == missing_family_id + + # No new event was written on the rejected command. + _, version = await deps.event_store.load("Model", model_id) + assert version == 1 + + +@pytest.mark.integration +async def test_add_model_family_rejects_duplicate_family( + db_pool: asyncpg.Pool, +) -> None: + """Strict-not-idempotent: re-adding a family already in + declared_families raises `ModelFamilyAlreadyPresentError` and writes + no new event.""" + family_id = UUID("01900000-0000-7000-8000-00000061f001") + family_event_id = UUID("01900000-0000-7000-8000-00000061f00e") + model_id = UUID("01900000-0000-7000-8000-00000061ca41") + define_event_id = UUID("01900000-0000-7000-8000-00000061ca4e") + unused_add_event_id = UUID("01900000-0000-7000-8000-00000061ca5a") + + deps = build_postgres_deps( + db_pool, + now=_NOW, + ids=[family_id, family_event_id, model_id, define_event_id, unused_add_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="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({family_id}), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + with pytest.raises(ModelFamilyAlreadyPresentError) as exc_info: + await add_model_family.bind(deps)( + AddModelFamily(model_id=model_id, family_id=family_id), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert exc_info.value.model_id == model_id + assert exc_info.value.family_id == family_id + + _, version = await deps.event_store.load("Model", model_id) + assert version == 1 + + +@pytest.mark.integration +async def test_add_model_family_succeeds_when_family_is_deprecated( + db_pool: asyncpg.Pool, +) -> None: + """Family.deprecation is an authoring signal NOT a runtime gate + per the Model aggregate's design memo. Seed two Families, deprecate + the second, define a Model declaring only the first, then add the + deprecated Family. The handler's cross-BC lookup goes through + `list_all_family_ids` which INCLUDES Deprecated rows, so the call + proceeds to event-write without raising `FamilyNotFoundError`.""" + family_a_id = UUID("01900000-0000-7000-8000-00000062d001") + family_a_event_id = UUID("01900000-0000-7000-8000-00000062d00e") + family_b_id = UUID("01900000-0000-7000-8000-00000062d002") + family_b_event_id = UUID("01900000-0000-7000-8000-00000062d00f") + family_b_deprecate_event_id = UUID("01900000-0000-7000-8000-00000062d01a") + model_id = UUID("01900000-0000-7000-8000-00000062ca01") + define_event_id = UUID("01900000-0000-7000-8000-00000062ca0e") + added_event_id = UUID("01900000-0000-7000-8000-00000062ca1a") + + deps = build_postgres_deps( + db_pool, + now=_NOW, + ids=[ + family_a_id, + family_a_event_id, + family_b_id, + family_b_event_id, + family_b_deprecate_event_id, + model_id, + define_event_id, + added_event_id, + ], + ) + await define_family.bind(deps)( + DefineFamily(name="ContinuousRotationTomography", affordances=frozenset()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await define_family.bind(deps)( + DefineFamily(name="LegacyStepScan", affordances=frozenset()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await deprecate_family.bind(deps)( + DeprecateFamily(family_id=family_b_id), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await _drain_equipment_projections(db_pool) + + await define_model.bind(deps)( + DefineModel( + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({family_a_id}), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + await add_model_family.bind(deps)( + AddModelFamily(model_id=model_id, family_id=family_b_id), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + events, version = await deps.event_store.load("Model", model_id) + assert version == 2 + assert [e.event_type for e in events] == ["ModelDefined", "ModelFamilyAdded"] + added = events[1] + assert added.payload == { + "model_id": str(model_id), + "family_id": str(family_b_id), + "occurred_at": _NOW.isoformat(), + } + + history = [from_stored(s) for s in events] + state = fold(history) + assert state is not None + assert state.declared_families == frozenset({family_a_id, family_b_id}) diff --git a/apps/api/tests/integration/test_define_model_handler_postgres.py b/apps/api/tests/integration/test_define_model_handler_postgres.py new file mode 100644 index 000000000..933c4d7d2 --- /dev/null +++ b/apps/api/tests/integration/test_define_model_handler_postgres.py @@ -0,0 +1,324 @@ +"""End-to-end integration test: define_model handler against real Postgres. + +Pinned: +- Happy path: ModelDefined round-trips through jsonb with the + manufacturer sub-dict, sorted declared_families UUID list, and the + optional version_tag dropped when None. +- Cross-BC family_lookup: define_model loads the Family read repo + (`list_family_ids`) against the real `proj_equipment_family_summary` + projection before invoking the decider, so an unregistered family id + surfaces `FamilyNotFoundError` (404), and a registered family id + proceeds to event-write. +- Idempotency: the wired `IdempotentHandler` (`define_model` slice in + `wire_equipment`) round-trips the Brandur envelope at the storage + tier: same Idempotency-Key plus same command body returns the same + model_id without writing a second Model stream. +""" + +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +import asyncpg +import pytest + +from cora.equipment._projections import register_equipment_projections +from cora.equipment.aggregates.family import FamilyNotFoundError +from cora.equipment.aggregates.model import ( + Manufacturer, + ManufacturerIdentifier, + ManufacturerIdentifierType, + ManufacturerName, +) +from cora.equipment.features import define_family, define_model, deprecate_family +from cora.equipment.features.define_family import DefineFamily +from cora.equipment.features.define_model import DefineModel +from cora.equipment.features.deprecate_family import DeprecateFamily +from cora.equipment.wire import wire_equipment +from cora.infrastructure.projection import ProjectionRegistry, drain_projections +from tests.integration._helpers import build_postgres_deps + +_NOW = datetime(2026, 5, 31, 12, 0, 0, tzinfo=UTC) +_PRINCIPAL_ID = UUID("01900000-0000-7000-8000-000000000099") +_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000aa") + + +async def _drain_equipment_projections(db_pool: asyncpg.Pool) -> None: + """Pump the Equipment-owned projections to flush `FamilyDefined` + rows into `proj_equipment_family_summary`. The Family read repo + (`list_family_ids`) called by `define_model.handler` queries this + projection, so an upstream `define_family` write is only visible + to the next `define_model` after a drain. + """ + registry = ProjectionRegistry() + register_equipment_projections(registry) + await drain_projections(db_pool, registry, deadline_seconds=2.0) + + +@pytest.mark.integration +async def test_define_model_persists_event_to_postgres( + db_pool: asyncpg.Pool, +) -> None: + """Happy path: register a Family, define a Model declaring it, + read the events back from the event store, and verify ModelDefined + is persisted with the expected payload shape (sorted + declared_families, no version_tag key when None).""" + family_id = UUID("01900000-0000-7000-8000-000000054c01") + family_event_id = UUID("01900000-0000-7000-8000-000000054c0e") + model_id = UUID("01900000-0000-7000-8000-00000054ca01") + model_event_id = UUID("01900000-0000-7000-8000-00000054ca0e") + + deps = build_postgres_deps( + db_pool, + now=_NOW, + ids=[family_id, family_event_id, model_id, model_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) + + returned_id = await define_model.bind(deps)( + DefineModel( + name="Aerotech ANT130-L", + manufacturer=Manufacturer( + name=ManufacturerName("Aerotech"), + identifier=ManufacturerIdentifier("https://ror.org/02jbv0t02"), + identifier_type=ManufacturerIdentifierType.ROR, + ), + part_number="ANT130-L", + declared_families=frozenset({family_id}), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert returned_id == model_id + + events, version = await deps.event_store.load("Model", model_id) + assert version == 1 + assert len(events) == 1 + stored = events[0] + assert stored.event_type == "ModelDefined" + assert stored.schema_version == 1 + assert stored.payload == { + "model_id": str(model_id), + "name": "Aerotech ANT130-L", + "manufacturer": { + "name": "Aerotech", + "identifier": "https://ror.org/02jbv0t02", + "identifier_type": "ROR", + }, + "part_number": "ANT130-L", + # Sorted by UUID string form (deterministic). Pinned by + # tests/unit/equipment/test_model_events.py. + "declared_families": [str(family_id)], + # version_tag is omitted from payload when None (per to_payload). + "occurred_at": _NOW.isoformat(), + } + assert stored.correlation_id == _CORRELATION_ID + assert stored.causation_id is None + assert stored.event_id == model_event_id + assert stored.metadata == {"command": "DefineModel"} + assert stored.occurred_at == _NOW + + +@pytest.mark.integration +async def test_define_model_rejects_unregistered_family_id( + db_pool: asyncpg.Pool, +) -> None: + """Cross-BC family_lookup: defining a Model with a Family id that + has never been registered raises `FamilyNotFoundError`. Real PG + lookup against `proj_equipment_family_summary`; no Family seeded.""" + model_id = UUID("01900000-0000-7000-8000-00000054ca02") + model_event_id = UUID("01900000-0000-7000-8000-00000054ca0f") + missing_family_id = UUID("01900000-0000-7000-8000-0000000bad01") + + deps = build_postgres_deps(db_pool, now=_NOW, ids=[model_id, model_event_id]) + + with pytest.raises(FamilyNotFoundError) as exc_info: + await define_model.bind(deps)( + DefineModel( + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({missing_family_id}), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert exc_info.value.family_id == missing_family_id + + # No Model stream was written on the rejected command. + _, version = await deps.event_store.load("Model", model_id) + assert version == 0 + + +@pytest.mark.integration +async def test_define_model_proceeds_when_family_is_registered( + db_pool: asyncpg.Pool, +) -> None: + """Cross-BC family_lookup success: a Family seeded via + `define_family` plus a projection drain resolves through + `list_family_ids`, and `define_model` proceeds to event-write.""" + family_id = UUID("01900000-0000-7000-8000-000000054d01") + family_event_id = UUID("01900000-0000-7000-8000-000000054d0e") + model_id = UUID("01900000-0000-7000-8000-00000054ca03") + model_event_id = UUID("01900000-0000-7000-8000-00000054ca1a") + + deps = build_postgres_deps( + db_pool, + now=_NOW, + ids=[family_id, family_event_id, model_id, model_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) + + returned_id = await define_model.bind(deps)( + DefineModel( + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({family_id}), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert returned_id == model_id + + events, version = await deps.event_store.load("Model", model_id) + assert version == 1 + assert events[0].event_type == "ModelDefined" + assert events[0].payload["declared_families"] == [str(family_id)] + + +@pytest.mark.integration +async def test_define_model_idempotency_key_replay_returns_same_model_id( + db_pool: asyncpg.Pool, +) -> None: + """Same Idempotency-Key plus same command body returns the same + model_id without writing a second Model stream. Storage-cardinality + pin against the Brandur cache-miss regression class.""" + family_id = UUID("01900000-0000-7000-8000-000000054e01") + family_event_id = UUID("01900000-0000-7000-8000-000000054e0e") + first_model_id = UUID("01900000-0000-7000-8000-00000054ca21") + first_event_id = UUID("01900000-0000-7000-8000-00000054ca2e") + # The second model_id is queued but never consumed: the Brandur + # cache hit short-circuits before `id_generator.new_id()` runs on + # the replay. The id sits unclaimed at the end of the test. + unused_replay_model_id = UUID("01900000-0000-7000-8000-00000054ca31") + unused_replay_event_id = UUID("01900000-0000-7000-8000-00000054ca3e") + + deps = build_postgres_deps( + db_pool, + now=_NOW, + ids=[ + family_id, + family_event_id, + first_model_id, + first_event_id, + unused_replay_model_id, + unused_replay_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) + + handler = wire_equipment(deps).define_model + idempotency_key = f"ck-define-model-{uuid4().hex[:8]}" + cmd = DefineModel( + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({family_id}), + ) + + first_id = await handler( + cmd, + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + idempotency_key=idempotency_key, + ) + second_id = await handler( + cmd, + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + idempotency_key=idempotency_key, + ) + + # Same model_id replayed via Brandur cache. + assert first_id == second_id + assert first_id == first_model_id + + # Exactly one Model stream exists, with exactly one ModelDefined event. + _, version = await deps.event_store.load("Model", first_model_id) + assert version == 1 + # The "second" queued model_id was never written. + _, second_version = await deps.event_store.load("Model", unused_replay_model_id) + assert second_version == 0 + + +@pytest.mark.integration +async def test_define_model_succeeds_when_declared_family_is_deprecated( + db_pool: asyncpg.Pool, +) -> None: + """Family.deprecation is an authoring signal NOT a runtime gate + per the Model aggregate's design memo. A Family that has been + deprecated still resolves through `list_all_family_ids` (which + drops the `WHERE deprecated_at IS NULL` filter that + `list_family_ids` enforces for the discovery path), so + `define_model` proceeds to event-write without raising + `FamilyNotFoundError`.""" + family_id = UUID("01900000-0000-7000-8000-000000054f01") + family_event_id = UUID("01900000-0000-7000-8000-000000054f0e") + deprecate_event_id = UUID("01900000-0000-7000-8000-000000054f1a") + model_id = UUID("01900000-0000-7000-8000-00000054ca04") + model_event_id = UUID("01900000-0000-7000-8000-00000054ca2a") + + deps = build_postgres_deps( + db_pool, + now=_NOW, + ids=[ + family_id, + family_event_id, + deprecate_event_id, + model_id, + model_event_id, + ], + ) + await define_family.bind(deps)( + DefineFamily(name="LegacyTomography", affordances=frozenset()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await deprecate_family.bind(deps)( + DeprecateFamily(family_id=family_id), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await _drain_equipment_projections(db_pool) + + returned_id = await define_model.bind(deps)( + DefineModel( + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({family_id}), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert returned_id == model_id + + events, version = await deps.event_store.load("Model", model_id) + assert version == 1 + assert events[0].event_type == "ModelDefined" + assert events[0].payload["declared_families"] == [str(family_id)] diff --git a/apps/api/tests/integration/test_deprecate_model_handler_postgres.py b/apps/api/tests/integration/test_deprecate_model_handler_postgres.py new file mode 100644 index 000000000..df5ad29c6 --- /dev/null +++ b/apps/api/tests/integration/test_deprecate_model_handler_postgres.py @@ -0,0 +1,195 @@ +"""End-to-end integration test: deprecate_model against real Postgres. + +Round-trip: define Family, define Model, deprecate Model, and read the +events back from the event store. Verifies the ModelDeprecated payload +shape (model_id, trimmed reason, occurred_at), the multi-source guard +(ModelNotFoundError on a missing stream), and the strict-not-idempotent +re-deprecate rejection (ModelCannotDeprecateError). +""" + +from datetime import UTC, datetime +from uuid import UUID + +import asyncpg +import pytest + +from cora.equipment._projections import register_equipment_projections +from cora.equipment.aggregates.model import ( + Manufacturer, + ManufacturerName, + ModelCannotDeprecateError, + ModelNotFoundError, + ModelStatus, + fold, + from_stored, +) +from cora.equipment.features import define_family, define_model, deprecate_model +from cora.equipment.features.define_family import DefineFamily +from cora.equipment.features.define_model import DefineModel +from cora.equipment.features.deprecate_model import DeprecateModel +from cora.infrastructure.projection import ProjectionRegistry, drain_projections +from tests.integration._helpers import build_postgres_deps + +_NOW = datetime(2026, 6, 1, 12, 0, 0, tzinfo=UTC) +_PRINCIPAL_ID = UUID("01900000-0000-7000-8000-000000000099") +_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000aa") +_REASON = "Vendor end-of-life 2026-Q3" + + +async def _drain_equipment_projections(db_pool: asyncpg.Pool) -> None: + """Flush FamilyDefined into `proj_equipment_family_summary` so the + Family read repo called by `define_model.handler` sees the seed.""" + registry = ProjectionRegistry() + register_equipment_projections(registry) + await drain_projections(db_pool, registry, deadline_seconds=2.0) + + +@pytest.mark.integration +async def test_deprecate_model_persists_event_with_full_payload( + db_pool: asyncpg.Pool, +) -> None: + """Happy path: seed Family + define Model + deprecate Model. Verify + ModelDeprecated is persisted with the trimmed reason payload and the + state folds to Deprecated.""" + family_id = UUID("01900000-0000-7000-8000-00000061d001") + family_event_id = UUID("01900000-0000-7000-8000-00000061d00e") + model_id = UUID("01900000-0000-7000-8000-00000061ca01") + define_event_id = UUID("01900000-0000-7000-8000-00000061ca0e") + deprecate_event_id = UUID("01900000-0000-7000-8000-00000061ca1a") + + deps = build_postgres_deps( + db_pool, + now=_NOW, + ids=[ + family_id, + family_event_id, + model_id, + define_event_id, + deprecate_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="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({family_id}), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + await deprecate_model.bind(deps)( + DeprecateModel(model_id=model_id, reason=_REASON), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + events, version = await deps.event_store.load("Model", model_id) + assert version == 2 + assert [e.event_type for e in events] == ["ModelDefined", "ModelDeprecated"] + deprecated = events[1] + assert deprecated.event_id == deprecate_event_id + assert deprecated.metadata == {"command": "DeprecateModel"} + assert deprecated.payload == { + "model_id": str(model_id), + "reason": _REASON, + "occurred_at": _NOW.isoformat(), + } + + # State round-trip via fold confirms the Deprecated status. + history = [from_stored(s) for s in events] + state = fold(history) + assert state is not None + assert state.status is ModelStatus.DEPRECATED + + +@pytest.mark.integration +async def test_deprecate_model_raises_not_found_for_unknown_id( + db_pool: asyncpg.Pool, +) -> None: + """Deprecating a model whose stream has no events raises ModelNotFoundError.""" + missing_id = UUID("01900000-0000-7000-8000-0000000bad12") + deprecate_event_id = UUID("01900000-0000-7000-8000-0000000bad1e") + + deps = build_postgres_deps(db_pool, now=_NOW, ids=[deprecate_event_id]) + + with pytest.raises(ModelNotFoundError) as exc_info: + await deprecate_model.bind(deps)( + DeprecateModel(model_id=missing_id, reason=_REASON), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert exc_info.value.model_id == missing_id + + _, version = await deps.event_store.load("Model", missing_id) + assert version == 0 + + +@pytest.mark.integration +async def test_deprecate_model_raises_cannot_deprecate_after_first_deprecation( + db_pool: asyncpg.Pool, +) -> None: + """Strict-not-idempotent: re-deprecating raises ModelCannotDeprecateError + and no new event is written.""" + family_id = UUID("01900000-0000-7000-8000-00000061e001") + family_event_id = UUID("01900000-0000-7000-8000-00000061e00e") + model_id = UUID("01900000-0000-7000-8000-00000061ca21") + define_event_id = UUID("01900000-0000-7000-8000-00000061ca2e") + deprecate_event_id = UUID("01900000-0000-7000-8000-00000061ca2f") + # The second deprecate_model call lands on the disallowed source and + # rejects before consuming any id; queue an extra to be safe. + unused_event_id = UUID("01900000-0000-7000-8000-00000061ca3a") + + deps = build_postgres_deps( + db_pool, + now=_NOW, + ids=[ + family_id, + family_event_id, + model_id, + define_event_id, + deprecate_event_id, + unused_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="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({family_id}), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + await deprecate_model.bind(deps)( + DeprecateModel(model_id=model_id, reason=_REASON), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + with pytest.raises(ModelCannotDeprecateError): + await deprecate_model.bind(deps)( + DeprecateModel(model_id=model_id, reason="another reason"), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + _, version = await deps.event_store.load("Model", model_id) + assert version == 2 diff --git a/apps/api/tests/integration/test_get_model_handler_postgres.py b/apps/api/tests/integration/test_get_model_handler_postgres.py new file mode 100644 index 000000000..8189f829a --- /dev/null +++ b/apps/api/tests/integration/test_get_model_handler_postgres.py @@ -0,0 +1,160 @@ +"""Integration test: get_model handler against real Postgres. + +End-to-end: seed a Family + define a Model declaring it + version the +Model + add a second Family via add_model_family + GET back. Verifies +the handler folds the full event-stream history (Defined + Versioned ++ FamilyAdded) and returns the post-mutation Model state. + +Projection-row contents are NOT verified here (that's the projection +unit test's job). The GET endpoint loads via the event-store fold, +not the projection; this test pins that fold path against real +Postgres. +""" + +from datetime import UTC, datetime +from uuid import UUID + +import asyncpg +import pytest + +from cora.equipment._projections import register_equipment_projections +from cora.equipment.aggregates.model import ( + Manufacturer, + ManufacturerName, + ModelName, + ModelStatus, + PartNumber, +) +from cora.equipment.features import ( + add_model_family, + define_family, + define_model, + get_model, + version_model, +) +from cora.equipment.features.add_model_family import AddModelFamily +from cora.equipment.features.define_family import DefineFamily +from cora.equipment.features.define_model import DefineModel +from cora.equipment.features.get_model import GetModel +from cora.equipment.features.version_model import VersionModel +from cora.infrastructure.projection import ProjectionRegistry, drain_projections +from tests.integration._helpers import build_postgres_deps + +_NOW = datetime(2026, 6, 1, 12, 0, 0, tzinfo=UTC) +_PRINCIPAL_ID = UUID("01900000-0000-7000-8000-000000000099") +_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000aa") + + +async def _drain_equipment_projections(db_pool: asyncpg.Pool) -> None: + """Flush FamilyDefined rows into `proj_equipment_family_summary` so + the Family read repo called by `define_model.handler` and + `add_model_family.handler` sees the seed.""" + registry = ProjectionRegistry() + register_equipment_projections(registry) + await drain_projections(db_pool, registry, deadline_seconds=2.0) + + +@pytest.mark.integration +async def test_get_model_loads_full_history_from_real_postgres( + db_pool: asyncpg.Pool, +) -> None: + """Seed Family A + Family B, define Model declaring A, version + Model (wholesale identity replace), then add Family B via the + targeted-mutation slice. GET returns the post-mutation state: the + Versioned identity block plus the appended family.""" + family_a_id = UUID("01900000-0000-7000-8000-00000063d001") + family_a_event_id = UUID("01900000-0000-7000-8000-00000063d00e") + family_b_id = UUID("01900000-0000-7000-8000-00000063d002") + family_b_event_id = UUID("01900000-0000-7000-8000-00000063d00f") + model_id = UUID("01900000-0000-7000-8000-00000063ca01") + define_event_id = UUID("01900000-0000-7000-8000-00000063ca0e") + versioned_event_id = UUID("01900000-0000-7000-8000-00000063ca1a") + added_event_id = UUID("01900000-0000-7000-8000-00000063ca2b") + + deps = build_postgres_deps( + db_pool, + now=_NOW, + ids=[ + family_a_id, + family_a_event_id, + family_b_id, + family_b_event_id, + model_id, + define_event_id, + versioned_event_id, + added_event_id, + ], + ) + await define_family.bind(deps)( + DefineFamily(name="ContinuousRotationTomography", affordances=frozenset()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await define_family.bind(deps)( + DefineFamily(name="StepScanTomography", affordances=frozenset()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await _drain_equipment_projections(db_pool) + + await define_model.bind(deps)( + DefineModel( + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({family_a_id}), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + await version_model.bind(deps)( + VersionModel( + model_id=model_id, + name="Aerotech ANT130-LZS", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-LZS", + declared_families=frozenset({family_a_id}), + version_tag="v2", + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + await add_model_family.bind(deps)( + AddModelFamily(model_id=model_id, family_id=family_b_id), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + model = await get_model.bind(deps)( + GetModel(model_id=model_id), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + assert model is not None + assert model.id == model_id + # Versioned identity block (replaced wholesale on version_model). + assert model.name == ModelName("Aerotech ANT130-LZS") + assert model.part_number == PartNumber("ANT130-LZS") + assert model.status is ModelStatus.VERSIONED + assert model.version == "v2" + # Family B was appended via add_model_family after the version. + assert model.declared_families == frozenset({family_a_id, family_b_id}) + + +@pytest.mark.integration +async def test_get_model_returns_none_for_unknown_id_against_postgres( + db_pool: asyncpg.Pool, +) -> None: + """Missing stream against real PG event store folds to None.""" + missing_model_id = UUID("01900000-0000-7000-8000-00000063bad9") + deps = build_postgres_deps(db_pool, now=_NOW) + + model = await get_model.bind(deps)( + GetModel(model_id=missing_model_id), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert model is None diff --git a/apps/api/tests/integration/test_postgres_list_model_ids.py b/apps/api/tests/integration/test_postgres_list_model_ids.py new file mode 100644 index 000000000..0eecfcef7 --- /dev/null +++ b/apps/api/tests/integration/test_postgres_list_model_ids.py @@ -0,0 +1,182 @@ +"""End-to-end: `list_model_ids` against `proj_equipment_model_summary`. + +Pins the two SQL-tier contracts the read function carries: + + - Deprecated Models are excluded (`WHERE status <> 'Deprecated'`), + so future cross-BC candidate-enumeration callers do not surface + Deprecated Models as bindable sources. + - Returned ids are sorted by `model_id::text` ascending, so the + list is deterministic across calls regardless of insert order. + +Plus the empty-projection arm: zero rows in the summary projection +yields `[]`, matching the `pool=None` short-circuit pinned at unit +tier in `tests/unit/equipment/test_list_model_ids.py`. + +Sibling Kernel + pg_pool fixture pattern from +`test_postgres_model_summary_projection.py`. +""" + +# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false + +from datetime import UTC, datetime +from uuid import UUID + +import asyncpg +import pytest + +from cora.equipment.aggregates.model import ( + Manufacturer, + ManufacturerName, + list_model_ids, +) +from cora.equipment.features import ( + define_family, + define_model, + deprecate_model, +) +from cora.equipment.features.define_family import DefineFamily +from cora.equipment.features.define_model import DefineModel +from cora.equipment.features.deprecate_model import DeprecateModel +from tests.integration._equipment_helpers import drain_equipment_projections +from tests.integration._helpers import build_postgres_deps + +_NOW = datetime(2026, 6, 2, 12, 0, 0, tzinfo=UTC) +_PRINCIPAL_ID = UUID("01900000-0000-7000-8000-000000000099") +_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000aa") + + +@pytest.mark.integration +async def test_list_model_ids_excludes_deprecated_models( + db_pool: asyncpg.Pool, +) -> None: + """Seed 3 Models, deprecate 1, drain: `list_model_ids` returns the + 2 non-Deprecated ids in `model_id::text`-sorted ascending order. + Pins the `WHERE status <> 'Deprecated'` filter.""" + family_id = UUID("01900000-0000-7000-8000-0000000cd001") + family_event_id = UUID("01900000-0000-7000-8000-0000000cd00e") + model_a_id = UUID("01900000-0000-7000-8000-0000000cd0a1") + model_a_event_id = UUID("01900000-0000-7000-8000-0000000cd0ae") + model_b_id = UUID("01900000-0000-7000-8000-0000000cd0a2") + model_b_event_id = UUID("01900000-0000-7000-8000-0000000cd0af") + model_c_id = UUID("01900000-0000-7000-8000-0000000cd0a3") + model_c_event_id = UUID("01900000-0000-7000-8000-0000000cd0b0") + deprecate_event_id = UUID("01900000-0000-7000-8000-0000000cd0b1") + + deps = build_postgres_deps( + db_pool, + now=_NOW, + ids=[ + family_id, + family_event_id, + model_a_id, + model_a_event_id, + model_b_id, + model_b_event_id, + model_c_id, + model_c_event_id, + deprecate_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) + + for name, part_number in ( + ("Aerotech ANT130-L", "ANT130-L"), + ("Aerotech ANT130-LZS", "ANT130-LZS"), + ("Aerotech ANT95-L", "ANT95-L"), + ): + await define_model.bind(deps)( + DefineModel( + name=name, + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number=part_number, + declared_families=frozenset({family_id}), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await deprecate_model.bind(deps)( + DeprecateModel(model_id=model_b_id, reason="superseded"), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await drain_equipment_projections(db_pool) + + ids = await list_model_ids(db_pool) + expected = sorted([model_a_id, model_c_id], key=lambda u: str(u)) + assert ids == expected + + +@pytest.mark.integration +async def test_list_model_ids_returns_models_in_canonical_sort_order( + db_pool: asyncpg.Pool, +) -> None: + """Seed 3 Models, drain: `list_model_ids` returns ids sorted by + `model_id::text` ascending regardless of insert order. Pins the + `ORDER BY model_id::text` clause.""" + family_id = UUID("01900000-0000-7000-8000-0000000cd101") + family_event_id = UUID("01900000-0000-7000-8000-0000000cd10e") + # Insert order (c, a, b) differs from text-sort order (a, b, c) + # so an accidental "natural insertion order" implementation would + # not pass. + model_a_id = UUID("01900000-0000-7000-8000-0000000cd1a1") + model_a_event_id = UUID("01900000-0000-7000-8000-0000000cd1ae") + model_b_id = UUID("01900000-0000-7000-8000-0000000cd1a2") + model_b_event_id = UUID("01900000-0000-7000-8000-0000000cd1af") + model_c_id = UUID("01900000-0000-7000-8000-0000000cd1a3") + model_c_event_id = UUID("01900000-0000-7000-8000-0000000cd1b0") + + deps = build_postgres_deps( + db_pool, + now=_NOW, + ids=[ + family_id, + family_event_id, + model_c_id, + model_c_event_id, + model_a_id, + model_a_event_id, + model_b_id, + model_b_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) + + for name, part_number in ( + ("Aerotech ANT95-L", "ANT95-L"), + ("Aerotech ANT130-L", "ANT130-L"), + ("Aerotech ANT130-LZS", "ANT130-LZS"), + ): + await define_model.bind(deps)( + DefineModel( + name=name, + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number=part_number, + declared_families=frozenset({family_id}), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await drain_equipment_projections(db_pool) + + ids = await list_model_ids(db_pool) + expected = sorted([model_a_id, model_b_id, model_c_id], key=lambda u: str(u)) + assert ids == expected + + +@pytest.mark.integration +async def test_list_model_ids_returns_empty_when_no_models( + db_pool: asyncpg.Pool, +) -> None: + """Empty projection: `list_model_ids` returns `[]`. Matches the + `pool=None` short-circuit pinned at unit tier.""" + assert await list_model_ids(db_pool) == [] diff --git a/apps/api/tests/integration/test_postgres_model_summary_projection.py b/apps/api/tests/integration/test_postgres_model_summary_projection.py new file mode 100644 index 000000000..3b6cb39f3 --- /dev/null +++ b/apps/api/tests/integration/test_postgres_model_summary_projection.py @@ -0,0 +1,715 @@ +"""End-to-end: `proj_equipment_model_summary` against real Postgres. + +Exercises every Model-aggregate event handler in +`cora.equipment.projections.model.ModelSummaryProjection` against the +real projection table: + + - ModelDefined -> INSERT row with status=Defined, manufacturer + flat columns, sorted declared_families JSONB + - ModelVersioned -> UPDATE wholesale (name / manufacturer / + part_number / declared_families / version_tag) + with status=Versioned + - ModelDeprecated -> UPDATE status=Deprecated + deprecation_reason, + vendor-key columns preserved for audit + - ModelFamilyAdded -> UPDATE declared_families appending the new + family_id and re-sorting + - ModelFamilyRemoved -> UPDATE declared_families dropping the family_id + while preserving the sorted-array shape + +Plus the load-bearing fitness pin for the +20260602100000_drop_proj_equipment_model_summary_vendor_key_unique +migration: two define_model calls with the same +(manufacturer_name, part_number) but distinct model_id values BOTH +land in `proj_equipment_model_summary` without UniqueViolation, and +the projection bookmark advances past both events. This is the +regression class the migration exists to retire. +""" + +# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false + +import json +from datetime import UTC, datetime, timedelta +from uuid import UUID, uuid4 + +import asyncpg +import pytest + +from cora.equipment.aggregates.model import ( + Manufacturer, + ManufacturerIdentifier, + ManufacturerIdentifierType, + ManufacturerName, +) +from cora.equipment.features import ( + add_model_family, + define_family, + define_model, + deprecate_model, + remove_model_family, + version_model, +) +from cora.equipment.features.add_model_family import AddModelFamily +from cora.equipment.features.define_family import DefineFamily +from cora.equipment.features.define_model import DefineModel +from cora.equipment.features.deprecate_model import DeprecateModel +from cora.equipment.features.remove_model_family import RemoveModelFamily +from cora.equipment.features.version_model import VersionModel +from cora.equipment.projections.model import ModelSummaryProjection +from cora.infrastructure.ports.event_store import StoredEvent +from tests.integration._equipment_helpers import drain_equipment_projections +from tests.integration._helpers import build_postgres_deps + +_NOW = datetime(2026, 6, 2, 12, 0, 0, tzinfo=UTC) +_PRINCIPAL_ID = UUID("01900000-0000-7000-8000-000000000099") +_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000aa") + + +async def _fetch_summary( + pool: asyncpg.Pool, + model_id: UUID, +) -> asyncpg.Record | None: + async with pool.acquire() as conn: + return await conn.fetchrow( + """ + SELECT model_id, name, + manufacturer_name, manufacturer_identifier, + manufacturer_identifier_type, + part_number, declared_families, + status, version_tag, deprecation_reason + FROM proj_equipment_model_summary + WHERE model_id = $1 + """, + model_id, + ) + + +def _decode_jsonb_array(value: object) -> list[str]: + """Normalize a JSONB array column: asyncpg returns either a JSON + string or an already-parsed list depending on whether the codec + is bound; coerce to list[str] uniformly so assertions stay terse. + + Matches the both-shapes-tolerant pattern in + test_bootstrap_policy_seed_postgres.py.""" + decoded = json.loads(value) if isinstance(value, str) else value + assert isinstance(decoded, list) + return [str(elem) for elem in decoded] + + +async def _fetch_bookmark_position(pool: asyncpg.Pool) -> int: + """Return the bookmark's `last_position` (BIGINT, monotonic per + appended event) for the `proj_equipment_model_summary` row. Used + to pin "bookmark moved past head" after draining the post-fix + double-define case: if the second projection apply had raised + UniqueViolation, the worker batch would have rolled back and + `last_position` would stay pinned to the previous value (the + bookmark UPDATE shares the same transaction as the projection + writes).""" + async with pool.acquire() as conn: + value = await conn.fetchval( + "SELECT last_position FROM projection_bookmarks WHERE name = $1", + "proj_equipment_model_summary", + ) + return int(value) if value is not None else 0 + + +async def _fetch_model_defined_head(pool: asyncpg.Pool) -> int: + """Return MAX(position) across all `ModelDefined` events. The + bookmark must be at or past this value after a successful drain.""" + async with pool.acquire() as conn: + value = await conn.fetchval( + "SELECT MAX(position) FROM events WHERE event_type = $1", + "ModelDefined", + ) + return int(value) if value is not None else 0 + + +@pytest.mark.integration +async def test_model_defined_inserts_summary_row( + db_pool: asyncpg.Pool, +) -> None: + """ModelDefined arm: INSERT row with status=Defined, manufacturer + flat columns, sorted declared_families JSONB, version_tag from + payload when present.""" + family_id = UUID("01900000-0000-7000-8000-0000000ca701") + family_event_id = UUID("01900000-0000-7000-8000-0000000ca70e") + model_id = UUID("01900000-0000-7000-8000-0000000ca7a1") + model_event_id = UUID("01900000-0000-7000-8000-0000000ca7ae") + + deps = build_postgres_deps( + db_pool, + now=_NOW, + ids=[family_id, family_event_id, model_id, model_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="Aerotech ANT130-L", + manufacturer=Manufacturer( + name=ManufacturerName("Aerotech"), + identifier=ManufacturerIdentifier("https://ror.org/02jbv0t02"), + identifier_type=ManufacturerIdentifierType.ROR, + ), + part_number="ANT130-L", + declared_families=frozenset({family_id}), + version_tag="rev-A", + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await drain_equipment_projections(db_pool) + + row = await _fetch_summary(db_pool, model_id) + assert row is not None + assert row["name"] == "Aerotech ANT130-L" + assert row["manufacturer_name"] == "Aerotech" + assert row["manufacturer_identifier"] == "https://ror.org/02jbv0t02" + assert row["manufacturer_identifier_type"] == "ROR" + assert row["part_number"] == "ANT130-L" + assert _decode_jsonb_array(row["declared_families"]) == [str(family_id)] + assert row["status"] == "Defined" + assert row["version_tag"] == "rev-A" + assert row["deprecation_reason"] is None + + +@pytest.mark.integration +async def test_model_versioned_replaces_summary_wholesale( + db_pool: asyncpg.Pool, +) -> None: + """ModelVersioned arm: UPDATE wholesale replaces name, manufacturer + columns, part_number, declared_families JSONB, version_tag; status + flips to Versioned.""" + family_a_id = UUID("01900000-0000-7000-8000-0000000ca801") + family_a_event_id = UUID("01900000-0000-7000-8000-0000000ca80e") + family_b_id = UUID("01900000-0000-7000-8000-0000000ca802") + family_b_event_id = UUID("01900000-0000-7000-8000-0000000ca80f") + model_id = UUID("01900000-0000-7000-8000-0000000ca8a1") + define_event_id = UUID("01900000-0000-7000-8000-0000000ca8ae") + version_event_id = UUID("01900000-0000-7000-8000-0000000ca8af") + + deps = build_postgres_deps( + db_pool, + now=_NOW, + ids=[ + family_a_id, + family_a_event_id, + family_b_id, + family_b_event_id, + model_id, + define_event_id, + version_event_id, + ], + ) + await define_family.bind(deps)( + DefineFamily(name="ContinuousRotationTomography", affordances=frozenset()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await define_family.bind(deps)( + DefineFamily(name="StepScanTomography", affordances=frozenset()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await drain_equipment_projections(db_pool) + + await define_model.bind(deps)( + DefineModel( + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({family_a_id}), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await version_model.bind(deps)( + VersionModel( + model_id=model_id, + name="Aerotech ANT130-LZS", + manufacturer=Manufacturer( + name=ManufacturerName("Aerotech"), + identifier=ManufacturerIdentifier("https://ror.org/02jbv0t02"), + identifier_type=ManufacturerIdentifierType.ROR, + ), + part_number="ANT130-LZS", + declared_families=frozenset({family_a_id, family_b_id}), + version_tag="rev-B", + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await drain_equipment_projections(db_pool) + + row = await _fetch_summary(db_pool, model_id) + assert row is not None + assert row["name"] == "Aerotech ANT130-LZS" + assert row["manufacturer_name"] == "Aerotech" + assert row["manufacturer_identifier"] == "https://ror.org/02jbv0t02" + assert row["manufacturer_identifier_type"] == "ROR" + assert row["part_number"] == "ANT130-LZS" + sorted_families = sorted([str(family_a_id), str(family_b_id)]) + assert _decode_jsonb_array(row["declared_families"]) == sorted_families + assert row["status"] == "Versioned" + assert row["version_tag"] == "rev-B" + + +@pytest.mark.integration +async def test_model_deprecated_sets_reason_and_preserves_vendor_key( + db_pool: asyncpg.Pool, +) -> None: + """ModelDeprecated arm: UPDATE status=Deprecated + deprecation_reason; + vendor-key columns (manufacturer_name, part_number) and + declared_families preserved so the audit trail of "what was + deprecated" stays answerable.""" + family_id = UUID("01900000-0000-7000-8000-0000000ca901") + family_event_id = UUID("01900000-0000-7000-8000-0000000ca90e") + model_id = UUID("01900000-0000-7000-8000-0000000ca9a1") + define_event_id = UUID("01900000-0000-7000-8000-0000000ca9ae") + deprecate_event_id = UUID("01900000-0000-7000-8000-0000000ca9af") + + deps = build_postgres_deps( + db_pool, + now=_NOW, + ids=[ + family_id, + family_event_id, + model_id, + define_event_id, + deprecate_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="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({family_id}), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await deprecate_model.bind(deps)( + DeprecateModel(model_id=model_id, reason="superseded by ANT130-LZS"), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await drain_equipment_projections(db_pool) + + row = await _fetch_summary(db_pool, model_id) + assert row is not None + assert row["status"] == "Deprecated" + assert row["deprecation_reason"] == "superseded by ANT130-LZS" + assert row["manufacturer_name"] == "Aerotech" + assert row["part_number"] == "ANT130-L" + assert _decode_jsonb_array(row["declared_families"]) == [str(family_id)] + + +@pytest.mark.integration +async def test_model_family_added_appends_and_resorts( + db_pool: asyncpg.Pool, +) -> None: + """ModelFamilyAdded arm: declared_families gains family_id and is + re-sorted to match the canonical sorted-string-array shape that + event payloads carry.""" + family_a_id = UUID("01900000-0000-7000-8000-0000000caa01") + family_a_event_id = UUID("01900000-0000-7000-8000-0000000caa0e") + family_b_id = UUID("01900000-0000-7000-8000-0000000caa02") + family_b_event_id = UUID("01900000-0000-7000-8000-0000000caa0f") + model_id = UUID("01900000-0000-7000-8000-0000000caaa1") + define_event_id = UUID("01900000-0000-7000-8000-0000000caaae") + added_event_id = UUID("01900000-0000-7000-8000-0000000caaaf") + + deps = build_postgres_deps( + db_pool, + now=_NOW, + ids=[ + family_a_id, + family_a_event_id, + family_b_id, + family_b_event_id, + model_id, + define_event_id, + added_event_id, + ], + ) + await define_family.bind(deps)( + DefineFamily(name="ContinuousRotationTomography", affordances=frozenset()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await define_family.bind(deps)( + DefineFamily(name="StepScanTomography", affordances=frozenset()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await drain_equipment_projections(db_pool) + + await define_model.bind(deps)( + DefineModel( + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({family_a_id}), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await add_model_family.bind(deps)( + AddModelFamily(model_id=model_id, family_id=family_b_id), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await drain_equipment_projections(db_pool) + + row = await _fetch_summary(db_pool, model_id) + assert row is not None + sorted_families = sorted([str(family_a_id), str(family_b_id)]) + assert _decode_jsonb_array(row["declared_families"]) == sorted_families + + +@pytest.mark.integration +async def test_model_family_removed_drops_and_preserves_sort( + db_pool: asyncpg.Pool, +) -> None: + """ModelFamilyRemoved arm: declared_families loses family_id while + the remaining elements keep canonical sort order.""" + family_a_id = UUID("01900000-0000-7000-8000-0000000cab01") + family_a_event_id = UUID("01900000-0000-7000-8000-0000000cab0e") + family_b_id = UUID("01900000-0000-7000-8000-0000000cab02") + family_b_event_id = UUID("01900000-0000-7000-8000-0000000cab0f") + model_id = UUID("01900000-0000-7000-8000-0000000caba1") + define_event_id = UUID("01900000-0000-7000-8000-0000000cabae") + removed_event_id = UUID("01900000-0000-7000-8000-0000000cabaf") + + deps = build_postgres_deps( + db_pool, + now=_NOW, + ids=[ + family_a_id, + family_a_event_id, + family_b_id, + family_b_event_id, + model_id, + define_event_id, + removed_event_id, + ], + ) + await define_family.bind(deps)( + DefineFamily(name="ContinuousRotationTomography", affordances=frozenset()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await define_family.bind(deps)( + DefineFamily(name="StepScanTomography", affordances=frozenset()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await drain_equipment_projections(db_pool) + + await define_model.bind(deps)( + DefineModel( + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({family_a_id, family_b_id}), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await remove_model_family.bind(deps)( + RemoveModelFamily(model_id=model_id, family_id=family_b_id), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await drain_equipment_projections(db_pool) + + row = await _fetch_summary(db_pool, model_id) + assert row is not None + assert _decode_jsonb_array(row["declared_families"]) == [str(family_a_id)] + + +@pytest.mark.integration +async def test_two_models_with_same_vendor_key_both_persist_and_advance_bookmark( + db_pool: asyncpg.Pool, +) -> None: + """Post-fix fitness: two distinct Models defined with the same + (manufacturer_name, part_number) pair must both land in + `proj_equipment_model_summary` and the projection bookmark must + advance past both ModelDefined events without UniqueViolation. + + This is the regression class the + 20260602100000_drop_proj_equipment_model_summary_vendor_key_unique + migration exists to retire: before the drop, the decider accepted + both define_model commands (no vendor-key uniqueness invariant at + the aggregate tier), then the second projection apply blew up on + the UNIQUE INDEX, poisoning the bookmark and stalling the + projection indefinitely. + + The Capability precedent at + 20260518210000_drop_proj_recipe_capability_summary_code_unique + motivated this drop; vendor-key uniqueness is now + decider-tier operator-curation discipline, not a projection-tier + UNIQUE constraint.""" + family_id = UUID("01900000-0000-7000-8000-0000000cac01") + family_event_id = UUID("01900000-0000-7000-8000-0000000cac0e") + first_model_id = UUID("01900000-0000-7000-8000-0000000caca1") + first_event_id = UUID("01900000-0000-7000-8000-0000000cacae") + second_model_id = UUID("01900000-0000-7000-8000-0000000caca2") + second_event_id = UUID("01900000-0000-7000-8000-0000000cacaf") + + deps = build_postgres_deps( + db_pool, + now=_NOW, + ids=[ + family_id, + family_event_id, + first_model_id, + first_event_id, + second_model_id, + second_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) + + shared_command = DefineModel( + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({family_id}), + ) + await define_model.bind(deps)( + shared_command, + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await define_model.bind(deps)( + shared_command, + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + bookmark_before = await _fetch_bookmark_position(db_pool) + # Drain returns cleanly: no UniqueViolation, no bookmark poison. + await drain_equipment_projections(db_pool) + bookmark_after = await _fetch_bookmark_position(db_pool) + model_defined_head = await _fetch_model_defined_head(db_pool) + + first_row = await _fetch_summary(db_pool, first_model_id) + second_row = await _fetch_summary(db_pool, second_model_id) + assert first_row is not None + assert second_row is not None + assert first_row["manufacturer_name"] == second_row["manufacturer_name"] + assert first_row["part_number"] == second_row["part_number"] + assert first_row["model_id"] != second_row["model_id"] + # Bookmark advanced AND is at or past the head ModelDefined + # position: the worker would not have moved past either + # ModelDefined if the second projection apply had raised + # UniqueViolation (the bookmark UPDATE shares the same transaction + # as the projection writes, so a rolled-back batch leaves the + # bookmark pinned to the previous value). + assert bookmark_after > bookmark_before + assert bookmark_after >= model_defined_head + + +# ---------------------------------------------------------------------------- +# Projection-tier replay-safety tests. +# +# The aggregate decider rejects duplicate-add (ModelFamilyAlreadyPresentError) +# and absent-removal (ModelFamilyNotPresentError) at command time, so the +# only path to exercise the projector SQL's idempotency-under-replay shape +# is to construct StoredEvent values and call `projection.apply` directly. +# Sibling precedent: `test_postgres_surface_active_visit_projection.py` uses +# the same pattern for stale-Took and double-Released replay pins. +# ---------------------------------------------------------------------------- + +_T0 = datetime(2026, 6, 2, 14, 0, 0, tzinfo=UTC) +_T1 = _T0 + timedelta(hours=1) +_T2 = _T0 + timedelta(hours=2) +_T3 = _T0 + timedelta(hours=3) +_T4 = _T0 + timedelta(hours=4) + + +def _stored_event( + event_type: str, + model_id: UUID, + payload: dict[str, object], + occurred_at: datetime, +) -> StoredEvent: + return StoredEvent( + position=1, + event_id=uuid4(), + stream_type="Model", + stream_id=model_id, + version=1, + event_type=event_type, + schema_version=1, + payload=payload, + correlation_id=_CORRELATION_ID, + causation_id=None, + occurred_at=occurred_at, + recorded_at=occurred_at, + ) + + +def _defined_stored( + model_id: UUID, + *, + name: str, + manufacturer_name: str, + part_number: str, + declared_families: list[UUID], + occurred_at: datetime, +) -> StoredEvent: + payload: dict[str, object] = { + "model_id": str(model_id), + "name": name, + "manufacturer": {"name": manufacturer_name}, + "part_number": part_number, + "declared_families": sorted(str(family_id) for family_id in declared_families), + "occurred_at": occurred_at.isoformat(), + } + return _stored_event("ModelDefined", model_id, payload, occurred_at) + + +def _family_added_stored(model_id: UUID, family_id: UUID, *, occurred_at: datetime) -> StoredEvent: + payload: dict[str, object] = { + "model_id": str(model_id), + "family_id": str(family_id), + "occurred_at": occurred_at.isoformat(), + } + return _stored_event("ModelFamilyAdded", model_id, payload, occurred_at) + + +def _family_removed_stored( + model_id: UUID, family_id: UUID, *, occurred_at: datetime +) -> StoredEvent: + payload: dict[str, object] = { + "model_id": str(model_id), + "family_id": str(family_id), + "occurred_at": occurred_at.isoformat(), + } + return _stored_event("ModelFamilyRemoved", model_id, payload, occurred_at) + + +@pytest.mark.integration +async def test_family_added_idempotent_with_canonical_ordering( + db_pool: asyncpg.Pool, +) -> None: + """Define + add family A + add family A again + add family B: + declared_families = sorted([A, B]) with no duplicates. The + projector's UNION-based re-aggregation is the load-bearing + replay-safety layer (the aggregate rejects duplicate-add at + command time; this exercises the projection-tier replay path).""" + projection = ModelSummaryProjection() + model_id = uuid4() + family_a = uuid4() + family_b = uuid4() + + async with db_pool.acquire() as conn: + await projection.apply( + _defined_stored( + model_id, + name="PCO edge 5.5", + manufacturer_name="PCO", + part_number="edge-5.5", + declared_families=[], + occurred_at=_T0, + ), + conn, + ) + await projection.apply(_family_added_stored(model_id, family_a, occurred_at=_T1), conn) + await projection.apply(_family_added_stored(model_id, family_a, occurred_at=_T2), conn) + await projection.apply(_family_added_stored(model_id, family_b, occurred_at=_T3), conn) + + row = await _fetch_summary(db_pool, model_id) + assert row is not None + expected = sorted([str(family_a), str(family_b)]) + assert _decode_jsonb_array(row["declared_families"]) == expected + + +@pytest.mark.integration +async def test_family_removed_is_no_op_if_absent( + db_pool: asyncpg.Pool, +) -> None: + """Define + remove a family that was never added: the projector's + `WHERE elem <> $2::text` filter drops nothing, declared_families + is unchanged. Replay-safety pin (the aggregate's strict guard + rejects this at command time; this exercises the projection-tier + replay path).""" + projection = ModelSummaryProjection() + model_id = uuid4() + family_a = uuid4() + absent_family = uuid4() + + async with db_pool.acquire() as conn: + await projection.apply( + _defined_stored( + model_id, + name="Mitutoyo SR1500", + manufacturer_name="Mitutoyo", + part_number="SR1500", + declared_families=[family_a], + occurred_at=_T0, + ), + conn, + ) + await projection.apply( + _family_removed_stored(model_id, absent_family, occurred_at=_T1), conn + ) + + row = await _fetch_summary(db_pool, model_id) + assert row is not None + assert _decode_jsonb_array(row["declared_families"]) == [str(family_a)] + + +@pytest.mark.integration +async def test_sort_order_survives_add_remove_churn( + db_pool: asyncpg.Pool, +) -> None: + """Define + add A + add B + remove A + add C lands the final + declared_families = sorted([B, C]). Exercises both projector SQL + paths (UNION-add and filter-remove) under interleaved churn.""" + projection = ModelSummaryProjection() + model_id = uuid4() + family_a = uuid4() + family_b = uuid4() + family_c = uuid4() + + async with db_pool.acquire() as conn: + await projection.apply( + _defined_stored( + model_id, + name="Newport SR50CC", + manufacturer_name="Newport", + part_number="SR50CC", + declared_families=[], + occurred_at=_T0, + ), + conn, + ) + await projection.apply(_family_added_stored(model_id, family_a, occurred_at=_T1), conn) + await projection.apply(_family_added_stored(model_id, family_b, occurred_at=_T2), conn) + await projection.apply(_family_removed_stored(model_id, family_a, occurred_at=_T3), conn) + await projection.apply(_family_added_stored(model_id, family_c, occurred_at=_T4), conn) + + row = await _fetch_summary(db_pool, model_id) + assert row is not None + expected = sorted([str(family_b), str(family_c)]) + assert _decode_jsonb_array(row["declared_families"]) == expected diff --git a/apps/api/tests/integration/test_remove_model_family_handler_postgres.py b/apps/api/tests/integration/test_remove_model_family_handler_postgres.py new file mode 100644 index 000000000..90db70144 --- /dev/null +++ b/apps/api/tests/integration/test_remove_model_family_handler_postgres.py @@ -0,0 +1,196 @@ +"""End-to-end integration test: remove_model_family against real Postgres. + +Round-trip: define a Family, define a Model declaring it, add a second +Family to the Model, remove one of them, and read the events back from +the event store. Verifies the ModelFamilyRemoved payload shape and +the strict-not-idempotent guard (removing an absent family raises). + +Unlike `add_model_family`, this slice performs NO cross-BC Family +lookup; the only cross-BC seeding still required is via +`define_model` (and `add_model_family`) which DO call +`list_family_ids`. Those calls hit the real +`proj_equipment_family_summary` projection backed by the +`db_pool` fixture. +""" + +from datetime import UTC, datetime +from uuid import UUID + +import asyncpg +import pytest + +from cora.equipment._projections import register_equipment_projections +from cora.equipment.aggregates.model import ( + Manufacturer, + ManufacturerName, + ModelFamilyNotPresentError, + fold, + from_stored, +) +from cora.equipment.features import ( + add_model_family, + define_family, + define_model, + remove_model_family, +) +from cora.equipment.features.add_model_family import AddModelFamily +from cora.equipment.features.define_family import DefineFamily +from cora.equipment.features.define_model import DefineModel +from cora.equipment.features.remove_model_family import RemoveModelFamily +from cora.infrastructure.projection import ProjectionRegistry, drain_projections +from tests.integration._helpers import build_postgres_deps + +_NOW = datetime(2026, 6, 1, 12, 0, 0, tzinfo=UTC) +_PRINCIPAL_ID = UUID("01900000-0000-7000-8000-000000000099") +_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000aa") + + +async def _drain_equipment_projections(db_pool: asyncpg.Pool) -> None: + """Flush FamilyDefined rows into `proj_equipment_family_summary` so + the Family read repo called by `define_model.handler` and + `add_model_family.handler` sees the seed.""" + registry = ProjectionRegistry() + register_equipment_projections(registry) + await drain_projections(db_pool, registry, deadline_seconds=2.0) + + +@pytest.mark.integration +async def test_remove_model_family_persists_event_with_payload( + db_pool: asyncpg.Pool, +) -> None: + """Happy path: seed two Families, define a Model declaring one, + add the other via add_model_family, then remove the second one via + remove_model_family. Verify ModelFamilyRemoved is persisted with + the expected payload shape and fold reflects the contracted + declared_families set.""" + family_a_id = UUID("01900000-0000-7000-8000-00000062d001") + family_a_event_id = UUID("01900000-0000-7000-8000-00000062d00e") + family_b_id = UUID("01900000-0000-7000-8000-00000062d002") + family_b_event_id = UUID("01900000-0000-7000-8000-00000062d00f") + model_id = UUID("01900000-0000-7000-8000-00000062ca01") + define_event_id = UUID("01900000-0000-7000-8000-00000062ca0e") + added_event_id = UUID("01900000-0000-7000-8000-00000062ca1a") + removed_event_id = UUID("01900000-0000-7000-8000-00000062ca2a") + + deps = build_postgres_deps( + db_pool, + now=_NOW, + ids=[ + family_a_id, + family_a_event_id, + family_b_id, + family_b_event_id, + model_id, + define_event_id, + added_event_id, + removed_event_id, + ], + ) + await define_family.bind(deps)( + DefineFamily(name="ContinuousRotationTomography", affordances=frozenset()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await define_family.bind(deps)( + DefineFamily(name="StepScanTomography", affordances=frozenset()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await _drain_equipment_projections(db_pool) + + await define_model.bind(deps)( + DefineModel( + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({family_a_id}), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + await add_model_family.bind(deps)( + AddModelFamily(model_id=model_id, family_id=family_b_id), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + await remove_model_family.bind(deps)( + RemoveModelFamily(model_id=model_id, family_id=family_b_id), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + events, version = await deps.event_store.load("Model", model_id) + assert version == 3 + assert [e.event_type for e in events] == [ + "ModelDefined", + "ModelFamilyAdded", + "ModelFamilyRemoved", + ] + removed = events[2] + assert removed.event_id == removed_event_id + assert removed.metadata == {"command": "RemoveModelFamily"} + assert removed.payload == { + "model_id": str(model_id), + "family_id": str(family_b_id), + "occurred_at": _NOW.isoformat(), + } + + # State round-trip via fold confirms the targeted mutation. + history = [from_stored(s) for s in events] + state = fold(history) + assert state is not None + assert state.declared_families == frozenset({family_a_id}) + + +@pytest.mark.integration +async def test_remove_model_family_rejects_absent_family( + db_pool: asyncpg.Pool, +) -> None: + """Strict-not-idempotent: removing a family not in declared_families + raises `ModelFamilyNotPresentError` and writes no new event. No + cross-BC Family lookup is performed by the slice; the absent + family_id is rejected purely by the decider against the folded + state.""" + family_id = UUID("01900000-0000-7000-8000-00000062f001") + family_event_id = UUID("01900000-0000-7000-8000-00000062f00e") + model_id = UUID("01900000-0000-7000-8000-00000062ca41") + define_event_id = UUID("01900000-0000-7000-8000-00000062ca4e") + unused_remove_event_id = UUID("01900000-0000-7000-8000-00000062ca5a") + absent_family_id = UUID("01900000-0000-7000-8000-0000000bad42") + + deps = build_postgres_deps( + db_pool, + now=_NOW, + ids=[family_id, family_event_id, model_id, define_event_id, unused_remove_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="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({family_id}), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + with pytest.raises(ModelFamilyNotPresentError) as exc_info: + await remove_model_family.bind(deps)( + RemoveModelFamily(model_id=model_id, family_id=absent_family_id), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert exc_info.value.model_id == model_id + assert exc_info.value.family_id == absent_family_id + + _, version = await deps.event_store.load("Model", model_id) + assert version == 1 diff --git a/apps/api/tests/integration/test_version_model_handler_postgres.py b/apps/api/tests/integration/test_version_model_handler_postgres.py new file mode 100644 index 000000000..b23ef9fd5 --- /dev/null +++ b/apps/api/tests/integration/test_version_model_handler_postgres.py @@ -0,0 +1,252 @@ +"""End-to-end integration test: version_model against real Postgres. + +Round-trip: define Family, define Model, version Model, and read the +events back from the event store. Verifies the ModelVersioned payload +shape (sorted declared_families, manufacturer sub-dict, version_tag), +the multi-source guard (ModelNotFoundError on a missing stream), and +the Deprecated rejection (ModelCannotVersionError after appending a +ModelDeprecated event onto the same stream). +""" + +from datetime import UTC, datetime +from uuid import UUID + +import asyncpg +import pytest + +from cora.equipment._projections import register_equipment_projections +from cora.equipment.aggregates.model import ( + Manufacturer, + ManufacturerName, + ModelCannotVersionError, + ModelDeprecated, + ModelNotFoundError, + event_type_name, + fold, + from_stored, + to_payload, +) +from cora.equipment.features import define_family, define_model, version_model +from cora.equipment.features.define_family import DefineFamily +from cora.equipment.features.define_model import DefineModel +from cora.equipment.features.version_model import VersionModel +from cora.infrastructure.event_envelope import to_new_event +from cora.infrastructure.projection import ProjectionRegistry, drain_projections +from tests.integration._helpers import build_postgres_deps + +_NOW = datetime(2026, 6, 1, 12, 0, 0, tzinfo=UTC) +_PRINCIPAL_ID = UUID("01900000-0000-7000-8000-000000000099") +_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000aa") + + +async def _drain_equipment_projections(db_pool: asyncpg.Pool) -> None: + """Flush FamilyDefined into `proj_equipment_family_summary` so the + Family read repo called by `define_model.handler` sees the seed.""" + registry = ProjectionRegistry() + register_equipment_projections(registry) + await drain_projections(db_pool, registry, deadline_seconds=2.0) + + +@pytest.mark.integration +async def test_version_model_persists_event_with_full_payload( + db_pool: asyncpg.Pool, +) -> None: + """Happy path: seed Family + define Model + version Model. Verify + ModelVersioned is persisted with the wholesale-replacement payload + (sorted declared_families, manufacturer sub-dict, version_tag).""" + family_id = UUID("01900000-0000-7000-8000-00000060d001") + family_event_id = UUID("01900000-0000-7000-8000-00000060d00e") + other_family_id = UUID("01900000-0000-7000-8000-00000060d002") + other_family_event_id = UUID("01900000-0000-7000-8000-00000060d00f") + model_id = UUID("01900000-0000-7000-8000-00000060ca01") + define_event_id = UUID("01900000-0000-7000-8000-00000060ca0e") + version_event_id = UUID("01900000-0000-7000-8000-00000060ca1a") + + deps = build_postgres_deps( + db_pool, + now=_NOW, + ids=[ + family_id, + family_event_id, + other_family_id, + other_family_event_id, + model_id, + define_event_id, + version_event_id, + ], + ) + await define_family.bind(deps)( + DefineFamily(name="ContinuousRotationTomography", affordances=frozenset()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await define_family.bind(deps)( + DefineFamily(name="StepScanTomography", affordances=frozenset()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await _drain_equipment_projections(db_pool) + + await define_model.bind(deps)( + DefineModel( + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({family_id}), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + await version_model.bind(deps)( + VersionModel( + model_id=model_id, + name="Aerotech ANT130-L rev-B", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L-B", + declared_families=frozenset({family_id, other_family_id}), + version_tag="2026-Q3", + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + events, version = await deps.event_store.load("Model", model_id) + assert version == 2 + assert [e.event_type for e in events] == ["ModelDefined", "ModelVersioned"] + versioned = events[1] + assert versioned.event_id == version_event_id + assert versioned.metadata == {"command": "VersionModel"} + assert versioned.payload == { + "model_id": str(model_id), + "name": "Aerotech ANT130-L rev-B", + "manufacturer": {"name": "Aerotech"}, + "part_number": "ANT130-L-B", + "declared_families": sorted([str(family_id), str(other_family_id)]), + "version_tag": "2026-Q3", + "occurred_at": _NOW.isoformat(), + } + + # State round-trip via fold confirms the wholesale replacement. + history = [from_stored(s) for s in events] + state = fold(history) + assert state is not None + assert state.name.value == "Aerotech ANT130-L rev-B" + assert state.part_number.value == "ANT130-L-B" + assert state.declared_families == frozenset({family_id, other_family_id}) + assert state.version == "2026-Q3" + + +@pytest.mark.integration +async def test_version_model_raises_not_found_for_unknown_id( + db_pool: asyncpg.Pool, +) -> None: + """Versioning a model whose stream has no events raises ModelNotFoundError.""" + missing_id = UUID("01900000-0000-7000-8000-0000000bad02") + family_id = UUID("01900000-0000-7000-8000-0000000bad03") + version_event_id = UUID("01900000-0000-7000-8000-0000000bad0e") + + deps = build_postgres_deps(db_pool, now=_NOW, ids=[version_event_id]) + + with pytest.raises(ModelNotFoundError) as exc_info: + await version_model.bind(deps)( + VersionModel( + model_id=missing_id, + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({family_id}), + version_tag="v2", + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert exc_info.value.model_id == missing_id + + _, version = await deps.event_store.load("Model", missing_id) + assert version == 0 + + +@pytest.mark.integration +async def test_version_model_raises_cannot_version_after_deprecation( + db_pool: asyncpg.Pool, +) -> None: + """After appending a ModelDeprecated event, version_model raises + ModelCannotVersionError and no new event is written.""" + family_id = UUID("01900000-0000-7000-8000-00000060e001") + family_event_id = UUID("01900000-0000-7000-8000-00000060e00e") + model_id = UUID("01900000-0000-7000-8000-00000060ca21") + define_event_id = UUID("01900000-0000-7000-8000-00000060ca2e") + deprecate_event_id = UUID("01900000-0000-7000-8000-00000060ca2f") + # The version_model call lands on the disallowed source and rejects + # before consuming any id; queue an extra to be safe. + unused_version_event_id = UUID("01900000-0000-7000-8000-00000060ca3a") + + deps = build_postgres_deps( + db_pool, + now=_NOW, + ids=[ + family_id, + family_event_id, + model_id, + define_event_id, + unused_version_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="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({family_id}), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + deprecated = ModelDeprecated( + model_id=model_id, + reason="superseded by next-gen part", + occurred_at=_NOW, + ) + await deps.event_store.append( + stream_type="Model", + stream_id=model_id, + expected_version=1, + events=[ + to_new_event( + event_type=event_type_name(deprecated), + payload=to_payload(deprecated), + occurred_at=deprecated.occurred_at, + event_id=deprecate_event_id, + command_name="DeprecateModel", + correlation_id=_CORRELATION_ID, + causation_id=None, + principal_id=_PRINCIPAL_ID, + ) + ], + ) + + with pytest.raises(ModelCannotVersionError): + await version_model.bind(deps)( + VersionModel( + model_id=model_id, + name="Aerotech ANT130-L rev-B", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L-B", + declared_families=frozenset({family_id}), + version_tag="v2", + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + _, version = await deps.event_store.load("Model", model_id) + assert version == 2 diff --git a/apps/api/tests/unit/equipment/test_add_model_family_decider.py b/apps/api/tests/unit/equipment/test_add_model_family_decider.py new file mode 100644 index 000000000..d93509783 --- /dev/null +++ b/apps/api/tests/unit/equipment/test_add_model_family_decider.py @@ -0,0 +1,131 @@ +"""Pure-decider tests for the `add_model_family` slice. + +Targeted mutation of `Model.declared_families`, not a lifecycle +transition. Status is preserved (`Defined` stays `Defined`, +`Versioned` stays `Versioned`); only `Deprecated` is rejected via +the per-verb `ModelCannotAddFamilyError` (mirrors +`AssetCannotAddFamilyError`). + +Strict-not-idempotent: re-adding a present family raises +`ModelFamilyAlreadyPresentError`, mirroring the `add_asset_family` +precedent. +""" + +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +import pytest + +from cora.equipment.aggregates.model import ( + Manufacturer, + ManufacturerName, + Model, + ModelCannotAddFamilyError, + ModelFamilyAdded, + ModelFamilyAlreadyPresentError, + ModelName, + ModelNotFoundError, + ModelStatus, + PartNumber, +) +from cora.equipment.features import add_model_family +from cora.equipment.features.add_model_family import AddModelFamily + +_NOW = datetime(2026, 6, 1, 12, 0, 0, tzinfo=UTC) + + +def _model( + *, + status: ModelStatus = ModelStatus.DEFINED, + declared_families: frozenset[UUID] | None = None, + version: str | None = None, +) -> Model: + return Model( + id=uuid4(), + name=ModelName("Aerotech ANT130-L"), + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number=PartNumber("ANT130-L"), + declared_families=declared_families + if declared_families is not None + else frozenset({uuid4()}), + status=status, + version=version, + ) + + +@pytest.mark.unit +def test_decide_emits_model_family_added_from_defined_state() -> None: + state = _model(status=ModelStatus.DEFINED) + new_family = uuid4() + events = add_model_family.decide( + state=state, + command=AddModelFamily(model_id=state.id, family_id=new_family), + now=_NOW, + ) + assert events == [ModelFamilyAdded(model_id=state.id, family_id=new_family, occurred_at=_NOW)] + + +@pytest.mark.unit +def test_decide_emits_model_family_added_from_versioned_state() -> None: + """Status preserved across targeted mutation; Versioned is a valid source.""" + state = _model(status=ModelStatus.VERSIONED, version="v2") + new_family = uuid4() + events = add_model_family.decide( + state=state, + command=AddModelFamily(model_id=state.id, family_id=new_family), + now=_NOW, + ) + assert events == [ModelFamilyAdded(model_id=state.id, family_id=new_family, occurred_at=_NOW)] + + +@pytest.mark.unit +def test_decide_raises_cannot_add_family_when_deprecated() -> None: + """Deprecated catalog entries are frozen; add_model_family rejects.""" + state = _model(status=ModelStatus.DEPRECATED, version="v1") + with pytest.raises(ModelCannotAddFamilyError) as exc_info: + add_model_family.decide( + state=state, + command=AddModelFamily(model_id=state.id, family_id=uuid4()), + now=_NOW, + ) + assert exc_info.value.model_id == state.id + assert exc_info.value.current_status is ModelStatus.DEPRECATED + + +@pytest.mark.unit +def test_decide_raises_model_not_found_when_state_is_none() -> None: + target_id = uuid4() + with pytest.raises(ModelNotFoundError) as exc_info: + add_model_family.decide( + state=None, + command=AddModelFamily(model_id=target_id, family_id=uuid4()), + now=_NOW, + ) + assert exc_info.value.model_id == target_id + + +@pytest.mark.unit +def test_decide_raises_already_present_on_duplicate_family() -> None: + """Strict-not-idempotent: re-adding a present family raises rather + than no-op so operators can detect 'wait, this is already declared' + instead of silently succeeding.""" + existing = uuid4() + state = _model(declared_families=frozenset({existing})) + with pytest.raises(ModelFamilyAlreadyPresentError) as exc_info: + add_model_family.decide( + state=state, + command=AddModelFamily(model_id=state.id, family_id=existing), + now=_NOW, + ) + assert exc_info.value.model_id == state.id + assert exc_info.value.family_id == existing + + +@pytest.mark.unit +def test_decide_is_pure_same_inputs_same_outputs() -> None: + state = _model() + family = uuid4() + command = AddModelFamily(model_id=state.id, family_id=family) + first = add_model_family.decide(state=state, command=command, now=_NOW) + second = add_model_family.decide(state=state, command=command, now=_NOW) + assert first == second diff --git a/apps/api/tests/unit/equipment/test_add_model_family_decider_properties.py b/apps/api/tests/unit/equipment/test_add_model_family_decider_properties.py new file mode 100644 index 000000000..5f8d907e1 --- /dev/null +++ b/apps/api/tests/unit/equipment/test_add_model_family_decider_properties.py @@ -0,0 +1,167 @@ +"""Property-based tests for `add_model_family.decide` (Equipment BC). + +Targeted mutation of `Model.declared_families`; status is preserved +across the mutation and only `Deprecated` is rejected (via the +per-verb `ModelCannotAddFamilyError` gate). Universal claims across +generated inputs: + + - state in {Defined, Versioned} + family_id NOT in + declared_families emits exactly one ModelFamilyAdded with the + injected `now` timestamp. + - family_id IN declared_families always raises + ModelFamilyAlreadyPresentError carrying the model + family id. + - state=None always raises ModelNotFoundError carrying the + command's model_id. + - state.status==Deprecated always raises ModelCannotAddFamilyError + carrying the Deprecated source status. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from hypothesis import given +from hypothesis import strategies as st + +from cora.equipment.aggregates.model import ( + Manufacturer, + ManufacturerName, + Model, + ModelCannotAddFamilyError, + ModelFamilyAdded, + ModelFamilyAlreadyPresentError, + ModelName, + ModelNotFoundError, + ModelStatus, + PartNumber, +) +from cora.equipment.features import add_model_family +from cora.equipment.features.add_model_family import AddModelFamily +from tests._strategies import aware_datetimes + +if TYPE_CHECKING: + from datetime import datetime + from uuid import UUID + + +# Mutable source statuses; only Deprecated is rejected at the slice gate. +_MUTABLE_STATUS = st.sampled_from([ModelStatus.DEFINED, ModelStatus.VERSIONED]) + +# 1 to 5 pre-existing declared family ids; frozenset dedupes naturally. +_DECLARED_FAMILIES = st.frozensets(st.uuids(), min_size=1, max_size=5) + + +def _model( + model_id: UUID, + *, + status: ModelStatus, + declared_families: frozenset[UUID], +) -> Model: + return Model( + id=model_id, + name=ModelName("Existing"), + manufacturer=Manufacturer(name=ManufacturerName("M")), + part_number=PartNumber("P"), + declared_families=declared_families, + status=status, + version="v0" if status is ModelStatus.VERSIONED else None, + ) + + +@pytest.mark.unit +@given( + model_id=st.uuids(), + status=_MUTABLE_STATUS, + declared_families=_DECLARED_FAMILIES, + new_family=st.uuids(), + now=aware_datetimes(), +) +def test_add_model_family_emits_one_event_for_absent_family( + model_id: UUID, + status: ModelStatus, + declared_families: frozenset[UUID], + new_family: UUID, + now: datetime, +) -> None: + """Mutable source + family_id NOT in declared_families -> exactly one + ModelFamilyAdded with the injected `now`.""" + # Ensure the new family is genuinely absent from the prior set. + declared_without_new = declared_families - {new_family} + state = _model(model_id, status=status, declared_families=declared_without_new) + command = AddModelFamily(model_id=model_id, family_id=new_family) + events = add_model_family.decide(state=state, command=command, now=now) + assert events == [ModelFamilyAdded(model_id=model_id, family_id=new_family, occurred_at=now)] + + +@pytest.mark.unit +@given( + model_id=st.uuids(), + status=_MUTABLE_STATUS, + declared_families=_DECLARED_FAMILIES, + now=aware_datetimes(), + pick_index=st.integers(min_value=0, max_value=4), +) +def test_add_model_family_with_duplicate_family_always_raises( + model_id: UUID, + status: ModelStatus, + declared_families: frozenset[UUID], + now: datetime, + pick_index: int, +) -> None: + """family_id already in declared_families -> ModelFamilyAlreadyPresentError.""" + # Pick a deterministic family id from the existing set (declared has + # at least one member, so the modulo always lands). + declared_list = sorted(declared_families, key=str) + duplicate = declared_list[pick_index % len(declared_list)] + state = _model(model_id, status=status, declared_families=declared_families) + command = AddModelFamily(model_id=model_id, family_id=duplicate) + with pytest.raises(ModelFamilyAlreadyPresentError) as exc: + add_model_family.decide(state=state, command=command, now=now) + assert exc.value.model_id == model_id + assert exc.value.family_id == duplicate + + +@pytest.mark.unit +@given( + model_id=st.uuids(), + family_id=st.uuids(), + now=aware_datetimes(), +) +def test_add_model_family_on_empty_state_always_raises_not_found( + model_id: UUID, + family_id: UUID, + now: datetime, +) -> None: + """state=None -> ModelNotFoundError carrying command.model_id.""" + command = AddModelFamily(model_id=model_id, family_id=family_id) + with pytest.raises(ModelNotFoundError) as exc: + add_model_family.decide(state=None, command=command, now=now) + assert exc.value.model_id == model_id + + +@pytest.mark.unit +@given( + model_id=st.uuids(), + declared_families=_DECLARED_FAMILIES, + family_id=st.uuids(), + now=aware_datetimes(), +) +def test_add_model_family_on_deprecated_state_always_raises_cannot_add_family( + model_id: UUID, + declared_families: frozenset[UUID], + family_id: UUID, + now: datetime, +) -> None: + """state.status==Deprecated -> ModelCannotAddFamilyError, regardless of + whether family_id would have been a duplicate or a fresh add.""" + state = _model( + model_id, + status=ModelStatus.DEPRECATED, + declared_families=declared_families, + ) + command = AddModelFamily(model_id=model_id, family_id=family_id) + with pytest.raises(ModelCannotAddFamilyError) as exc: + add_model_family.decide(state=state, command=command, now=now) + assert exc.value.model_id == model_id + assert exc.value.current_status is ModelStatus.DEPRECATED diff --git a/apps/api/tests/unit/equipment/test_add_model_family_handler.py b/apps/api/tests/unit/equipment/test_add_model_family_handler.py new file mode 100644 index 000000000..6e2058953 --- /dev/null +++ b/apps/api/tests/unit/equipment/test_add_model_family_handler.py @@ -0,0 +1,274 @@ +"""Unit tests for the `add_model_family` application handler. + +Update-style handler (mirrors `add_asset_family` and `version_model`): +load + fold + decide + append. Not idempotency-wrapped. + +Cross-BC concern: the referenced `family_id` must resolve to a +registered Family via `list_all_family_ids`. The unit harness has no +Postgres pool, so we monkeypatch the symbol imported into the +handler module to a fixed accept-all stub (mirrors the +`define_model` handler test pattern). The seeding `define_model` +call is also monkeypatched against the same stub for the same +reason. + +The Deprecated path seeds a `ModelDeprecated` event directly onto +the in-memory store (no `deprecate_model` slice is exercised in +this test file), then invokes the handler and expects +`ModelCannotAddFamilyError`. +""" + +from datetime import UTC, datetime +from uuid import UUID + +import pytest + +from cora.equipment import EquipmentHandlers, UnauthorizedError, wire_equipment +from cora.equipment.aggregates.family import FamilyNotFoundError +from cora.equipment.aggregates.model import ( + Manufacturer, + ManufacturerName, + ModelCannotAddFamilyError, + ModelDeprecated, + ModelFamilyAlreadyPresentError, + ModelNotFoundError, + event_type_name, + to_payload, +) +from cora.equipment.features import add_model_family, define_model +from cora.equipment.features.add_model_family import AddModelFamily +from cora.equipment.features.define_model import DefineModel +from cora.infrastructure.adapters.in_memory_event_store import InMemoryEventStore +from cora.infrastructure.event_envelope import to_new_event +from cora.infrastructure.kernel import Kernel +from tests.unit._helpers import build_deps as _build_deps_shared + +_NOW = datetime(2026, 6, 1, 12, 0, 0, tzinfo=UTC) +_MODEL_ID = UUID("01900000-0000-7000-8000-00000007ac11") +_DEFINED_EVENT_ID = UUID("01900000-0000-7000-8000-00000007ac12") +_ADDED_EVENT_ID = UUID("01900000-0000-7000-8000-00000007ac13") +_DEPRECATED_EVENT_ID = UUID("01900000-0000-7000-8000-00000007ac14") +_PRINCIPAL_ID = UUID("01900000-0000-7000-8000-000000000099") +_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000aa") +_FAMILY_A_ID = UUID("01900000-0000-7000-8000-00000000fb01") +_FAMILY_B_ID = UUID("01900000-0000-7000-8000-00000000fb02") +_FAMILY_MISSING_ID = UUID("01900000-0000-7000-8000-00000000fb99") + + +def _build_deps( + *, + event_store: InMemoryEventStore | None = None, + deny: bool = False, +) -> Kernel: + """Thin wrapper preserving this file's ID list + clock.""" + return _build_deps_shared( + ids=[_MODEL_ID, _DEFINED_EVENT_ID, _ADDED_EVENT_ID, _DEPRECATED_EVENT_ID], + now=_NOW, + event_store=event_store, + deny=deny, + ) + + +def _patch_known_families( + monkeypatch: pytest.MonkeyPatch, + family_ids: list[UUID], +) -> None: + """Patch `list_all_family_ids` in both handler modules that look it up. + + The seeding `define_model` call AND the slice under test + (`add_model_family`) each import `list_all_family_ids` by name at module + load. Patching the binding in each handler's namespace ensures both + paths see the stub. + """ + + async def _fake_list_family_ids(_pool: object) -> list[UUID]: + return list(family_ids) + + monkeypatch.setattr( + "cora.equipment.features.define_model.handler.list_all_family_ids", + _fake_list_family_ids, + ) + monkeypatch.setattr( + "cora.equipment.features.add_model_family.handler.list_all_family_ids", + _fake_list_family_ids, + ) + + +def _define_command() -> DefineModel: + return DefineModel( + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({_FAMILY_A_ID}), + ) + + +async def _seed_model(deps: Kernel) -> None: + """Define a Model via the public handler so the stream is initialized + in `Defined` status with `_FAMILY_A_ID` declared.""" + await define_model.bind(deps)( + _define_command(), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + +async def _seed_deprecated_model(deps: Kernel, store: InMemoryEventStore) -> None: + """Append a `ModelDeprecated` event directly so the model lands in + `Deprecated` status without going through a `deprecate_model` slice + in this test file.""" + await _seed_model(deps) + deprecated = ModelDeprecated( + model_id=_MODEL_ID, + reason="superseded by next-gen part", + occurred_at=_NOW, + ) + new_event = to_new_event( + event_type=event_type_name(deprecated), + payload=to_payload(deprecated), + occurred_at=deprecated.occurred_at, + event_id=_DEPRECATED_EVENT_ID, + command_name="DeprecateModel", + correlation_id=_CORRELATION_ID, + causation_id=None, + principal_id=_PRINCIPAL_ID, + ) + await store.append( + stream_type="Model", + stream_id=_MODEL_ID, + expected_version=1, + events=[new_event], + ) + + +@pytest.mark.unit +async def test_handler_returns_none_and_appends_event_on_success( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_known_families(monkeypatch, [_FAMILY_A_ID, _FAMILY_B_ID]) + store = InMemoryEventStore() + deps = _build_deps(event_store=store) + await _seed_model(deps) + + result = await add_model_family.bind(deps)( + AddModelFamily(model_id=_MODEL_ID, family_id=_FAMILY_B_ID), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert result is None + + events, version = await store.load("Model", _MODEL_ID) + assert version == 2 + assert [e.event_type for e in events] == ["ModelDefined", "ModelFamilyAdded"] + added = events[1] + assert added.event_id == _ADDED_EVENT_ID + assert added.metadata == {"command": "AddModelFamily"} + assert added.payload["model_id"] == str(_MODEL_ID) + assert added.payload["family_id"] == str(_FAMILY_B_ID) + assert added.payload["occurred_at"] == _NOW.isoformat() + + +@pytest.mark.unit +async def test_handler_raises_unauthorized_on_deny( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_known_families(monkeypatch, [_FAMILY_A_ID, _FAMILY_B_ID]) + store = InMemoryEventStore() + deps = _build_deps(event_store=store) + await _seed_model(deps) + + deny_deps = _build_deps(event_store=store, deny=True) + with pytest.raises(UnauthorizedError) as exc_info: + await add_model_family.bind(deny_deps)( + AddModelFamily(model_id=_MODEL_ID, family_id=_FAMILY_B_ID), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert exc_info.value.reason == "denied for test" + + +@pytest.mark.unit +async def test_handler_raises_family_not_found_for_unregistered_family( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Cross-BC precondition: the family_id must resolve via + `list_all_family_ids`; an unregistered id raises `FamilyNotFoundError` + before the decider is reached.""" + _patch_known_families(monkeypatch, [_FAMILY_A_ID, _FAMILY_B_ID]) + store = InMemoryEventStore() + deps = _build_deps(event_store=store) + await _seed_model(deps) + + with pytest.raises(FamilyNotFoundError) as exc_info: + await add_model_family.bind(deps)( + AddModelFamily(model_id=_MODEL_ID, family_id=_FAMILY_MISSING_ID), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert exc_info.value.family_id == _FAMILY_MISSING_ID + + +@pytest.mark.unit +async def test_handler_raises_model_not_found_when_stream_is_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """An unseeded model stream surfaces ModelNotFoundError. The + cross-BC lookup passes (family is known); the decider rejects + because state is None.""" + _patch_known_families(monkeypatch, [_FAMILY_A_ID]) + deps = _build_deps() + + with pytest.raises(ModelNotFoundError) as exc_info: + await add_model_family.bind(deps)( + AddModelFamily(model_id=_MODEL_ID, family_id=_FAMILY_A_ID), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert exc_info.value.model_id == _MODEL_ID + + +@pytest.mark.unit +async def test_handler_raises_already_present_on_duplicate_family( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Re-adding a family already in declared_families surfaces + ModelFamilyAlreadyPresentError (strict-not-idempotent).""" + _patch_known_families(monkeypatch, [_FAMILY_A_ID]) + store = InMemoryEventStore() + deps = _build_deps(event_store=store) + await _seed_model(deps) + + with pytest.raises(ModelFamilyAlreadyPresentError) as exc_info: + await add_model_family.bind(deps)( + # _FAMILY_A_ID is already declared at define_model time. + AddModelFamily(model_id=_MODEL_ID, family_id=_FAMILY_A_ID), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert exc_info.value.model_id == _MODEL_ID + assert exc_info.value.family_id == _FAMILY_A_ID + + +@pytest.mark.unit +async def test_handler_raises_cannot_add_family_when_deprecated( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Deprecated Models cannot accept new family declarations.""" + _patch_known_families(monkeypatch, [_FAMILY_A_ID, _FAMILY_B_ID]) + store = InMemoryEventStore() + deps = _build_deps(event_store=store) + await _seed_deprecated_model(deps, store) + + with pytest.raises(ModelCannotAddFamilyError): + await add_model_family.bind(deps)( + AddModelFamily(model_id=_MODEL_ID, family_id=_FAMILY_B_ID), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + +@pytest.mark.unit +def test_wire_equipment_includes_add_model_family() -> None: + deps = _build_deps() + handlers = wire_equipment(deps) + assert isinstance(handlers, EquipmentHandlers) + assert callable(handlers.add_model_family) diff --git a/apps/api/tests/unit/equipment/test_define_model_decider.py b/apps/api/tests/unit/equipment/test_define_model_decider.py new file mode 100644 index 000000000..92f20354c --- /dev/null +++ b/apps/api/tests/unit/equipment/test_define_model_decider.py @@ -0,0 +1,144 @@ +"""Pure-decider tests for the `define_model` slice.""" + +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest + +from cora.equipment.aggregates.model import ( + InvalidDeclaredFamiliesError, + InvalidModelNameError, + InvalidModelVersionTagError, + InvalidPartNumberError, + Manufacturer, + ManufacturerIdentifier, + ManufacturerIdentifierType, + ManufacturerName, + Model, + ModelAlreadyExistsError, + ModelName, + ModelStatus, + PartNumber, +) +from cora.equipment.features.define_model import DefineModel, decide + +_NOW = datetime(2026, 6, 1, 12, 0, tzinfo=UTC) + + +def _minimal_command() -> DefineModel: + return DefineModel( + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({uuid4()}), + ) + + +@pytest.mark.unit +def test_decide_emits_model_defined_for_minimal_command() -> None: + cmd = _minimal_command() + new_id = uuid4() + events = decide(None, cmd, now=_NOW, new_id=new_id) + assert len(events) == 1 + event = events[0] + assert event.model_id == new_id + assert event.name == "Aerotech ANT130-L" + assert event.manufacturer == cmd.manufacturer + assert event.part_number == "ANT130-L" + assert event.declared_families == cmd.declared_families + assert event.occurred_at == _NOW + assert event.version_tag is None + + +@pytest.mark.unit +def test_decide_carries_version_tag_when_supplied() -> None: + cmd = DefineModel( + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({uuid4()}), + version_tag="rev-A", + ) + events = decide(None, cmd, now=_NOW, new_id=uuid4()) + assert events[0].version_tag == "rev-A" + + +@pytest.mark.unit +def test_decide_carries_full_manufacturer_triple() -> None: + cmd = DefineModel( + name="Aerotech ANT130-L", + manufacturer=Manufacturer( + name=ManufacturerName("Aerotech"), + identifier=ManufacturerIdentifier("https://ror.org/05gvnxz63"), + identifier_type=ManufacturerIdentifierType.ROR, + ), + part_number="ANT130-L", + declared_families=frozenset({uuid4()}), + ) + events = decide(None, cmd, now=_NOW, new_id=uuid4()) + assert events[0].manufacturer.identifier is not None + assert events[0].manufacturer.identifier.value == "https://ror.org/05gvnxz63" + assert events[0].manufacturer.identifier_type is ManufacturerIdentifierType.ROR + + +@pytest.mark.unit +def test_decide_rejects_when_stream_already_has_state() -> None: + existing = Model( + id=uuid4(), + name=ModelName("Existing"), + manufacturer=Manufacturer(name=ManufacturerName("M")), + part_number=PartNumber("P"), + declared_families=frozenset({uuid4()}), + status=ModelStatus.DEFINED, + ) + with pytest.raises(ModelAlreadyExistsError): + decide(existing, _minimal_command(), now=_NOW, new_id=uuid4()) + + +@pytest.mark.unit +def test_decide_rejects_empty_declared_families() -> None: + cmd = DefineModel( + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset(), + ) + with pytest.raises(InvalidDeclaredFamiliesError): + decide(None, cmd, now=_NOW, new_id=uuid4()) + + +@pytest.mark.unit +def test_decide_rejects_invalid_name() -> None: + cmd = DefineModel( + name=" ", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({uuid4()}), + ) + with pytest.raises(InvalidModelNameError): + decide(None, cmd, now=_NOW, new_id=uuid4()) + + +@pytest.mark.unit +def test_decide_rejects_invalid_part_number() -> None: + cmd = DefineModel( + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="", + declared_families=frozenset({uuid4()}), + ) + with pytest.raises(InvalidPartNumberError): + decide(None, cmd, now=_NOW, new_id=uuid4()) + + +@pytest.mark.unit +def test_decide_rejects_empty_initial_version_tag() -> None: + cmd = DefineModel( + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({uuid4()}), + version_tag=" ", + ) + with pytest.raises(InvalidModelVersionTagError): + decide(None, cmd, now=_NOW, new_id=uuid4()) diff --git a/apps/api/tests/unit/equipment/test_define_model_decider_properties.py b/apps/api/tests/unit/equipment/test_define_model_decider_properties.py new file mode 100644 index 000000000..51de19f46 --- /dev/null +++ b/apps/api/tests/unit/equipment/test_define_model_decider_properties.py @@ -0,0 +1,426 @@ +"""Property-based tests for `define_model.decide` (Equipment BC). + +Mirrors the Recipe BC `define_capability` decider-PBT pattern on an +Equipment BC create-style command with bounded-text VOs, an optional +paired manufacturer identifier, and a non-empty `declared_families` +frozenset invariant. Universal claims across generated inputs: + + - state=None + valid command emits a single ModelDefined with the + injected new_id / now and the command's manufacturer / parts / + declared_families intact. + - state=Model always raises ModelAlreadyExistsError, carrying the + pre-existing model_id. + - Empty `declared_families` always raises InvalidDeclaredFamiliesError. + - Empty, whitespace-only, or over-long `name` always raises + InvalidModelNameError (via the ModelName VO). + - Empty, whitespace-only, or over-long `part_number` always raises + InvalidPartNumberError (via the PartNumber VO). + - Empty, whitespace-only, or over-long `version_tag` (when non-None) + always raises InvalidModelVersionTagError (via the ModelVersionTag + VO). + - Pure: same (state, command, now, new_id) returns the same events. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from uuid import UUID + +import pytest +from hypothesis import given +from hypothesis import strategies as st + +from cora.equipment.aggregates.model import ( + MANUFACTURER_IDENTIFIER_MAX_LENGTH, + MANUFACTURER_NAME_MAX_LENGTH, + MODEL_NAME_MAX_LENGTH, + MODEL_PART_NUMBER_MAX_LENGTH, + MODEL_VERSION_TAG_MAX_LENGTH, + InvalidDeclaredFamiliesError, + InvalidModelNameError, + InvalidModelVersionTagError, + InvalidPartNumberError, + Manufacturer, + ManufacturerIdentifier, + ManufacturerIdentifierType, + ManufacturerName, + Model, + ModelAlreadyExistsError, + ModelDefined, + ModelName, + PartNumber, +) +from cora.equipment.features import define_model +from cora.equipment.features.define_model import DefineModel +from tests._strategies import aware_datetimes, printable_ascii_text + +if TYPE_CHECKING: + from datetime import datetime + + +_NAME = printable_ascii_text(min_size=1, max_size=MODEL_NAME_MAX_LENGTH) +_PART_NUMBER = printable_ascii_text(min_size=1, max_size=MODEL_PART_NUMBER_MAX_LENGTH) +_MANUFACTURER_NAME = printable_ascii_text(min_size=1, max_size=MANUFACTURER_NAME_MAX_LENGTH) +_MANUFACTURER_IDENTIFIER = printable_ascii_text( + min_size=1, max_size=MANUFACTURER_IDENTIFIER_MAX_LENGTH +) +_VERSION_TAG = printable_ascii_text(min_size=1, max_size=MODEL_VERSION_TAG_MAX_LENGTH) + +# 1 to 5 distinct Family ids per the prompt; frozenset dedupes naturally. +_DECLARED_FAMILIES = st.frozensets(st.uuids(), min_size=1, max_size=5) + +# Negative-case alphabet for bounded-text VOs: empty, whitespace-only, +# and over-long strings. Each ALWAYS raises after `.strip()` either +# yields "" (empty/whitespace) or exceeds the length cap. +_WHITESPACE_CHARS = st.sampled_from([" ", "\t", "\n", "\r", " ", " \t\n"]) + + +def _invalid_bounded_text(max_length: int) -> st.SearchStrategy[str]: + """Empty, whitespace-only, or over-length strings for VO rejection PBTs. + + Bounded-text VOs reject when `.strip()` yields an empty string or + when the trimmed length exceeds `max_length`. This strategy unions + all three rejection shapes; every drawn value triggers the VO's + error class. + """ + return st.one_of( + st.just(""), + _WHITESPACE_CHARS, + printable_ascii_text(min_size=max_length + 1, max_size=max_length + 50), + ) + + +def _padded_text(inner_strategy: st.SearchStrategy[str]) -> st.SearchStrategy[str]: + """Wrap an inner text strategy in random leading + trailing whitespace. + + Distinguishes "VO trims at construction" from "decider stores raw + command text": if the emitted event payload still carries the + untrimmed wrapper, the decider is leaking `command.` instead + of the VO's `.value`. + """ + + @st.composite + def build(draw: st.DrawFn) -> str: + leading = draw(st.text(alphabet=" \t\n", max_size=10)) + core = draw(inner_strategy) + trailing = draw(st.text(alphabet=" \t\n", max_size=10)) + return leading + core + trailing + + return build() + + +@st.composite +def _manufacturers(draw: st.DrawFn) -> Manufacturer: + """Build a Manufacturer VO with optional paired identifier + type. + + The pairing invariant is enforced inside the Manufacturer dataclass: + `identifier` and `identifier_type` are both set or both None. This + composite draws both halves together so generated Manufacturers + always satisfy the invariant. + """ + name = ManufacturerName(draw(_MANUFACTURER_NAME)) + has_identifier = draw(st.booleans()) + if not has_identifier: + return Manufacturer(name=name) + identifier = ManufacturerIdentifier(draw(_MANUFACTURER_IDENTIFIER)) + identifier_type = draw(st.sampled_from(list(ManufacturerIdentifierType))) + return Manufacturer(name=name, identifier=identifier, identifier_type=identifier_type) + + +def _command( + *, + name: str, + manufacturer: Manufacturer, + part_number: str, + declared_families: frozenset[UUID], + version_tag: str | None = None, +) -> DefineModel: + return DefineModel( + name=name, + manufacturer=manufacturer, + part_number=part_number, + declared_families=declared_families, + version_tag=version_tag, + ) + + +def _model(model_id: UUID) -> Model: + return Model( + id=model_id, + name=ModelName("Existing"), + manufacturer=Manufacturer(name=ManufacturerName("M")), + part_number=PartNumber("P"), + declared_families=frozenset({model_id}), + ) + + +@pytest.mark.unit +@given( + name=_NAME, + manufacturer=_manufacturers(), + part_number=_PART_NUMBER, + declared_families=_DECLARED_FAMILIES, + version_tag=st.one_of(st.none(), _VERSION_TAG), + now=aware_datetimes(), + new_id=st.uuids(), +) +def test_define_model_emits_exactly_one_event_with_injected_fields( + name: str, + manufacturer: Manufacturer, + part_number: str, + declared_families: frozenset[UUID], + version_tag: str | None, + now: datetime, + new_id: UUID, +) -> None: + """Empty stream + valid command -> single ModelDefined with injected ids/time.""" + command = _command( + name=name, + manufacturer=manufacturer, + part_number=part_number, + declared_families=declared_families, + version_tag=version_tag, + ) + events = define_model.decide(state=None, command=command, now=now, new_id=new_id) + assert events == [ + ModelDefined( + model_id=new_id, + name=name, + manufacturer=manufacturer, + part_number=part_number, + declared_families=declared_families, + occurred_at=now, + version_tag=version_tag, + ) + ] + + +@pytest.mark.unit +@given( + existing_id=st.uuids(), + name=_NAME, + manufacturer=_manufacturers(), + part_number=_PART_NUMBER, + declared_families=_DECLARED_FAMILIES, + version_tag=st.one_of(st.none(), _VERSION_TAG), + now=aware_datetimes(), + new_id=st.uuids(), +) +def test_define_model_on_existing_state_always_raises_already_exists( + existing_id: UUID, + name: str, + manufacturer: Manufacturer, + part_number: str, + declared_families: frozenset[UUID], + version_tag: str | None, + now: datetime, + new_id: UUID, +) -> None: + """Any non-None state -> ModelAlreadyExistsError, regardless of command.""" + command = _command( + name=name, + manufacturer=manufacturer, + part_number=part_number, + declared_families=declared_families, + version_tag=version_tag, + ) + with pytest.raises(ModelAlreadyExistsError) as exc: + define_model.decide(state=_model(existing_id), command=command, now=now, new_id=new_id) + assert exc.value.model_id == existing_id + + +@pytest.mark.unit +@given( + name=_NAME, + manufacturer=_manufacturers(), + part_number=_PART_NUMBER, + version_tag=st.one_of(st.none(), _VERSION_TAG), + now=aware_datetimes(), + new_id=st.uuids(), +) +def test_define_model_with_empty_declared_families_always_raises( + name: str, + manufacturer: Manufacturer, + part_number: str, + version_tag: str | None, + now: datetime, + new_id: UUID, +) -> None: + """Empty declared_families -> InvalidDeclaredFamiliesError, regardless of other fields.""" + command = _command( + name=name, + manufacturer=manufacturer, + part_number=part_number, + declared_families=frozenset[UUID](), + version_tag=version_tag, + ) + with pytest.raises(InvalidDeclaredFamiliesError): + define_model.decide(state=None, command=command, now=now, new_id=new_id) + + +@pytest.mark.unit +@given( + name=_invalid_bounded_text(MODEL_NAME_MAX_LENGTH), + manufacturer=_manufacturers(), + part_number=_PART_NUMBER, + declared_families=_DECLARED_FAMILIES, + version_tag=st.one_of(st.none(), _VERSION_TAG), + now=aware_datetimes(), + new_id=st.uuids(), +) +def test_define_model_with_invalid_name_always_raises( + name: str, + manufacturer: Manufacturer, + part_number: str, + declared_families: frozenset[UUID], + version_tag: str | None, + now: datetime, + new_id: UUID, +) -> None: + """Empty, whitespace-only, or over-long name -> InvalidModelNameError.""" + command = _command( + name=name, + manufacturer=manufacturer, + part_number=part_number, + declared_families=declared_families, + version_tag=version_tag, + ) + with pytest.raises(InvalidModelNameError): + define_model.decide(state=None, command=command, now=now, new_id=new_id) + + +@pytest.mark.unit +@given( + name=_NAME, + manufacturer=_manufacturers(), + part_number=_invalid_bounded_text(MODEL_PART_NUMBER_MAX_LENGTH), + declared_families=_DECLARED_FAMILIES, + version_tag=st.one_of(st.none(), _VERSION_TAG), + now=aware_datetimes(), + new_id=st.uuids(), +) +def test_define_model_with_invalid_part_number_always_raises( + name: str, + manufacturer: Manufacturer, + part_number: str, + declared_families: frozenset[UUID], + version_tag: str | None, + now: datetime, + new_id: UUID, +) -> None: + """Empty, whitespace-only, or over-long part_number -> InvalidPartNumberError.""" + command = _command( + name=name, + manufacturer=manufacturer, + part_number=part_number, + declared_families=declared_families, + version_tag=version_tag, + ) + with pytest.raises(InvalidPartNumberError): + define_model.decide(state=None, command=command, now=now, new_id=new_id) + + +@pytest.mark.unit +@given( + name=_NAME, + manufacturer=_manufacturers(), + part_number=_PART_NUMBER, + declared_families=_DECLARED_FAMILIES, + version_tag=_invalid_bounded_text(MODEL_VERSION_TAG_MAX_LENGTH), + now=aware_datetimes(), + new_id=st.uuids(), +) +def test_define_model_with_invalid_version_tag_always_raises( + name: str, + manufacturer: Manufacturer, + part_number: str, + declared_families: frozenset[UUID], + version_tag: str, + now: datetime, + new_id: UUID, +) -> None: + """Empty, whitespace-only, or over-long non-None version_tag -> + InvalidModelVersionTagError. None is excluded from this strategy + because None is a valid version_tag (no initial revision label). + """ + command = _command( + name=name, + manufacturer=manufacturer, + part_number=part_number, + declared_families=declared_families, + version_tag=version_tag, + ) + with pytest.raises(InvalidModelVersionTagError): + define_model.decide(state=None, command=command, now=now, new_id=new_id) + + +@pytest.mark.unit +@given( + name=_padded_text(_NAME), + manufacturer=_manufacturers(), + part_number=_padded_text(_PART_NUMBER), + declared_families=_DECLARED_FAMILIES, + version_tag=st.one_of(st.none(), _VERSION_TAG), + now=aware_datetimes(), + new_id=st.uuids(), +) +def test_define_model_event_carries_trimmed_name_and_part_number( + name: str, + manufacturer: Manufacturer, + part_number: str, + declared_families: frozenset[UUID], + version_tag: str | None, + now: datetime, + new_id: UUID, +) -> None: + """Padded input -> ModelDefined.name / .part_number carry the trimmed + value, never the raw command string with leading or trailing whitespace. + + Closes a coverage gap in printable_ascii_text (which excludes + whitespace): without this property, the decider could emit + `command.name` raw and still pass every other PBT in this module. + """ + command = _command( + name=name, + manufacturer=manufacturer, + part_number=part_number, + declared_families=declared_families, + version_tag=version_tag, + ) + events = define_model.decide(state=None, command=command, now=now, new_id=new_id) + assert len(events) == 1 + event = events[0] + assert event.name == event.name.strip() + assert event.part_number == event.part_number.strip() + + +@pytest.mark.unit +@given( + name=_NAME, + manufacturer=_manufacturers(), + part_number=_PART_NUMBER, + declared_families=_DECLARED_FAMILIES, + version_tag=st.one_of(st.none(), _VERSION_TAG), + now=aware_datetimes(), + new_id=st.uuids(), +) +def test_define_model_is_pure_same_input_same_output( + name: str, + manufacturer: Manufacturer, + part_number: str, + declared_families: frozenset[UUID], + version_tag: str | None, + now: datetime, + new_id: UUID, +) -> None: + """Two calls with identical args return identical events (no clock leakage).""" + command = _command( + name=name, + manufacturer=manufacturer, + part_number=part_number, + declared_families=declared_families, + version_tag=version_tag, + ) + first = define_model.decide(state=None, command=command, now=now, new_id=new_id) + second = define_model.decide(state=None, command=command, now=now, new_id=new_id) + assert first == second diff --git a/apps/api/tests/unit/equipment/test_define_model_handler.py b/apps/api/tests/unit/equipment/test_define_model_handler.py new file mode 100644 index 000000000..775661d22 --- /dev/null +++ b/apps/api/tests/unit/equipment/test_define_model_handler.py @@ -0,0 +1,209 @@ +"""Unit tests for the `define_model` application handler. + +Mirrors the `define_family` handler test's shape (same Handler +protocol, same authorize + event-store wiring, same Kernel deps). +The Model-specific addition is the cross-BC `list_all_family_ids` +precondition: the handler resolves every element of +`command.declared_families` against the Family read repo before +invoking the decider, and raises `FamilyNotFoundError` on miss. + +`list_all_family_ids` reads from `proj_equipment_family_summary` and +returns `[]` when `pool is None` (the in-memory test default). +Tests that need a populated Family set monkeypatch the symbol +imported into `define_model.handler` rather than seeding a real +projection. +""" + +from datetime import UTC, datetime +from uuid import UUID + +import pytest + +from cora.equipment import UnauthorizedError +from cora.equipment.aggregates.family import FamilyNotFoundError +from cora.equipment.aggregates.model import Manufacturer, ManufacturerName +from cora.equipment.features import define_model +from cora.equipment.features.define_model import DefineModel +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, 1, 12, 0, 0, tzinfo=UTC) +_NEW_ID = UUID("01900000-0000-7000-8000-000000007ab1") +_EVENT_ID = UUID("01900000-0000-7000-8000-000000007be1") +_PRINCIPAL_ID = UUID("01900000-0000-7000-8000-000000000099") +_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000aa") +_FAMILY_A_ID = UUID("01900000-0000-7000-8000-00000000fa01") +_FAMILY_B_ID = UUID("01900000-0000-7000-8000-00000000fa02") +_FAMILY_MISSING_ID = UUID("01900000-0000-7000-8000-00000000fa99") + + +def _build_deps( + *, + event_store: InMemoryEventStore | None = None, + deny: bool = False, +) -> Kernel: + """Thin wrapper preserving this file's ID list + clock.""" + return _build_deps_shared( + ids=[_NEW_ID, _EVENT_ID], + now=_NOW, + event_store=event_store, + deny=deny, + ) + + +def _patch_known_families( + monkeypatch: pytest.MonkeyPatch, + family_ids: list[UUID], +) -> None: + """Patch `list_all_family_ids` as imported into the handler module. + + The handler does `from cora.equipment.aggregates.family import + list_family_ids` at module load, so monkeypatching the source + function leaves the bound name in the handler stale. We patch the + name in the handler module's namespace, which is the binding the + handler actually calls. + """ + + async def _fake_list_family_ids(_pool: object) -> list[UUID]: + return list(family_ids) + + monkeypatch.setattr( + "cora.equipment.features.define_model.handler.list_all_family_ids", + _fake_list_family_ids, + ) + + +def _command(declared_families: frozenset[UUID]) -> DefineModel: + return DefineModel( + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=declared_families, + ) + + +@pytest.mark.unit +async def test_handler_returns_generated_model_id( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_known_families(monkeypatch, [_FAMILY_A_ID]) + deps = _build_deps() + handler = define_model.bind(deps) + + result = await handler( + _command(frozenset({_FAMILY_A_ID})), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + assert result == _NEW_ID + + +@pytest.mark.unit +async def test_handler_raises_unauthorized_on_deny( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_known_families(monkeypatch, [_FAMILY_A_ID]) + deps = _build_deps(deny=True) + handler = define_model.bind(deps) + + with pytest.raises(UnauthorizedError) as exc_info: + await handler( + _command(frozenset({_FAMILY_A_ID})), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert exc_info.value.reason == "denied for test" + + +@pytest.mark.unit +async def test_handler_does_not_append_when_denied( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_known_families(monkeypatch, [_FAMILY_A_ID]) + store = InMemoryEventStore() + deps = _build_deps(event_store=store, deny=True) + handler = define_model.bind(deps) + + with pytest.raises(UnauthorizedError): + await handler( + _command(frozenset({_FAMILY_A_ID})), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + events, version = await store.load("Model", _NEW_ID) + assert events == [] + assert version == 0 + + +@pytest.mark.unit +async def test_handler_raises_family_not_found_for_unregistered_family( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Cross-BC precondition: declared_families containing an id that + doesn't resolve via `list_all_family_ids` raises `FamilyNotFoundError`.""" + _patch_known_families(monkeypatch, [_FAMILY_A_ID]) + deps = _build_deps() + handler = define_model.bind(deps) + + with pytest.raises(FamilyNotFoundError): + await handler( + _command(frozenset({_FAMILY_A_ID, _FAMILY_MISSING_ID})), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + +@pytest.mark.unit +async def test_handler_proceeds_when_all_declared_families_resolve( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Every declared family resolves against the fake lookup, so the + handler reaches the decider and returns the new id.""" + _patch_known_families(monkeypatch, [_FAMILY_A_ID, _FAMILY_B_ID]) + deps = _build_deps() + handler = define_model.bind(deps) + + result = await handler( + _command(frozenset({_FAMILY_A_ID, _FAMILY_B_ID})), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + assert result == _NEW_ID + + +@pytest.mark.unit +async def test_handler_appends_model_defined_event_to_store( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """After success, the event store receives stream_type='Model', + expected_version=0, and exactly one NewEvent of type 'ModelDefined'.""" + _patch_known_families(monkeypatch, [_FAMILY_A_ID]) + store = InMemoryEventStore() + deps = _build_deps(event_store=store) + handler = define_model.bind(deps) + + await handler( + _command(frozenset({_FAMILY_A_ID})), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + events, version = await store.load("Model", _NEW_ID) + assert version == 1 + assert len(events) == 1 + stored = events[0] + assert stored.event_type == "ModelDefined" + assert stored.schema_version == 1 + assert stored.correlation_id == _CORRELATION_ID + assert stored.causation_id is None + assert stored.event_id == _EVENT_ID + assert stored.metadata == {"command": "DefineModel"} + assert stored.occurred_at == _NOW + assert stored.payload["model_id"] == str(_NEW_ID) + assert stored.payload["name"] == "Aerotech ANT130-L" + assert stored.payload["part_number"] == "ANT130-L" + assert stored.payload["declared_families"] == [str(_FAMILY_A_ID)] diff --git a/apps/api/tests/unit/equipment/test_deprecate_model_decider.py b/apps/api/tests/unit/equipment/test_deprecate_model_decider.py new file mode 100644 index 000000000..12da52551 --- /dev/null +++ b/apps/api/tests/unit/equipment/test_deprecate_model_decider.py @@ -0,0 +1,164 @@ +"""Unit tests for the `deprecate_model` slice's pure decider. + +Multi-source-state guard: `Defined | Versioned -> Deprecated`. Same +source-set as version_model but the target is terminal. +Re-deprecating raises (strict-not-idempotent, mirrors deprecate_family). +The `reason` is validated defensively via `ModelDeprecationReason` so +direct decider callers get the same bounded-text protection as +API-boundary callers. +""" + +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest + +from cora.equipment.aggregates.model import ( + MODEL_DEPRECATION_REASON_MAX_LENGTH, + InvalidModelDeprecationReasonError, + Manufacturer, + ManufacturerName, + Model, + ModelCannotDeprecateError, + ModelDeprecated, + ModelName, + ModelNotFoundError, + ModelStatus, + PartNumber, +) +from cora.equipment.features import deprecate_model +from cora.equipment.features.deprecate_model import DeprecateModel + +_NOW = datetime(2026, 6, 1, 12, 0, 0, tzinfo=UTC) +_REASON = "Vendor end-of-life 2026-Q3; replaced by ANT130-LZS" + + +def _model( + *, + status: ModelStatus = ModelStatus.DEFINED, + version: str | None = None, +) -> Model: + return Model( + id=uuid4(), + name=ModelName("Aerotech ANT130-L"), + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number=PartNumber("ANT130-L"), + declared_families=frozenset({uuid4()}), + status=status, + version=version, + ) + + +@pytest.mark.unit +@pytest.mark.parametrize( + "source", + [ModelStatus.DEFINED, ModelStatus.VERSIONED], +) +def test_decide_emits_model_deprecated_for_each_allowed_source_status( + source: ModelStatus, +) -> None: + state = _model(status=source, version="v1" if source is ModelStatus.VERSIONED else None) + events = deprecate_model.decide( + state=state, + command=DeprecateModel(model_id=state.id, reason=_REASON), + now=_NOW, + ) + assert events == [ + ModelDeprecated(model_id=state.id, reason=_REASON, occurred_at=_NOW), + ] + + +@pytest.mark.unit +def test_decide_raises_model_not_found_when_state_is_none() -> None: + target_id = uuid4() + with pytest.raises(ModelNotFoundError) as exc_info: + deprecate_model.decide( + state=None, + command=DeprecateModel(model_id=target_id, reason=_REASON), + now=_NOW, + ) + assert exc_info.value.model_id == target_id + + +@pytest.mark.unit +def test_decide_raises_cannot_deprecate_when_already_deprecated() -> None: + """Strict-not-idempotent: re-deprecating raises.""" + state = _model(status=ModelStatus.DEPRECATED, version="v1") + with pytest.raises(ModelCannotDeprecateError) as exc_info: + deprecate_model.decide( + state=state, + command=DeprecateModel(model_id=state.id, reason=_REASON), + now=_NOW, + ) + assert exc_info.value.model_id == state.id + assert exc_info.value.current_status is ModelStatus.DEPRECATED + + +@pytest.mark.unit +def test_decide_error_message_lists_both_allowed_source_statuses() -> None: + state = _model(status=ModelStatus.DEPRECATED, version="v1") + with pytest.raises(ModelCannotDeprecateError) as exc_info: + deprecate_model.decide( + state=state, + command=DeprecateModel(model_id=state.id, reason=_REASON), + now=_NOW, + ) + msg = str(exc_info.value) + assert "Defined" in msg + assert "Versioned" in msg + + +@pytest.mark.unit +def test_decide_rejects_empty_reason() -> None: + state = _model() + with pytest.raises(InvalidModelDeprecationReasonError): + deprecate_model.decide( + state=state, + command=DeprecateModel(model_id=state.id, reason=""), + now=_NOW, + ) + + +@pytest.mark.unit +def test_decide_rejects_whitespace_only_reason() -> None: + state = _model() + with pytest.raises(InvalidModelDeprecationReasonError): + deprecate_model.decide( + state=state, + command=DeprecateModel(model_id=state.id, reason=" "), + now=_NOW, + ) + + +@pytest.mark.unit +def test_decide_rejects_over_long_reason() -> None: + state = _model() + too_long = "x" * (MODEL_DEPRECATION_REASON_MAX_LENGTH + 1) + with pytest.raises(InvalidModelDeprecationReasonError): + deprecate_model.decide( + state=state, + command=DeprecateModel(model_id=state.id, reason=too_long), + now=_NOW, + ) + + +@pytest.mark.unit +def test_decide_trims_reason_before_embedding_in_event() -> None: + """The VO trims surrounding whitespace; the emitted event carries + the trimmed value, not the raw input.""" + state = _model() + events = deprecate_model.decide( + state=state, + command=DeprecateModel(model_id=state.id, reason=f" {_REASON} "), + now=_NOW, + ) + assert events[0].reason == _REASON + + +@pytest.mark.unit +def test_decide_is_pure_same_inputs_same_outputs() -> None: + state = _model() + command = DeprecateModel(model_id=state.id, reason=_REASON) + first = deprecate_model.decide(state=state, command=command, now=_NOW) + second = deprecate_model.decide(state=state, command=command, now=_NOW) + assert first == second diff --git a/apps/api/tests/unit/equipment/test_deprecate_model_decider_properties.py b/apps/api/tests/unit/equipment/test_deprecate_model_decider_properties.py new file mode 100644 index 000000000..0ac4e9137 --- /dev/null +++ b/apps/api/tests/unit/equipment/test_deprecate_model_decider_properties.py @@ -0,0 +1,233 @@ +"""Property-based tests for `deprecate_model.decide` (Equipment BC). + +Mirrors the `version_model` decider-PBT pattern, adapted for the +multi-source `Defined | Versioned -> Deprecated` transition. Universal +claims across generated inputs: + + - state in {Defined, Versioned} + valid command emits exactly one + ModelDeprecated carrying the trimmed reason and the injected + `now` timestamp. + - state=None always raises ModelNotFoundError, regardless of command. + - state.status==Deprecated always raises ModelCannotDeprecateError. + - Empty, whitespace-only, or over-long `reason` always raises + InvalidModelDeprecationReasonError (via the ModelDeprecationReason VO). + - Pure: same (state, command, now) returns the same events. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from hypothesis import given +from hypothesis import strategies as st + +from cora.equipment.aggregates.model import ( + MODEL_DEPRECATION_REASON_MAX_LENGTH, + InvalidModelDeprecationReasonError, + Manufacturer, + ManufacturerName, + Model, + ModelCannotDeprecateError, + ModelDeprecated, + ModelName, + ModelNotFoundError, + ModelStatus, + PartNumber, +) +from cora.equipment.features import deprecate_model +from cora.equipment.features.deprecate_model import DeprecateModel +from tests._strategies import aware_datetimes, printable_ascii_text + +if TYPE_CHECKING: + from datetime import datetime + from uuid import UUID + + +_REASON = printable_ascii_text(min_size=1, max_size=MODEL_DEPRECATION_REASON_MAX_LENGTH) + +# Deprecatable source statuses: Defined (first revision) and Versioned +# (subsequent revisions). Deprecated is excluded; it's covered by a +# dedicated rejection property. +_DEPRECATABLE_STATUS = st.sampled_from([ModelStatus.DEFINED, ModelStatus.VERSIONED]) + +# Negative-case alphabet for the bounded-text reason VO. +_WHITESPACE_CHARS = st.sampled_from([" ", "\t", "\n", "\r", " ", " \t\n"]) + + +def _invalid_reason() -> st.SearchStrategy[str]: + """Empty, whitespace-only, or over-length strings for VO rejection PBTs.""" + return st.one_of( + st.just(""), + _WHITESPACE_CHARS, + printable_ascii_text( + min_size=MODEL_DEPRECATION_REASON_MAX_LENGTH + 1, + max_size=MODEL_DEPRECATION_REASON_MAX_LENGTH + 50, + ), + ) + + +def _padded_text(inner_strategy: st.SearchStrategy[str]) -> st.SearchStrategy[str]: + """Wrap an inner text strategy in random leading + trailing whitespace. + + Distinguishes "VO trims at construction" from "decider stores raw + command text": if the emitted event payload still carries the + untrimmed wrapper, the decider is leaking `command.` instead + of the VO's `.value`. + """ + + @st.composite + def build(draw: st.DrawFn) -> str: + leading = draw(st.text(alphabet=" \t\n", max_size=10)) + core = draw(inner_strategy) + trailing = draw(st.text(alphabet=" \t\n", max_size=10)) + return leading + core + trailing + + return build() + + +def _model(model_id: UUID, *, status: ModelStatus) -> Model: + return Model( + id=model_id, + name=ModelName("Existing"), + manufacturer=Manufacturer(name=ManufacturerName("M")), + part_number=PartNumber("P"), + declared_families=frozenset({model_id}), + status=status, + version="v0" if status is ModelStatus.VERSIONED else None, + ) + + +@pytest.mark.unit +@given( + model_id=st.uuids(), + status=_DEPRECATABLE_STATUS, + reason=_REASON, + now=aware_datetimes(), +) +def test_deprecate_model_emits_exactly_one_event_with_injected_fields( + model_id: UUID, + status: ModelStatus, + reason: str, + now: datetime, +) -> None: + """Deprecatable source + valid command -> single ModelDeprecated with + the trimmed reason and injected `now`.""" + state = _model(model_id, status=status) + command = DeprecateModel(model_id=model_id, reason=reason) + events = deprecate_model.decide(state=state, command=command, now=now) + assert events == [ + ModelDeprecated( + model_id=model_id, + reason=reason, + occurred_at=now, + ) + ] + + +@pytest.mark.unit +@given( + model_id=st.uuids(), + reason=_REASON, + now=aware_datetimes(), +) +def test_deprecate_model_on_empty_state_always_raises_not_found( + model_id: UUID, + reason: str, + now: datetime, +) -> None: + """state=None -> ModelNotFoundError carrying command.model_id.""" + command = DeprecateModel(model_id=model_id, reason=reason) + with pytest.raises(ModelNotFoundError) as exc: + deprecate_model.decide(state=None, command=command, now=now) + assert exc.value.model_id == model_id + + +@pytest.mark.unit +@given( + model_id=st.uuids(), + reason=_REASON, + now=aware_datetimes(), +) +def test_deprecate_model_on_deprecated_state_always_raises_cannot_deprecate( + model_id: UUID, + reason: str, + now: datetime, +) -> None: + """state.status==Deprecated -> ModelCannotDeprecateError.""" + state = _model(model_id, status=ModelStatus.DEPRECATED) + command = DeprecateModel(model_id=model_id, reason=reason) + with pytest.raises(ModelCannotDeprecateError) as exc: + deprecate_model.decide(state=state, command=command, now=now) + assert exc.value.model_id == model_id + assert exc.value.current_status is ModelStatus.DEPRECATED + + +@pytest.mark.unit +@given( + model_id=st.uuids(), + status=_DEPRECATABLE_STATUS, + reason=_invalid_reason(), + now=aware_datetimes(), +) +def test_deprecate_model_with_invalid_reason_always_raises( + model_id: UUID, + status: ModelStatus, + reason: str, + now: datetime, +) -> None: + """Empty, whitespace-only, or over-long reason -> InvalidModelDeprecationReasonError.""" + state = _model(model_id, status=status) + command = DeprecateModel(model_id=model_id, reason=reason) + with pytest.raises(InvalidModelDeprecationReasonError): + deprecate_model.decide(state=state, command=command, now=now) + + +@pytest.mark.unit +@given( + model_id=st.uuids(), + status=_DEPRECATABLE_STATUS, + reason=_padded_text(_REASON), + now=aware_datetimes(), +) +def test_deprecate_model_event_carries_trimmed_reason( + model_id: UUID, + status: ModelStatus, + reason: str, + now: datetime, +) -> None: + """Padded input -> ModelDeprecated.reason carries the trimmed value, + never the raw command string with leading or trailing whitespace. + + Closes a coverage gap in printable_ascii_text (which excludes + whitespace): without this property, the decider could emit + `command.reason` raw instead of `ModelDeprecationReason(...).value` + and still pass every other PBT in this module. + """ + state = _model(model_id, status=status) + command = DeprecateModel(model_id=model_id, reason=reason) + events = deprecate_model.decide(state=state, command=command, now=now) + assert len(events) == 1 + event = events[0] + assert event.reason == event.reason.strip() + + +@pytest.mark.unit +@given( + model_id=st.uuids(), + status=_DEPRECATABLE_STATUS, + reason=_REASON, + now=aware_datetimes(), +) +def test_deprecate_model_is_pure_same_input_same_output( + model_id: UUID, + status: ModelStatus, + reason: str, + now: datetime, +) -> None: + """Two calls with identical args return identical events.""" + state = _model(model_id, status=status) + command = DeprecateModel(model_id=model_id, reason=reason) + first = deprecate_model.decide(state=state, command=command, now=now) + second = deprecate_model.decide(state=state, command=command, now=now) + assert first == second diff --git a/apps/api/tests/unit/equipment/test_deprecate_model_handler.py b/apps/api/tests/unit/equipment/test_deprecate_model_handler.py new file mode 100644 index 000000000..731b8b1d9 --- /dev/null +++ b/apps/api/tests/unit/equipment/test_deprecate_model_handler.py @@ -0,0 +1,224 @@ +"""Unit tests for the `deprecate_model` application handler. + +Update-style handler (mirrors version_model and deprecate_family): +load + fold + decide + append. Not idempotency-wrapped; domain- +idempotent via `ModelCannotDeprecateError` on retry from `Deprecated`. + +Tests cover the happy path (returns None + appends one +ModelDeprecated event), the auth deny path, the +ModelNotFoundError on a missing stream, and the +ModelCannotDeprecateError on re-deprecation. + +The Deprecated path drives the slice itself to land the Model in +Deprecated state, then invokes the handler a second time and expects +ModelCannotDeprecateError (strict-not-idempotent). +""" + +from datetime import UTC, datetime +from uuid import UUID + +import pytest + +from cora.equipment import EquipmentHandlers, UnauthorizedError, wire_equipment +from cora.equipment.aggregates.model import ( + Manufacturer, + ManufacturerName, + ModelCannotDeprecateError, + ModelNotFoundError, +) +from cora.equipment.features import define_model, deprecate_model +from cora.equipment.features.define_model import DefineModel +from cora.equipment.features.deprecate_model import DeprecateModel +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, 1, 12, 0, 0, tzinfo=UTC) +_MODEL_ID = UUID("01900000-0000-7000-8000-00000007ad11") +_DEFINED_EVENT_ID = UUID("01900000-0000-7000-8000-00000007ad12") +_DEPRECATED_EVENT_ID = UUID("01900000-0000-7000-8000-00000007ad13") +_EXTRA_EVENT_ID = UUID("01900000-0000-7000-8000-00000007ad14") +_PRINCIPAL_ID = UUID("01900000-0000-7000-8000-000000000099") +_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000aa") +_FAMILY_A_ID = UUID("01900000-0000-7000-8000-00000000fad1") + +_REASON = "Vendor end-of-life 2026-Q3" + + +def _build_deps( + *, + event_store: InMemoryEventStore | None = None, + deny: bool = False, +) -> Kernel: + """Thin wrapper preserving this file's ID list + clock.""" + return _build_deps_shared( + ids=[_MODEL_ID, _DEFINED_EVENT_ID, _DEPRECATED_EVENT_ID, _EXTRA_EVENT_ID], + now=_NOW, + event_store=event_store, + deny=deny, + ) + + +def _patch_known_families( + monkeypatch: pytest.MonkeyPatch, + family_ids: list[UUID], +) -> None: + """Patch `list_all_family_ids` as imported into the define_model handler. + + `deprecate_model` does NOT call `list_all_family_ids`, but the + seeding call to `define_model` does. We stub it accept-all so the + seed succeeds in the in-memory harness. + """ + + async def _fake_list_family_ids(_pool: object) -> list[UUID]: + return list(family_ids) + + monkeypatch.setattr( + "cora.equipment.features.define_model.handler.list_all_family_ids", + _fake_list_family_ids, + ) + + +def _define_command() -> DefineModel: + return DefineModel( + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({_FAMILY_A_ID}), + ) + + +async def _seed_model(deps: Kernel) -> None: + """Define a Model via the public handler so the stream is initialized.""" + await define_model.bind(deps)( + _define_command(), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + +@pytest.mark.unit +async def test_handler_returns_none_on_success( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_known_families(monkeypatch, [_FAMILY_A_ID]) + store = InMemoryEventStore() + deps = _build_deps(event_store=store) + await _seed_model(deps) + + result = await deprecate_model.bind(deps)( + DeprecateModel(model_id=_MODEL_ID, reason=_REASON), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert result is None + + +@pytest.mark.unit +async def test_handler_appends_model_deprecated_event( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_known_families(monkeypatch, [_FAMILY_A_ID]) + store = InMemoryEventStore() + deps = _build_deps(event_store=store) + await _seed_model(deps) + + await deprecate_model.bind(deps)( + DeprecateModel(model_id=_MODEL_ID, reason=_REASON), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + events, version = await store.load("Model", _MODEL_ID) + assert version == 2 + assert [e.event_type for e in events] == ["ModelDefined", "ModelDeprecated"] + deprecated = events[1] + assert deprecated.event_id == _DEPRECATED_EVENT_ID + assert deprecated.metadata == {"command": "DeprecateModel"} + assert deprecated.payload["model_id"] == str(_MODEL_ID) + assert deprecated.payload["reason"] == _REASON + + +@pytest.mark.unit +async def test_handler_raises_model_not_found_when_model_does_not_exist() -> None: + deps = _build_deps() + handler = deprecate_model.bind(deps) + + with pytest.raises(ModelNotFoundError): + await handler( + DeprecateModel(model_id=_MODEL_ID, reason=_REASON), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + +@pytest.mark.unit +async def test_handler_raises_cannot_deprecate_when_already_deprecated( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Strict-not-idempotent: re-deprecating raises.""" + _patch_known_families(monkeypatch, [_FAMILY_A_ID]) + store = InMemoryEventStore() + deps = _build_deps(event_store=store) + await _seed_model(deps) + + handler = deprecate_model.bind(deps) + await handler( + DeprecateModel(model_id=_MODEL_ID, reason=_REASON), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + with pytest.raises(ModelCannotDeprecateError): + await handler( + DeprecateModel(model_id=_MODEL_ID, reason=_REASON), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + +@pytest.mark.unit +async def test_handler_raises_unauthorized_on_deny( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_known_families(monkeypatch, [_FAMILY_A_ID]) + store = InMemoryEventStore() + deps = _build_deps(event_store=store) + await _seed_model(deps) + + deny_deps = _build_deps(event_store=store, deny=True) + with pytest.raises(UnauthorizedError) as exc_info: + await deprecate_model.bind(deny_deps)( + DeprecateModel(model_id=_MODEL_ID, reason=_REASON), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert exc_info.value.reason == "denied for test" + + +@pytest.mark.unit +async def test_handler_propagates_causation_id_to_appended_event( + monkeypatch: pytest.MonkeyPatch, +) -> None: + causation = UUID("01900000-0000-7000-8000-0000000000bb") + _patch_known_families(monkeypatch, [_FAMILY_A_ID]) + store = InMemoryEventStore() + deps = _build_deps(event_store=store) + await _seed_model(deps) + + await deprecate_model.bind(deps)( + DeprecateModel(model_id=_MODEL_ID, reason=_REASON), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + causation_id=causation, + ) + + events, _ = await store.load("Model", _MODEL_ID) + assert events[1].causation_id == causation + + +@pytest.mark.unit +def test_wire_equipment_includes_deprecate_model() -> None: + deps = _build_deps() + handlers = wire_equipment(deps) + assert isinstance(handlers, EquipmentHandlers) + assert callable(handlers.deprecate_model) diff --git a/apps/api/tests/unit/equipment/test_get_model_handler.py b/apps/api/tests/unit/equipment/test_get_model_handler.py new file mode 100644 index 000000000..df3f9086b --- /dev/null +++ b/apps/api/tests/unit/equipment/test_get_model_handler.py @@ -0,0 +1,212 @@ +"""Unit tests for the `get_model` query handler. + +Mirrors `test_get_family_handler.py`. Round-trips through the write +side (define + add_model_family + get) verify that fold-on-read +correctly returns the registered Model state. Read slices don't emit +events, so the assertions focus on (1) authorize wiring (2) the +load + fold spine and (3) None-on-miss semantics. Unlike Family, +no `ModelView` wrapper exists: the Model summary projection does +not carry per-FSM-transition timestamps, so the handler returns +`Model | None` directly. +""" + +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +import pytest + +from cora.equipment import EquipmentHandlers, UnauthorizedError, wire_equipment +from cora.equipment.aggregates.model import ( + Manufacturer, + ManufacturerName, + Model, + ModelName, + ModelStatus, + PartNumber, +) +from cora.equipment.features import add_model_family, define_model, get_model +from cora.equipment.features.add_model_family import AddModelFamily +from cora.equipment.features.define_model import DefineModel +from cora.equipment.features.get_model import GetModel +from cora.infrastructure.adapters.in_memory_event_store import InMemoryEventStore +from cora.infrastructure.kernel import Kernel +from tests.unit._helpers import DenyAllAuthorize as _DenyAllAuthorize +from tests.unit._helpers import RecordingAuthorize as _RecordingAuthorize +from tests.unit._helpers import build_deps as _build_deps_shared + +_NOW = datetime(2026, 6, 1, 12, 0, 0, tzinfo=UTC) +_NEW_ID = UUID("01900000-0000-7000-8000-000000007ab1") +_EVENT_ID = UUID("01900000-0000-7000-8000-000000007be1") +_ADD_EVENT_ID = UUID("01900000-0000-7000-8000-000000007be2") +_PRINCIPAL_ID = UUID("01900000-0000-7000-8000-000000000099") +_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000aa") +_FAMILY_A_ID = UUID("01900000-0000-7000-8000-00000000fa01") +_FAMILY_B_ID = UUID("01900000-0000-7000-8000-00000000fa02") + + +def _build_deps(event_store: InMemoryEventStore | None = None) -> Kernel: + """Thin wrapper preserving this file's ID list + clock.""" + return _build_deps_shared( + ids=[_NEW_ID, _EVENT_ID, _ADD_EVENT_ID], + now=_NOW, + event_store=event_store, + ) + + +def _patch_known_families( + monkeypatch: pytest.MonkeyPatch, + family_ids: list[UUID], + *, + targets: tuple[str, ...] = ( + "cora.equipment.features.define_model.handler.list_all_family_ids", + "cora.equipment.features.add_model_family.handler.list_all_family_ids", + ), +) -> None: + """Stub `list_all_family_ids` as imported into upstream command handlers. + + `get_model` itself does NOT call `list_all_family_ids`; the stub is + needed only for the upstream `define_model` and `add_model_family` + calls that seed the stream this test reads back. + """ + + async def _fake_list_family_ids(_pool: object) -> list[UUID]: + return list(family_ids) + + for target in targets: + monkeypatch.setattr(target, _fake_list_family_ids) + + +@pytest.mark.unit +async def test_handler_returns_model_for_known_id( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Round-trip: define + get.""" + _patch_known_families(monkeypatch, [_FAMILY_A_ID]) + deps = _build_deps() + await define_model.bind(deps)( + DefineModel( + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({_FAMILY_A_ID}), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + handler = get_model.bind(deps) + model = await handler( + GetModel(model_id=_NEW_ID), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + assert model == Model( + id=_NEW_ID, + name=ModelName("Aerotech ANT130-L"), + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number=PartNumber("ANT130-L"), + declared_families=frozenset({_FAMILY_A_ID}), + status=ModelStatus.DEFINED, + version=None, + ) + + +@pytest.mark.unit +async def test_handler_reflects_targeted_mutation_history( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """fold-on-read returns the post-mutation state: define + add a + second family yields a 2-element `declared_families` frozenset.""" + _patch_known_families(monkeypatch, [_FAMILY_A_ID, _FAMILY_B_ID]) + deps = _build_deps() + await define_model.bind(deps)( + DefineModel( + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({_FAMILY_A_ID}), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await add_model_family.bind(deps)( + AddModelFamily(model_id=_NEW_ID, family_id=_FAMILY_B_ID), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + handler = get_model.bind(deps) + model = await handler( + GetModel(model_id=_NEW_ID), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + assert model is not None + assert model.declared_families == frozenset({_FAMILY_A_ID, _FAMILY_B_ID}) + assert model.status is ModelStatus.DEFINED + + +@pytest.mark.unit +async def test_handler_returns_none_for_unknown_id() -> None: + """Missing stream folds to None; the handler does NOT raise + `ModelNotFoundError` here (transition / mutation handlers do; read + handlers leave the not-found mapping to the route / tool layer).""" + deps = _build_deps() + handler = get_model.bind(deps) + model = await handler( + GetModel(model_id=uuid4()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert model is None + + +@pytest.mark.unit +async def test_handler_authorizes_with_query_name_and_default_conduit() -> None: + """Query handlers DO call authorize. Pinned because the eventual + TrustAuthorize swap is mechanical per handler , the call site has + to exist.""" + tracking = _RecordingAuthorize() + deps = _build_deps_shared( + ids=[_NEW_ID, _EVENT_ID], + now=_NOW, + authz=tracking, + ) + + handler = get_model.bind(deps) + await handler( + GetModel(model_id=uuid4()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + assert tracking.calls == [(_PRINCIPAL_ID, "GetModel", UUID(int=0), UUID(int=0))] + + +@pytest.mark.unit +async def test_handler_raises_unauthorized_on_deny() -> None: + deps = _build_deps_shared( + ids=[_NEW_ID, _EVENT_ID], + now=_NOW, + authz=_DenyAllAuthorize(), + ) + + handler = get_model.bind(deps) + with pytest.raises(UnauthorizedError) as exc_info: + await handler( + GetModel(model_id=uuid4()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert exc_info.value.reason == "denied for test" + + +@pytest.mark.unit +def test_wire_equipment_includes_get_model() -> None: + deps = _build_deps() + handlers = wire_equipment(deps) + assert isinstance(handlers, EquipmentHandlers) + assert callable(handlers.get_model) + assert callable(handlers.define_model) diff --git a/apps/api/tests/unit/equipment/test_list_all_family_ids.py b/apps/api/tests/unit/equipment/test_list_all_family_ids.py new file mode 100644 index 000000000..bea73feeb --- /dev/null +++ b/apps/api/tests/unit/equipment/test_list_all_family_ids.py @@ -0,0 +1,30 @@ +"""Unit tests for `list_all_family_ids`. + +Sibling to `list_family_ids` that differs ONLY in whether Deprecated +Families are filtered out. The discovery-side helper +(`list_family_ids`) keeps the `WHERE deprecated_at IS NULL` filter; +the cross-BC existence-check helper (`list_all_family_ids`, used by +`define_model` and `add_model_family`) drops it. Per the Model +aggregate's design memo, Family.deprecation is an authoring signal +NOT a runtime gate, so binding a Model to a Deprecated Family is +permitted; using the discovery filter for the existence check would +surface a misleading `FamilyNotFoundError` for a Family that +genuinely exists. + +Database-backed differentiator (Deprecated-INCLUDED behavior) is +pinned in the integration suite via the deprecated-family flows +through `define_model` and `add_model_family`. This file pins the +pool-None short-circuit at unit tier. +""" + +import pytest + +from cora.equipment.aggregates.family import list_all_family_ids + + +@pytest.mark.unit +async def test_list_all_family_ids_returns_empty_for_none_pool() -> None: + """No-database app_env contract: `pool=None` returns `[]` instead + of raising. Mirrors `list_family_ids`; tests that need a populated + lookup must wire a real pool.""" + assert await list_all_family_ids(None) == [] diff --git a/apps/api/tests/unit/equipment/test_list_model_ids.py b/apps/api/tests/unit/equipment/test_list_model_ids.py new file mode 100644 index 000000000..0db3ac3fd --- /dev/null +++ b/apps/api/tests/unit/equipment/test_list_model_ids.py @@ -0,0 +1,24 @@ +"""Unit tests for `list_model_ids`. + +Discovery-side helper that reads every non-Deprecated Model id from +the `proj_equipment_model_summary` projection. Mirrors +`list_family_ids`: returns `[]` when `pool is None` so the +no-database app_env (and unit tests that do not wire a pool) do not +need a defensive None-check at every call site. + +The Deprecated-excluded behavior and the canonical +`model_id::text`-ascending sort order are pinned in the integration +suite at `tests/integration/test_postgres_list_model_ids.py`. +""" + +import pytest + +from cora.equipment.aggregates.model import list_model_ids + + +@pytest.mark.unit +async def test_list_model_ids_returns_empty_list_when_pool_is_none() -> None: + """No-database app_env contract: `pool=None` returns `[]` instead + of raising. Mirrors `list_family_ids`; tests that need a populated + lookup must wire a real pool.""" + assert await list_model_ids(None) == [] diff --git a/apps/api/tests/unit/equipment/test_model.py b/apps/api/tests/unit/equipment/test_model.py new file mode 100644 index 000000000..504ccc74d --- /dev/null +++ b/apps/api/tests/unit/equipment/test_model.py @@ -0,0 +1,195 @@ +"""Value objects + Model dataclass + ModelStatus enum tests.""" + +from uuid import uuid4 + +import pytest + +from cora.equipment.aggregates.model import ( + MANUFACTURER_IDENTIFIER_MAX_LENGTH, + MANUFACTURER_NAME_MAX_LENGTH, + MODEL_DEPRECATION_REASON_MAX_LENGTH, + MODEL_NAME_MAX_LENGTH, + MODEL_PART_NUMBER_MAX_LENGTH, + MODEL_VERSION_TAG_MAX_LENGTH, + InvalidManufacturerIdentifierError, + InvalidManufacturerIdentifierPairingError, + InvalidManufacturerNameError, + InvalidModelDeprecationReasonError, + InvalidModelNameError, + InvalidModelVersionTagError, + InvalidPartNumberError, + Manufacturer, + ManufacturerIdentifier, + ManufacturerIdentifierType, + ManufacturerName, + Model, + ModelDeprecationReason, + ModelName, + ModelStatus, + ModelVersionTag, + PartNumber, +) + + +@pytest.mark.unit +def test_model_name_accepts_normal_string() -> None: + name = ModelName("Aerotech ANT130-L") + assert name.value == "Aerotech ANT130-L" + + +@pytest.mark.unit +def test_model_name_trims_whitespace() -> None: + name = ModelName(" PCO Edge 5.5 ") + assert name.value == "PCO Edge 5.5" + + +@pytest.mark.unit +def test_model_name_rejects_empty_string() -> None: + with pytest.raises(InvalidModelNameError): + ModelName("") + + +@pytest.mark.unit +def test_model_name_rejects_whitespace_only() -> None: + with pytest.raises(InvalidModelNameError): + ModelName(" \t\n ") + + +@pytest.mark.unit +def test_model_name_rejects_too_long() -> None: + with pytest.raises(InvalidModelNameError): + ModelName("X" * (MODEL_NAME_MAX_LENGTH + 1)) + + +@pytest.mark.unit +def test_part_number_accepts_case_sensitive_sku() -> None: + upper = PartNumber("RV120CCHL") + lower = PartNumber("rv120cchl") + assert upper.value == "RV120CCHL" + assert lower.value == "rv120cchl" + assert upper.value != lower.value + + +@pytest.mark.unit +def test_part_number_trims_whitespace() -> None: + assert PartNumber(" ANT130-L ").value == "ANT130-L" + + +@pytest.mark.unit +def test_part_number_rejects_empty() -> None: + with pytest.raises(InvalidPartNumberError): + PartNumber("") + + +@pytest.mark.unit +def test_part_number_rejects_too_long() -> None: + with pytest.raises(InvalidPartNumberError): + PartNumber("X" * (MODEL_PART_NUMBER_MAX_LENGTH + 1)) + + +@pytest.mark.unit +def test_manufacturer_name_rejects_empty() -> None: + with pytest.raises(InvalidManufacturerNameError): + ManufacturerName("") + + +@pytest.mark.unit +def test_manufacturer_name_rejects_too_long() -> None: + with pytest.raises(InvalidManufacturerNameError): + ManufacturerName("X" * (MANUFACTURER_NAME_MAX_LENGTH + 1)) + + +@pytest.mark.unit +def test_manufacturer_identifier_trims_and_accepts_ror() -> None: + ident = ManufacturerIdentifier(" https://ror.org/05gvnxz63 ") + assert ident.value == "https://ror.org/05gvnxz63" + + +@pytest.mark.unit +def test_manufacturer_identifier_rejects_empty() -> None: + with pytest.raises(InvalidManufacturerIdentifierError): + ManufacturerIdentifier(" ") + + +@pytest.mark.unit +def test_manufacturer_identifier_rejects_too_long() -> None: + with pytest.raises(InvalidManufacturerIdentifierError): + ManufacturerIdentifier("X" * (MANUFACTURER_IDENTIFIER_MAX_LENGTH + 1)) + + +@pytest.mark.unit +def test_manufacturer_accepts_name_only() -> None: + mfr = Manufacturer(name=ManufacturerName("Aerotech")) + assert mfr.name.value == "Aerotech" + assert mfr.identifier is None + assert mfr.identifier_type is None + + +@pytest.mark.unit +def test_manufacturer_accepts_full_triple() -> None: + mfr = Manufacturer( + name=ManufacturerName("Aerotech"), + identifier=ManufacturerIdentifier("https://ror.org/05gvnxz63"), + identifier_type=ManufacturerIdentifierType.ROR, + ) + assert mfr.identifier is not None + assert mfr.identifier.value == "https://ror.org/05gvnxz63" + assert mfr.identifier_type is ManufacturerIdentifierType.ROR + + +@pytest.mark.unit +def test_manufacturer_rejects_identifier_without_type() -> None: + with pytest.raises(InvalidManufacturerIdentifierPairingError): + Manufacturer( + name=ManufacturerName("Aerotech"), + identifier=ManufacturerIdentifier("https://ror.org/05gvnxz63"), + identifier_type=None, + ) + + +@pytest.mark.unit +def test_manufacturer_rejects_type_without_identifier() -> None: + with pytest.raises(InvalidManufacturerIdentifierPairingError): + Manufacturer( + name=ManufacturerName("Aerotech"), + identifier=None, + identifier_type=ManufacturerIdentifierType.GRID, + ) + + +@pytest.mark.unit +def test_model_version_tag_rejects_empty_and_too_long() -> None: + with pytest.raises(InvalidModelVersionTagError): + ModelVersionTag("") + with pytest.raises(InvalidModelVersionTagError): + ModelVersionTag("X" * (MODEL_VERSION_TAG_MAX_LENGTH + 1)) + + +@pytest.mark.unit +def test_model_deprecation_reason_rejects_empty_and_too_long() -> None: + with pytest.raises(InvalidModelDeprecationReasonError): + ModelDeprecationReason("") + with pytest.raises(InvalidModelDeprecationReasonError): + ModelDeprecationReason("X" * (MODEL_DEPRECATION_REASON_MAX_LENGTH + 1)) + + +@pytest.mark.unit +def test_model_status_enum_values() -> None: + assert ModelStatus.DEFINED.value == "Defined" + assert ModelStatus.VERSIONED.value == "Versioned" + assert ModelStatus.DEPRECATED.value == "Deprecated" + + +@pytest.mark.unit +def test_model_aggregate_constructs_with_required_fields() -> None: + family_a = uuid4() + model = Model( + id=uuid4(), + name=ModelName("Aerotech ANT130-L"), + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number=PartNumber("ANT130-L"), + declared_families=frozenset({family_a}), + ) + assert model.status is ModelStatus.DEFINED + assert model.version is None + assert model.declared_families == frozenset({family_a}) diff --git a/apps/api/tests/unit/equipment/test_model_events.py b/apps/api/tests/unit/equipment/test_model_events.py new file mode 100644 index 000000000..da0556483 --- /dev/null +++ b/apps/api/tests/unit/equipment/test_model_events.py @@ -0,0 +1,396 @@ +"""Round-trip tests for Model event payloads (to_payload / from_stored).""" + +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest + +from cora.equipment.aggregates.model import ( + Manufacturer, + ManufacturerIdentifier, + ManufacturerIdentifierType, + ManufacturerName, + ModelDefined, + ModelDeprecated, + ModelFamilyAdded, + ModelFamilyRemoved, + ModelVersioned, + event_type_name, + from_stored, + to_payload, +) +from cora.infrastructure.ports.event_store import StoredEvent + +_NOW = datetime(2026, 6, 1, 12, 0, tzinfo=UTC) + + +def _stored(event_type: str, payload: dict[str, object]) -> StoredEvent: + """Wrap a payload as a StoredEvent for from_stored round-tripping. + + Only `event_type` and `payload` are read by `from_stored`; the rest + is fixture noise. + """ + return StoredEvent( + position=1, + event_id=uuid4(), + stream_type="Model", + stream_id=uuid4(), + version=1, + event_type=event_type, + schema_version=1, + payload=payload, + correlation_id=uuid4(), + causation_id=None, + occurred_at=_NOW, + recorded_at=_NOW, + ) + + +@pytest.mark.unit +def test_model_defined_round_trips_with_minimal_manufacturer() -> None: + family_a = uuid4() + family_b = uuid4() + event = ModelDefined( + model_id=uuid4(), + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({family_a, family_b}), + occurred_at=datetime(2026, 6, 1, 12, 0, tzinfo=UTC), + ) + payload = to_payload(event) + assert payload["manufacturer"] == {"name": "Aerotech"} + assert "identifier" not in payload["manufacturer"] + assert "version_tag" not in payload + restored = from_stored(_stored("ModelDefined", payload)) + assert restored == event + + +@pytest.mark.unit +def test_model_defined_to_payload_serializes_to_canonical_dict_literal() -> None: + """Pin the WIRE shape: explicit dict literal catches key renames on + the to_payload side that a round-trip would mask.""" + model_id = uuid4() + family_a = uuid4() + family_b = uuid4() + event = ModelDefined( + model_id=model_id, + name="Aerotech ANT130-L", + manufacturer=Manufacturer( + name=ManufacturerName("Aerotech"), + identifier=ManufacturerIdentifier("https://ror.org/05gvnxz63"), + identifier_type=ManufacturerIdentifierType.ROR, + ), + part_number="ANT130-L", + declared_families=frozenset({family_a, family_b}), + occurred_at=datetime(2026, 6, 1, 12, 0, tzinfo=UTC), + version_tag="rev-A", + ) + assert to_payload(event) == { + "model_id": str(model_id), + "name": "Aerotech ANT130-L", + "manufacturer": { + "name": "Aerotech", + "identifier": "https://ror.org/05gvnxz63", + "identifier_type": "ROR", + }, + "part_number": "ANT130-L", + "declared_families": sorted([str(family_a), str(family_b)]), + "occurred_at": "2026-06-01T12:00:00+00:00", + "version_tag": "rev-A", + } + + +@pytest.mark.unit +def test_model_defined_from_stored_rebuilds_from_canonical_dict_literal() -> None: + """Pin the READ shape: explicit dict literal catches key renames on + the from_stored side that a round-trip would mask.""" + model_id = uuid4() + family_a = uuid4() + family_b = uuid4() + payload: dict[str, object] = { + "model_id": str(model_id), + "name": "Aerotech ANT130-L", + "manufacturer": { + "name": "Aerotech", + "identifier": "https://ror.org/05gvnxz63", + "identifier_type": "ROR", + }, + "part_number": "ANT130-L", + "declared_families": sorted([str(family_a), str(family_b)]), + "occurred_at": "2026-06-01T12:00:00+00:00", + "version_tag": "rev-A", + } + rebuilt = from_stored(_stored("ModelDefined", payload)) + assert rebuilt == ModelDefined( + model_id=model_id, + name="Aerotech ANT130-L", + manufacturer=Manufacturer( + name=ManufacturerName("Aerotech"), + identifier=ManufacturerIdentifier("https://ror.org/05gvnxz63"), + identifier_type=ManufacturerIdentifierType.ROR, + ), + part_number="ANT130-L", + declared_families=frozenset({family_a, family_b}), + occurred_at=datetime(2026, 6, 1, 12, 0, tzinfo=UTC), + version_tag="rev-A", + ) + + +@pytest.mark.unit +def test_model_defined_round_trips_with_full_manufacturer_and_version_tag() -> None: + event = ModelDefined( + model_id=uuid4(), + name="Aerotech ANT130-L", + manufacturer=Manufacturer( + name=ManufacturerName("Aerotech"), + identifier=ManufacturerIdentifier("https://ror.org/05gvnxz63"), + identifier_type=ManufacturerIdentifierType.ROR, + ), + part_number="ANT130-L", + declared_families=frozenset({uuid4()}), + occurred_at=datetime(2026, 6, 1, 12, 0, tzinfo=UTC), + version_tag="rev-A", + ) + payload = to_payload(event) + assert payload["manufacturer"]["identifier"] == "https://ror.org/05gvnxz63" + assert payload["manufacturer"]["identifier_type"] == "ROR" + assert payload["version_tag"] == "rev-A" + restored = from_stored(_stored("ModelDefined", payload)) + assert restored == event + + +@pytest.mark.unit +def test_model_defined_payload_sorts_declared_families_deterministically() -> None: + family_a = uuid4() + family_b = uuid4() + event = ModelDefined( + model_id=uuid4(), + name="N", + manufacturer=Manufacturer(name=ManufacturerName("M")), + part_number="P", + declared_families=frozenset({family_a, family_b}), + occurred_at=datetime(2026, 6, 1, 0, 0, tzinfo=UTC), + ) + payload = to_payload(event) + assert payload["declared_families"] == sorted([str(family_a), str(family_b)]) + + +@pytest.mark.unit +def test_model_versioned_round_trips() -> None: + event = ModelVersioned( + model_id=uuid4(), + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({uuid4()}), + version_tag="rev-B", + occurred_at=datetime(2026, 6, 1, 13, 0, tzinfo=UTC), + ) + payload = to_payload(event) + restored = from_stored(_stored("ModelVersioned", payload)) + assert restored == event + + +@pytest.mark.unit +def test_model_versioned_to_payload_serializes_to_canonical_dict_literal() -> None: + """Pin the WIRE shape for ModelVersioned: every key + every value.""" + model_id = uuid4() + family_a = uuid4() + family_b = uuid4() + event = ModelVersioned( + model_id=model_id, + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({family_a, family_b}), + version_tag="rev-B", + occurred_at=datetime(2026, 6, 1, 13, 0, tzinfo=UTC), + ) + assert to_payload(event) == { + "model_id": str(model_id), + "name": "Aerotech ANT130-L", + "manufacturer": {"name": "Aerotech"}, + "part_number": "ANT130-L", + "declared_families": sorted([str(family_a), str(family_b)]), + "version_tag": "rev-B", + "occurred_at": "2026-06-01T13:00:00+00:00", + } + + +@pytest.mark.unit +def test_model_versioned_from_stored_rebuilds_from_canonical_dict_literal() -> None: + """Pin the READ shape for ModelVersioned.""" + model_id = uuid4() + family_a = uuid4() + family_b = uuid4() + payload: dict[str, object] = { + "model_id": str(model_id), + "name": "Aerotech ANT130-L", + "manufacturer": {"name": "Aerotech"}, + "part_number": "ANT130-L", + "declared_families": sorted([str(family_a), str(family_b)]), + "version_tag": "rev-B", + "occurred_at": "2026-06-01T13:00:00+00:00", + } + rebuilt = from_stored(_stored("ModelVersioned", payload)) + assert rebuilt == ModelVersioned( + model_id=model_id, + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({family_a, family_b}), + version_tag="rev-B", + occurred_at=datetime(2026, 6, 1, 13, 0, tzinfo=UTC), + ) + + +@pytest.mark.unit +def test_model_deprecated_round_trips() -> None: + event = ModelDeprecated( + model_id=uuid4(), + reason="Vendor end-of-life announcement 2026-05-28", + occurred_at=datetime(2026, 6, 1, 14, 0, tzinfo=UTC), + ) + payload = to_payload(event) + restored = from_stored(_stored("ModelDeprecated", payload)) + assert restored == event + + +@pytest.mark.unit +def test_model_deprecated_to_payload_serializes_to_canonical_dict_literal() -> None: + """Pin the WIRE shape for ModelDeprecated.""" + model_id = uuid4() + event = ModelDeprecated( + model_id=model_id, + reason="Vendor end-of-life announcement 2026-05-28", + occurred_at=datetime(2026, 6, 1, 14, 0, tzinfo=UTC), + ) + assert to_payload(event) == { + "model_id": str(model_id), + "reason": "Vendor end-of-life announcement 2026-05-28", + "occurred_at": "2026-06-01T14:00:00+00:00", + } + + +@pytest.mark.unit +def test_model_deprecated_from_stored_rebuilds_from_canonical_dict_literal() -> None: + """Pin the READ shape for ModelDeprecated.""" + model_id = uuid4() + payload: dict[str, object] = { + "model_id": str(model_id), + "reason": "Vendor end-of-life announcement 2026-05-28", + "occurred_at": "2026-06-01T14:00:00+00:00", + } + rebuilt = from_stored(_stored("ModelDeprecated", payload)) + assert rebuilt == ModelDeprecated( + model_id=model_id, + reason="Vendor end-of-life announcement 2026-05-28", + occurred_at=datetime(2026, 6, 1, 14, 0, tzinfo=UTC), + ) + + +@pytest.mark.unit +def test_model_family_added_round_trips() -> None: + event = ModelFamilyAdded( + model_id=uuid4(), + family_id=uuid4(), + occurred_at=datetime(2026, 6, 1, 15, 0, tzinfo=UTC), + ) + payload = to_payload(event) + restored = from_stored(_stored("ModelFamilyAdded", payload)) + assert restored == event + + +@pytest.mark.unit +def test_model_family_added_to_payload_serializes_to_canonical_dict_literal() -> None: + """Pin the WIRE shape for ModelFamilyAdded.""" + model_id = uuid4() + family_id = uuid4() + event = ModelFamilyAdded( + model_id=model_id, + family_id=family_id, + occurred_at=datetime(2026, 6, 1, 15, 0, tzinfo=UTC), + ) + assert to_payload(event) == { + "model_id": str(model_id), + "family_id": str(family_id), + "occurred_at": "2026-06-01T15:00:00+00:00", + } + + +@pytest.mark.unit +def test_model_family_added_from_stored_rebuilds_from_canonical_dict_literal() -> None: + """Pin the READ shape for ModelFamilyAdded.""" + model_id = uuid4() + family_id = uuid4() + payload: dict[str, object] = { + "model_id": str(model_id), + "family_id": str(family_id), + "occurred_at": "2026-06-01T15:00:00+00:00", + } + rebuilt = from_stored(_stored("ModelFamilyAdded", payload)) + assert rebuilt == ModelFamilyAdded( + model_id=model_id, + family_id=family_id, + occurred_at=datetime(2026, 6, 1, 15, 0, tzinfo=UTC), + ) + + +@pytest.mark.unit +def test_model_family_removed_round_trips() -> None: + event = ModelFamilyRemoved( + model_id=uuid4(), + family_id=uuid4(), + occurred_at=datetime(2026, 6, 1, 16, 0, tzinfo=UTC), + ) + payload = to_payload(event) + restored = from_stored(_stored("ModelFamilyRemoved", payload)) + assert restored == event + + +@pytest.mark.unit +def test_model_family_removed_to_payload_serializes_to_canonical_dict_literal() -> None: + """Pin the WIRE shape for ModelFamilyRemoved.""" + model_id = uuid4() + family_id = uuid4() + event = ModelFamilyRemoved( + model_id=model_id, + family_id=family_id, + occurred_at=datetime(2026, 6, 1, 16, 0, tzinfo=UTC), + ) + assert to_payload(event) == { + "model_id": str(model_id), + "family_id": str(family_id), + "occurred_at": "2026-06-01T16:00:00+00:00", + } + + +@pytest.mark.unit +def test_model_family_removed_from_stored_rebuilds_from_canonical_dict_literal() -> None: + """Pin the READ shape for ModelFamilyRemoved.""" + model_id = uuid4() + family_id = uuid4() + payload: dict[str, object] = { + "model_id": str(model_id), + "family_id": str(family_id), + "occurred_at": "2026-06-01T16:00:00+00:00", + } + rebuilt = from_stored(_stored("ModelFamilyRemoved", payload)) + assert rebuilt == ModelFamilyRemoved( + model_id=model_id, + family_id=family_id, + occurred_at=datetime(2026, 6, 1, 16, 0, tzinfo=UTC), + ) + + +@pytest.mark.unit +def test_from_stored_rejects_unknown_event_type() -> None: + with pytest.raises(ValueError, match="Unknown ModelEvent event_type"): + from_stored(_stored("ModelMystery", {})) + + +@pytest.mark.unit +def test_event_type_name_returns_class_name() -> None: + event = ModelDeprecated(model_id=uuid4(), reason="r", occurred_at=datetime.now(tz=UTC)) + assert event_type_name(event) == "ModelDeprecated" diff --git a/apps/api/tests/unit/equipment/test_model_evolver.py b/apps/api/tests/unit/equipment/test_model_evolver.py new file mode 100644 index 000000000..e2b233587 --- /dev/null +++ b/apps/api/tests/unit/equipment/test_model_evolver.py @@ -0,0 +1,199 @@ +"""FSM evolution tests for the Model aggregate.""" + +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest + +from cora.equipment.aggregates.model import ( + Manufacturer, + ManufacturerName, + ModelDefined, + ModelDeprecated, + ModelFamilyAdded, + ModelFamilyRemoved, + ModelStatus, + ModelVersioned, + evolve, + fold, +) + + +def _now() -> datetime: + return datetime(2026, 6, 1, 12, 0, tzinfo=UTC) + + +def _defined(family_id: object | None = None) -> ModelDefined: + family = family_id if isinstance(family_id, type(uuid4())) else uuid4() + return ModelDefined( + model_id=uuid4(), + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({family}), + occurred_at=_now(), + ) + + +@pytest.mark.unit +def test_model_defined_sets_genesis_state() -> None: + event = _defined() + state = evolve(None, event) + assert state.id == event.model_id + assert state.name.value == "Aerotech ANT130-L" + assert state.status is ModelStatus.DEFINED + assert state.version is None + assert state.declared_families == event.declared_families + + +@pytest.mark.unit +def test_model_defined_with_initial_version_tag_carries_through() -> None: + event = ModelDefined( + model_id=uuid4(), + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({uuid4()}), + occurred_at=_now(), + version_tag="rev-A", + ) + state = evolve(None, event) + assert state.version == "rev-A" + + +@pytest.mark.unit +def test_model_versioned_transitions_from_defined() -> None: + defined = _defined() + new_family = uuid4() + versioned = ModelVersioned( + model_id=defined.model_id, + name="Aerotech ANT130-L (rev B)", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech Newport JV")), + part_number="ANT130-L-B", + declared_families=frozenset({new_family}), + version_tag="rev-B", + occurred_at=_now(), + ) + state = fold([defined, versioned]) + assert state is not None + assert state.status is ModelStatus.VERSIONED + assert state.version == "rev-B" + # Wholesale replacement: name, manufacturer, part_number, families all swapped. + assert state.name.value == "Aerotech ANT130-L (rev B)" + assert state.manufacturer.name.value == "Aerotech Newport JV" + assert state.part_number.value == "ANT130-L-B" + assert state.declared_families == frozenset({new_family}) + + +@pytest.mark.unit +def test_model_versioned_transitions_from_versioned() -> None: + defined = _defined() + v1 = ModelVersioned( + model_id=defined.model_id, + name="A", + manufacturer=Manufacturer(name=ManufacturerName("M")), + part_number="P", + declared_families=frozenset({uuid4()}), + version_tag="rev-B", + occurred_at=_now(), + ) + v2 = ModelVersioned( + model_id=defined.model_id, + name="A", + manufacturer=Manufacturer(name=ManufacturerName("M")), + part_number="P", + declared_families=frozenset({uuid4()}), + version_tag="rev-C", + occurred_at=_now(), + ) + state = fold([defined, v1, v2]) + assert state is not None + assert state.status is ModelStatus.VERSIONED + assert state.version == "rev-C" + + +@pytest.mark.unit +def test_model_deprecated_transitions_from_defined() -> None: + defined = _defined() + deprecated = ModelDeprecated( + model_id=defined.model_id, + reason="EOL", + occurred_at=_now(), + ) + state = fold([defined, deprecated]) + assert state is not None + assert state.status is ModelStatus.DEPRECATED + # declared_families preserved across deprecation (audit trail). + assert state.declared_families == defined.declared_families + + +@pytest.mark.unit +def test_model_deprecated_transitions_from_versioned() -> None: + defined = _defined() + versioned = ModelVersioned( + model_id=defined.model_id, + name="A", + manufacturer=Manufacturer(name=ManufacturerName("M")), + part_number="P", + declared_families=frozenset({uuid4()}), + version_tag="rev-B", + occurred_at=_now(), + ) + deprecated = ModelDeprecated(model_id=defined.model_id, reason="r", occurred_at=_now()) + state = fold([defined, versioned, deprecated]) + assert state is not None + assert state.status is ModelStatus.DEPRECATED + assert state.version == "rev-B" + + +@pytest.mark.unit +def test_model_family_added_extends_declared_families() -> None: + defined = _defined() + extra = uuid4() + added = ModelFamilyAdded(model_id=defined.model_id, family_id=extra, occurred_at=_now()) + state = fold([defined, added]) + assert state is not None + assert state.status is ModelStatus.DEFINED # status preserved on targeted mutation + assert state.declared_families == defined.declared_families | {extra} + + +@pytest.mark.unit +def test_model_family_removed_shrinks_declared_families() -> None: + family_a = uuid4() + family_b = uuid4() + defined = ModelDefined( + model_id=uuid4(), + name="N", + manufacturer=Manufacturer(name=ManufacturerName("M")), + part_number="P", + declared_families=frozenset({family_a, family_b}), + occurred_at=_now(), + ) + removed = ModelFamilyRemoved( + model_id=defined.model_id, + family_id=family_a, + occurred_at=_now(), + ) + state = fold([defined, removed]) + assert state is not None + assert state.declared_families == frozenset({family_b}) + + +@pytest.mark.unit +def test_versioning_empty_stream_raises() -> None: + versioned = ModelVersioned( + model_id=uuid4(), + name="N", + manufacturer=Manufacturer(name=ManufacturerName("M")), + part_number="P", + declared_families=frozenset({uuid4()}), + version_tag="rev-B", + occurred_at=_now(), + ) + with pytest.raises(ValueError): + evolve(None, versioned) + + +@pytest.mark.unit +def test_fold_empty_stream_returns_none() -> None: + assert fold([]) is None diff --git a/apps/api/tests/unit/equipment/test_model_summary_projection.py b/apps/api/tests/unit/equipment/test_model_summary_projection.py new file mode 100644 index 000000000..fede8d560 --- /dev/null +++ b/apps/api/tests/unit/equipment/test_model_summary_projection.py @@ -0,0 +1,295 @@ +"""Unit tests for ModelSummaryProjection. + +Pins per-event-type apply() dispatch + idempotency for the 5 +subscribed Model events. Postgres-side behavior (vendor-key UNIQUE +index, JSONB re-aggregation correctness, replay safety) is in the +integration suite. +""" + +from datetime import UTC, datetime +from typing import Any +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest + +from cora.equipment.projections import ModelSummaryProjection +from cora.infrastructure.ports.event_store import StoredEvent + +_MODEL_ID = uuid4() +_FAMILY_A_ID = uuid4() +_FAMILY_B_ID = uuid4() +_EVENT_ID = uuid4() +_CORRELATION_ID = uuid4() +_NOW = datetime(2026, 6, 1, 14, 0, 0, tzinfo=UTC) + + +def _stored(event_type: str, payload: dict[str, Any]) -> StoredEvent: + return StoredEvent( + position=1, + event_id=_EVENT_ID, + stream_type="Model", + stream_id=_MODEL_ID, + version=1, + event_type=event_type, + schema_version=1, + payload=payload, + correlation_id=_CORRELATION_ID, + causation_id=None, + occurred_at=_NOW, + recorded_at=_NOW, + ) + + +@pytest.mark.unit +def test_projection_metadata() -> None: + proj = ModelSummaryProjection() + assert proj.name == "proj_equipment_model_summary" + assert proj.subscribed_event_types == frozenset( + { + "ModelDefined", + "ModelVersioned", + "ModelDeprecated", + "ModelFamilyAdded", + "ModelFamilyRemoved", + } + ) + + +@pytest.mark.unit +async def test_projection_does_not_subscribe_to_family_or_asset_events() -> None: + """Cross-aggregate guard: Family and Asset events belong in their + own projection modules.""" + proj = ModelSummaryProjection() + assert "FamilyDefined" not in proj.subscribed_event_types + assert "AssetRegistered" not in proj.subscribed_event_types + + +@pytest.mark.unit +async def test_model_defined_inserts_row_with_defined_status() -> None: + proj = ModelSummaryProjection() + conn = AsyncMock() + event = _stored( + "ModelDefined", + { + "model_id": str(_MODEL_ID), + "name": "Aerotech ANT130-L", + "manufacturer": {"name": "Aerotech"}, + "part_number": "ANT130-L", + "declared_families": [str(_FAMILY_A_ID)], + "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 "INSERT INTO proj_equipment_model_summary" in sql + assert "ON CONFLICT (model_id) DO NOTHING" in sql + assert "'Defined'" in sql + assert args.args[1] == _MODEL_ID + assert args.args[2] == "Aerotech ANT130-L" + assert args.args[3] == "Aerotech" + # manufacturer_identifier + identifier_type both None (optional pair). + assert args.args[4] is None + assert args.args[5] is None + assert args.args[6] == "ANT130-L" + # declared_families bound as a Python list; asyncpg's jsonb codec + # encodes via json.dumps at the connection layer, so we pass the + # raw list and let the codec do the encoding once (the previous + # double-encode landed the value as a JSONB scalar string, which + # broke jsonb_array_elements_text in the targeted-mutation SQL). + assert args.args[7] == [str(_FAMILY_A_ID)] + # version_tag absent in payload -> None bound. + assert args.args[8] is None + assert args.args[9] == _NOW + + +@pytest.mark.unit +async def test_model_defined_with_optional_manufacturer_identifier_pair() -> None: + """The optional manufacturer-identifier pair lands on the flat + `manufacturer_identifier` + `manufacturer_identifier_type` columns + (both set or both null per the VO's pairing invariant).""" + proj = ModelSummaryProjection() + conn = AsyncMock() + event = _stored( + "ModelDefined", + { + "model_id": str(_MODEL_ID), + "name": "Aerotech ANT130-L", + "manufacturer": { + "name": "Aerotech", + "identifier": "https://ror.org/02jbv0t02", + "identifier_type": "ROR", + }, + "part_number": "ANT130-L", + "declared_families": [str(_FAMILY_A_ID)], + "occurred_at": _NOW.isoformat(), + "version_tag": "rev-A", + }, + ) + + await proj.apply(event, conn) + + args = conn.execute.await_args + assert args is not None + assert args.args[3] == "Aerotech" + assert args.args[4] == "https://ror.org/02jbv0t02" + assert args.args[5] == "ROR" + # version_tag carried on Defined when present. + assert args.args[8] == "rev-A" + + +@pytest.mark.unit +async def test_model_versioned_updates_status_and_replaces_identity_block() -> None: + """ModelVersioned writes status=Versioned AND replaces the full + identity block (name, manufacturer, part_number, declared_families, + version_tag) wholesale.""" + proj = ModelSummaryProjection() + conn = AsyncMock() + event = _stored( + "ModelVersioned", + { + "model_id": str(_MODEL_ID), + "name": "Aerotech ANT130-LZS", + "manufacturer": {"name": "Aerotech"}, + "part_number": "ANT130-LZS", + "declared_families": [str(_FAMILY_A_ID), str(_FAMILY_B_ID)], + "version_tag": "v2.1.0", + "occurred_at": _NOW.isoformat(), + }, + ) + + await proj.apply(event, conn) + + args = conn.execute.await_args + assert args is not None + sql = args.args[0] + assert "UPDATE proj_equipment_model_summary" in sql + assert "SET status = 'Versioned'" in sql + assert "name = $2" in sql + assert "manufacturer_name = $3" in sql + assert "part_number = $6" in sql + assert "declared_families = $7" in sql + assert "version_tag = $8" in sql + assert args.args[1] == _MODEL_ID + assert args.args[2] == "Aerotech ANT130-LZS" + assert args.args[3] == "Aerotech" + assert args.args[6] == "ANT130-LZS" + assert args.args[8] == "v2.1.0" + + +@pytest.mark.unit +async def test_model_deprecated_updates_status_and_sets_reason() -> None: + """ModelDeprecated sets status=Deprecated + deprecation_reason and + intentionally leaves vendor-key columns alone so the audit trail + of "what was deprecated" stays answerable.""" + proj = ModelSummaryProjection() + conn = AsyncMock() + event = _stored( + "ModelDeprecated", + { + "model_id": str(_MODEL_ID), + "reason": "Superseded by ANT130-LZS", + "occurred_at": _NOW.isoformat(), + }, + ) + + await proj.apply(event, conn) + + args = conn.execute.await_args + assert args is not None + sql = args.args[0] + assert "UPDATE proj_equipment_model_summary" in sql + assert "SET status = 'Deprecated'" in sql + assert "deprecation_reason = $2" in sql + # Vendor-key + identity columns NOT touched on Deprecated. + assert "manufacturer_name" not in sql + assert "part_number" not in sql + assert "declared_families" not in sql + assert "version_tag" not in sql + assert args.args[1] == _MODEL_ID + assert args.args[2] == "Superseded by ANT130-LZS" + + +@pytest.mark.unit +async def test_model_family_added_appends_to_jsonb_declared_families() -> None: + """ModelFamilyAdded re-aggregates the JSONB declared_families + column via pure SQL (UNION + jsonb_agg ORDER BY) to append a + single family_id and preserve canonical sort order.""" + proj = ModelSummaryProjection() + conn = AsyncMock() + event = _stored( + "ModelFamilyAdded", + { + "model_id": str(_MODEL_ID), + "family_id": str(_FAMILY_B_ID), + "occurred_at": _NOW.isoformat(), + }, + ) + + await proj.apply(event, conn) + + args = conn.execute.await_args + assert args is not None + sql = args.args[0] + assert "UPDATE proj_equipment_model_summary" in sql + assert "declared_families" in sql + assert "jsonb_array_elements_text" in sql + assert "UNION" in sql + assert "jsonb_agg" in sql + assert args.args[1] == _MODEL_ID + assert args.args[2] == str(_FAMILY_B_ID) + + +@pytest.mark.unit +async def test_model_family_removed_drops_family_from_jsonb() -> None: + """ModelFamilyRemoved re-aggregates the JSONB declared_families + column to drop a single family_id while preserving sort order.""" + proj = ModelSummaryProjection() + conn = AsyncMock() + event = _stored( + "ModelFamilyRemoved", + { + "model_id": str(_MODEL_ID), + "family_id": str(_FAMILY_B_ID), + "occurred_at": _NOW.isoformat(), + }, + ) + + await proj.apply(event, conn) + + args = conn.execute.await_args + assert args is not None + sql = args.args[0] + assert "UPDATE proj_equipment_model_summary" in sql + assert "declared_families" in sql + assert "jsonb_array_elements_text" in sql + # Filter clause drops the removed id, no UNION. + assert "WHERE elem <> $2::text" in sql + assert args.args[1] == _MODEL_ID + assert args.args[2] == str(_FAMILY_B_ID) + + +@pytest.mark.unit +async def test_unknown_event_type_falls_through_match() -> None: + proj = ModelSummaryProjection() + conn = AsyncMock() + event = _stored("UnrelatedEvent", {}) + await proj.apply(event, conn) + conn.execute.assert_not_awaited() + + +@pytest.mark.unit +async def test_family_defined_is_silently_dropped() -> None: + """Cross-aggregate-event guard: FamilyDefined is not in + subscribed_event_types, but if the SQL filter ever lets one + through, the bare match drops it without error.""" + proj = ModelSummaryProjection() + conn = AsyncMock() + event = _stored("FamilyDefined", {"family_id": str(uuid4())}) + await proj.apply(event, conn) + conn.execute.assert_not_awaited() diff --git a/apps/api/tests/unit/equipment/test_remove_model_family_decider.py b/apps/api/tests/unit/equipment/test_remove_model_family_decider.py new file mode 100644 index 000000000..0e690dc15 --- /dev/null +++ b/apps/api/tests/unit/equipment/test_remove_model_family_decider.py @@ -0,0 +1,141 @@ +"""Pure-decider tests for the `remove_model_family` slice. + +Targeted mutation of `Model.declared_families`, not a lifecycle +transition. Status is preserved (`Defined` stays `Defined`, +`Versioned` stays `Versioned`); only `Deprecated` is rejected via +the per-verb `ModelCannotRemoveFamilyError` (mirrors +`AssetCannotRemoveFamilyError`). + +Strict-not-idempotent: removing a family not in `declared_families` +raises `ModelFamilyNotPresentError`, mirroring the +`remove_asset_family` precedent. +""" + +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +import pytest + +from cora.equipment.aggregates.model import ( + Manufacturer, + ManufacturerName, + Model, + ModelCannotRemoveFamilyError, + ModelFamilyNotPresentError, + ModelFamilyRemoved, + ModelName, + ModelNotFoundError, + ModelStatus, + PartNumber, +) +from cora.equipment.features import remove_model_family +from cora.equipment.features.remove_model_family import RemoveModelFamily + +_NOW = datetime(2026, 6, 1, 12, 0, 0, tzinfo=UTC) + + +def _model( + *, + status: ModelStatus = ModelStatus.DEFINED, + declared_families: frozenset[UUID] | None = None, + version: str | None = None, +) -> Model: + return Model( + id=uuid4(), + name=ModelName("Aerotech ANT130-L"), + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number=PartNumber("ANT130-L"), + declared_families=declared_families + if declared_families is not None + else frozenset({uuid4()}), + status=status, + version=version, + ) + + +@pytest.mark.unit +def test_decide_emits_model_family_removed_from_defined_state() -> None: + existing = uuid4() + state = _model(status=ModelStatus.DEFINED, declared_families=frozenset({existing})) + events = remove_model_family.decide( + state=state, + command=RemoveModelFamily(model_id=state.id, family_id=existing), + now=_NOW, + ) + assert events == [ModelFamilyRemoved(model_id=state.id, family_id=existing, occurred_at=_NOW)] + + +@pytest.mark.unit +def test_decide_emits_model_family_removed_from_versioned_state() -> None: + """Status preserved across targeted mutation; Versioned is a valid source.""" + existing = uuid4() + state = _model( + status=ModelStatus.VERSIONED, + version="v2", + declared_families=frozenset({existing}), + ) + events = remove_model_family.decide( + state=state, + command=RemoveModelFamily(model_id=state.id, family_id=existing), + now=_NOW, + ) + assert events == [ModelFamilyRemoved(model_id=state.id, family_id=existing, occurred_at=_NOW)] + + +@pytest.mark.unit +def test_decide_raises_cannot_remove_family_when_deprecated() -> None: + """Deprecated catalog entries are frozen; remove_model_family rejects.""" + existing = uuid4() + state = _model( + status=ModelStatus.DEPRECATED, + version="v1", + declared_families=frozenset({existing}), + ) + with pytest.raises(ModelCannotRemoveFamilyError) as exc_info: + remove_model_family.decide( + state=state, + command=RemoveModelFamily(model_id=state.id, family_id=existing), + now=_NOW, + ) + assert exc_info.value.model_id == state.id + assert exc_info.value.current_status is ModelStatus.DEPRECATED + + +@pytest.mark.unit +def test_decide_raises_model_not_found_when_state_is_none() -> None: + target_id = uuid4() + with pytest.raises(ModelNotFoundError) as exc_info: + remove_model_family.decide( + state=None, + command=RemoveModelFamily(model_id=target_id, family_id=uuid4()), + now=_NOW, + ) + assert exc_info.value.model_id == target_id + + +@pytest.mark.unit +def test_decide_raises_not_present_on_absent_family() -> None: + """Strict-not-idempotent: removing an absent family raises rather + than no-op so operators can detect 'wait, this was never declared' + instead of silently succeeding.""" + declared = uuid4() + absent = uuid4() + state = _model(declared_families=frozenset({declared})) + with pytest.raises(ModelFamilyNotPresentError) as exc_info: + remove_model_family.decide( + state=state, + command=RemoveModelFamily(model_id=state.id, family_id=absent), + now=_NOW, + ) + assert exc_info.value.model_id == state.id + assert exc_info.value.family_id == absent + + +@pytest.mark.unit +def test_decide_is_pure_same_inputs_same_outputs() -> None: + family = uuid4() + state = _model(declared_families=frozenset({family})) + command = RemoveModelFamily(model_id=state.id, family_id=family) + first = remove_model_family.decide(state=state, command=command, now=_NOW) + second = remove_model_family.decide(state=state, command=command, now=_NOW) + assert first == second diff --git a/apps/api/tests/unit/equipment/test_remove_model_family_decider_properties.py b/apps/api/tests/unit/equipment/test_remove_model_family_decider_properties.py new file mode 100644 index 000000000..b42d145e0 --- /dev/null +++ b/apps/api/tests/unit/equipment/test_remove_model_family_decider_properties.py @@ -0,0 +1,167 @@ +"""Property-based tests for `remove_model_family.decide` (Equipment BC). + +Targeted mutation of `Model.declared_families`; status is preserved +across the mutation and only `Deprecated` is rejected (via the +per-verb `ModelCannotRemoveFamilyError` gate). Universal claims +across generated inputs: + + - state in {Defined, Versioned} + family_id IN declared_families + emits exactly one ModelFamilyRemoved with the injected `now` + timestamp. + - family_id NOT in declared_families always raises + ModelFamilyNotPresentError carrying the model + family id. + - state=None always raises ModelNotFoundError carrying the + command's model_id. + - state.status==Deprecated always raises ModelCannotRemoveFamilyError + carrying the Deprecated source status. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from hypothesis import given +from hypothesis import strategies as st + +from cora.equipment.aggregates.model import ( + Manufacturer, + ManufacturerName, + Model, + ModelCannotRemoveFamilyError, + ModelFamilyNotPresentError, + ModelFamilyRemoved, + ModelName, + ModelNotFoundError, + ModelStatus, + PartNumber, +) +from cora.equipment.features import remove_model_family +from cora.equipment.features.remove_model_family import RemoveModelFamily +from tests._strategies import aware_datetimes + +if TYPE_CHECKING: + from datetime import datetime + from uuid import UUID + + +# Mutable source statuses; only Deprecated is rejected at the slice gate. +_MUTABLE_STATUS = st.sampled_from([ModelStatus.DEFINED, ModelStatus.VERSIONED]) + +# 1 to 5 pre-existing declared family ids; frozenset dedupes naturally. +_DECLARED_FAMILIES = st.frozensets(st.uuids(), min_size=1, max_size=5) + + +def _model( + model_id: UUID, + *, + status: ModelStatus, + declared_families: frozenset[UUID], +) -> Model: + return Model( + id=model_id, + name=ModelName("Existing"), + manufacturer=Manufacturer(name=ManufacturerName("M")), + part_number=PartNumber("P"), + declared_families=declared_families, + status=status, + version="v0" if status is ModelStatus.VERSIONED else None, + ) + + +@pytest.mark.unit +@given( + model_id=st.uuids(), + status=_MUTABLE_STATUS, + declared_families=_DECLARED_FAMILIES, + now=aware_datetimes(), + pick_index=st.integers(min_value=0, max_value=4), +) +def test_remove_model_family_emits_one_event_for_present_family( + model_id: UUID, + status: ModelStatus, + declared_families: frozenset[UUID], + now: datetime, + pick_index: int, +) -> None: + """Mutable source + family_id IN declared_families -> exactly one + ModelFamilyRemoved with the injected `now`.""" + # Pick a deterministic family id from the existing set (declared has + # at least one member, so the modulo always lands). + declared_list = sorted(declared_families, key=str) + target = declared_list[pick_index % len(declared_list)] + state = _model(model_id, status=status, declared_families=declared_families) + command = RemoveModelFamily(model_id=model_id, family_id=target) + events = remove_model_family.decide(state=state, command=command, now=now) + assert events == [ModelFamilyRemoved(model_id=model_id, family_id=target, occurred_at=now)] + + +@pytest.mark.unit +@given( + model_id=st.uuids(), + status=_MUTABLE_STATUS, + declared_families=_DECLARED_FAMILIES, + absent_family=st.uuids(), + now=aware_datetimes(), +) +def test_remove_model_family_with_absent_family_always_raises( + model_id: UUID, + status: ModelStatus, + declared_families: frozenset[UUID], + absent_family: UUID, + now: datetime, +) -> None: + """family_id NOT in declared_families -> ModelFamilyNotPresentError.""" + # Ensure the absent family is genuinely missing from the prior set. + declared_without_absent = declared_families - {absent_family} + state = _model(model_id, status=status, declared_families=declared_without_absent) + command = RemoveModelFamily(model_id=model_id, family_id=absent_family) + with pytest.raises(ModelFamilyNotPresentError) as exc: + remove_model_family.decide(state=state, command=command, now=now) + assert exc.value.model_id == model_id + assert exc.value.family_id == absent_family + + +@pytest.mark.unit +@given( + model_id=st.uuids(), + family_id=st.uuids(), + now=aware_datetimes(), +) +def test_remove_model_family_on_empty_state_always_raises_not_found( + model_id: UUID, + family_id: UUID, + now: datetime, +) -> None: + """state=None -> ModelNotFoundError carrying command.model_id.""" + command = RemoveModelFamily(model_id=model_id, family_id=family_id) + with pytest.raises(ModelNotFoundError) as exc: + remove_model_family.decide(state=None, command=command, now=now) + assert exc.value.model_id == model_id + + +@pytest.mark.unit +@given( + model_id=st.uuids(), + declared_families=_DECLARED_FAMILIES, + family_id=st.uuids(), + now=aware_datetimes(), +) +def test_remove_model_family_on_deprecated_state_always_raises_cannot_remove_family( + model_id: UUID, + declared_families: frozenset[UUID], + family_id: UUID, + now: datetime, +) -> None: + """state.status==Deprecated -> ModelCannotRemoveFamilyError, regardless of + whether family_id would have been a present remove or an absent one.""" + state = _model( + model_id, + status=ModelStatus.DEPRECATED, + declared_families=declared_families, + ) + command = RemoveModelFamily(model_id=model_id, family_id=family_id) + with pytest.raises(ModelCannotRemoveFamilyError) as exc: + remove_model_family.decide(state=state, command=command, now=now) + assert exc.value.model_id == model_id + assert exc.value.current_status is ModelStatus.DEPRECATED diff --git a/apps/api/tests/unit/equipment/test_remove_model_family_handler.py b/apps/api/tests/unit/equipment/test_remove_model_family_handler.py new file mode 100644 index 000000000..72464c236 --- /dev/null +++ b/apps/api/tests/unit/equipment/test_remove_model_family_handler.py @@ -0,0 +1,246 @@ +"""Unit tests for the `remove_model_family` application handler. + +Update-style handler (mirrors `remove_asset_family` and `version_model`): +load + fold + decide + append. Not idempotency-wrapped. + +Unlike `add_model_family`, this slice performs NO cross-BC Family +lookup: removal only requires `family_id` to be present in +`declared_families`. The Family may have been deprecated or deleted +from the Family registry and removal still proceeds. + +The seeding `define_model` call DOES still resolve `list_all_family_ids` +cross-BC; the unit harness has no Postgres pool, so we monkeypatch +that symbol on the `define_model` handler module to a fixed accept- +all stub so the seed succeeds. The slice under test imports nothing +from `list_all_family_ids`. + +The Deprecated path seeds a `ModelDeprecated` event directly onto +the in-memory store (no `deprecate_model` slice is exercised in +this test file), then invokes the handler and expects +`ModelCannotRemoveFamilyError`. +""" + +from datetime import UTC, datetime +from uuid import UUID + +import pytest + +from cora.equipment import EquipmentHandlers, UnauthorizedError, wire_equipment +from cora.equipment.aggregates.model import ( + Manufacturer, + ManufacturerName, + ModelCannotRemoveFamilyError, + ModelDeprecated, + ModelFamilyNotPresentError, + ModelNotFoundError, + event_type_name, + to_payload, +) +from cora.equipment.features import define_model, remove_model_family +from cora.equipment.features.define_model import DefineModel +from cora.equipment.features.remove_model_family import RemoveModelFamily +from cora.infrastructure.adapters.in_memory_event_store import InMemoryEventStore +from cora.infrastructure.event_envelope import to_new_event +from cora.infrastructure.kernel import Kernel +from tests.unit._helpers import build_deps as _build_deps_shared + +_NOW = datetime(2026, 6, 1, 12, 0, 0, tzinfo=UTC) +_MODEL_ID = UUID("01900000-0000-7000-8000-00000007ad11") +_DEFINED_EVENT_ID = UUID("01900000-0000-7000-8000-00000007ad12") +_REMOVED_EVENT_ID = UUID("01900000-0000-7000-8000-00000007ad13") +_DEPRECATED_EVENT_ID = UUID("01900000-0000-7000-8000-00000007ad14") +_PRINCIPAL_ID = UUID("01900000-0000-7000-8000-000000000099") +_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000aa") +_FAMILY_A_ID = UUID("01900000-0000-7000-8000-00000000fc01") +_FAMILY_ABSENT_ID = UUID("01900000-0000-7000-8000-00000000fc99") + + +def _build_deps( + *, + event_store: InMemoryEventStore | None = None, + deny: bool = False, +) -> Kernel: + """Thin wrapper preserving this file's ID list + clock.""" + return _build_deps_shared( + ids=[_MODEL_ID, _DEFINED_EVENT_ID, _REMOVED_EVENT_ID, _DEPRECATED_EVENT_ID], + now=_NOW, + event_store=event_store, + deny=deny, + ) + + +def _patch_seed_known_families( + monkeypatch: pytest.MonkeyPatch, + family_ids: list[UUID], +) -> None: + """Patch `list_all_family_ids` only on the `define_model` handler. + + The slice under test (`remove_model_family`) does NOT perform a + cross-BC family lookup, so only the seeding `define_model` call + needs the stub. + """ + + async def _fake_list_family_ids(_pool: object) -> list[UUID]: + return list(family_ids) + + monkeypatch.setattr( + "cora.equipment.features.define_model.handler.list_all_family_ids", + _fake_list_family_ids, + ) + + +def _define_command() -> DefineModel: + return DefineModel( + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({_FAMILY_A_ID}), + ) + + +async def _seed_model(deps: Kernel) -> None: + """Define a Model via the public handler so the stream is initialized + in `Defined` status with `_FAMILY_A_ID` declared.""" + await define_model.bind(deps)( + _define_command(), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + +async def _seed_deprecated_model(deps: Kernel, store: InMemoryEventStore) -> None: + """Append a `ModelDeprecated` event directly so the model lands in + `Deprecated` status without going through a `deprecate_model` slice + in this test file.""" + await _seed_model(deps) + deprecated = ModelDeprecated( + model_id=_MODEL_ID, + reason="superseded by next-gen part", + occurred_at=_NOW, + ) + new_event = to_new_event( + event_type=event_type_name(deprecated), + payload=to_payload(deprecated), + occurred_at=deprecated.occurred_at, + event_id=_DEPRECATED_EVENT_ID, + command_name="DeprecateModel", + correlation_id=_CORRELATION_ID, + causation_id=None, + principal_id=_PRINCIPAL_ID, + ) + await store.append( + stream_type="Model", + stream_id=_MODEL_ID, + expected_version=1, + events=[new_event], + ) + + +@pytest.mark.unit +async def test_handler_returns_none_and_appends_event_on_success( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_seed_known_families(monkeypatch, [_FAMILY_A_ID]) + store = InMemoryEventStore() + deps = _build_deps(event_store=store) + await _seed_model(deps) + + result = await remove_model_family.bind(deps)( + RemoveModelFamily(model_id=_MODEL_ID, family_id=_FAMILY_A_ID), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert result is None + + events, version = await store.load("Model", _MODEL_ID) + assert version == 2 + assert [e.event_type for e in events] == ["ModelDefined", "ModelFamilyRemoved"] + removed = events[1] + assert removed.event_id == _REMOVED_EVENT_ID + assert removed.metadata == {"command": "RemoveModelFamily"} + assert removed.payload["model_id"] == str(_MODEL_ID) + assert removed.payload["family_id"] == str(_FAMILY_A_ID) + assert removed.payload["occurred_at"] == _NOW.isoformat() + + +@pytest.mark.unit +async def test_handler_raises_unauthorized_on_deny( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_seed_known_families(monkeypatch, [_FAMILY_A_ID]) + store = InMemoryEventStore() + deps = _build_deps(event_store=store) + await _seed_model(deps) + + deny_deps = _build_deps(event_store=store, deny=True) + with pytest.raises(UnauthorizedError) as exc_info: + await remove_model_family.bind(deny_deps)( + RemoveModelFamily(model_id=_MODEL_ID, family_id=_FAMILY_A_ID), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert exc_info.value.reason == "denied for test" + + +@pytest.mark.unit +async def test_handler_raises_model_not_found_when_stream_is_missing() -> None: + """An unseeded model stream surfaces ModelNotFoundError. No cross-BC + lookup runs; the decider rejects because state is None.""" + deps = _build_deps() + + with pytest.raises(ModelNotFoundError) as exc_info: + await remove_model_family.bind(deps)( + RemoveModelFamily(model_id=_MODEL_ID, family_id=_FAMILY_A_ID), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert exc_info.value.model_id == _MODEL_ID + + +@pytest.mark.unit +async def test_handler_raises_not_present_on_absent_family( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Removing a family not in declared_families surfaces + ModelFamilyNotPresentError (strict-not-idempotent). No cross-BC + lookup runs.""" + _patch_seed_known_families(monkeypatch, [_FAMILY_A_ID]) + store = InMemoryEventStore() + deps = _build_deps(event_store=store) + await _seed_model(deps) + + with pytest.raises(ModelFamilyNotPresentError) as exc_info: + await remove_model_family.bind(deps)( + # _FAMILY_ABSENT_ID was never declared at define_model time. + RemoveModelFamily(model_id=_MODEL_ID, family_id=_FAMILY_ABSENT_ID), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert exc_info.value.model_id == _MODEL_ID + assert exc_info.value.family_id == _FAMILY_ABSENT_ID + + +@pytest.mark.unit +async def test_handler_raises_cannot_remove_family_when_deprecated( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Deprecated Models cannot accept family removals.""" + _patch_seed_known_families(monkeypatch, [_FAMILY_A_ID]) + store = InMemoryEventStore() + deps = _build_deps(event_store=store) + await _seed_deprecated_model(deps, store) + + with pytest.raises(ModelCannotRemoveFamilyError): + await remove_model_family.bind(deps)( + RemoveModelFamily(model_id=_MODEL_ID, family_id=_FAMILY_A_ID), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + +@pytest.mark.unit +def test_wire_equipment_includes_remove_model_family() -> None: + deps = _build_deps() + handlers = wire_equipment(deps) + assert isinstance(handlers, EquipmentHandlers) + assert callable(handlers.remove_model_family) diff --git a/apps/api/tests/unit/equipment/test_version_model_decider.py b/apps/api/tests/unit/equipment/test_version_model_decider.py new file mode 100644 index 000000000..5eb00746f --- /dev/null +++ b/apps/api/tests/unit/equipment/test_version_model_decider.py @@ -0,0 +1,194 @@ +"""Pure-decider tests for the `version_model` slice. + +Multi-source-state guard: `Defined | Versioned -> Versioned`. Both +source states are valid; only Deprecated is rejected. Bounded-text VOs +(name, part_number, version_tag) and `declared_families` cardinality +are validated defensively in the decider so direct callers get the same +protection as API-boundary callers. +""" + +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest + +from cora.equipment.aggregates.model import ( + InvalidDeclaredFamiliesError, + InvalidModelNameError, + InvalidModelVersionTagError, + InvalidPartNumberError, + Manufacturer, + ManufacturerName, + Model, + ModelCannotVersionError, + ModelName, + ModelNotFoundError, + ModelStatus, + ModelVersioned, + PartNumber, +) +from cora.equipment.features import version_model +from cora.equipment.features.version_model import VersionModel + +_NOW = datetime(2026, 6, 1, 12, 0, tzinfo=UTC) + + +def _model( + *, + status: ModelStatus = ModelStatus.DEFINED, + version: str | None = None, +) -> Model: + return Model( + id=uuid4(), + name=ModelName("Aerotech ANT130-L"), + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number=PartNumber("ANT130-L"), + declared_families=frozenset({uuid4()}), + status=status, + version=version, + ) + + +def _command( + model_id: object, + *, + name: str = "Aerotech ANT130-L rev-B", + part_number: str = "ANT130-L-B", + version_tag: str = "v2", + declared_families: frozenset[object] | None = None, +) -> VersionModel: + return VersionModel( + model_id=model_id, # type: ignore[arg-type] + name=name, + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number=part_number, + declared_families=declared_families # type: ignore[arg-type] + if declared_families is not None + else frozenset({uuid4()}), + version_tag=version_tag, + ) + + +@pytest.mark.unit +@pytest.mark.parametrize( + "source", + [ModelStatus.DEFINED, ModelStatus.VERSIONED], +) +def test_decide_emits_model_versioned_for_each_allowed_source_status( + source: ModelStatus, +) -> None: + """Both Defined and Versioned are valid sources; the emitted event + carries the same wholesale-replacement payload regardless of which + one preceded.""" + state = _model(status=source) + new_families = frozenset({uuid4(), uuid4()}) + events = version_model.decide( + state=state, + command=_command( + state.id, + name="Aerotech ANT130-L rev-B", + part_number="ANT130-L-B", + version_tag="v2", + declared_families=new_families, + ), + now=_NOW, + ) + assert events == [ + ModelVersioned( + model_id=state.id, + name="Aerotech ANT130-L rev-B", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L-B", + declared_families=new_families, + version_tag="v2", + occurred_at=_NOW, + ) + ] + + +@pytest.mark.unit +def test_decide_raises_model_not_found_when_state_is_none() -> None: + target_id = uuid4() + with pytest.raises(ModelNotFoundError) as exc_info: + version_model.decide( + state=None, + command=_command(target_id), + now=_NOW, + ) + assert exc_info.value.model_id == target_id + + +@pytest.mark.unit +def test_decide_raises_cannot_version_for_deprecated_status() -> None: + """Deprecated is the only disallowed source state. Re-versioning a + deprecated model raises (would otherwise un-deprecate via side + effect).""" + state = _model(status=ModelStatus.DEPRECATED, version="v1") + with pytest.raises(ModelCannotVersionError) as exc_info: + version_model.decide( + state=state, + command=_command(state.id), + now=_NOW, + ) + assert exc_info.value.model_id == state.id + assert exc_info.value.current_status is ModelStatus.DEPRECATED + + +@pytest.mark.unit +def test_decide_rejects_empty_declared_families() -> None: + state = _model() + with pytest.raises(InvalidDeclaredFamiliesError): + version_model.decide( + state=state, + command=_command(state.id, declared_families=frozenset()), + now=_NOW, + ) + + +@pytest.mark.unit +def test_decide_rejects_invalid_name() -> None: + state = _model() + with pytest.raises(InvalidModelNameError): + version_model.decide( + state=state, + command=_command(state.id, name=" "), + now=_NOW, + ) + + +@pytest.mark.unit +def test_decide_rejects_invalid_part_number() -> None: + state = _model() + with pytest.raises(InvalidPartNumberError): + version_model.decide( + state=state, + command=_command(state.id, part_number=""), + now=_NOW, + ) + + +@pytest.mark.unit +def test_decide_rejects_invalid_version_tag_for_whitespace_only() -> None: + state = _model() + with pytest.raises(InvalidModelVersionTagError): + version_model.decide( + state=state, + command=_command(state.id, version_tag=" "), + now=_NOW, + ) + + +@pytest.mark.unit +def test_decide_allows_versioning_with_same_tag_for_re_attestation() -> None: + """Deliberate divergence from strict-not-idempotent: calling + version_model with a tag that already matches state.version + succeeds rather than raising. Re-attestation is a legitimate audit + moment, mirroring the version_family precedent.""" + state = _model(status=ModelStatus.VERSIONED, version="v2") + events = version_model.decide( + state=state, + command=_command(state.id, version_tag="v2"), + now=_NOW, + ) + assert len(events) == 1 + assert events[0].version_tag == "v2" diff --git a/apps/api/tests/unit/equipment/test_version_model_decider_properties.py b/apps/api/tests/unit/equipment/test_version_model_decider_properties.py new file mode 100644 index 000000000..101a24d54 --- /dev/null +++ b/apps/api/tests/unit/equipment/test_version_model_decider_properties.py @@ -0,0 +1,486 @@ +"""Property-based tests for `version_model.decide` (Equipment BC). + +Mirrors the `define_model` decider-PBT pattern, adapted for the +multi-source `Defined | Versioned -> Versioned` transition. Universal +claims across generated inputs: + + - state in {Defined, Versioned} + valid command emits exactly one + ModelVersioned with the wholesale replacement payload and the + injected `now` timestamp. + - state=None always raises ModelNotFoundError, regardless of command. + - state.status==Deprecated always raises ModelCannotVersionError. + - Empty `declared_families` always raises InvalidDeclaredFamiliesError. + - Empty, whitespace-only, or over-long `name` always raises + InvalidModelNameError (via the ModelName VO). + - Empty, whitespace-only, or over-long `part_number` always raises + InvalidPartNumberError (via the PartNumber VO). + - Empty, whitespace-only, or over-long `version_tag` always raises + InvalidModelVersionTagError (via the ModelVersionTag VO). The tag + is REQUIRED for version_model (unlike define_model). + - Pure: same (state, command, now) returns the same events. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from uuid import UUID + +import pytest +from hypothesis import given +from hypothesis import strategies as st + +from cora.equipment.aggregates.model import ( + MANUFACTURER_IDENTIFIER_MAX_LENGTH, + MANUFACTURER_NAME_MAX_LENGTH, + MODEL_NAME_MAX_LENGTH, + MODEL_PART_NUMBER_MAX_LENGTH, + MODEL_VERSION_TAG_MAX_LENGTH, + InvalidDeclaredFamiliesError, + InvalidModelNameError, + InvalidModelVersionTagError, + InvalidPartNumberError, + Manufacturer, + ManufacturerIdentifier, + ManufacturerIdentifierType, + ManufacturerName, + Model, + ModelCannotVersionError, + ModelName, + ModelNotFoundError, + ModelStatus, + ModelVersioned, + PartNumber, +) +from cora.equipment.features import version_model +from cora.equipment.features.version_model import VersionModel +from tests._strategies import aware_datetimes, printable_ascii_text + +if TYPE_CHECKING: + from datetime import datetime + + +_NAME = printable_ascii_text(min_size=1, max_size=MODEL_NAME_MAX_LENGTH) +_PART_NUMBER = printable_ascii_text(min_size=1, max_size=MODEL_PART_NUMBER_MAX_LENGTH) +_MANUFACTURER_NAME = printable_ascii_text(min_size=1, max_size=MANUFACTURER_NAME_MAX_LENGTH) +_MANUFACTURER_IDENTIFIER = printable_ascii_text( + min_size=1, max_size=MANUFACTURER_IDENTIFIER_MAX_LENGTH +) +_VERSION_TAG = printable_ascii_text(min_size=1, max_size=MODEL_VERSION_TAG_MAX_LENGTH) + +# 1 to 5 distinct Family ids per the prompt; frozenset dedupes naturally. +_DECLARED_FAMILIES = st.frozensets(st.uuids(), min_size=1, max_size=5) + +# Versionable source statuses: Defined (first revision) and Versioned +# (subsequent revisions). Deprecated is excluded; it's covered by a +# dedicated rejection property. +_VERSIONABLE_STATUS = st.sampled_from([ModelStatus.DEFINED, ModelStatus.VERSIONED]) + +# Negative-case alphabet for bounded-text VOs. +_WHITESPACE_CHARS = st.sampled_from([" ", "\t", "\n", "\r", " ", " \t\n"]) + + +def _invalid_bounded_text(max_length: int) -> st.SearchStrategy[str]: + """Empty, whitespace-only, or over-length strings for VO rejection PBTs.""" + return st.one_of( + st.just(""), + _WHITESPACE_CHARS, + printable_ascii_text(min_size=max_length + 1, max_size=max_length + 50), + ) + + +def _padded_text(inner_strategy: st.SearchStrategy[str]) -> st.SearchStrategy[str]: + """Wrap an inner text strategy in random leading + trailing whitespace. + + Distinguishes "VO trims at construction" from "decider stores raw + command text": if the emitted event payload still carries the + untrimmed wrapper, the decider is leaking `command.` instead + of the VO's `.value`. + """ + + @st.composite + def build(draw: st.DrawFn) -> str: + leading = draw(st.text(alphabet=" \t\n", max_size=10)) + core = draw(inner_strategy) + trailing = draw(st.text(alphabet=" \t\n", max_size=10)) + return leading + core + trailing + + return build() + + +@st.composite +def _manufacturers(draw: st.DrawFn) -> Manufacturer: + """Build a Manufacturer VO with optional paired identifier + type.""" + name = ManufacturerName(draw(_MANUFACTURER_NAME)) + has_identifier = draw(st.booleans()) + if not has_identifier: + return Manufacturer(name=name) + identifier = ManufacturerIdentifier(draw(_MANUFACTURER_IDENTIFIER)) + identifier_type = draw(st.sampled_from(list(ManufacturerIdentifierType))) + return Manufacturer(name=name, identifier=identifier, identifier_type=identifier_type) + + +def _command( + *, + model_id: UUID, + name: str, + manufacturer: Manufacturer, + part_number: str, + declared_families: frozenset[UUID], + version_tag: str, +) -> VersionModel: + return VersionModel( + model_id=model_id, + name=name, + manufacturer=manufacturer, + part_number=part_number, + declared_families=declared_families, + version_tag=version_tag, + ) + + +def _model(model_id: UUID, *, status: ModelStatus) -> Model: + return Model( + id=model_id, + name=ModelName("Existing"), + manufacturer=Manufacturer(name=ManufacturerName("M")), + part_number=PartNumber("P"), + declared_families=frozenset({model_id}), + status=status, + version="v0" if status is ModelStatus.VERSIONED else None, + ) + + +@pytest.mark.unit +@given( + model_id=st.uuids(), + status=_VERSIONABLE_STATUS, + name=_NAME, + manufacturer=_manufacturers(), + part_number=_PART_NUMBER, + declared_families=_DECLARED_FAMILIES, + version_tag=_VERSION_TAG, + now=aware_datetimes(), +) +def test_version_model_emits_exactly_one_event_with_injected_fields( + model_id: UUID, + status: ModelStatus, + name: str, + manufacturer: Manufacturer, + part_number: str, + declared_families: frozenset[UUID], + version_tag: str, + now: datetime, +) -> None: + """Versionable source + valid command -> single ModelVersioned with the + wholesale-replacement payload and injected `now`.""" + state = _model(model_id, status=status) + command = _command( + model_id=model_id, + name=name, + manufacturer=manufacturer, + part_number=part_number, + declared_families=declared_families, + version_tag=version_tag, + ) + events = version_model.decide(state=state, command=command, now=now) + assert events == [ + ModelVersioned( + model_id=model_id, + name=name, + manufacturer=manufacturer, + part_number=part_number, + declared_families=declared_families, + version_tag=version_tag, + occurred_at=now, + ) + ] + + +@pytest.mark.unit +@given( + model_id=st.uuids(), + name=_NAME, + manufacturer=_manufacturers(), + part_number=_PART_NUMBER, + declared_families=_DECLARED_FAMILIES, + version_tag=_VERSION_TAG, + now=aware_datetimes(), +) +def test_version_model_on_empty_state_always_raises_not_found( + model_id: UUID, + name: str, + manufacturer: Manufacturer, + part_number: str, + declared_families: frozenset[UUID], + version_tag: str, + now: datetime, +) -> None: + """state=None -> ModelNotFoundError carrying command.model_id.""" + command = _command( + model_id=model_id, + name=name, + manufacturer=manufacturer, + part_number=part_number, + declared_families=declared_families, + version_tag=version_tag, + ) + with pytest.raises(ModelNotFoundError) as exc: + version_model.decide(state=None, command=command, now=now) + assert exc.value.model_id == model_id + + +@pytest.mark.unit +@given( + model_id=st.uuids(), + name=_NAME, + manufacturer=_manufacturers(), + part_number=_PART_NUMBER, + declared_families=_DECLARED_FAMILIES, + version_tag=_VERSION_TAG, + now=aware_datetimes(), +) +def test_version_model_on_deprecated_state_always_raises_cannot_version( + model_id: UUID, + name: str, + manufacturer: Manufacturer, + part_number: str, + declared_families: frozenset[UUID], + version_tag: str, + now: datetime, +) -> None: + """state.status==Deprecated -> ModelCannotVersionError.""" + state = _model(model_id, status=ModelStatus.DEPRECATED) + command = _command( + model_id=model_id, + name=name, + manufacturer=manufacturer, + part_number=part_number, + declared_families=declared_families, + version_tag=version_tag, + ) + with pytest.raises(ModelCannotVersionError) as exc: + version_model.decide(state=state, command=command, now=now) + assert exc.value.model_id == model_id + assert exc.value.current_status is ModelStatus.DEPRECATED + + +@pytest.mark.unit +@given( + model_id=st.uuids(), + status=_VERSIONABLE_STATUS, + name=_NAME, + manufacturer=_manufacturers(), + part_number=_PART_NUMBER, + version_tag=_VERSION_TAG, + now=aware_datetimes(), +) +def test_version_model_with_empty_declared_families_always_raises( + model_id: UUID, + status: ModelStatus, + name: str, + manufacturer: Manufacturer, + part_number: str, + version_tag: str, + now: datetime, +) -> None: + """Empty declared_families -> InvalidDeclaredFamiliesError, regardless + of other fields.""" + state = _model(model_id, status=status) + command = _command( + model_id=model_id, + name=name, + manufacturer=manufacturer, + part_number=part_number, + declared_families=frozenset[UUID](), + version_tag=version_tag, + ) + with pytest.raises(InvalidDeclaredFamiliesError): + version_model.decide(state=state, command=command, now=now) + + +@pytest.mark.unit +@given( + model_id=st.uuids(), + status=_VERSIONABLE_STATUS, + name=_invalid_bounded_text(MODEL_NAME_MAX_LENGTH), + manufacturer=_manufacturers(), + part_number=_PART_NUMBER, + declared_families=_DECLARED_FAMILIES, + version_tag=_VERSION_TAG, + now=aware_datetimes(), +) +def test_version_model_with_invalid_name_always_raises( + model_id: UUID, + status: ModelStatus, + name: str, + manufacturer: Manufacturer, + part_number: str, + declared_families: frozenset[UUID], + version_tag: str, + now: datetime, +) -> None: + """Empty, whitespace-only, or over-long name -> InvalidModelNameError.""" + state = _model(model_id, status=status) + command = _command( + model_id=model_id, + name=name, + manufacturer=manufacturer, + part_number=part_number, + declared_families=declared_families, + version_tag=version_tag, + ) + with pytest.raises(InvalidModelNameError): + version_model.decide(state=state, command=command, now=now) + + +@pytest.mark.unit +@given( + model_id=st.uuids(), + status=_VERSIONABLE_STATUS, + name=_NAME, + manufacturer=_manufacturers(), + part_number=_invalid_bounded_text(MODEL_PART_NUMBER_MAX_LENGTH), + declared_families=_DECLARED_FAMILIES, + version_tag=_VERSION_TAG, + now=aware_datetimes(), +) +def test_version_model_with_invalid_part_number_always_raises( + model_id: UUID, + status: ModelStatus, + name: str, + manufacturer: Manufacturer, + part_number: str, + declared_families: frozenset[UUID], + version_tag: str, + now: datetime, +) -> None: + """Empty, whitespace-only, or over-long part_number -> InvalidPartNumberError.""" + state = _model(model_id, status=status) + command = _command( + model_id=model_id, + name=name, + manufacturer=manufacturer, + part_number=part_number, + declared_families=declared_families, + version_tag=version_tag, + ) + with pytest.raises(InvalidPartNumberError): + version_model.decide(state=state, command=command, now=now) + + +@pytest.mark.unit +@given( + model_id=st.uuids(), + status=_VERSIONABLE_STATUS, + name=_NAME, + manufacturer=_manufacturers(), + part_number=_PART_NUMBER, + declared_families=_DECLARED_FAMILIES, + version_tag=_invalid_bounded_text(MODEL_VERSION_TAG_MAX_LENGTH), + now=aware_datetimes(), +) +def test_version_model_with_invalid_version_tag_always_raises( + model_id: UUID, + status: ModelStatus, + name: str, + manufacturer: Manufacturer, + part_number: str, + declared_families: frozenset[UUID], + version_tag: str, + now: datetime, +) -> None: + """Empty, whitespace-only, or over-long version_tag -> InvalidModelVersionTagError. + The tag is REQUIRED here (unlike define_model where None is valid).""" + state = _model(model_id, status=status) + command = _command( + model_id=model_id, + name=name, + manufacturer=manufacturer, + part_number=part_number, + declared_families=declared_families, + version_tag=version_tag, + ) + with pytest.raises(InvalidModelVersionTagError): + version_model.decide(state=state, command=command, now=now) + + +@pytest.mark.unit +@given( + model_id=st.uuids(), + status=_VERSIONABLE_STATUS, + name=_padded_text(_NAME), + manufacturer=_manufacturers(), + part_number=_padded_text(_PART_NUMBER), + declared_families=_DECLARED_FAMILIES, + version_tag=_padded_text(_VERSION_TAG), + now=aware_datetimes(), +) +def test_version_model_event_carries_trimmed_name_part_number_and_version_tag( + model_id: UUID, + status: ModelStatus, + name: str, + manufacturer: Manufacturer, + part_number: str, + declared_families: frozenset[UUID], + version_tag: str, + now: datetime, +) -> None: + """Padded input -> ModelVersioned.name / .part_number / .version_tag + carry the trimmed value, never the raw command string with leading + or trailing whitespace. + + Closes a coverage gap in printable_ascii_text (which excludes + whitespace): without this property, the decider could emit raw + `command.` and still pass every other PBT in this module. + """ + state = _model(model_id, status=status) + command = _command( + model_id=model_id, + name=name, + manufacturer=manufacturer, + part_number=part_number, + declared_families=declared_families, + version_tag=version_tag, + ) + events = version_model.decide(state=state, command=command, now=now) + assert len(events) == 1 + event = events[0] + assert event.name == event.name.strip() + assert event.part_number == event.part_number.strip() + assert event.version_tag == event.version_tag.strip() + + +@pytest.mark.unit +@given( + model_id=st.uuids(), + status=_VERSIONABLE_STATUS, + name=_NAME, + manufacturer=_manufacturers(), + part_number=_PART_NUMBER, + declared_families=_DECLARED_FAMILIES, + version_tag=_VERSION_TAG, + now=aware_datetimes(), +) +def test_version_model_is_pure_same_input_same_output( + model_id: UUID, + status: ModelStatus, + name: str, + manufacturer: Manufacturer, + part_number: str, + declared_families: frozenset[UUID], + version_tag: str, + now: datetime, +) -> None: + """Two calls with identical args return identical events.""" + state = _model(model_id, status=status) + command = _command( + model_id=model_id, + name=name, + manufacturer=manufacturer, + part_number=part_number, + declared_families=declared_families, + version_tag=version_tag, + ) + first = version_model.decide(state=state, command=command, now=now) + second = version_model.decide(state=state, command=command, now=now) + assert first == second diff --git a/apps/api/tests/unit/equipment/test_version_model_handler.py b/apps/api/tests/unit/equipment/test_version_model_handler.py new file mode 100644 index 000000000..ff6a129de --- /dev/null +++ b/apps/api/tests/unit/equipment/test_version_model_handler.py @@ -0,0 +1,248 @@ +"""Unit tests for the `version_model` application handler. + +Update-style handler (mirrors version_family): load + fold + decide + +append. Not idempotency-wrapped. No cross-BC family lookup at version +time (per the design memo Lock: incremental edits go through +add_model_family). Tests cover auth deny, multi-source guard, the +Deprecated rejection, the ModelNotFoundError on a missing stream, and +the appended event payload shape. + +The Deprecated path seeds a `ModelDeprecated` event directly onto the +in-memory store (no `deprecate_model` slice exists yet), then invokes +the version handler and expects `ModelCannotVersionError`. +""" + +from datetime import UTC, datetime +from uuid import UUID + +import pytest + +from cora.equipment import EquipmentHandlers, UnauthorizedError, wire_equipment +from cora.equipment.aggregates.model import ( + Manufacturer, + ManufacturerName, + ModelCannotVersionError, + ModelDeprecated, + ModelNotFoundError, + event_type_name, + to_payload, +) +from cora.equipment.features import define_model, version_model +from cora.equipment.features.define_model import DefineModel +from cora.equipment.features.version_model import VersionModel +from cora.infrastructure.adapters.in_memory_event_store import InMemoryEventStore +from cora.infrastructure.event_envelope import to_new_event +from cora.infrastructure.kernel import Kernel +from tests.unit._helpers import build_deps as _build_deps_shared + +_NOW = datetime(2026, 6, 1, 12, 0, 0, tzinfo=UTC) +_MODEL_ID = UUID("01900000-0000-7000-8000-00000007ab11") +_DEFINED_EVENT_ID = UUID("01900000-0000-7000-8000-00000007ab12") +_VERSIONED_EVENT_ID = UUID("01900000-0000-7000-8000-00000007ab13") +_DEPRECATED_EVENT_ID = UUID("01900000-0000-7000-8000-00000007ab14") +_PRINCIPAL_ID = UUID("01900000-0000-7000-8000-000000000099") +_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000aa") +_FAMILY_A_ID = UUID("01900000-0000-7000-8000-00000000fa01") +_FAMILY_B_ID = UUID("01900000-0000-7000-8000-00000000fa02") + + +def _build_deps( + *, + event_store: InMemoryEventStore | None = None, + deny: bool = False, +) -> Kernel: + """Thin wrapper preserving this file's ID list + clock.""" + return _build_deps_shared( + ids=[_MODEL_ID, _DEFINED_EVENT_ID, _VERSIONED_EVENT_ID, _DEPRECATED_EVENT_ID], + now=_NOW, + event_store=event_store, + deny=deny, + ) + + +def _patch_known_families( + monkeypatch: pytest.MonkeyPatch, + family_ids: list[UUID], +) -> None: + """Patch `list_all_family_ids` as imported into the define_model handler. + + `version_model` does NOT call `list_all_family_ids`, but the + seeding call to `define_model` does. We stub it accept-all so the + seed succeeds in the in-memory harness. + """ + + async def _fake_list_family_ids(_pool: object) -> list[UUID]: + return list(family_ids) + + monkeypatch.setattr( + "cora.equipment.features.define_model.handler.list_all_family_ids", + _fake_list_family_ids, + ) + + +def _define_command() -> DefineModel: + return DefineModel( + name="Aerotech ANT130-L", + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number="ANT130-L", + declared_families=frozenset({_FAMILY_A_ID}), + ) + + +def _version_command( + *, + name: str = "Aerotech ANT130-L rev-B", + part_number: str = "ANT130-L-B", + version_tag: str = "v2", + declared_families: frozenset[UUID] | None = None, +) -> VersionModel: + return VersionModel( + model_id=_MODEL_ID, + name=name, + manufacturer=Manufacturer(name=ManufacturerName("Aerotech")), + part_number=part_number, + declared_families=declared_families + if declared_families is not None + else frozenset({_FAMILY_A_ID, _FAMILY_B_ID}), + version_tag=version_tag, + ) + + +async def _seed_model(deps: Kernel) -> None: + """Define a Model via the public handler so the stream is initialized.""" + await define_model.bind(deps)( + _define_command(), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + +async def _seed_deprecated_model(deps: Kernel, store: InMemoryEventStore) -> None: + """Append a `ModelDeprecated` event directly so the model lands in + Deprecated state without going through a `deprecate_model` slice + (which does not exist yet).""" + await _seed_model(deps) + deprecated = ModelDeprecated( + model_id=_MODEL_ID, + reason="superseded by next-gen part", + occurred_at=_NOW, + ) + new_event = to_new_event( + event_type=event_type_name(deprecated), + payload=to_payload(deprecated), + occurred_at=deprecated.occurred_at, + event_id=_DEPRECATED_EVENT_ID, + command_name="DeprecateModel", + correlation_id=_CORRELATION_ID, + causation_id=None, + principal_id=_PRINCIPAL_ID, + ) + await store.append( + stream_type="Model", + stream_id=_MODEL_ID, + expected_version=1, + events=[new_event], + ) + + +@pytest.mark.unit +async def test_handler_returns_none_on_success( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_known_families(monkeypatch, [_FAMILY_A_ID, _FAMILY_B_ID]) + store = InMemoryEventStore() + deps = _build_deps(event_store=store) + await _seed_model(deps) + + result = await version_model.bind(deps)( + _version_command(), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert result is None + + +@pytest.mark.unit +async def test_handler_appends_model_versioned_event_with_replacement_payload( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_known_families(monkeypatch, [_FAMILY_A_ID, _FAMILY_B_ID]) + store = InMemoryEventStore() + deps = _build_deps(event_store=store) + await _seed_model(deps) + + await version_model.bind(deps)( + _version_command(), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + events, version = await store.load("Model", _MODEL_ID) + assert version == 2 + assert [e.event_type for e in events] == ["ModelDefined", "ModelVersioned"] + versioned = events[1] + assert versioned.event_id == _VERSIONED_EVENT_ID + assert versioned.metadata == {"command": "VersionModel"} + assert versioned.payload["model_id"] == str(_MODEL_ID) + assert versioned.payload["name"] == "Aerotech ANT130-L rev-B" + assert versioned.payload["part_number"] == "ANT130-L-B" + assert versioned.payload["version_tag"] == "v2" + assert versioned.payload["declared_families"] == sorted([str(_FAMILY_A_ID), str(_FAMILY_B_ID)]) + assert versioned.payload["manufacturer"] == {"name": "Aerotech"} + + +@pytest.mark.unit +async def test_handler_raises_model_not_found_when_model_does_not_exist() -> None: + deps = _build_deps() + handler = version_model.bind(deps) + + with pytest.raises(ModelNotFoundError): + await handler( + _version_command(), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + +@pytest.mark.unit +async def test_handler_raises_cannot_version_when_deprecated( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_known_families(monkeypatch, [_FAMILY_A_ID]) + store = InMemoryEventStore() + deps = _build_deps(event_store=store) + await _seed_deprecated_model(deps, store) + + with pytest.raises(ModelCannotVersionError): + await version_model.bind(deps)( + _version_command(), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + +@pytest.mark.unit +async def test_handler_raises_unauthorized_on_deny( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_known_families(monkeypatch, [_FAMILY_A_ID, _FAMILY_B_ID]) + store = InMemoryEventStore() + deps = _build_deps(event_store=store) + await _seed_model(deps) + + deny_deps = _build_deps(event_store=store, deny=True) + with pytest.raises(UnauthorizedError) as exc_info: + await version_model.bind(deny_deps)( + _version_command(), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert exc_info.value.reason == "denied for test" + + +@pytest.mark.unit +def test_wire_equipment_includes_version_model() -> None: + deps = _build_deps() + handlers = wire_equipment(deps) + assert isinstance(handlers, EquipmentHandlers) + assert callable(handlers.version_model) diff --git a/infra/atlas/migrations/20260601110000_init_proj_equipment_model_summary.sql b/infra/atlas/migrations/20260601110000_init_proj_equipment_model_summary.sql new file mode 100644 index 000000000..01856d632 --- /dev/null +++ b/infra/atlas/migrations/20260601110000_init_proj_equipment_model_summary.sql @@ -0,0 +1,95 @@ +-- Equipment BC projection: model summary. +-- +-- Folds the Model aggregate's lifecycle events into the +-- `proj_equipment_model_summary` read model used by the future +-- `list_models` slice for `GET /models` keyset-paginated list +-- endpoint and by the vendor-key uniqueness guard at command time. +-- +-- Subscribed events: +-- - ModelDefined -> INSERT (status=Defined, version_tag preserved +-- from payload when present, declared_families +-- materialized from payload array) +-- - ModelVersioned -> UPDATE status=Versioned, replaces +-- name / manufacturer_* / part_number / +-- declared_families / version_tag wholesale +-- (a new revision restates the full identity +-- block of the Model) +-- - ModelDeprecated -> UPDATE status=Deprecated, sets +-- deprecation_reason; vendor-key columns +-- (manufacturer_name, part_number) preserved +-- so the audit trail of "what was deprecated" +-- stays answerable from the projection +-- - ModelFamilyAdded -> UPDATE declared_families (append family_id, +-- re-sorted to match the canonical event- +-- payload ordering) +-- - ModelFamilyRemoved -> UPDATE declared_families (remove family_id) +-- +-- `version_tag` is nullable because a freshly Defined Model may carry +-- no version label yet; ModelVersioned sets it on later revisions. +-- `deprecation_reason` is nullable for the same reason: only set when +-- ModelDeprecated fires. +-- +-- `manufacturer_identifier` + `manufacturer_identifier_type` are both +-- nullable and travel together. The CHECK constraint enforces that +-- when an identifier is present the type is one of the closed set +-- (ROR, GRID, ISNI) and the two columns are both-set-or-both-null +-- (the value-object invariant lifted from the aggregate). +-- +-- `declared_families` is JSONB rather than a join table because the +-- payload-as-stored shape is an array of family-id strings sorted +-- canonically, and the read slice returns it verbatim. A future +-- `proj_equipment_model_families` join projection would be added if +-- a list-models-by-family use case lands. +-- +-- The UNIQUE (manufacturer_name, part_number) index is the Lock-4 +-- vendor-key uniqueness guard from the design memo: two Models from +-- the same manufacturer cannot share a part number. Indexed for both +-- the constraint and for the manufacturer-keyed lookup path. +-- +-- Mutable read model. cora_app gets full DML. +-- proj_equipment_model_summary matches: +-- - the table name (here) +-- - the bookmark row (INSERT below) +-- - `ModelSummaryProjection.name` in cora.equipment.projections.model. + +CREATE TABLE proj_equipment_model_summary ( + model_id UUID PRIMARY KEY, + name TEXT NOT NULL, + manufacturer_name TEXT NOT NULL, + manufacturer_identifier TEXT, + manufacturer_identifier_type TEXT, + part_number TEXT NOT NULL, + declared_families JSONB NOT NULL, + status TEXT NOT NULL CHECK ( + status IN ('Defined', 'Versioned', 'Deprecated') + ), + version_tag TEXT, + deprecation_reason TEXT, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT proj_equipment_model_summary_identifier_type_chk CHECK ( + manufacturer_identifier_type IS NULL + OR manufacturer_identifier_type IN ('ROR', 'GRID', 'ISNI') + ), + CONSTRAINT proj_equipment_model_summary_identifier_paired_chk CHECK ( + (manufacturer_identifier IS NULL + AND manufacturer_identifier_type IS NULL) + OR (manufacturer_identifier IS NOT NULL + AND manufacturer_identifier_type IS NOT NULL) + ) +); + +-- Lock-4 vendor-key uniqueness: a manufacturer can publish at most +-- one Model under a given part number. +CREATE UNIQUE INDEX proj_equipment_model_summary_vendor_key_idx + ON proj_equipment_model_summary (manufacturer_name, part_number); + +CREATE INDEX proj_equipment_model_summary_keyset_idx + ON proj_equipment_model_summary (created_at, model_id); + +GRANT SELECT, INSERT, UPDATE, DELETE + ON proj_equipment_model_summary TO cora_app; + +INSERT INTO projection_bookmarks (name) +VALUES ('proj_equipment_model_summary') +ON CONFLICT DO NOTHING; diff --git a/infra/atlas/migrations/20260602100000_drop_proj_equipment_model_summary_vendor_key_unique.sql b/infra/atlas/migrations/20260602100000_drop_proj_equipment_model_summary_vendor_key_unique.sql new file mode 100644 index 000000000..c2b92c303 --- /dev/null +++ b/infra/atlas/migrations/20260602100000_drop_proj_equipment_model_summary_vendor_key_unique.sql @@ -0,0 +1,39 @@ +-- Drop UNIQUE INDEX on `proj_equipment_model_summary (manufacturer_name, part_number)`. +-- +-- The original Model summary migration (20260601110000) created this +-- UNIQUE INDEX to materialize the Lock-4 vendor-key uniqueness guard +-- at the projection layer. The Model aggregate decider does NOT +-- enforce vendor-key uniqueness (define_model only checks stream +-- non-existence + cross-BC Family resolution), so two parallel +-- define_model calls with the same (manufacturer_name, part_number) +-- but a fresh model_id each would: (a) successfully append events +-- to two new streams, then (b) the projection INSERT for the second +-- stream would blow up with UniqueViolation, poisoning the bookmark +-- and diverging aggregate state from projection state. +-- +-- Resolution: drop the projection-side uniqueness constraint, match +-- CORA's eventual-consistency convention (Family.name, Method.name, +-- Plan.name, Practice.name, Capability.code all lack uniqueness +-- checks at the projection tier; Capability cleared the exact same +-- shape in migration 20260518210000). Vendor-key uniqueness becomes +-- decider-tier operator-curation discipline at v1, enforced via a +-- list-by-vendor-key projection only if a real collision surfaces +-- during pilot operation. +-- +-- The composite `(manufacturer_name, part_number)` columns stay on +-- the table for the manufacturer-keyed lookup path (audit + future +-- list-by-vendor-key read slice). Re-created as a non-unique index +-- so equality + prefix scans on the pair stay cheap; matches the +-- Capability precedent of "keep the column queryable, drop only the +-- UNIQUE constraint." +-- +-- Forward-only cleanup follow-up to the original Model summary +-- migration; the table + columns + bookmark stay. Standard DROP + +-- CREATE; allowed-data-preserving. + +-- atlas:safety:allow=drop-index-allowed-data-preserving + +DROP INDEX IF EXISTS proj_equipment_model_summary_vendor_key_idx; + +CREATE INDEX IF NOT EXISTS proj_equipment_model_summary_vendor_key_idx + ON proj_equipment_model_summary (manufacturer_name, part_number); diff --git a/infra/atlas/migrations/atlas.sum b/infra/atlas/migrations/atlas.sum index 65ffb60fa..807bcd182 100644 --- a/infra/atlas/migrations/atlas.sum +++ b/infra/atlas/migrations/atlas.sum @@ -1,4 +1,4 @@ -h1:VXmY7xyKsm1V0JWDpkthfSkiuao+q5CtzVHOkakv+lI= +h1:/PBvl/WaDTcp3Z3ORWwifpHrRsphgOK6hg6vLEU/gXs= 20260509120000_init_events.sql h1:GmgCZKfaqXu1m96/cKAks2vhaLWTdEaHTLkFtUo9FXg= 20260509170000_init_idempotency.sql h1:Nbu8DIE4Sv1WiHw3G22+tYffPhKc5Jryw3PMK8wB2zY= 20260510010000_add_event_id.sql h1:RbtYP6uMnOB20zhJ9dNXUi4YVqbmlEzf562pmygnRW8= @@ -89,3 +89,5 @@ h1:VXmY7xyKsm1V0JWDpkthfSkiuao+q5CtzVHOkakv+lI= 20260601100000_rename_seal_key_ref_to_credential_id.sql h1:xFecq2lgvE3U4v65DNPwgcKtXaSzQ8+y9zJnKUHUh6U= 20260601100100_rename_frame_summary_placement_column.sql h1:aDObXIGv3jTavyX0n2XbApJAxjD+UHOovdOqKPjCabo= 20260601100200_add_proj_federation_seal_summary_stream_id.sql h1:/sgFuocyP63WPyKSk8wrZq1r8AZ9wfQV6iqt5eyhfPI= +20260601110000_init_proj_equipment_model_summary.sql h1:QJCanmiewUXP1knkN62ajcTon0dskClItX8pNOUwCzw= +20260602100000_drop_proj_equipment_model_summary_vendor_key_unique.sql h1:3zElIH2cC7y2mOyOmRgU+Asf3bsvfLtekrVH9mYnBqM=