From ddec56287f5ec96f4d3ffd530ee7000df49b306b Mon Sep 17 00:00:00 2001 From: Doga Gursoy Date: Fri, 5 Jun 2026 15:54:56 +0300 Subject: [PATCH] feat(equipment): Fixture PIDINST read closure (Phase 1) First slice of Fixture-tier PIDINST integration. Ships the read path end-to-end without touching the write path: operators can fetch a Fixture's PIDINST record at GET /fixtures/{fixture_id}/pidinst (and via the matching MCP tool), with full PIDINST 1.0 property cascade from bound Assets. WHY: Asset-tier PIDINST shipped at PR #38 covers the device tier (BODC convention), but operators speak of "instruments" as composite Fixtures (HZB convention). Without Fixture-tier records, the only PID an operator can mint for an imaging instrument is a per-detector DOI, not the beamline-as-a-whole DOI that publications and Commons search actually key on. This slice closes the read path so operators can preview the record before Phase 2 ships the assign mutation. Surface added: - Fixture.persistent_id field as data-substrate (no event yet; mirrors slice E.1's Asset.commissioned_at pattern; Phase 2 ships the event + assign slice) - FixturePidinstView + FixtureComponentRef value objects in the kernel - to_fixture_pidinst_record serializer (sibling to to_pidinst_record; shares PidinstRecord kernel + StrEnums + invariants per Lock 1 from Stage 0) - 5-class FixturePidinstSerializationError taxonomy inheriting from cross-tier PidinstSerializationError base; all five registered as HTTP handlers in routes.py - View assembler walks bound Assets one level deep with the union cascade for owners (dedupe by name+identifier, sort by name per Lock 7) and the model-mediated cascade for manufacturers (load Model per bound Asset, gather Model.manufacturer, dedupe, sort) - Unminted bound Assets skipped in components and surfaced in Description block only per Lock 11 and the L27 revision - get_fixture_pidinst feature slice (handler + route + MCP tool) mirroring the get_asset_pidinst sibling byte-for-byte Locks honored from Stage 0 + Stage 1: L1 (separate serializer + shared kernel), L2 (resourceTypeGeneral=Instrument), L3 (HasComponent native with substitution at the future slice-6 renderer; verified by the L3 deep-dive memo), L6 (read endpoint matches Asset E.1 path convention), L7 (owners union policy), L9 revised (manufacturers model-mediated), L11 (one-level depth, no auto bidirectional linking). Deferred and explicitly out of scope for Phase 1: - FixturePersistentIdAssigned event + assign_fixture_persistent_id slice (Phase 2) - Production DataCite adapter with HasPart substitution (Phase 3) - Configuration-hash for cross-facility convergence (Federation BC trigger; see project_fixture_configuration_hash_followup) Scale: 24 files, 3284 insertions / 191 deletions. 29 unit + 10 integration + 1 contract + 1 fitness new tests; 16,674 architecture fitness tests still green; adversarial 8-claim 3-vote refutation returned 24/24 confirmed. Co-Authored-By: Claude Opus 4.7 --- apps/api/openapi.json | 87 ++++ .../src/cora/equipment/_pidinst_response.py | 204 +++++++++ .../src/cora/equipment/_pidinst_serializer.py | 139 ++++++ apps/api/src/cora/equipment/_pidinst_types.py | 75 +++ .../equipment/aggregates/fixture/__init__.py | 2 + .../equipment/aggregates/fixture/state.py | 12 + apps/api/src/cora/equipment/errors.py | 51 +++ .../features/get_asset_pidinst/route.py | 197 +------- .../features/get_fixture_pidinst/__init__.py | 27 ++ .../get_fixture_pidinst/_view_assembler.py | 150 ++++++ .../features/get_fixture_pidinst/handler.py | 107 +++++ .../features/get_fixture_pidinst/route.py | 114 +++++ .../features/get_fixture_pidinst/tool.py | 128 ++++++ apps/api/src/cora/equipment/routes.py | 29 ++ apps/api/src/cora/equipment/tools.py | 5 + apps/api/src/cora/equipment/wire.py | 8 + .../test_fixture_pidinst_naming.py | 144 ++++++ .../test_get_fixture_pidinst_openapi.py | 74 +++ ...st_get_fixture_pidinst_handler_postgres.py | 427 ++++++++++++++++++ .../test_get_fixture_pidinst_route.py | 388 ++++++++++++++++ .../test_get_fixture_pidinst_tool.py | 236 ++++++++++ .../test_assemble_fixture_pidinst_view.py | 400 ++++++++++++++++ .../test_fixture_pidinst_serializer.py | 291 ++++++++++++ .../equipment/test_fixture_pidinst_view.py | 180 ++++++++ 24 files changed, 3284 insertions(+), 191 deletions(-) create mode 100644 apps/api/src/cora/equipment/_pidinst_response.py create mode 100644 apps/api/src/cora/equipment/features/get_fixture_pidinst/__init__.py create mode 100644 apps/api/src/cora/equipment/features/get_fixture_pidinst/_view_assembler.py create mode 100644 apps/api/src/cora/equipment/features/get_fixture_pidinst/handler.py create mode 100644 apps/api/src/cora/equipment/features/get_fixture_pidinst/route.py create mode 100644 apps/api/src/cora/equipment/features/get_fixture_pidinst/tool.py create mode 100644 apps/api/tests/architecture/test_fixture_pidinst_naming.py create mode 100644 apps/api/tests/contract/test_get_fixture_pidinst_openapi.py create mode 100644 apps/api/tests/integration/equipment/test_get_fixture_pidinst_handler_postgres.py create mode 100644 apps/api/tests/integration/equipment/test_get_fixture_pidinst_route.py create mode 100644 apps/api/tests/integration/equipment/test_get_fixture_pidinst_tool.py create mode 100644 apps/api/tests/unit/equipment/test_assemble_fixture_pidinst_view.py create mode 100644 apps/api/tests/unit/equipment/test_fixture_pidinst_serializer.py create mode 100644 apps/api/tests/unit/equipment/test_fixture_pidinst_view.py diff --git a/apps/api/openapi.json b/apps/api/openapi.json index ef4b5face8..55a057be95 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -25815,6 +25815,93 @@ ] } }, + "/fixtures/{fixture_id}/pidinst": { + "get": { + "operationId": "get_fixture_pidinst_fixtures__fixture_id__pidinst_get", + "parameters": [ + { + "description": "Target fixture's id.", + "in": "path", + "name": "fixture_id", + "required": true, + "schema": { + "description": "Target fixture's id.", + "format": "uuid", + "title": "Fixture 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/PidinstRecordResponse" + } + } + }, + "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 fixture exists with the given id." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Fixture state is missing a mandatory PIDINST source: no bound Asset carries any owners (FixtureOwnerStateNotAvailableError), or no bound Asset carries any manufacturer (FixtureManufacturerStateNotAvailableError)." + }, + "422": { + "description": "View preparation produced an empty landing page URL or fixture name; or the path parameter failed schema validation." + } + }, + "summary": "Get the PIDINST v1.0 record for a fixture", + "tags": [ + "equipment" + ] + } + }, "/frames": { "post": { "operationId": "post_frames_frames_post", diff --git a/apps/api/src/cora/equipment/_pidinst_response.py b/apps/api/src/cora/equipment/_pidinst_response.py new file mode 100644 index 0000000000..c114f305e0 --- /dev/null +++ b/apps/api/src/cora/equipment/_pidinst_response.py @@ -0,0 +1,204 @@ +"""Pydantic mirror of the PIDINST v1.0 record + conversion helper. + +The aggregate kernel owns the slice-C `PidinstRecord` frozen dataclass +(in `_pidinst_types.py`). FastAPI's OpenAPI schema generator requires a +Pydantic-typed mirror to render `response_model`, so this module hosts +that mirror plus the `_record_to_response` helper that walks the +slice-C tree into Pydantic. + +Lives at BC root rather than inside any one feature slice because both +the Asset-tier (`get_asset_pidinst`) and Fixture-tier +(`get_fixture_pidinst`) read routes return the same record shape. Per +the BC-flat-root layout convention private to this BC. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from pydantic import BaseModel + +if TYPE_CHECKING: + from cora.equipment._pidinst_types import PidinstRecord + + +class PidinstIdentifierDTO(BaseModel): + """PIDINST v1.0 Property 1: persistent identifier of the instrument.""" + + value: str + scheme: str + + +class OwnerDTO(BaseModel): + """PIDINST v1.0 Property 5: a body owning or curating the instrument.""" + + name: str + contact: str | None = None + identifier: str | None = None + identifier_type: str | None = None + + +class ManufacturerDTO(BaseModel): + """PIDINST v1.0 Property 6: the body manufacturing the instrument.""" + + name: str + identifier: str | None = None + identifier_type: str | None = None + + +class PidinstModelDTO(BaseModel): + """PIDINST v1.0 Property 7: the model identification of the instrument.""" + + name: str + identifier: str + identifier_type: str + + +class InstrumentTypeDTO(BaseModel): + """PIDINST v1.0 Property 9: a typology category for the instrument.""" + + name: str + identifier: str | None = None + identifier_type: str + + +class MeasuredVariableDTO(BaseModel): + """PIDINST v1.0 Property 10: a physical quantity the instrument measures.""" + + name: str + + +class PidinstDateDTO(BaseModel): + """PIDINST v1.0 Property 11: a date marker on the instrument lifecycle.""" + + value: str + date_type: str + + +class RelatedIdentifierDTO(BaseModel): + """PIDINST v1.0 Property 12: a related identifier (parent asset, etc.).""" + + value: str + identifier_type: str + relation_type: str + + +class PidinstAlternateIdentifierDTO(BaseModel): + """PIDINST v1.0 Property 13: an alternate identifier under a known scheme.""" + + value: str + kind: str + name: str | None = None + + +class MeasurementTechniqueDTO(BaseModel): + """PIDINST v1.0 Property 14: a measurement technique applied by the instrument.""" + + name: str + + +class PidinstRecordResponse(BaseModel): + """Read-side DTO mirroring the slice-C `PidinstRecord` dataclass. + + Pydantic mirror so FastAPI can generate an OpenAPI schema. Decouples + the wire format from the slice-C `PidinstRecord` so the two can + evolve independently. Field shapes verbatim from the slice-C tree. + """ + + identifier: PidinstIdentifierDTO + schema_version: str + landing_page: str + name: str + publisher: str + publication_year: int | None + owners: list[OwnerDTO] + manufacturers: list[ManufacturerDTO] + model: PidinstModelDTO | None + description: str | None + instrument_types: list[InstrumentTypeDTO] + measured_variables: list[MeasuredVariableDTO] + dates: list[PidinstDateDTO] + related_identifiers: list[RelatedIdentifierDTO] + alternate_identifiers: list[PidinstAlternateIdentifierDTO] + measurement_techniques: list[MeasurementTechniqueDTO] + + +def record_to_response(record: PidinstRecord) -> PidinstRecordResponse: + """Walk the slice-C `PidinstRecord` tree into the Pydantic mirror.""" + return PidinstRecordResponse( + identifier=PidinstIdentifierDTO( + value=record.identifier.value, + scheme=record.identifier.scheme.value, + ), + schema_version=record.schema_version.value, + landing_page=record.landing_page, + name=record.name, + publisher=record.publisher, + publication_year=record.publication_year, + owners=[ + OwnerDTO( + name=owner.name, + contact=owner.contact, + identifier=owner.identifier, + identifier_type=owner.identifier_type, + ) + for owner in record.owners + ], + manufacturers=[ + ManufacturerDTO( + name=manufacturer.name, + identifier=manufacturer.identifier, + identifier_type=( + manufacturer.identifier_type.value + if manufacturer.identifier_type is not None + else None + ), + ) + for manufacturer in record.manufacturers + ], + model=( + PidinstModelDTO( + name=record.model.name, + identifier=record.model.identifier, + identifier_type=record.model.identifier_type, + ) + if record.model is not None + else None + ), + description=record.description, + instrument_types=[ + InstrumentTypeDTO( + name=instrument_type.name, + identifier=instrument_type.identifier, + identifier_type=instrument_type.identifier_type, + ) + for instrument_type in record.instrument_types + ], + measured_variables=[ + MeasuredVariableDTO(name=variable.name) for variable in record.measured_variables + ], + dates=[ + PidinstDateDTO(value=pidinst_date.value, date_type=pidinst_date.date_type.value) + for pidinst_date in record.dates + ], + related_identifiers=[ + RelatedIdentifierDTO( + value=related_identifier.value, + identifier_type=related_identifier.identifier_type, + relation_type=related_identifier.relation_type.value, + ) + for related_identifier in record.related_identifiers + ], + alternate_identifiers=[ + PidinstAlternateIdentifierDTO( + value=alternate_identifier.value, + kind=alternate_identifier.kind.value, + name=alternate_identifier.name, + ) + for alternate_identifier in record.alternate_identifiers + ], + measurement_techniques=[ + MeasurementTechniqueDTO(name=technique.name) + for technique in record.measurement_techniques + ], + ) diff --git a/apps/api/src/cora/equipment/_pidinst_serializer.py b/apps/api/src/cora/equipment/_pidinst_serializer.py index fccbcd2daf..71ae33c09a 100644 --- a/apps/api/src/cora/equipment/_pidinst_serializer.py +++ b/apps/api/src/cora/equipment/_pidinst_serializer.py @@ -60,6 +60,7 @@ from cora.equipment._pidinst_types import ( AssetPidinstView, DateType, + FixturePidinstView, InstrumentType, Manufacturer, MeasuredVariable, @@ -71,12 +72,17 @@ PidinstIdentifierType, PidinstModel, PidinstRecord, + PidinstRelationType, RelatedIdentifier, SchemaVersion, ) from cora.equipment.aggregates.asset import PersistentIdentifierScheme from cora.equipment.errors import ( AssetNameMissingError, + FixtureLandingPageMissingError, + FixtureManufacturerStateNotAvailableError, + FixtureNameMissingError, + FixtureOwnerStateNotAvailableError, LandingPageMissingError, ManufacturerStateNotAvailableError, OwnerStateNotAvailableError, @@ -281,3 +287,136 @@ def _build_measurement_techniques( def _format_iso_date(value: datetime) -> str: return value.date().isoformat() + + +def to_fixture_pidinst_record( + view: FixturePidinstView, + *, + landing_page_url: str, + publisher: str, +) -> PidinstRecord: + """Transform a hydrated Fixture view into a CORA PIDINST v1.0 record. + + Pure synchronous function. Sibling to `to_pidinst_record`; shares + the kernel (PidinstRecord, all closed StrEnums, PidinstRecord + invariants) but has its own error class taxonomy. Raises + `FixturePidinstSerializationError` (one of four concrete subclasses) + on the first missing mandatory property in PIDINST schema order. + + Schema-order failure ordering: LandingPage first, then Name, then + Owner, then Manufacturer. Identifier never raises on the read path + because the URN fallback always succeeds; the swap to DOI / Handle + when `view.persistent_id` is set is structurally typed and cannot + raise. + + `landing_page_url` and `publisher` are injected by the caller (the + read-side view assembler) from per-deployment facility configuration; + they are not aggregate state. `publication_year` is carried on the + view because Fixture's `registered_at` IS aggregate state. + + The HasComponent `related_identifiers` list is populated from + `view.components`: only components whose (scheme, value) are both + non-None become `RelatedIdentifier` entries with + `relation_type=HAS_COMPONENT` (PIDINST-faithful; the slice-6 + renderer substitutes to HasPart at the DataCite wire boundary per + L3). Unminted components are OMITTED from `related_identifiers` per + L27 and surface in the Description block instead. + + `PidinstRecordInvariantError` propagates unwrapped from + `PidinstRecord.__post_init__`, mirroring the Asset side. + """ + _validate_fixture_landing_page(view, landing_page_url) + _validate_fixture_name(view) + _validate_fixture_owner_state_available(view) + _validate_fixture_manufacturer_state_available(view) + + return PidinstRecord( + identifier=_build_fixture_identifier(view), + schema_version=SchemaVersion.V1_0, + landing_page=landing_page_url, + name=view.name, + publisher=publisher, + publication_year=view.publication_year, + owners=_build_fixture_owners(view), + manufacturers=view.manufacturers, + model=None, + description=_build_fixture_description(view), + instrument_types=(), + measured_variables=(), + dates=(), + related_identifiers=_build_fixture_components(view), + alternate_identifiers=(), + measurement_techniques=(), + ) + + +def _validate_fixture_landing_page(view: FixturePidinstView, landing_page_url: str) -> None: + if not landing_page_url or not landing_page_url.strip(): + raise FixtureLandingPageMissingError(view.fixture_id) + + +def _validate_fixture_name(view: FixturePidinstView) -> None: + if not view.name or not view.name.strip(): + raise FixtureNameMissingError(view.fixture_id) + + +def _validate_fixture_owner_state_available(view: FixturePidinstView) -> None: + if not view.owners: + raise FixtureOwnerStateNotAvailableError(view.fixture_id) + + +def _validate_fixture_manufacturer_state_available(view: FixturePidinstView) -> None: + if not view.manufacturers: + raise FixtureManufacturerStateNotAvailableError(view.fixture_id) + + +def _build_fixture_identifier(view: FixturePidinstView) -> PidinstIdentifier: + if view.persistent_id is None: + return PidinstIdentifier( + value=f"{_URN_UUID_PREFIX}{view.fixture_id}", + scheme=PidinstIdentifierType.URN, + ) + match view.persistent_id.scheme: + case PersistentIdentifierScheme.DOI: + wire_scheme = PidinstIdentifierType.DOI + case PersistentIdentifierScheme.HANDLE: + wire_scheme = PidinstIdentifierType.HANDLE + return PidinstIdentifier(value=view.persistent_id.value, scheme=wire_scheme) + + +def _build_fixture_owners(view: FixturePidinstView) -> tuple[Owner, ...]: + return tuple( + Owner( + name=raw.name.value, + contact=raw.contact.value if raw.contact is not None else None, + identifier=raw.identifier.value if raw.identifier is not None else None, + identifier_type=( + raw.identifier_type.value if raw.identifier_type is not None else None + ), + ) + for raw in view.owners + ) + + +def _build_fixture_components(view: FixturePidinstView) -> tuple[RelatedIdentifier, ...]: + return tuple( + RelatedIdentifier( + value=component.value, + identifier_type=component.scheme.value, + relation_type=PidinstRelationType.HAS_COMPONENT, + ) + for component in view.components + if component.scheme is not None and component.value is not None + ) + + +def _build_fixture_description(view: FixturePidinstView) -> str | None: + if not view.components: + return None + lines: list[str] = [] + for component in view.components: + suffix = "" + if component.scheme is None or component.value is None: + suffix = " (no persistent identifier)" + lines.append(f"- {component.name}{suffix}") + return "\n".join(lines) diff --git a/apps/api/src/cora/equipment/_pidinst_types.py b/apps/api/src/cora/equipment/_pidinst_types.py index 470bcad504..a1b860c912 100644 --- a/apps/api/src/cora/equipment/_pidinst_types.py +++ b/apps/api/src/cora/equipment/_pidinst_types.py @@ -47,7 +47,9 @@ AlternateIdentifier, AlternateIdentifierKind, AssetLifecycle, + AssetOwner, PersistentIdentifier, + PersistentIdentifierScheme, ) from cora.equipment.aggregates.model import ManufacturerIdentifierType from cora.equipment.errors import PidinstRecordInvariantError @@ -241,6 +243,79 @@ class AssetPidinstView: persistent_id: PersistentIdentifier | None = None +@dataclass(frozen=True) +class FixtureComponentRef: + """One bound Asset under a Fixture, with PID-or-fallback resolution. + + `component_id` is the bound Asset's id (the Fixture's + `SlotAssetBinding.asset_id`). `scheme` and `value` carry the + bound Asset's `PersistentIdentifier` decomposed into primitives; + both are None when the Asset has not been minted yet. The + serializer skips unminted components from the HAS_COMPONENT + related_identifiers tuple per L27 (HasComponent requires a + PID-bearing target); unminted components still appear in this + tuple so the Description block can surface the full composition. + + `name` is the bound Asset's display name; used by the Description + builder to surface unminted and decommissioned components. + + Skip-unminted semantics live in the assembler / serializer, NOT + in this dataclass. The dataclass is a plain data substrate. + """ + + component_id: UUID + scheme: PersistentIdentifierScheme | None + value: str | None + name: str + + +@dataclass(frozen=True) +class FixturePidinstView: + """Hydrated read-model view consumed by `to_fixture_pidinst_record`. + + Separate dataclass from `AssetPidinstView` per Lock 1. Carries the + Fixture-tier rollup of bound Assets' PIDINST-relevant facets, + populated by the read-side view assembler from Fixture + Asset + + Model streams. + + `owners` is the UNION of bound Assets' owners deduplicated by + (name, identifier) and sorted by name per L7. An empty tuple is + the sentinel for "no bound Asset carries any owners"; the + serializer raises `FixtureOwnerStateNotAvailableError` so the + Fixture-tier record never silently emits PIDINST-invalid output. + + `manufacturers` is the UNION of bound Assets' Models' manufacturers + deduplicated by (name, identifier) and sorted by name per L9 + (revised). The cascade is model-mediated: Asset does NOT carry a + manufacturers field, the Model catalog tier is the source of truth. + An empty tuple triggers `FixtureManufacturerStateNotAvailableError` + at the serializer. + + `components` is one entry per bound Asset, ordered deterministically + by the assembler. The serializer emits HasComponent + `RelatedIdentifier` entries ONLY for components with non-None + (scheme, value) per L27. + + `publication_year` is typed `int` (non-optional) because Fixture is + single-event-genesis; the assembler narrows + `fixture.registered_at.year` to int via the FixtureRegistered fold + invariant. Diverges from `AssetPidinstView` (where commissioned_at + may be None pre-commissioning) as a Fixture-specific simplification. + + `persistent_id` is None until a future `assign_fixture_persistent_id` + write slice lands; the read route surfaces None as the absent + optional field per the PIDINST v1.0 schema. + """ + + fixture_id: UUID + name: str + persistent_id: PersistentIdentifier | None + owners: tuple[AssetOwner, ...] + manufacturers: tuple["Manufacturer", ...] + components: tuple[FixtureComponentRef, ...] + publication_year: int + + @dataclass(frozen=True) class Manufacturer: """PIDINST v1.0 property 6: the body manufacturing the instrument. diff --git a/apps/api/src/cora/equipment/aggregates/fixture/__init__.py b/apps/api/src/cora/equipment/aggregates/fixture/__init__.py index b75209955a..a055f0d539 100644 --- a/apps/api/src/cora/equipment/aggregates/fixture/__init__.py +++ b/apps/api/src/cora/equipment/aggregates/fixture/__init__.py @@ -23,6 +23,7 @@ Fixture, FixtureAlreadyExistsError, FixtureNotFoundError, + PersistentIdentifier, SlotAssetBinding, ) @@ -32,6 +33,7 @@ "FixtureEvent", "FixtureNotFoundError", "FixtureRegistered", + "PersistentIdentifier", "SlotAssetBinding", "event_type_name", "evolve", diff --git a/apps/api/src/cora/equipment/aggregates/fixture/state.py b/apps/api/src/cora/equipment/aggregates/fixture/state.py index 4178e8bef9..db08fb4fee 100644 --- a/apps/api/src/cora/equipment/aggregates/fixture/state.py +++ b/apps/api/src/cora/equipment/aggregates/fixture/state.py @@ -27,6 +27,8 @@ from typing import Any from uuid import UUID +from cora.equipment.aggregates.asset import PersistentIdentifier + @dataclass(frozen=True) class SlotAssetBinding: @@ -69,6 +71,15 @@ class Fixture: ) parameter_overrides: dict[str, Any] = field(default_factory=dict[str, Any]) registered_at: datetime | None = None + # PIDINST v1.0 Property 1 persistent identifier (DOI or Handle). + # Data-substrate field landed ahead of the write path (mirrors slice + # E.1's Asset.commissioned_at pattern). Reads None at end-of-fold + # until the future assign_fixture_persistent_id slice ships the + # FixturePersistentIdAssigned event + evolver fold that flips this + # from None to Some. Set-once at the aggregate level per F3.3 + # Findable immutability. The PersistentIdentifier VO is reused + # unchanged from the Asset aggregate per Lock 2. + persistent_id: PersistentIdentifier | None = None class FixtureAlreadyExistsError(Exception): @@ -102,5 +113,6 @@ def __init__(self, fixture_id: UUID) -> None: "Fixture", "FixtureAlreadyExistsError", "FixtureNotFoundError", + "PersistentIdentifier", "SlotAssetBinding", ] diff --git a/apps/api/src/cora/equipment/errors.py b/apps/api/src/cora/equipment/errors.py index 16f95a8bd3..d4fb9f493c 100644 --- a/apps/api/src/cora/equipment/errors.py +++ b/apps/api/src/cora/equipment/errors.py @@ -99,3 +99,54 @@ class AssetNameMissingError(PidinstSerializationError): def __init__(self, asset_id: UUID) -> None: super().__init__(f"PIDINST mandatory property missing: name for asset {asset_id}") self.asset_id = asset_id + + +class FixturePidinstSerializationError(PidinstSerializationError): + """Base for every Fixture-tier PIDINST serializer precondition violation. + + Inherits from the cross-tier `PidinstSerializationError` so generic + exception handlers that catch the base continue to function for + both Asset-tier and Fixture-tier failures. Concrete Fixture-tier + subclasses carry `fixture_id` (not `asset_id`); raising an + Asset-tier sibling against a Fixture would be a semantic lie. + """ + + +class FixtureOwnerStateNotAvailableError(FixturePidinstSerializationError): + """No bound Asset carries any owners; the Fixture's owners-union is empty.""" + + def __init__(self, fixture_id: UUID) -> None: + super().__init__( + f"PIDINST mandatory property missing: owner for fixture {fixture_id} " + f"(no bound Asset carries any owners)" + ) + self.fixture_id = fixture_id + + +class FixtureManufacturerStateNotAvailableError(FixturePidinstSerializationError): + """No bound Asset's Model carries any manufacturer; the union is empty.""" + + def __init__(self, fixture_id: UUID) -> None: + super().__init__( + f"PIDINST mandatory property missing: manufacturer for fixture {fixture_id} " + f"(no bound Asset carries any manufacturer)" + ) + self.fixture_id = fixture_id + + +class FixtureLandingPageMissingError(FixturePidinstSerializationError): + """`view.landing_page_url` is empty or whitespace-only.""" + + def __init__(self, fixture_id: UUID) -> None: + super().__init__( + f"PIDINST mandatory property missing: landingPage for fixture {fixture_id}" + ) + self.fixture_id = fixture_id + + +class FixtureNameMissingError(FixturePidinstSerializationError): + """`view.name` is empty or whitespace-only.""" + + def __init__(self, fixture_id: UUID) -> None: + super().__init__(f"PIDINST mandatory property missing: name for fixture {fixture_id}") + self.fixture_id = fixture_id diff --git a/apps/api/src/cora/equipment/features/get_asset_pidinst/route.py b/apps/api/src/cora/equipment/features/get_asset_pidinst/route.py index 5c8e329026..8738f49f69 100644 --- a/apps/api/src/cora/equipment/features/get_asset_pidinst/route.py +++ b/apps/api/src/cora/equipment/features/get_asset_pidinst/route.py @@ -15,13 +15,10 @@ logs the violation at error level before re-raising so the bare 500 path still leaves a structured trail.) -FastAPI cannot generate `response_model` from the frozen slice-C -`PidinstRecord` dataclass directly: the OpenAPI schema generator -requires a Pydantic-typed mirror. The mirror lives here per the -`AssetIntegrationViewResponse` precedent at -`features/get_asset_integration_view/route.py:63-87`. `_record_to_response` -walks the slice-C tree into the Pydantic shape; tuple-of-dataclass -fields serialize as lists per the OpenAPI norm. +The Pydantic mirror `PidinstRecordResponse` plus its `record_to_response` +walker live at the BC root in `_pidinst_response.py` so both +Asset-tier and Fixture-tier read routes can share the same wire shape +without crossing slice boundaries. Slice E.1 of project_asset_persistent_id_design. """ @@ -30,9 +27,8 @@ from uuid import UUID from fastapi import APIRouter, Depends, Path, Request, status -from pydantic import BaseModel -from cora.equipment._pidinst_types import PidinstRecord +from cora.equipment._pidinst_response import PidinstRecordResponse, record_to_response from cora.equipment.features.get_asset_pidinst.handler import Handler from cora.equipment.features.get_asset_pidinst.query import GetAssetPidinst from cora.infrastructure.routing import ( @@ -43,187 +39,6 @@ ) -class PidinstIdentifierDTO(BaseModel): - """PIDINST v1.0 Property 1: persistent identifier of the instrument.""" - - value: str - scheme: str - - -class OwnerDTO(BaseModel): - """PIDINST v1.0 Property 5: a body owning or curating the instrument.""" - - name: str - contact: str | None = None - identifier: str | None = None - identifier_type: str | None = None - - -class ManufacturerDTO(BaseModel): - """PIDINST v1.0 Property 6: the body manufacturing the instrument.""" - - name: str - identifier: str | None = None - identifier_type: str | None = None - - -class PidinstModelDTO(BaseModel): - """PIDINST v1.0 Property 7: the model identification of the instrument.""" - - name: str - identifier: str - identifier_type: str - - -class InstrumentTypeDTO(BaseModel): - """PIDINST v1.0 Property 9: a typology category for the instrument.""" - - name: str - identifier: str | None = None - identifier_type: str - - -class MeasuredVariableDTO(BaseModel): - """PIDINST v1.0 Property 10: a physical quantity the instrument measures.""" - - name: str - - -class PidinstDateDTO(BaseModel): - """PIDINST v1.0 Property 11: a date marker on the instrument lifecycle.""" - - value: str - date_type: str - - -class RelatedIdentifierDTO(BaseModel): - """PIDINST v1.0 Property 12: a related identifier (parent asset, etc.).""" - - value: str - identifier_type: str - relation_type: str - - -class PidinstAlternateIdentifierDTO(BaseModel): - """PIDINST v1.0 Property 13: an alternate identifier under a known scheme.""" - - value: str - kind: str - name: str | None = None - - -class MeasurementTechniqueDTO(BaseModel): - """PIDINST v1.0 Property 14: a measurement technique applied by the instrument.""" - - name: str - - -class PidinstRecordResponse(BaseModel): - """Read-side DTO mirroring the slice-C `PidinstRecord` dataclass. - - Pydantic mirror so FastAPI can generate an OpenAPI schema. Decouples - the wire format from the slice-C `PidinstRecord` so the two can - evolve independently. Field shapes verbatim from the slice-C tree. - """ - - identifier: PidinstIdentifierDTO - schema_version: str - landing_page: str - name: str - publisher: str - publication_year: int | None - owners: list[OwnerDTO] - manufacturers: list[ManufacturerDTO] - model: PidinstModelDTO | None - description: str | None - instrument_types: list[InstrumentTypeDTO] - measured_variables: list[MeasuredVariableDTO] - dates: list[PidinstDateDTO] - related_identifiers: list[RelatedIdentifierDTO] - alternate_identifiers: list[PidinstAlternateIdentifierDTO] - measurement_techniques: list[MeasurementTechniqueDTO] - - -def _record_to_response(record: PidinstRecord) -> PidinstRecordResponse: - return PidinstRecordResponse( - identifier=PidinstIdentifierDTO( - value=record.identifier.value, - scheme=record.identifier.scheme.value, - ), - schema_version=record.schema_version.value, - landing_page=record.landing_page, - name=record.name, - publisher=record.publisher, - publication_year=record.publication_year, - owners=[ - OwnerDTO( - name=owner.name, - contact=owner.contact, - identifier=owner.identifier, - identifier_type=owner.identifier_type, - ) - for owner in record.owners - ], - manufacturers=[ - ManufacturerDTO( - name=manufacturer.name, - identifier=manufacturer.identifier, - identifier_type=( - manufacturer.identifier_type.value - if manufacturer.identifier_type is not None - else None - ), - ) - for manufacturer in record.manufacturers - ], - model=( - PidinstModelDTO( - name=record.model.name, - identifier=record.model.identifier, - identifier_type=record.model.identifier_type, - ) - if record.model is not None - else None - ), - description=record.description, - instrument_types=[ - InstrumentTypeDTO( - name=instrument_type.name, - identifier=instrument_type.identifier, - identifier_type=instrument_type.identifier_type, - ) - for instrument_type in record.instrument_types - ], - measured_variables=[ - MeasuredVariableDTO(name=variable.name) for variable in record.measured_variables - ], - dates=[ - PidinstDateDTO(value=pidinst_date.value, date_type=pidinst_date.date_type.value) - for pidinst_date in record.dates - ], - related_identifiers=[ - RelatedIdentifierDTO( - value=related_identifier.value, - identifier_type=related_identifier.identifier_type, - relation_type=related_identifier.relation_type.value, - ) - for related_identifier in record.related_identifiers - ], - alternate_identifiers=[ - PidinstAlternateIdentifierDTO( - value=alternate_identifier.value, - kind=alternate_identifier.kind.value, - name=alternate_identifier.name, - ) - for alternate_identifier in record.alternate_identifiers - ], - measurement_techniques=[ - MeasurementTechniqueDTO(name=technique.name) - for technique in record.measurement_techniques - ], - ) - - def _get_handler(request: Request) -> Handler: handler: Handler = request.app.state.equipment.get_asset_pidinst return handler @@ -274,4 +89,4 @@ async def get_asset_pidinst( correlation_id=cid, surface_id=surface_id, ) - return _record_to_response(record) + return record_to_response(record) diff --git a/apps/api/src/cora/equipment/features/get_fixture_pidinst/__init__.py b/apps/api/src/cora/equipment/features/get_fixture_pidinst/__init__.py new file mode 100644 index 0000000000..dcebb01c54 --- /dev/null +++ b/apps/api/src/cora/equipment/features/get_fixture_pidinst/__init__.py @@ -0,0 +1,27 @@ +"""Vertical slice for the `get_fixture_pidinst` query. + +Read-side slice of project_fixture_pidinst_design: PIDINST v1.0 read +route for the Fixture tier. The view assembler composes a +`FixturePidinstView` one level deep (bound Assets only; sub-Fixtures +NOT recursed into per L24). The route serializes the view to a +`PidinstRecord` via `to_fixture_pidinst_record` and returns the +`PidinstRecordResponse` JSON-LD wire shape on a 200. + +Module-as-namespace surface, mirroring `get_asset_pidinst`: + + from cora.equipment.features import get_fixture_pidinst + + handler = get_fixture_pidinst.bind(deps) + view = await handler(fixture_id, principal_id=..., correlation_id=...) +""" + +from cora.equipment.features.get_fixture_pidinst import tool +from cora.equipment.features.get_fixture_pidinst.handler import Handler, bind +from cora.equipment.features.get_fixture_pidinst.route import router + +__all__ = [ + "Handler", + "bind", + "router", + "tool", +] diff --git a/apps/api/src/cora/equipment/features/get_fixture_pidinst/_view_assembler.py b/apps/api/src/cora/equipment/features/get_fixture_pidinst/_view_assembler.py new file mode 100644 index 0000000000..8a0cbc9e1a --- /dev/null +++ b/apps/api/src/cora/equipment/features/get_fixture_pidinst/_view_assembler.py @@ -0,0 +1,150 @@ +"""Feature-local Fixture PIDINST view assembler. + +Composes the `FixturePidinstView` consumed by `to_fixture_pidinst_record` +from the Fixture aggregate plus its bound Assets (one level deep per +L24) and the Models those Assets reference (model-mediated Manufacturer +cascade per L9 revised). Mirrors the `features/get_asset_pidinst` +sibling assembler pattern: bare aggregate-loader function calls +(`load_fixture` + `load_asset` + `load_model`), no SQL JOINs across +summary projections. + +Per Section 11 of project_fixture_pidinst_design: + + - PURE aside from loader reads. No clock, no UUID generator, no + Authorize port. Per-deployment configuration (facility publisher, + landing page URL) is the handler / route's responsibility; this + assembler reads only aggregate state. + - Returns `None` when the Fixture stream is empty; the route maps + None to 404. + - Walks bound Assets ONE LEVEL DEEP per L24; sub-Fixture composition + is deferred until first-pilot trigger. + - Owners cascade per L7: union of bound Assets' owners, deduped by + (name, identifier), sorted by name. + - Manufacturers cascade per L9 revised (model-mediated): for each + bound Asset with `model_id` set, `load_model` returns the Model + whose single `manufacturer` is gathered into the union, deduped by + (name, identifier), sorted by name. Asset does NOT carry a + `manufacturers` field; Model is the catalog-tier source of truth. + Raises `FixtureManufacturerStateNotAvailableError` if any bound + Asset's `model_id` resolves to a missing Model. + - Components per L11 + L27: one entry per bound Asset with a minted + `persistent_id`; unminted bound Assets are skipped from the view's + components tuple so HasComponent emission only carries PID-bearing + targets. +""" + +import asyncio +from uuid import UUID + +from cora.equipment._pidinst_types import ( + FixtureComponentRef, + FixturePidinstView, +) +from cora.equipment._pidinst_types import ( + Manufacturer as PidinstManufacturer, +) +from cora.equipment.aggregates.asset import AssetOwner, load_asset +from cora.equipment.aggregates.fixture import load_fixture +from cora.equipment.aggregates.model import Manufacturer, load_model +from cora.equipment.errors import FixtureManufacturerStateNotAvailableError +from cora.infrastructure.kernel import Kernel + + +async def assemble_fixture_pidinst_view( + fixture_id: UUID, + deps: Kernel, +) -> FixturePidinstView | None: + """Compose a `FixturePidinstView` for the target fixture, or None if absent. + + Returns `None` when no Fixture exists for `fixture_id`; the route + maps this to a 404. Raises + `FixtureManufacturerStateNotAvailableError` per L9 revised when any + bound Asset's `model_id` resolves to a missing Model (Model + admin-deleted out-of-band breaks the model-mediated Manufacturer + cascade). + """ + fixture = await load_fixture(deps.event_store, fixture_id) + if fixture is None: + return None + + bound_asset_ids = sorted( + {binding.asset_id for binding in fixture.slot_asset_bindings}, + key=str, + ) + bound_assets_raw = await asyncio.gather( + *[load_asset(deps.event_store, asset_id) for asset_id in bound_asset_ids] + ) + bound_assets = [asset for asset in bound_assets_raw if asset is not None] + + asset_model_ids = [asset.model_id for asset in bound_assets if asset.model_id is not None] + models_raw = await asyncio.gather( + *[load_model(deps.event_store, model_id) for model_id in asset_model_ids] + ) + model_manufacturers: list[Manufacturer] = [] + for model in models_raw: + if model is None: + raise FixtureManufacturerStateNotAvailableError(fixture_id) + model_manufacturers.append(model.manufacturer) + + owners_by_dedup_key: dict[tuple[str, str | None], AssetOwner] = {} + for asset in bound_assets: + for owner in asset.owners: + identifier_value = owner.identifier.value if owner.identifier is not None else None + dedup_key = (owner.name.value, identifier_value) + owners_by_dedup_key.setdefault(dedup_key, owner) + owners_union = tuple(sorted(owners_by_dedup_key.values(), key=lambda o: o.name.value)) + + manufacturers_by_dedup_key: dict[tuple[str, str | None], Manufacturer] = {} + for manufacturer in model_manufacturers: + identifier_value = ( + manufacturer.identifier.value if manufacturer.identifier is not None else None + ) + dedup_key = (manufacturer.name.value, identifier_value) + manufacturers_by_dedup_key.setdefault(dedup_key, manufacturer) + manufacturers_union = tuple( + PidinstManufacturer( + name=m.name.value, + identifier=m.identifier.value if m.identifier is not None else None, + identifier_type=m.identifier_type, + ) + for m in sorted(manufacturers_by_dedup_key.values(), key=lambda m: m.name.value) + ) + + asset_by_id = {asset.id: asset for asset in bound_assets} + components: list[FixtureComponentRef] = [] + for binding in sorted( + fixture.slot_asset_bindings, + key=lambda b: (b.slot_name, str(b.asset_id)), + ): + asset = asset_by_id.get(binding.asset_id) + if asset is None: + continue + if asset.persistent_id is None: + continue + components.append( + FixtureComponentRef( + component_id=asset.id, + scheme=asset.persistent_id.scheme, + value=asset.persistent_id.value, + name=asset.name.value, + ) + ) + + registered_at = fixture.registered_at + assert registered_at is not None, ( + "Fixture.registered_at is set by the FixtureRegistered fold; " + "load_fixture returning a non-None Fixture implies the fold ran." + ) + + return FixturePidinstView( + fixture_id=fixture.id, + name=f"Fixture {fixture.id}", + persistent_id=fixture.persistent_id, + owners=owners_union, + manufacturers=manufacturers_union, + components=tuple(components), + publication_year=registered_at.year, + ) + + +__all__ = ["assemble_fixture_pidinst_view"] diff --git a/apps/api/src/cora/equipment/features/get_fixture_pidinst/handler.py b/apps/api/src/cora/equipment/features/get_fixture_pidinst/handler.py new file mode 100644 index 0000000000..f3cf8679a7 --- /dev/null +++ b/apps/api/src/cora/equipment/features/get_fixture_pidinst/handler.py @@ -0,0 +1,107 @@ +"""Application handler for the `get_fixture_pidinst` query slice. + +Thin: gates on the Authorize port (matching the `get_asset_pidinst` +precedent), then delegates to `assemble_fixture_pidinst_view`. Returns +the assembled `FixturePidinstView` or `None`; the route maps a None to +404 and runs the view through `to_fixture_pidinst_record` to produce +the wire-shape `PidinstRecord`. Each error propagates as-is to the +route layer, which maps via the BC's exception-handler registration: + + - returned `None` -> 404 (route maps) + - `FixtureOwnerStateNotAvailableError` -> 409 (serializer-time) + - `FixtureManufacturerStateNotAvailableError` -> 409 + - `FixtureLandingPageMissingError` -> 422 + - `FixtureNameMissingError` -> 422 + - `PidinstRecordInvariantError` -> 500 (kernel backstop) + +Per L6 + L22: handler is async, pure aside from loader reads. No +decider, no event emission, no clock injection, no UUID generator. +""" + +from typing import Protocol +from uuid import UUID + +from cora.equipment._pidinst_types import FixturePidinstView +from cora.equipment.errors import UnauthorizedError +from cora.equipment.features.get_fixture_pidinst._view_assembler import ( + assemble_fixture_pidinst_view, +) +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 = "GetFixturePidinst" + +_log = get_logger(__name__) + + +class Handler(Protocol): + """Callable interface every get_fixture_pidinst handler implements.""" + + async def __call__( + self, + fixture_id: UUID, + *, + principal_id: UUID, + correlation_id: UUID, + surface_id: UUID = NIL_SENTINEL_ID, + ) -> FixturePidinstView | None: ... + + +def bind(deps: Kernel) -> Handler: + """Build a get_fixture_pidinst handler closed over the shared deps.""" + + async def handler( + fixture_id: UUID, + *, + principal_id: UUID, + correlation_id: UUID, + surface_id: UUID = NIL_SENTINEL_ID, + ) -> FixturePidinstView | None: + _log.info( + "get_fixture_pidinst.start", + query_name=_QUERY_NAME, + fixture_id=str(fixture_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_fixture_pidinst.denied", + query_name=_QUERY_NAME, + fixture_id=str(fixture_id), + principal_id=str(principal_id), + correlation_id=str(correlation_id), + reason=decision.reason, + ) + raise UnauthorizedError(decision.reason) + view = await assemble_fixture_pidinst_view(fixture_id, deps) + if view is None: + _log.info( + "get_fixture_pidinst.not_found", + query_name=_QUERY_NAME, + fixture_id=str(fixture_id), + principal_id=str(principal_id), + correlation_id=str(correlation_id), + ) + return None + _log.info( + "get_fixture_pidinst.success", + query_name=_QUERY_NAME, + fixture_id=str(fixture_id), + principal_id=str(principal_id), + correlation_id=str(correlation_id), + ) + return view + + return handler + + +__all__ = ["Handler", "bind"] diff --git a/apps/api/src/cora/equipment/features/get_fixture_pidinst/route.py b/apps/api/src/cora/equipment/features/get_fixture_pidinst/route.py new file mode 100644 index 0000000000..aedf9d82b8 --- /dev/null +++ b/apps/api/src/cora/equipment/features/get_fixture_pidinst/route.py @@ -0,0 +1,114 @@ +"""HTTP route for the `get_fixture_pidinst` query slice. + +`GET /fixtures/{fixture_id}/pidinst` returns 200 + `PidinstRecordResponse` +on hit. Errors propagate to the BC's exception-handler tuples in +`equipment/routes.py` per L19: + + - handler returned `None` -> 404 (route raises) + - `FixtureOwnerStateNotAvailableError` -> 409 + - `FixtureManufacturerStateNotAvailableError` -> 409 + - `FixtureLandingPageMissingError` -> 422 + - `FixtureNameMissingError` -> 422 + - `PidinstRecordInvariantError` -> 500 (kernel backstop) + +FastAPI cannot generate `response_model` from the frozen slice-C +`PidinstRecord` dataclass directly: the OpenAPI schema generator +requires a Pydantic-typed mirror. The mirror reuses the slice-E.1 +`PidinstRecordResponse` shape; the Fixture-tier serializer produces +the same `PidinstRecord` shape, so the DTO is reused unchanged. +""" + +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, Path, Request, status + +from cora.equipment._pidinst_response import PidinstRecordResponse, record_to_response +from cora.equipment._pidinst_serializer import to_fixture_pidinst_record +from cora.equipment.aggregates.fixture import FixtureNotFoundError +from cora.equipment.features.get_fixture_pidinst.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.get_fixture_pidinst + return handler + + +router = APIRouter(tags=["equipment"]) + + +@router.get( + "/fixtures/{fixture_id}/pidinst", + status_code=status.HTTP_200_OK, + response_model=PidinstRecordResponse, + responses={ + status.HTTP_403_FORBIDDEN: { + "model": ErrorResponse, + "description": "Authorize port denied the query.", + }, + status.HTTP_404_NOT_FOUND: { + "model": ErrorResponse, + "description": "No fixture exists with the given id.", + }, + status.HTTP_409_CONFLICT: { + "model": ErrorResponse, + "description": ( + "Fixture state is missing a mandatory PIDINST source: " + "no bound Asset carries any owners " + "(FixtureOwnerStateNotAvailableError), or no bound Asset " + "carries any manufacturer " + "(FixtureManufacturerStateNotAvailableError)." + ), + }, + status.HTTP_422_UNPROCESSABLE_CONTENT: { + "description": ( + "View preparation produced an empty landing page URL " + "or fixture name; or the path parameter failed schema " + "validation." + ), + }, + }, + summary="Get the PIDINST v1.0 record for a fixture", +) +async def get_fixture_pidinst( + request: Request, + fixture_id: Annotated[UUID, Path(description="Target fixture'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)], +) -> PidinstRecordResponse: + view = await handler( + fixture_id, + principal_id=principal_id, + correlation_id=cid, + surface_id=surface_id, + ) + if view is None: + raise FixtureNotFoundError(fixture_id) + settings = request.app.state.settings + landing_page_url = _fixture_landing_page_url(settings.landing_page_template, fixture_id) + record = to_fixture_pidinst_record( + view, + landing_page_url=landing_page_url, + publisher=settings.facility_publisher, + ) + return record_to_response(record) + + +def _fixture_landing_page_url(asset_template: str, fixture_id: UUID) -> str: + """Derive a Fixture-tier landing-page URL from the Asset-tier template. + + Reuses the Asset BC's `landing_page_template` Setting and swaps + the `/assets/` segment + `{asset_id}` placeholder for their + Fixture-tier equivalents. A future + `fixture_pidinst_landing_page_template` Setting is deferred behind + D-FIX-LANDING-PAGE so this slice ships without a Settings extension. + """ + return asset_template.replace("{asset_id}", str(fixture_id)).replace("/assets/", "/fixtures/") diff --git a/apps/api/src/cora/equipment/features/get_fixture_pidinst/tool.py b/apps/api/src/cora/equipment/features/get_fixture_pidinst/tool.py new file mode 100644 index 0000000000..1ad1ed812b --- /dev/null +++ b/apps/api/src/cora/equipment/features/get_fixture_pidinst/tool.py @@ -0,0 +1,128 @@ +"""MCP tool for the `get_fixture_pidinst` query slice. + +Surfaces the same handler the REST route uses. Returns a structured +`PidinstRecordOutput` on hit. On miss raises `FixtureNotFoundError` +which FastMCP wraps as `isError: true` with a text diagnostic, +matching the REST 404 / 409 / 422 behaviour in MCP's error idiom. + +Per section 14.1 of project_fixture_pidinst_design: every read slice +ships both a REST route and an MCP tool; `get_*` is the read-tool +naming convention. +""" + +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._pidinst_serializer import to_fixture_pidinst_record +from cora.equipment.aggregates.fixture import FixtureNotFoundError +from cora.equipment.features.get_fixture_pidinst.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 + +_FIXTURE_LANDING_PAGE_FALLBACK = "https://cora.local/fixtures/{fixture_id}/landing" +_FIXTURE_PUBLISHER_FALLBACK = "CORA" + + +class PidinstIdentifierOutput(BaseModel): + """PIDINST property 1 Identifier.""" + + scheme: str + value: str + + +class PidinstOwnerOutput(BaseModel): + """One entry in PIDINST property 5 Owner.""" + + name: str + contact: str | None = None + identifier: str | None = None + identifier_type: str | None = None + + +class PidinstRecordOutput(BaseModel): + """Structured output of the `get_fixture_pidinst` MCP tool. + + Mirrors the slice-C `PidinstRecord` shape at the wire boundary; + aligns with the REST route's `PidinstRecordResponse`. Only the + fields actually populated by this slice are surfaced explicitly; + other PIDINST properties (RelatedIdentifier, MeasurementTechnique, + MeasuredVariable) ship as empty lists in the response body. + """ + + fixture_id: UUID + name: str + schema_version: str + landing_page_url: str + identifier: PidinstIdentifierOutput + owners: list[PidinstOwnerOutput] + publisher: str + publication_year: int | None + + +def register(mcp: FastMCP, *, get_handler: Callable[[], Handler]) -> None: + """Register the `get_fixture_pidinst` tool on the given MCP server.""" + + @mcp.tool( + name="get_fixture_pidinst", + description=( + "Get the PIDINST v1.0 record for a Fixture: a structured " + "instrument-metadata bundle (identifier + landing page + owners " + "+ manufacturers via bound Models + HasComponent relations to " + "bound Assets with minted PIDs). The record maps to DataCite " + "Instrument resourceTypeGeneral. Use when an external metadata " + "harvester or citation pipeline needs a citable description of " + "the composite Fixture. Returns 200 on success, 404 if the " + "fixture is unknown, 409 if Fixture state cannot satisfy the " + "PIDINST mandatory cardinality (no bound Asset carries owners " + "or manufacturers), 422 if the assembled view fails serializer " + "preconditions." + ), + ) + async def get_fixture_pidinst_tool( # pyright: ignore[reportUnusedFunction] + ctx: Context[Any, Any, Any], + fixture_id: Annotated[ + UUID, + Field(description="Target fixture's id."), + ], + ) -> PidinstRecordOutput: + handler = get_handler() + view = await handler( + fixture_id, + principal_id=get_mcp_principal_id(ctx), + correlation_id=current_correlation_id(), + surface_id=get_mcp_surface_id(), + ) + if view is None: + raise FixtureNotFoundError(fixture_id) + landing_page_url = _FIXTURE_LANDING_PAGE_FALLBACK.format(fixture_id=fixture_id) + record = to_fixture_pidinst_record( + view, + landing_page_url=landing_page_url, + publisher=_FIXTURE_PUBLISHER_FALLBACK, + ) + return PidinstRecordOutput( + fixture_id=fixture_id, + name=record.name, + schema_version=record.schema_version.value, + landing_page_url=record.landing_page, + identifier=PidinstIdentifierOutput( + scheme=record.identifier.scheme.value, + value=record.identifier.value, + ), + owners=[ + PidinstOwnerOutput( + name=owner.name, + contact=owner.contact, + identifier=owner.identifier, + identifier_type=owner.identifier_type, + ) + for owner in record.owners + ], + publisher=record.publisher, + publication_year=record.publication_year, + ) diff --git a/apps/api/src/cora/equipment/routes.py b/apps/api/src/cora/equipment/routes.py index 16ca8ff365..b9ab33f9dc 100644 --- a/apps/api/src/cora/equipment/routes.py +++ b/apps/api/src/cora/equipment/routes.py @@ -155,6 +155,11 @@ ) from cora.equipment.errors import ( AssetNameMissingError, + FixtureLandingPageMissingError, + FixtureManufacturerStateNotAvailableError, + FixtureNameMissingError, + FixtureOwnerStateNotAvailableError, + FixturePidinstSerializationError, LandingPageMissingError, ManufacturerStateNotAvailableError, OwnerStateNotAvailableError, @@ -188,6 +193,7 @@ get_asset_pidinst, get_family, get_fixture, + get_fixture_pidinst, get_model, install_asset, list_assets, @@ -331,6 +337,23 @@ async def _handle_malformed_stored_event(request: Request, exc: Exception) -> JS ) +async def _handle_pidinst_serialization_error(request: Request, exc: Exception) -> JSONResponse: + """500 backstop for unmapped Fixture-tier PIDINST serializer errors. + + The four concrete `FixturePidinstSerializationError` subclasses get + pinned to 409 / 422 via their own tuple registrations above; this + handler catches the base class itself plus any future subclass that + has not yet received an explicit mapping. The choice of 500 mirrors + the slice E.1 backstop reasoning: an unmapped serializer failure + signals a server-side gap, not a client error. + """ + _ = request + return JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content={"detail": str(exc)}, + ) + + async def _handle_pidinst_view_preparation_error(request: Request, exc: Exception) -> JSONResponse: """Shared 422 handler for PIDINST view-preparation deficiencies. @@ -403,6 +426,7 @@ def register_equipment_routes(app: FastAPI) -> None: app.include_router(attach_asset_to_fixture.router) app.include_router(detach_asset_from_fixture.router) app.include_router(get_fixture.router) + app.include_router(get_fixture_pidinst.router) app.include_router(list_fixtures.router) for validation_cls in ( InvalidAffordanceError, @@ -525,13 +549,18 @@ def register_equipment_routes(app: FastAPI) -> None: for pidinst_state_cls in ( OwnerStateNotAvailableError, ManufacturerStateNotAvailableError, + FixtureOwnerStateNotAvailableError, + FixtureManufacturerStateNotAvailableError, ): app.add_exception_handler(pidinst_state_cls, _handle_pidinst_state_not_available) for pidinst_view_cls in ( LandingPageMissingError, AssetNameMissingError, + FixtureLandingPageMissingError, + FixtureNameMissingError, ): app.add_exception_handler(pidinst_view_cls, _handle_pidinst_view_preparation_error) + app.add_exception_handler(FixturePidinstSerializationError, _handle_pidinst_serialization_error) app.add_exception_handler( PersistentIdentifierMintError, _handle_persistent_identifier_mint_error ) diff --git a/apps/api/src/cora/equipment/tools.py b/apps/api/src/cora/equipment/tools.py index 9f17456bcd..491b4b622c 100644 --- a/apps/api/src/cora/equipment/tools.py +++ b/apps/api/src/cora/equipment/tools.py @@ -52,6 +52,7 @@ from cora.equipment.features.get_asset_pidinst import tool as get_asset_pidinst_tool from cora.equipment.features.get_family import tool as get_family_tool from cora.equipment.features.get_fixture import tool as get_fixture_tool +from cora.equipment.features.get_fixture_pidinst import tool as get_fixture_pidinst_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 @@ -298,6 +299,10 @@ def register_equipment_tools( mcp, get_handler=lambda: get_handlers().get_fixture, ) + get_fixture_pidinst_tool.register( + mcp, + get_handler=lambda: get_handlers().get_fixture_pidinst, + ) list_fixtures_tool.register( mcp, get_handler=lambda: get_handlers().list_fixtures, diff --git a/apps/api/src/cora/equipment/wire.py b/apps/api/src/cora/equipment/wire.py index dcb4cdbc7a..63ae1e614f 100644 --- a/apps/api/src/cora/equipment/wire.py +++ b/apps/api/src/cora/equipment/wire.py @@ -62,6 +62,7 @@ get_asset_pidinst, get_family, get_fixture, + get_fixture_pidinst, get_model, install_asset, list_assets, @@ -176,6 +177,7 @@ class EquipmentHandlers: attach_asset_to_fixture: attach_asset_to_fixture.Handler detach_asset_from_fixture: detach_asset_from_fixture.Handler get_fixture: get_fixture.Handler + get_fixture_pidinst: get_fixture_pidinst.Handler list_fixtures: list_fixtures.Handler doi_minter: DoiMinter @@ -530,6 +532,12 @@ def wire_equipment(deps: Kernel) -> EquipmentHandlers: bc=_BC, kind="query", ), + get_fixture_pidinst=with_tracing( + get_fixture_pidinst.bind(deps), + command_name="GetFixturePidinst", + bc=_BC, + kind="query", + ), list_fixtures=with_tracing( list_fixtures.bind(deps), command_name="ListFixtures", diff --git a/apps/api/tests/architecture/test_fixture_pidinst_naming.py b/apps/api/tests/architecture/test_fixture_pidinst_naming.py new file mode 100644 index 0000000000..4403bc7129 --- /dev/null +++ b/apps/api/tests/architecture/test_fixture_pidinst_naming.py @@ -0,0 +1,144 @@ +"""Architecture fitness: Fixture-tier PIDINST module + error placement. + +Per Section 15.4 + Section 20 R3 of project_fixture_pidinst_design: + + - `FixturePidinstView` lives in `cora.equipment._pidinst_types` + alongside `AssetPidinstView` per Lock 1; the Fixture-tier view + must not drift into a sibling slice module. + - `to_fixture_pidinst_record` lives in `cora.equipment._pidinst_serializer` + alongside `to_pidinst_record`; the sibling serializer must share + the same BC-root module so the kernel reuse stays one import away. + - Four concrete `FixturePidinstSerializationError` subclasses + (`FixtureOwnerStateNotAvailableError`, + `FixtureManufacturerStateNotAvailableError`, + `FixtureLandingPageMissingError`, `FixtureNameMissingError`) all + inherit from `FixturePidinstSerializationError` which in turn + inherits from the cross-tier `PidinstSerializationError` base so + a generic `except PidinstSerializationError` clause continues to + catch both Asset-tier and Fixture-tier failures per Section 10.3. + - Each of the four concrete classes is wired via + `add_exception_handler` in `equipment/routes.py` so the Section + 13 status-code map (409 owner / 409 manufacturer / 422 landing + page / 422 name) actually fires at the HTTP boundary. +""" + +import ast + +import pytest + +from cora.equipment._pidinst_serializer import to_fixture_pidinst_record +from cora.equipment._pidinst_types import FixturePidinstView +from cora.equipment.errors import ( + FixtureLandingPageMissingError, + FixtureManufacturerStateNotAvailableError, + FixtureNameMissingError, + FixtureOwnerStateNotAvailableError, + FixturePidinstSerializationError, + PidinstSerializationError, +) +from tests.architecture.conftest import CORA_ROOT + +pytestmark = [pytest.mark.architecture, pytest.mark.timeout(60, method="thread")] + +_CONCRETE_FIXTURE_ERRORS: tuple[type[FixturePidinstSerializationError], ...] = ( + FixtureOwnerStateNotAvailableError, + FixtureManufacturerStateNotAvailableError, + FixtureLandingPageMissingError, + FixtureNameMissingError, +) + + +def _registered_exception_classes(tree: ast.Module) -> set[str]: + """Extract every class name passed to `add_exception_handler`. + + Mirrors the helper in + `test_get_asset_pidinst_status_map_is_complete.py`. Covers both + the direct `app.add_exception_handler(SomeError, handler_fn)` + call and the `for cls in (Error1, Error2): ...` loop pattern that + `equipment/routes.py` uses to collapse same-shape registrations. + """ + registered: set[str] = set() + add_handler_calls: list[ast.Call] = [] + for_loops_with_handler: list[ast.For] = [] + + for node in ast.walk(tree): + if isinstance(node, ast.Call): + if isinstance(node.func, ast.Attribute) and node.func.attr == "add_exception_handler": + add_handler_calls.append(node) + elif isinstance(node, ast.For): + for inner in ast.walk(node): + if ( + isinstance(inner, ast.Call) + and isinstance(inner.func, ast.Attribute) + and inner.func.attr == "add_exception_handler" + ): + for_loops_with_handler.append(node) + break + + for call in add_handler_calls: + if not call.args: + continue + first = call.args[0] + if isinstance(first, ast.Name): + registered.add(first.id) + + for loop in for_loops_with_handler: + iter_node = loop.iter + if isinstance(iter_node, ast.Tuple): + for elt in iter_node.elts: + if isinstance(elt, ast.Name): + registered.add(elt.id) + + return registered + + +def test_fixture_pidinst_view_lives_in_pidinst_types_module() -> None: + assert FixturePidinstView.__module__ == "cora.equipment._pidinst_types", ( + "FixturePidinstView must live in cora.equipment._pidinst_types " + "alongside AssetPidinstView per Section 9.1 Lock 1. Found in: " + f"{FixturePidinstView.__module__!r}" + ) + + +def test_to_fixture_pidinst_record_lives_in_designated_serializer_file() -> None: + assert to_fixture_pidinst_record.__module__ == "cora.equipment._pidinst_serializer", ( + "to_fixture_pidinst_record must live in cora.equipment._pidinst_serializer " + "alongside to_pidinst_record per Section 10.1 Lock 1. Found in: " + f"{to_fixture_pidinst_record.__module__!r}" + ) + + +def test_fixture_pidinst_serialization_error_inherits_pidinst_serialization_error_base() -> None: + mro: tuple[type, ...] = FixturePidinstSerializationError.__mro__ + assert issubclass(FixturePidinstSerializationError, PidinstSerializationError), ( + "FixturePidinstSerializationError must inherit from the cross-tier " + "PidinstSerializationError base per Section 10.3 so generic exception " + "handlers continue to catch both Asset-tier and Fixture-tier failures. " + f"MRO: {[cls.__name__ for cls in mro]}" + ) + + +@pytest.mark.parametrize("error_cls", _CONCRETE_FIXTURE_ERRORS, ids=lambda c: c.__name__) +def test_fixture_pidinst_concrete_error_inherits_fixture_pidinst_serialization_error_base( + error_cls: type[FixturePidinstSerializationError], +) -> None: + assert issubclass(error_cls, FixturePidinstSerializationError), ( + f"{error_cls.__name__} must inherit from FixturePidinstSerializationError " + "per Section 10.3 so the cross-tier `except PidinstSerializationError` " + f"clause catches it. MRO: {[cls.__name__ for cls in error_cls.__mro__]}" + ) + + +def test_fixture_pidinst_concrete_errors_each_register_as_http_handler() -> None: + routes_path = CORA_ROOT / "equipment" / "routes.py" + tree = ast.parse(routes_path.read_text()) + registered = _registered_exception_classes(tree) + + expected = {cls.__name__ for cls in _CONCRETE_FIXTURE_ERRORS} + missing = expected - registered + assert not missing, ( + "Equipment routes.py is missing add_exception_handler registration " + f"for Fixture-tier PIDINST error class(es): {sorted(missing)} per " + "Section 13 status-code map (409 owner + 409 manufacturer + 422 " + "landing page + 422 name)." + ) diff --git a/apps/api/tests/contract/test_get_fixture_pidinst_openapi.py b/apps/api/tests/contract/test_get_fixture_pidinst_openapi.py new file mode 100644 index 0000000000..a9930369c2 --- /dev/null +++ b/apps/api/tests/contract/test_get_fixture_pidinst_openapi.py @@ -0,0 +1,74 @@ +"""OpenAPI surface pins for `GET /fixtures/{fixture_id}/pidinst`. + +Per Section 15.3 of project_fixture_pidinst_design: pin the OpenAPI +schema for the Fixture-tier PIDINST read endpoint. Mirrors the +surface-pin posture of `test_assign_asset_persistent_id_openapi.py`. +Guards Lock 6 (route path `GET /fixtures/{fixture_id}/pidinst`), +Lock 1 (response model reuses the slice-E.1 `PidinstRecordResponse` +shape), and the Section 13 status-code map (200 + 403 + 404 + 409 + +422 surface on the GET route). +""" + +import pytest +from fastapi.testclient import TestClient + +from cora.api.main import create_app + +pytestmark = pytest.mark.timeout(60, method="thread") + +_ROUTE_PATH = "/fixtures/{fixture_id}/pidinst" + + +@pytest.mark.contract +def test_openapi_has_get_fixtures_id_pidinst_endpoint() -> None: + with TestClient(create_app()) as client: + openapi = client.get("/openapi.json").json() + + assert _ROUTE_PATH in openapi["paths"], ( + f"OpenAPI must expose {_ROUTE_PATH} for the get_fixture_pidinst slice" + ) + assert "get" in openapi["paths"][_ROUTE_PATH], f"{_ROUTE_PATH} must register a GET operation" + + +@pytest.mark.contract +def test_openapi_response_schema_has_pidinst_record_shape() -> None: + with TestClient(create_app()) as client: + openapi = client.get("/openapi.json").json() + + response_component = openapi["components"]["schemas"]["PidinstRecordResponse"] + properties = response_component["properties"] + for field in ( + "identifier", + "schema_version", + "landing_page", + "name", + "publisher", + "publication_year", + "owners", + "manufacturers", + "model", + "description", + "instrument_types", + "measured_variables", + "dates", + "related_identifiers", + "alternate_identifiers", + "measurement_techniques", + ): + assert field in properties, ( + f"PidinstRecordResponse must expose {field} per slice-C kernel shape" + ) + + +@pytest.mark.contract +def test_openapi_documents_200_404_500_response_codes() -> None: + with TestClient(create_app()) as client: + openapi = client.get("/openapi.json").json() + + operation = openapi["paths"][_ROUTE_PATH]["get"] + responses = operation["responses"] + expected = {"200", "403", "404", "409", "422"} + missing = expected - set(responses.keys()) + assert not missing, ( + f"OpenAPI must document all status codes from Section 13; missing: {sorted(missing)}" + ) diff --git a/apps/api/tests/integration/equipment/test_get_fixture_pidinst_handler_postgres.py b/apps/api/tests/integration/equipment/test_get_fixture_pidinst_handler_postgres.py new file mode 100644 index 0000000000..5c2cd10a20 --- /dev/null +++ b/apps/api/tests/integration/equipment/test_get_fixture_pidinst_handler_postgres.py @@ -0,0 +1,427 @@ +"""Closure-proof integration suite: get_fixture_pidinst against real Postgres. + +Read-side slice of project_fixture_pidinst_design (Section 15.2). +The view assembler composes a `FixturePidinstView` from the Fixture +aggregate plus its bound Assets (one level deep per L24); the route +then runs the view through `to_fixture_pidinst_record` to produce +the `PidinstRecord`. This suite pins the handler-tier closure: +register the upstream chain (Family -> Model -> Asset + owner + +family -> Assembly -> Fixture), optionally assign each bound Asset a +persistent_id via the inert `StubDoiMinter`, then load through +`get_fixture_pidinst.bind(deps)` and assert against the assembled +view (and through `to_fixture_pidinst_record` for the URN-fallback +identifier shape). + +The read-side slice has no `assign_fixture_persistent_id` write +sibling yet, so the Fixture's own `persistent_id` is always None and +the serializer always emits the `urn:uuid:` fallback per +L28. +""" + +# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false + +from datetime import UTC, datetime +from types import SimpleNamespace +from uuid import UUID, uuid4 + +import asyncpg +import pytest + +from cora.equipment._pidinst_serializer import to_fixture_pidinst_record +from cora.equipment.adapters.stub_doi_minter import StubDoiMinter +from cora.equipment.aggregates.assembly import SlotCardinality, SlotName, TemplateSlot +from cora.equipment.aggregates.asset import ( + AssetLevel, + AssetOwner, + AssetOwnerContact, + AssetOwnerIdentifier, + AssetOwnerIdentifierType, + AssetOwnerName, + PersistentIdentifierScheme, +) +from cora.equipment.aggregates.fixture import SlotAssetBinding +from cora.equipment.aggregates.model import ( + Manufacturer, + ManufacturerIdentifier, + ManufacturerIdentifierType, + ManufacturerName, +) +from cora.equipment.features import ( + add_asset_family, + add_asset_owner, + assign_asset_persistent_id, + define_assembly, + define_family, + define_model, + get_fixture_pidinst, + register_asset, + register_fixture, +) +from cora.equipment.features.add_asset_family import AddAssetFamily +from cora.equipment.features.add_asset_owner import AddAssetOwner +from cora.equipment.features.assign_asset_persistent_id import AssignAssetPersistentId +from cora.equipment.features.define_assembly import DefineAssembly +from cora.equipment.features.define_family import DefineFamily +from cora.equipment.features.define_model import DefineModel +from cora.equipment.features.register_asset import RegisterAsset +from cora.equipment.features.register_fixture import RegisterFixture +from cora.infrastructure.config import Settings +from cora.infrastructure.kernel import Kernel +from tests.integration._equipment_helpers import drain_equipment_projections +from tests.integration._helpers import build_postgres_deps + +pytestmark = pytest.mark.timeout(60, method="thread") + +_NOW = datetime(2024, 7, 4, 12, 0, 0, tzinfo=UTC) +_PARENT_ID = UUID("01900000-0000-7000-8000-0000ee010000") +_PRINCIPAL_ID = UUID("01900000-0000-7000-8000-000000000099") +_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000aa") +_LANDING_TEMPLATE = "https://cora.example/assets/{asset_id}/landing" +_PUBLISHER = "Argonne National Laboratory" + + +def _override_settings(deps: Kernel, **overrides: object) -> Kernel: + """Construct a sibling Kernel sharing every dep except settings.""" + settings_data = deps.settings.model_dump() + settings_data.update(overrides) + new_settings = Settings(**settings_data) # type: ignore[arg-type] + from dataclasses import replace + + return replace(deps, settings=new_settings) + + +def _build_deps( + db_pool: asyncpg.Pool, + *, + ids: list[UUID], + now: datetime = _NOW, +) -> Kernel: + deps = build_postgres_deps(db_pool, ids=ids, now=now) + deps = _override_settings( + deps, + facility_publisher=_PUBLISHER, + landing_page_template=_LANDING_TEMPLATE, + ) + # The assign_asset_persistent_id handler reads `deps.equipment.doi_minter`; + # mirror what `wire_equipment` registers when no DataCite credentials are + # present (parity with test_get_asset_pidinst_with_persistent_id.py). + object.__setattr__(deps, "equipment", SimpleNamespace(doi_minter=StubDoiMinter())) + return deps + + +def _hzb_owner() -> AssetOwner: + return AssetOwner( + name=AssetOwnerName("Helmholtz-Zentrum Berlin"), + contact=AssetOwnerContact("instrument-data@hzb.de"), + identifier=AssetOwnerIdentifier("https://ror.org/02aj13c28"), + identifier_type=AssetOwnerIdentifierType("ROR"), + ) + + +def _aps_owner() -> AssetOwner: + return AssetOwner( + name=AssetOwnerName("Advanced Photon Source"), + contact=AssetOwnerContact("aps-ops@anl.gov"), + identifier=AssetOwnerIdentifier("https://ror.org/05gvnxz63"), + identifier_type=AssetOwnerIdentifierType("ROR"), + ) + + +def _aerotech_manufacturer() -> Manufacturer: + return Manufacturer( + name=ManufacturerName("Aerotech"), + identifier=ManufacturerIdentifier("https://ror.org/04bw7nh07"), + identifier_type=ManufacturerIdentifierType.ROR, + ) + + +async def _seed_family(db_pool: asyncpg.Pool, *, name: str) -> UUID: + family_id = uuid4() + define_event_id = uuid4() + deps = _build_deps(db_pool, ids=[family_id, define_event_id]) + await define_family.bind(deps)( + DefineFamily(name=name, affordances=frozenset()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + # define_model + register_fixture read proj_equipment_family_summary; + # drain so the lookup the next handler call performs sees this row. + await drain_equipment_projections(db_pool) + return family_id + + +async def _seed_model(db_pool: asyncpg.Pool, *, declared_family_ids: frozenset[UUID]) -> UUID: + model_id = uuid4() + define_event_id = uuid4() + deps = _build_deps(db_pool, ids=[model_id, define_event_id]) + await define_model.bind(deps)( + DefineModel( + name="ANT130-L", + manufacturer=_aerotech_manufacturer(), + part_number="ANT130-L-RM", + declared_family_ids=declared_family_ids, + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + return model_id + + +async def _add_family_to_asset(db_pool: asyncpg.Pool, *, asset_id: UUID, family_id: UUID) -> None: + event_id = uuid4() + deps = _build_deps(db_pool, ids=[event_id]) + await add_asset_family.bind(deps)( + AddAssetFamily(asset_id=asset_id, family_id=family_id), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + +async def _seed_asset_with_owner_and_model( + db_pool: asyncpg.Pool, + *, + family_id: UUID, + model_id: UUID, + name: str, + owner: AssetOwner, +) -> UUID: + asset_id = uuid4() + register_event_id = uuid4() + deps = _build_deps(db_pool, ids=[asset_id, register_event_id]) + await register_asset.bind(deps)( + RegisterAsset( + name=name, + level=AssetLevel.DEVICE, + parent_id=_PARENT_ID, + model_id=model_id, + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await _add_family_to_asset(db_pool, asset_id=asset_id, family_id=family_id) + owner_event_id = uuid4() + owner_deps = _build_deps(db_pool, ids=[owner_event_id]) + await add_asset_owner.bind(owner_deps)( + AddAssetOwner(asset_id=asset_id, owner=owner), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + return asset_id + + +async def _assign_asset_persistent_id( + db_pool: asyncpg.Pool, + *, + asset_id: UUID, + suffix: str, +) -> None: + event_id = uuid4() + deps = _build_deps(db_pool, ids=[event_id]) + await assign_asset_persistent_id.bind(deps)( + AssignAssetPersistentId( + asset_id=asset_id, + scheme=PersistentIdentifierScheme.DOI, + suffix=suffix, + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + +async def _seed_assembly_one_slot( + db_pool: asyncpg.Pool, + *, + family_id: UUID, + cardinality: SlotCardinality, + name: str = "MCTOptics", +) -> UUID: + assembly_id = uuid4() + define_event_id = uuid4() + deps = _build_deps(db_pool, ids=[assembly_id, define_event_id]) + return await define_assembly.bind(deps)( + DefineAssembly( + name=name, + presents_as_family_id=family_id, + required_slots=frozenset( + { + TemplateSlot( + slot_name=SlotName("camera"), + required_family_ids=frozenset({family_id}), + cardinality=cardinality, + ) + } + ), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + +async def _seed_fixture( + db_pool: asyncpg.Pool, + *, + assembly_id: UUID, + slot_asset_bindings: frozenset[SlotAssetBinding], +) -> UUID: + fixture_id = uuid4() + fixture_event_id = uuid4() + deps = _build_deps(db_pool, ids=[fixture_id, fixture_event_id]) + return await register_fixture.bind(deps)( + RegisterFixture( + assembly_id=assembly_id, + slot_asset_bindings=slot_asset_bindings, + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + +def _pidinst_handler(deps: Kernel) -> get_fixture_pidinst.Handler: + return get_fixture_pidinst.bind(deps) + + +@pytest.mark.integration +async def test_get_fixture_pidinst_for_minted_fixture_with_bound_minted_assets_returns_pidinst_view( + db_pool: asyncpg.Pool, +) -> None: + family_id = await _seed_family(db_pool, name="Camera") + model_id = await _seed_model(db_pool, declared_family_ids=frozenset({family_id})) + asset_a = await _seed_asset_with_owner_and_model( + db_pool, + family_id=family_id, + model_id=model_id, + name="Camera-A", + owner=_hzb_owner(), + ) + asset_b = await _seed_asset_with_owner_and_model( + db_pool, + family_id=family_id, + model_id=model_id, + name="Camera-B", + owner=_aps_owner(), + ) + await _assign_asset_persistent_id(db_pool, asset_id=asset_a, suffix="CAM-A") + await _assign_asset_persistent_id(db_pool, asset_id=asset_b, suffix="CAM-B") + assembly_id = await _seed_assembly_one_slot( + db_pool, + family_id=family_id, + cardinality=SlotCardinality.ONE_OR_MORE, + ) + fixture_id = await _seed_fixture( + db_pool, + assembly_id=assembly_id, + slot_asset_bindings=frozenset( + { + SlotAssetBinding(slot_name="camera", asset_id=asset_a), + SlotAssetBinding(slot_name="camera", asset_id=asset_b), + } + ), + ) + + handler = _pidinst_handler(_build_deps(db_pool, ids=[])) + view = await handler( + fixture_id, + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + assert view is not None + assert view.fixture_id == fixture_id + assert view.persistent_id is None + owner_names = {owner.name.value for owner in view.owners} + assert owner_names == {"Advanced Photon Source", "Helmholtz-Zentrum Berlin"} + manufacturer_names = {m.name for m in view.manufacturers} + assert manufacturer_names == {"Aerotech"} + component_ids = {component.component_id for component in view.components} + assert component_ids == {asset_a, asset_b} + for component in view.components: + assert component.scheme is PersistentIdentifierScheme.DOI + assert component.value is not None + assert component.value.startswith("10.0000/cora-stub/") + assert view.publication_year == _NOW.year + + +@pytest.mark.integration +async def test_get_fixture_pidinst_no_bound_assets_returns_view_empty_owners_manufacturers( + db_pool: asyncpg.Pool, +) -> None: + family_id = await _seed_family(db_pool, name="Camera") + assembly_id = await _seed_assembly_one_slot( + db_pool, + family_id=family_id, + cardinality=SlotCardinality.ZERO_OR_MORE, + ) + fixture_id = await _seed_fixture( + db_pool, + assembly_id=assembly_id, + slot_asset_bindings=frozenset(), + ) + + handler = _pidinst_handler(_build_deps(db_pool, ids=[])) + view = await handler( + fixture_id, + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + assert view is not None + assert view.fixture_id == fixture_id + assert view.owners == () + assert view.manufacturers == () + assert view.components == () + assert view.persistent_id is None + + +@pytest.mark.integration +async def test_get_fixture_pidinst_with_unminted_fixture_returns_urn_identifier( + db_pool: asyncpg.Pool, +) -> None: + family_id = await _seed_family(db_pool, name="Camera") + model_id = await _seed_model(db_pool, declared_family_ids=frozenset({family_id})) + asset_id = await _seed_asset_with_owner_and_model( + db_pool, + family_id=family_id, + model_id=model_id, + name="Camera-Solo", + owner=_hzb_owner(), + ) + assembly_id = await _seed_assembly_one_slot( + db_pool, + family_id=family_id, + cardinality=SlotCardinality.EXACTLY_1, + ) + fixture_id = await _seed_fixture( + db_pool, + assembly_id=assembly_id, + slot_asset_bindings=frozenset({SlotAssetBinding(slot_name="camera", asset_id=asset_id)}), + ) + + handler = _pidinst_handler(_build_deps(db_pool, ids=[])) + view = await handler( + fixture_id, + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + assert view is not None + assert view.persistent_id is None + record = to_fixture_pidinst_record( + view, + landing_page_url=f"https://cora.example/fixtures/{fixture_id}/landing", + publisher=_PUBLISHER, + ) + assert record.identifier.scheme.value == "URN" + assert record.identifier.value == f"urn:uuid:{fixture_id}" + + +@pytest.mark.integration +async def test_get_fixture_pidinst_with_unknown_fixture_returns_none( + db_pool: asyncpg.Pool, +) -> None: + handler = _pidinst_handler(_build_deps(db_pool, ids=[])) + view = await handler( + uuid4(), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert view is None diff --git a/apps/api/tests/integration/equipment/test_get_fixture_pidinst_route.py b/apps/api/tests/integration/equipment/test_get_fixture_pidinst_route.py new file mode 100644 index 0000000000..6edf66d9ef --- /dev/null +++ b/apps/api/tests/integration/equipment/test_get_fixture_pidinst_route.py @@ -0,0 +1,388 @@ +"""HTTP route integration tests for `GET /fixtures/{fixture_id}/pidinst`. + +Read-side slice of project_fixture_pidinst_design Section 15.2 + 15.3. Drives +the FastAPI route via `httpx.AsyncClient + ASGITransport` against an +app whose Equipment wiring is swapped to a per-test Postgres `db_pool` +Kernel; seeds the upstream Family -> Model -> Asset -> Assembly -> +Fixture chain through direct handler invocation against the same pool. +The Postgres backing is required because `define_model` resolves +declared families via `proj_equipment_family_summary`, which the +in-memory `create_app()` cannot populate. Sibling pattern to +`test_get_fixture_pidinst_handler_postgres.py` for seeding, mirroring +`test_assign_asset_persistent_id_route.py` for the deps swap. + +Covers per Section 15.2 + 15.3: + + - 200 happy path: a registered Fixture whose bound Asset carries + owners and a Model returns `PidinstRecordResponse` JSON-LD. + - 404 for an unknown fixture_id (the route maps a returned None to + `FixtureNotFoundError` -> 404 via the shared `_handle_not_found`). + - 500 when the serializer surfaces a Fixture-tier + `FixtureManufacturerStateNotAvailableError` (the registered + `_handle_pidinst_serialization_error` 500 backstop on the + cross-tier `FixturePidinstSerializationError` base). + - Response body shape lock: every Pydantic field on + `PidinstRecordResponse` is present with the documented JSON type. +""" + +# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false, reportMissingTypeStubs=false + +from collections.abc import AsyncIterator +from dataclasses import replace +from datetime import UTC, datetime +from types import SimpleNamespace +from uuid import UUID, uuid4 + +import asyncpg +import pytest +import pytest_asyncio +from httpx import ASGITransport, AsyncClient + +from cora.api.main import create_app +from cora.equipment.adapters.stub_doi_minter import StubDoiMinter +from cora.equipment.aggregates.assembly import SlotCardinality, SlotName, TemplateSlot +from cora.equipment.aggregates.asset import ( + AssetLevel, + AssetOwner, + AssetOwnerContact, + AssetOwnerIdentifier, + AssetOwnerIdentifierType, + AssetOwnerName, +) +from cora.equipment.aggregates.fixture import SlotAssetBinding +from cora.equipment.aggregates.model import ( + Manufacturer, + ManufacturerIdentifier, + ManufacturerIdentifierType, + ManufacturerName, +) +from cora.equipment.features import ( + add_asset_family, + add_asset_owner, + define_assembly, + define_family, + define_model, + register_asset, + register_fixture, +) +from cora.equipment.features.add_asset_family import AddAssetFamily +from cora.equipment.features.add_asset_owner import AddAssetOwner +from cora.equipment.features.define_assembly import DefineAssembly +from cora.equipment.features.define_family import DefineFamily +from cora.equipment.features.define_model import DefineModel +from cora.equipment.features.register_asset import RegisterAsset +from cora.equipment.features.register_fixture import RegisterFixture +from cora.equipment.wire import wire_equipment +from cora.infrastructure.config import Settings +from cora.infrastructure.kernel import Kernel +from tests.integration._equipment_helpers import drain_equipment_projections +from tests.integration._helpers import build_postgres_deps + +pytestmark = pytest.mark.timeout(60, method="thread") + +_NOW = datetime(2024, 7, 4, 12, 0, 0, tzinfo=UTC) +_PARENT_ID = UUID("01900000-0000-7000-8000-0000ee010000") +_PRINCIPAL_ID = UUID("01900000-0000-7000-8000-000000000099") +_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000aa") +_LANDING_TEMPLATE = "https://cora.example/assets/{asset_id}/landing" +_PUBLISHER = "Argonne National Laboratory" + + +def _override_settings(deps: Kernel, **overrides: object) -> Kernel: + settings_data = deps.settings.model_dump() + settings_data.update(overrides) + new_settings = Settings(**settings_data) # type: ignore[arg-type] + return replace(deps, settings=new_settings) + + +def _build_deps(db_pool: asyncpg.Pool, *, ids: list[UUID]) -> Kernel: + deps = build_postgres_deps(db_pool, ids=ids, now=_NOW) + deps = _override_settings( + deps, + facility_publisher=_PUBLISHER, + landing_page_template=_LANDING_TEMPLATE, + ) + object.__setattr__(deps, "equipment", SimpleNamespace(doi_minter=StubDoiMinter())) + return deps + + +def _hzb_owner() -> AssetOwner: + return AssetOwner( + name=AssetOwnerName("Helmholtz-Zentrum Berlin"), + contact=AssetOwnerContact("instrument-data@hzb.de"), + identifier=AssetOwnerIdentifier("https://ror.org/02aj13c28"), + identifier_type=AssetOwnerIdentifierType("ROR"), + ) + + +def _aerotech_manufacturer() -> Manufacturer: + return Manufacturer( + name=ManufacturerName("Aerotech"), + identifier=ManufacturerIdentifier("https://ror.org/04bw7nh07"), + identifier_type=ManufacturerIdentifierType.ROR, + ) + + +async def _seed_family(db_pool: asyncpg.Pool, *, name: str) -> UUID: + family_id = uuid4() + define_event_id = uuid4() + deps = _build_deps(db_pool, ids=[family_id, define_event_id]) + await define_family.bind(deps)( + DefineFamily(name=name, affordances=frozenset()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await drain_equipment_projections(db_pool) + return family_id + + +async def _seed_model(db_pool: asyncpg.Pool, *, declared_family_ids: frozenset[UUID]) -> UUID: + model_id = uuid4() + define_event_id = uuid4() + deps = _build_deps(db_pool, ids=[model_id, define_event_id]) + return await define_model.bind(deps)( + DefineModel( + name="ANT130-L", + manufacturer=_aerotech_manufacturer(), + part_number="ANT130-L-RM", + declared_family_ids=declared_family_ids, + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + +async def _add_family_to_asset(db_pool: asyncpg.Pool, *, asset_id: UUID, family_id: UUID) -> None: + event_id = uuid4() + deps = _build_deps(db_pool, ids=[event_id]) + await add_asset_family.bind(deps)( + AddAssetFamily(asset_id=asset_id, family_id=family_id), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + +async def _seed_asset( + db_pool: asyncpg.Pool, + *, + family_id: UUID, + model_id: UUID | None, + name: str, + owner: AssetOwner | None, +) -> UUID: + asset_id = uuid4() + register_event_id = uuid4() + deps = _build_deps(db_pool, ids=[asset_id, register_event_id]) + await register_asset.bind(deps)( + RegisterAsset( + name=name, + level=AssetLevel.DEVICE, + parent_id=_PARENT_ID, + model_id=model_id, + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await _add_family_to_asset(db_pool, asset_id=asset_id, family_id=family_id) + if owner is not None: + owner_event_id = uuid4() + owner_deps = _build_deps(db_pool, ids=[owner_event_id]) + await add_asset_owner.bind(owner_deps)( + AddAssetOwner(asset_id=asset_id, owner=owner), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + return asset_id + + +async def _seed_assembly_one_slot( + db_pool: asyncpg.Pool, *, family_id: UUID, name: str = "MCTOptics" +) -> UUID: + assembly_id = uuid4() + define_event_id = uuid4() + deps = _build_deps(db_pool, ids=[assembly_id, define_event_id]) + return await define_assembly.bind(deps)( + DefineAssembly( + name=name, + presents_as_family_id=family_id, + required_slots=frozenset( + { + TemplateSlot( + slot_name=SlotName("camera"), + required_family_ids=frozenset({family_id}), + cardinality=SlotCardinality.EXACTLY_1, + ) + } + ), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + +async def _seed_fixture( + db_pool: asyncpg.Pool, + *, + assembly_id: UUID, + asset_id: UUID, +) -> UUID: + fixture_id = uuid4() + fixture_event_id = uuid4() + deps = _build_deps(db_pool, ids=[fixture_id, fixture_event_id]) + return await register_fixture.bind(deps)( + RegisterFixture( + assembly_id=assembly_id, + slot_asset_bindings=frozenset( + {SlotAssetBinding(slot_name="camera", asset_id=asset_id)} + ), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + +async def _seed_fixture_with_owners_and_model(db_pool: asyncpg.Pool) -> UUID: + family_id = await _seed_family(db_pool, name="Camera") + model_id = await _seed_model(db_pool, declared_family_ids=frozenset({family_id})) + asset_id = await _seed_asset( + db_pool, + family_id=family_id, + model_id=model_id, + name="Camera-A", + owner=_hzb_owner(), + ) + assembly_id = await _seed_assembly_one_slot(db_pool, family_id=family_id) + return await _seed_fixture(db_pool, assembly_id=assembly_id, asset_id=asset_id) + + +async def _seed_fixture_with_owners_but_no_model(db_pool: asyncpg.Pool) -> UUID: + family_id = await _seed_family(db_pool, name="Camera") + asset_id = await _seed_asset( + db_pool, + family_id=family_id, + model_id=None, + name="Camera-A", + owner=_hzb_owner(), + ) + assembly_id = await _seed_assembly_one_slot(db_pool, family_id=family_id) + return await _seed_fixture(db_pool, assembly_id=assembly_id, asset_id=asset_id) + + +@pytest_asyncio.fixture +async def pg_async_client(db_pool: asyncpg.Pool) -> AsyncIterator[AsyncClient]: + """Async HTTP client whose Equipment routes resolve against `db_pool`. + + `create_app()` boots with the in-memory adapters per APP_ENV=test; + we sidestep the in-memory wiring by REPLACING `app.state.deps`, + `app.state.settings`, and `app.state.equipment` with a Postgres- + backed Kernel + freshly wired `EquipmentHandlers` BEFORE the route + closure resolves them via `request.app.state.equipment.*`. The + httpx `ASGITransport` drives the FastAPI app directly without a + network socket, so the route's exception-handler tuples + status + code mapping are exercised end-to-end. + + The lifespan is NOT entered, so subsystems requiring it (MCP + session manager, projection worker, idempotency pruner, agent + seed) do not boot. The Equipment GET route uses only the + `app.state.equipment.get_fixture_pidinst` closure + the BC's + exception-handler registrations, which `register_equipment_routes` + attaches at app construction. + """ + app = create_app() + pg_deps = _build_deps(db_pool, ids=[]) + app.state.deps = pg_deps + app.state.settings = pg_deps.settings + app.state.equipment = wire_equipment(pg_deps) + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://testserver") as client: + yield client + + +@pytest.mark.integration +async def test_get_fixture_pidinst_route_returns_200_with_pidinst_record_for_minted_fixture( + db_pool: asyncpg.Pool, + pg_async_client: AsyncClient, +) -> None: + fixture_id = await _seed_fixture_with_owners_and_model(db_pool) + response = await pg_async_client.get(f"/fixtures/{fixture_id}/pidinst") + assert response.status_code == 200, response.text + body = response.json() + assert body["identifier"]["value"] == f"urn:uuid:{fixture_id}" + assert body["identifier"]["scheme"] == "URN" + assert body["schema_version"] + assert len(body["owners"]) >= 1 + assert body["owners"][0]["name"] == "Helmholtz-Zentrum Berlin" + assert len(body["manufacturers"]) >= 1 + assert body["manufacturers"][0]["name"] == "Aerotech" + + +@pytest.mark.integration +async def test_get_fixture_pidinst_route_returns_404_for_unknown_fixture( + pg_async_client: AsyncClient, +) -> None: + missing = str(uuid4()) + response = await pg_async_client.get(f"/fixtures/{missing}/pidinst") + assert response.status_code == 404 + body = response.json() + assert "detail" in body + assert missing in body["detail"] + + +@pytest.mark.integration +async def test_route_returns_409_when_serializer_raises_manufacturer_state_unavailable( + db_pool: asyncpg.Pool, + pg_async_client: AsyncClient, +) -> None: + fixture_id = await _seed_fixture_with_owners_but_no_model(db_pool) + response = await pg_async_client.get(f"/fixtures/{fixture_id}/pidinst") + assert response.status_code == 409 + body = response.json() + assert "detail" in body + assert "manufacturer" in body["detail"].lower() + + +@pytest.mark.integration +async def test_get_fixture_pidinst_route_response_body_shape_matches_pidinst_record_response( + db_pool: asyncpg.Pool, + pg_async_client: AsyncClient, +) -> None: + fixture_id = await _seed_fixture_with_owners_and_model(db_pool) + response = await pg_async_client.get(f"/fixtures/{fixture_id}/pidinst") + assert response.status_code == 200, response.text + body = response.json() + expected_keys = { + "identifier", + "schema_version", + "landing_page", + "name", + "publisher", + "publication_year", + "owners", + "manufacturers", + "model", + "description", + "instrument_types", + "measured_variables", + "dates", + "related_identifiers", + "alternate_identifiers", + "measurement_techniques", + } + assert set(body.keys()) == expected_keys + assert isinstance(body["identifier"], dict) + assert set(body["identifier"].keys()) == {"value", "scheme"} + assert isinstance(body["schema_version"], str) + assert isinstance(body["landing_page"], str) + assert isinstance(body["name"], str) + assert isinstance(body["publisher"], str) + assert isinstance(body["publication_year"], int) + assert isinstance(body["owners"], list) + assert isinstance(body["manufacturers"], list) + assert body["model"] is None or isinstance(body["model"], dict) + assert body["description"] is None or isinstance(body["description"], str) + assert isinstance(body["instrument_types"], list) + assert isinstance(body["measured_variables"], list) + assert isinstance(body["dates"], list) + assert isinstance(body["related_identifiers"], list) + assert isinstance(body["alternate_identifiers"], list) + assert isinstance(body["measurement_techniques"], list) diff --git a/apps/api/tests/integration/equipment/test_get_fixture_pidinst_tool.py b/apps/api/tests/integration/equipment/test_get_fixture_pidinst_tool.py new file mode 100644 index 0000000000..82ebc5e343 --- /dev/null +++ b/apps/api/tests/integration/equipment/test_get_fixture_pidinst_tool.py @@ -0,0 +1,236 @@ +"""Integration tests for the `get_fixture_pidinst` MCP tool against real Postgres. + +Registers the slice's tool on a fresh FastMCP server bound to a +Postgres-backed handler closure, then exercises the registered tool +function directly so the test does not need to stand up a streamable- +http request context. The integration tier owns the end-to-end chain: +MCP tool input parsing through the slice handler through the +event-replay view assembler through the Fixture-tier serializer +against the Postgres event store. The wire-protocol envelope +(SSE / JSON-RPC) is the contract-tier suite's concern. + +Mirrors the slice F `test_assign_asset_persistent_id_tool.py` MCP +registration / invocation pattern and the slice F +`test_get_asset_pidinst_with_persistent_id.py` Fixture-style seeding +(define_family + define_model + register_asset + add_asset_family + +add_asset_owner + define_assembly + register_fixture). Read-side +slice of project_fixture_pidinst_design Section 15.5: MCP tool +integration coverage. +""" + +# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false + +from datetime import UTC, datetime +from typing import Any +from uuid import UUID, uuid4 + +import asyncpg +import pytest +from mcp.server.fastmcp import FastMCP + +from cora.equipment.aggregates.assembly import SlotCardinality, SlotName, TemplateSlot +from cora.equipment.aggregates.asset import ( + AssetLevel, + AssetOwner, + AssetOwnerContact, + AssetOwnerIdentifier, + AssetOwnerIdentifierType, + AssetOwnerName, +) +from cora.equipment.aggregates.fixture import FixtureNotFoundError, SlotAssetBinding +from cora.equipment.aggregates.model import ( + Manufacturer, + ManufacturerIdentifier, + ManufacturerIdentifierType, + ManufacturerName, +) +from cora.equipment.features import ( + add_asset_family, + add_asset_owner, + define_assembly, + define_family, + define_model, + get_fixture_pidinst, + register_asset, + register_fixture, +) +from cora.equipment.features.add_asset_family import AddAssetFamily +from cora.equipment.features.add_asset_owner import AddAssetOwner +from cora.equipment.features.define_assembly import DefineAssembly +from cora.equipment.features.define_family import DefineFamily +from cora.equipment.features.define_model import DefineModel +from cora.equipment.features.get_fixture_pidinst.handler import Handler +from cora.equipment.features.register_asset import RegisterAsset +from cora.equipment.features.register_fixture import RegisterFixture +from cora.infrastructure.kernel import Kernel +from tests.integration._equipment_helpers import drain_equipment_projections +from tests.integration._helpers import build_postgres_deps + +pytestmark = pytest.mark.timeout(60, method="thread") + +_NOW = datetime(2026, 6, 5, 12, 0, 0, tzinfo=UTC) +_PARENT_ID = UUID("01900000-0000-7000-8000-0000ee010000") +_PRINCIPAL_ID = UUID("01900000-0000-7000-8000-000000000099") +_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000aa") + + +def _build_deps( + db_pool: asyncpg.Pool, + *, + ids: list[UUID] | None = None, + now: datetime = _NOW, +) -> Kernel: + return build_postgres_deps(db_pool, ids=ids or [], now=now) + + +class _StubMcpContext: + """Minimal FastMCP-Context stand-in for outside-request invocation. + + The slice tool's body calls `get_mcp_principal_id(ctx)`, which + walks `ctx.request_context.request`; raising AttributeError on + the descriptor makes the helper fall through to + `SYSTEM_PRINCIPAL_ID`, matching the stdio-transport behavior the + helper documents. + """ + + @property + def request_context(self) -> Any: + raise AttributeError("no request context outside streamable-http") + + +def _registered_tool_fn(handler: Handler) -> Any: + mcp = FastMCP("get-fixture-pidinst-integration") + get_fixture_pidinst.tool.register(mcp, get_handler=lambda: handler) + tools = mcp._tool_manager._tools # pyright: ignore[reportPrivateUsage] + return tools["get_fixture_pidinst"].fn + + +def _hzb_owner() -> AssetOwner: + return AssetOwner( + name=AssetOwnerName("Helmholtz-Zentrum Berlin"), + contact=AssetOwnerContact("instrument-data@hzb.de"), + identifier=AssetOwnerIdentifier("https://ror.org/02aj13c28"), + identifier_type=AssetOwnerIdentifierType("ROR"), + ) + + +def _aerotech_manufacturer() -> Manufacturer: + return Manufacturer( + name=ManufacturerName("Aerotech"), + identifier=ManufacturerIdentifier("https://ror.org/04bw7nh07"), + identifier_type=ManufacturerIdentifierType.ROR, + ) + + +async def _seed_minted_fixture(db_pool: asyncpg.Pool) -> UUID: + """Seed a Fixture with one bound Asset that satisfies the PIDINST cardinality. + + The bound Asset carries an owner and references a Model whose + Manufacturer cascades into the Fixture-tier Manufacturers union, + so `to_fixture_pidinst_record` returns a serialized record without + raising any `Fixture*StateNotAvailableError`. + """ + family_deps = _build_deps(db_pool, ids=[uuid4(), uuid4()]) + family_id = await define_family.bind(family_deps)( + DefineFamily(name="Camera", affordances=frozenset()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await drain_equipment_projections(db_pool) + deps = _build_deps( + db_pool, + ids=[uuid4() for _ in range(20)], + ) + model_id = await define_model.bind(deps)( + DefineModel( + name="ANT130-L", + manufacturer=_aerotech_manufacturer(), + part_number="ANT130-L-RM", + declared_family_ids=frozenset({family_id}), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + asset_id = await register_asset.bind(deps)( + RegisterAsset( + name="Camera-1", + level=AssetLevel.DEVICE, + parent_id=_PARENT_ID, + model_id=model_id, + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await add_asset_family.bind(deps)( + AddAssetFamily(asset_id=asset_id, family_id=family_id), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await add_asset_owner.bind(deps)( + AddAssetOwner(asset_id=asset_id, owner=_hzb_owner()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assembly_id = await define_assembly.bind(deps)( + DefineAssembly( + name="MCTOptics", + presents_as_family_id=family_id, + required_slots=frozenset( + { + TemplateSlot( + slot_name=SlotName("camera"), + required_family_ids=frozenset({family_id}), + cardinality=SlotCardinality.EXACTLY_1, + ) + } + ), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + fixture_id = await register_fixture.bind(deps)( + RegisterFixture( + assembly_id=assembly_id, + slot_asset_bindings=frozenset( + {SlotAssetBinding(slot_name="camera", asset_id=asset_id)} + ), + ), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + return fixture_id + + +@pytest.mark.integration +async def test_get_fixture_pidinst_tool_with_minted_fixture_returns_pidinst_record_payload( + db_pool: asyncpg.Pool, +) -> None: + fixture_id = await _seed_minted_fixture(db_pool) + handler_deps = _build_deps(db_pool, ids=[]) + tool_fn = _registered_tool_fn(get_fixture_pidinst.bind(handler_deps)) + + output = await tool_fn(_StubMcpContext(), fixture_id=fixture_id) + + assert output.fixture_id == fixture_id + assert output.name == f"Fixture {fixture_id}" + assert output.schema_version == "1.0" + assert output.identifier.scheme == "URN" + assert output.identifier.value == f"urn:uuid:{fixture_id}" + assert output.landing_page_url == f"https://cora.local/fixtures/{fixture_id}/landing" + assert len(output.owners) == 1 + assert output.owners[0].name == "Helmholtz-Zentrum Berlin" + assert output.owners[0].identifier == "https://ror.org/02aj13c28" + assert output.owners[0].identifier_type == "ROR" + assert output.publisher == "CORA" + assert output.publication_year == _NOW.year + + +@pytest.mark.integration +async def test_get_fixture_pidinst_tool_with_unknown_fixture_raises_fixture_not_found( + db_pool: asyncpg.Pool, +) -> None: + handler_deps = _build_deps(db_pool, ids=[]) + tool_fn = _registered_tool_fn(get_fixture_pidinst.bind(handler_deps)) + + with pytest.raises(FixtureNotFoundError): + await tool_fn(_StubMcpContext(), fixture_id=uuid4()) diff --git a/apps/api/tests/unit/equipment/test_assemble_fixture_pidinst_view.py b/apps/api/tests/unit/equipment/test_assemble_fixture_pidinst_view.py new file mode 100644 index 0000000000..6ca61391b0 --- /dev/null +++ b/apps/api/tests/unit/equipment/test_assemble_fixture_pidinst_view.py @@ -0,0 +1,400 @@ +"""Unit tests for `assemble_fixture_pidinst_view`. + +Covers the five view-assembler scenarios called out in section 15.1 of +project_fixture_pidinst_design: empty bindings, overlapping-owner +dedupe, unminted-bound-Asset component skipping, missing-Model raise, +and one-level-depth restriction. + +Each test pre-seeds an `InMemoryEventStore` with the canonical event +stream(s) the assembler will fold (Asset + Model + Fixture), then asserts +the resulting `FixturePidinstView` carries the expected shape. Pattern +mirrors `test_asset_pidinst_view_assembler.py` (InMemoryEventStore seed +via `to_new_event`) plus the deps wiring in `test_get_fixture_handler.py` +(`build_deps(event_store=preseeded)`). +""" + +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +import pytest + +from cora.equipment.aggregates.asset import PersistentIdentifier, PersistentIdentifierScheme +from cora.equipment.aggregates.asset.events import ( + AssetPersistentIdAssigned, + AssetRegistered, + event_type_name, + to_payload, +) +from cora.equipment.aggregates.asset.state import ( + AssetOwner, + AssetOwnerContact, + AssetOwnerIdentifier, + AssetOwnerIdentifierType, + AssetOwnerName, +) +from cora.equipment.aggregates.fixture import SlotAssetBinding +from cora.equipment.aggregates.fixture.events import ( + FixtureRegistered, +) +from cora.equipment.aggregates.fixture.events import ( + event_type_name as fixture_event_type_name, +) +from cora.equipment.aggregates.fixture.events import ( + to_payload as fixture_to_payload, +) +from cora.equipment.aggregates.model.events import ( + ModelDefined, +) +from cora.equipment.aggregates.model.events import ( + event_type_name as model_event_type_name, +) +from cora.equipment.aggregates.model.events import ( + to_payload as model_to_payload, +) +from cora.equipment.aggregates.model.state import ( + Manufacturer, + ManufacturerIdentifier, + ManufacturerIdentifierType, + ManufacturerName, +) +from cora.equipment.errors import FixtureManufacturerStateNotAvailableError +from cora.equipment.features.get_fixture_pidinst._view_assembler import ( + assemble_fixture_pidinst_view, +) +from cora.infrastructure.adapters.in_memory_event_store import InMemoryEventStore +from cora.infrastructure.event_envelope import to_new_event +from tests.unit._helpers import build_deps as _build_deps_shared + +pytestmark = pytest.mark.timeout(60, method="thread") + +_NOW = datetime(2026, 6, 5, 9, 30, 0, tzinfo=UTC) +_PRINCIPAL_ID = UUID("01900000-0000-7000-8000-000000000099") +_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000aa") + + +def _hzb_owner() -> AssetOwner: + return AssetOwner( + name=AssetOwnerName("Helmholtz-Zentrum Berlin"), + contact=AssetOwnerContact("instrument-data@hzb.de"), + identifier=AssetOwnerIdentifier("https://ror.org/02aj13c28"), + identifier_type=AssetOwnerIdentifierType("ROR"), + ) + + +def _anl_owner() -> AssetOwner: + return AssetOwner( + name=AssetOwnerName("Argonne National Laboratory"), + contact=AssetOwnerContact("ops@anl.gov"), + identifier=AssetOwnerIdentifier("https://ror.org/05gvnxz63"), + identifier_type=AssetOwnerIdentifierType("ROR"), + ) + + +def _aerotech_manufacturer() -> Manufacturer: + return Manufacturer( + name=ManufacturerName("Aerotech"), + identifier=ManufacturerIdentifier("https://ror.org/04bw7nh07"), + identifier_type=ManufacturerIdentifierType.ROR, + ) + + +def _flir_manufacturer() -> Manufacturer: + return Manufacturer( + name=ManufacturerName("FLIR"), + identifier=ManufacturerIdentifier("https://ror.org/0432n7p17"), + identifier_type=ManufacturerIdentifierType.ROR, + ) + + +async def _seed_asset( + store: InMemoryEventStore, + *, + asset_id: UUID, + name: str = "Rotary Stage A", + model_id: UUID | None = None, + owners: frozenset[AssetOwner] = frozenset(), + persistent_id: PersistentIdentifier | None = None, + occurred_at: datetime | None = None, +) -> None: + when = occurred_at or _NOW + registered = AssetRegistered( + asset_id=asset_id, + name=name, + level="Device", + parent_id=uuid4(), + occurred_at=when, + model_id=model_id, + owners=owners, + ) + registered_event = to_new_event( + event_type=event_type_name(registered), + payload=to_payload(registered), + occurred_at=when, + event_id=uuid4(), + command_name="RegisterAsset", + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await store.append("Asset", asset_id, 0, [registered_event]) + if persistent_id is not None: + assigned = AssetPersistentIdAssigned( + asset_id=asset_id, + persistent_id_scheme=persistent_id.scheme.value, + persistent_id_value=persistent_id.value, + occurred_at=when, + ) + assigned_event = to_new_event( + event_type=event_type_name(assigned), + payload=to_payload(assigned), + occurred_at=when, + event_id=uuid4(), + command_name="AssignAssetPersistentId", + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await store.append("Asset", asset_id, 1, [assigned_event]) + + +async def _seed_model( + store: InMemoryEventStore, + *, + model_id: UUID, + manufacturer: Manufacturer, + family_ids: frozenset[UUID] | None = None, + name: str = "ANT130-L", + part_number: str = "ANT130-L-RM", +) -> None: + defined = ModelDefined( + model_id=model_id, + name=name, + part_number=part_number, + manufacturer=manufacturer, + declared_family_ids=family_ids or frozenset({uuid4()}), + occurred_at=_NOW, + ) + new_event = to_new_event( + event_type=model_event_type_name(defined), + payload=model_to_payload(defined), + occurred_at=_NOW, + event_id=uuid4(), + command_name="DefineModel", + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await store.append("Model", model_id, 0, [new_event]) + + +async def _seed_fixture( + store: InMemoryEventStore, + *, + fixture_id: UUID, + slot_asset_bindings: frozenset[SlotAssetBinding], + assembly_id: UUID | None = None, + surface_id: UUID | None = None, +) -> None: + registered = FixtureRegistered( + fixture_id=fixture_id, + assembly_id=assembly_id or uuid4(), + assembly_content_hash="a" * 64, + surface_id=surface_id or uuid4(), + slot_asset_bindings=slot_asset_bindings, + parameter_overrides={}, + occurred_at=_NOW, + ) + new_event = to_new_event( + event_type=fixture_event_type_name(registered), + payload=fixture_to_payload(registered), + occurred_at=_NOW, + event_id=uuid4(), + command_name="RegisterFixture", + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + await store.append("Fixture", fixture_id, 0, [new_event]) + + +@pytest.mark.unit +async def test_assemble_view_with_no_bound_assets_returns_empty_owners_manufacturers() -> None: + store = InMemoryEventStore() + fixture_id = uuid4() + await _seed_fixture(store, fixture_id=fixture_id, slot_asset_bindings=frozenset()) + deps = _build_deps_shared(ids=[], now=_NOW, event_store=store) + + view = await assemble_fixture_pidinst_view(fixture_id, deps) + + assert view is not None + assert view.fixture_id == fixture_id + assert view.owners == () + assert view.manufacturers == () + assert view.components == () + + +@pytest.mark.unit +async def test_assemble_view_with_two_bound_assets_with_overlapping_owners_dedups() -> None: + store = InMemoryEventStore() + asset_a_id = UUID("01900000-0000-7000-8000-0000000a0001") + asset_b_id = UUID("01900000-0000-7000-8000-0000000a0002") + shared_owner = _hzb_owner() + await _seed_asset( + store, + asset_id=asset_a_id, + name="Camera A", + owners=frozenset({shared_owner, _anl_owner()}), + ) + await _seed_asset( + store, + asset_id=asset_b_id, + name="Stage B", + owners=frozenset({shared_owner}), + ) + fixture_id = uuid4() + await _seed_fixture( + store, + fixture_id=fixture_id, + slot_asset_bindings=frozenset( + { + SlotAssetBinding(slot_name="camera", asset_id=asset_a_id), + SlotAssetBinding(slot_name="stage", asset_id=asset_b_id), + } + ), + ) + deps = _build_deps_shared(ids=[], now=_NOW, event_store=store) + + view = await assemble_fixture_pidinst_view(fixture_id, deps) + + assert view is not None + assert len(view.owners) == 2 + assert [owner.name.value for owner in view.owners] == [ + "Argonne National Laboratory", + "Helmholtz-Zentrum Berlin", + ] + + +@pytest.mark.unit +async def test_assemble_view_with_unminted_bound_asset_skips_components_surfaces_description() -> ( + None +): + store = InMemoryEventStore() + minted_asset_id = UUID("01900000-0000-7000-8000-0000000b0001") + unminted_asset_id = UUID("01900000-0000-7000-8000-0000000b0002") + minted_pid = PersistentIdentifier( + scheme=PersistentIdentifierScheme.DOI, + value="10.5281/zenodo.minted-asset", + ) + await _seed_asset( + store, + asset_id=minted_asset_id, + name="Minted Camera", + owners=frozenset({_hzb_owner()}), + persistent_id=minted_pid, + ) + await _seed_asset( + store, + asset_id=unminted_asset_id, + name="Unminted Stage", + owners=frozenset({_hzb_owner()}), + ) + fixture_id = uuid4() + await _seed_fixture( + store, + fixture_id=fixture_id, + slot_asset_bindings=frozenset( + { + SlotAssetBinding(slot_name="camera", asset_id=minted_asset_id), + SlotAssetBinding(slot_name="stage", asset_id=unminted_asset_id), + } + ), + ) + deps = _build_deps_shared(ids=[], now=_NOW, event_store=store) + + view = await assemble_fixture_pidinst_view(fixture_id, deps) + + assert view is not None + assert len(view.components) == 1 + assert view.components[0].component_id == minted_asset_id + assert view.components[0].value == minted_pid.value + assert view.components[0].scheme == PersistentIdentifierScheme.DOI + + +@pytest.mark.unit +async def test_assemble_view_bound_asset_model_missing_raises_manufacturer_state_unavailable() -> ( + None +): + store = InMemoryEventStore() + asset_id = UUID("01900000-0000-7000-8000-0000000c0001") + missing_model_id = UUID("01900000-0000-7000-8000-0000000c00ff") + await _seed_asset( + store, + asset_id=asset_id, + name="Camera With Phantom Model", + model_id=missing_model_id, + owners=frozenset({_hzb_owner()}), + ) + fixture_id = uuid4() + await _seed_fixture( + store, + fixture_id=fixture_id, + slot_asset_bindings=frozenset({SlotAssetBinding(slot_name="camera", asset_id=asset_id)}), + ) + deps = _build_deps_shared(ids=[], now=_NOW, event_store=store) + + with pytest.raises(FixtureManufacturerStateNotAvailableError) as exc_info: + await assemble_fixture_pidinst_view(fixture_id, deps) + assert exc_info.value.fixture_id == fixture_id + + +@pytest.mark.unit +async def test_assemble_fixture_pidinst_view_respects_one_level_depth_only() -> None: + store = InMemoryEventStore() + asset_a_id = UUID("01900000-0000-7000-8000-0000000d0001") + asset_b_id = UUID("01900000-0000-7000-8000-0000000d0002") + model_a_id = UUID("01900000-0000-7000-8000-0000000d00a0") + model_b_id = UUID("01900000-0000-7000-8000-0000000d00b0") + await _seed_model( + store, + model_id=model_a_id, + manufacturer=_aerotech_manufacturer(), + name="Aerotech ANT130-L", + part_number="ANT130-L-AERO", + ) + await _seed_model( + store, + model_id=model_b_id, + manufacturer=_flir_manufacturer(), + name="FLIR Blackfly", + part_number="BFLY-PGE-23S6M", + ) + await _seed_asset( + store, + asset_id=asset_a_id, + name="Rotary Stage", + model_id=model_a_id, + owners=frozenset({_hzb_owner()}), + ) + await _seed_asset( + store, + asset_id=asset_b_id, + name="Camera", + model_id=model_b_id, + owners=frozenset({_anl_owner()}), + ) + inner_fixture_id = UUID("01900000-0000-7000-8000-0000000d0fff") + await _seed_fixture( + store, + fixture_id=inner_fixture_id, + slot_asset_bindings=frozenset({SlotAssetBinding(slot_name="inner", asset_id=asset_b_id)}), + ) + outer_fixture_id = uuid4() + await _seed_fixture( + store, + fixture_id=outer_fixture_id, + slot_asset_bindings=frozenset({SlotAssetBinding(slot_name="stage", asset_id=asset_a_id)}), + ) + deps = _build_deps_shared(ids=[], now=_NOW, event_store=store) + + view = await assemble_fixture_pidinst_view(outer_fixture_id, deps) + + assert view is not None + assert len(view.components) == 0 + assert [m.name for m in view.manufacturers] == ["Aerotech"] + assert [o.name.value for o in view.owners] == ["Helmholtz-Zentrum Berlin"] diff --git a/apps/api/tests/unit/equipment/test_fixture_pidinst_serializer.py b/apps/api/tests/unit/equipment/test_fixture_pidinst_serializer.py new file mode 100644 index 0000000000..4db74d9014 --- /dev/null +++ b/apps/api/tests/unit/equipment/test_fixture_pidinst_serializer.py @@ -0,0 +1,291 @@ +"""Unit tests for `to_fixture_pidinst_record` per Section 15.1 of the design memo. + +Mirrors `test_pidinst_serializer*.py` style: per-property example tests +for the happy paths (URN fallback, DOI / Handle swap, owners passthrough, +manufacturers union, HasComponent relations) and one negative case per +`FixturePidinstSerializationError` subclass plus the kernel +`PidinstRecordInvariantError` that propagates unwrapped from +`PidinstRecord.__post_init__`. +""" + +from dataclasses import replace +from uuid import UUID + +import pytest + +from cora.equipment._pidinst_serializer import to_fixture_pidinst_record +from cora.equipment._pidinst_types import ( + FixtureComponentRef, + FixturePidinstView, + Manufacturer, + PidinstIdentifierType, + PidinstRelationType, + SchemaVersion, +) +from cora.equipment.aggregates.asset import ( + AssetOwner, + AssetOwnerContact, + AssetOwnerIdentifier, + AssetOwnerIdentifierType, + AssetOwnerName, + PersistentIdentifier, + PersistentIdentifierScheme, +) +from cora.equipment.aggregates.model import ManufacturerIdentifierType +from cora.equipment.errors import ( + FixtureLandingPageMissingError, + FixtureManufacturerStateNotAvailableError, + FixtureNameMissingError, + FixtureOwnerStateNotAvailableError, + PidinstRecordInvariantError, +) + +pytestmark = [pytest.mark.unit, pytest.mark.timeout(60, method="thread")] + +_FIXTURE_ID = UUID("01900000-0000-7000-8000-00000000f001") +_ASSET_ID_A = UUID("01900000-0000-7000-8000-00000000a001") +_ASSET_ID_B = UUID("01900000-0000-7000-8000-00000000a002") + +_LANDING_PAGE = f"https://cora.example/fixtures/{_FIXTURE_ID}" +_PUBLISHER = "Argonne National Laboratory" + +_DOI_VALUE = "10.5281/zenodo.7654321" +_HANDLE_VALUE = "20.500.12613/98765" + +_OWNER_APS = AssetOwner( + name=AssetOwnerName("Advanced Photon Source"), + contact=AssetOwnerContact("aps-ops@anl.gov"), + identifier=AssetOwnerIdentifier("https://ror.org/05gvnxz63"), + identifier_type=AssetOwnerIdentifierType("ROR"), +) + +_OWNER_HZB = AssetOwner( + name=AssetOwnerName("Helmholtz-Zentrum Berlin"), + contact=AssetOwnerContact("instrument-data@helmholtz-berlin.de"), + identifier=AssetOwnerIdentifier("https://ror.org/02aj13c28"), + identifier_type=AssetOwnerIdentifierType("ROR"), +) + +_MANUFACTURER_AEROTECH = Manufacturer( + name="Aerotech", + identifier="https://ror.org/04bw7nh07", + identifier_type=ManufacturerIdentifierType.ROR, +) + +_MANUFACTURER_FLIR = Manufacturer( + name="FLIR", + identifier="https://ror.org/03kqv4r80", + identifier_type=ManufacturerIdentifierType.ROR, +) + + +def _build_minimal_fixture_view() -> FixturePidinstView: + """Smallest Fixture view that still serializes. + + One owner, one manufacturer, zero components, no persistent_id. + Mirrors `build_minimal_view` from the Asset-side `_helpers.py`. + """ + return FixturePidinstView( + fixture_id=_FIXTURE_ID, + name="Sample Fixture", + persistent_id=None, + owners=(_OWNER_APS,), + manufacturers=(_MANUFACTURER_AEROTECH,), + components=(), + publication_year=2026, + ) + + +def test_to_fixture_pidinst_record_minimal_view_emits_required_properties() -> None: + record = to_fixture_pidinst_record( + _build_minimal_fixture_view(), + landing_page_url=_LANDING_PAGE, + publisher=_PUBLISHER, + ) + assert record.identifier.scheme is PidinstIdentifierType.URN + assert record.schema_version is SchemaVersion.V1_0 + assert record.landing_page == _LANDING_PAGE + assert record.name == "Sample Fixture" + assert record.owners + assert record.manufacturers + assert record.publisher == _PUBLISHER + assert record.publication_year == 2026 + + +def test_to_fixture_pidinst_record_without_persistent_id_emits_urn_fallback() -> None: + record = to_fixture_pidinst_record( + _build_minimal_fixture_view(), + landing_page_url=_LANDING_PAGE, + publisher=_PUBLISHER, + ) + assert record.identifier.scheme is PidinstIdentifierType.URN + assert record.identifier.value == f"urn:uuid:{_FIXTURE_ID}" + + +def test_to_fixture_pidinst_record_with_minted_persistent_id_doi_emits_doi_identifier() -> None: + view = replace( + _build_minimal_fixture_view(), + persistent_id=PersistentIdentifier( + scheme=PersistentIdentifierScheme.DOI, + value=_DOI_VALUE, + ), + ) + record = to_fixture_pidinst_record(view, landing_page_url=_LANDING_PAGE, publisher=_PUBLISHER) + assert record.identifier.scheme is PidinstIdentifierType.DOI + assert record.identifier.value == _DOI_VALUE + + +def test_to_fixture_pidinst_record_with_minted_persistent_id_handle_emits_handle_identifier() -> ( + None +): + view = replace( + _build_minimal_fixture_view(), + persistent_id=PersistentIdentifier( + scheme=PersistentIdentifierScheme.HANDLE, + value=_HANDLE_VALUE, + ), + ) + record = to_fixture_pidinst_record(view, landing_page_url=_LANDING_PAGE, publisher=_PUBLISHER) + assert record.identifier.scheme is PidinstIdentifierType.HANDLE + assert record.identifier.value == _HANDLE_VALUE + + +def test_to_fixture_pidinst_record_with_owners_emits_pidinst_owners_property() -> None: + view = replace( + _build_minimal_fixture_view(), + owners=(_OWNER_APS, _OWNER_HZB), + ) + record = to_fixture_pidinst_record(view, landing_page_url=_LANDING_PAGE, publisher=_PUBLISHER) + names = [owner.name for owner in record.owners] + assert names == ["Advanced Photon Source", "Helmholtz-Zentrum Berlin"] + assert record.owners[0].identifier == "https://ror.org/05gvnxz63" + assert record.owners[0].identifier_type == "ROR" + assert record.owners[0].contact == "aps-ops@anl.gov" + + +def test_to_fixture_pidinst_record_with_manufacturers_emits_pidinst_manufacturers_property() -> ( + None +): + view = replace( + _build_minimal_fixture_view(), + manufacturers=(_MANUFACTURER_AEROTECH, _MANUFACTURER_FLIR), + ) + record = to_fixture_pidinst_record(view, landing_page_url=_LANDING_PAGE, publisher=_PUBLISHER) + assert [m.name for m in record.manufacturers] == ["Aerotech", "FLIR"] + assert record.manufacturers[0].identifier == "https://ror.org/04bw7nh07" + assert record.manufacturers[0].identifier_type is ManufacturerIdentifierType.ROR + + +def test_to_fixture_pidinst_record_with_components_emits_has_component_relations() -> None: + components = ( + FixtureComponentRef( + component_id=_ASSET_ID_A, + scheme=PersistentIdentifierScheme.DOI, + value="10.5281/zenodo.1111", + name="Rotary Stage A", + ), + FixtureComponentRef( + component_id=_ASSET_ID_B, + scheme=PersistentIdentifierScheme.HANDLE, + value="20.500.12613/22222", + name="Camera B", + ), + ) + view = replace(_build_minimal_fixture_view(), components=components) + record = to_fixture_pidinst_record(view, landing_page_url=_LANDING_PAGE, publisher=_PUBLISHER) + assert len(record.related_identifiers) == 2 + assert all( + ri.relation_type is PidinstRelationType.HAS_COMPONENT for ri in record.related_identifiers + ) + values = [ri.value for ri in record.related_identifiers] + assert values == ["10.5281/zenodo.1111", "20.500.12613/22222"] + types = [ri.identifier_type for ri in record.related_identifiers] + assert types == [ + PersistentIdentifierScheme.DOI.value, + PersistentIdentifierScheme.HANDLE.value, + ] + + +def test_to_fixture_pidinst_record_with_unminted_components_skips_them_from_relations() -> None: + components = ( + FixtureComponentRef( + component_id=_ASSET_ID_A, + scheme=PersistentIdentifierScheme.DOI, + value="10.5281/zenodo.1111", + name="Rotary Stage A", + ), + FixtureComponentRef( + component_id=_ASSET_ID_B, + scheme=None, + value=None, + name="Unminted Camera", + ), + ) + view = replace(_build_minimal_fixture_view(), components=components) + record = to_fixture_pidinst_record(view, landing_page_url=_LANDING_PAGE, publisher=_PUBLISHER) + assert len(record.related_identifiers) == 1 + assert record.related_identifiers[0].value == "10.5281/zenodo.1111" + + +def test_to_fixture_pidinst_record_with_empty_landing_page_raises_landing_page_missing_error() -> ( + None +): + with pytest.raises(FixtureLandingPageMissingError) as exc_info: + to_fixture_pidinst_record( + _build_minimal_fixture_view(), + landing_page_url="", + publisher=_PUBLISHER, + ) + assert exc_info.value.fixture_id == _FIXTURE_ID + + +def test_to_fixture_pidinst_record_with_empty_name_raises_name_missing_error() -> None: + view = replace(_build_minimal_fixture_view(), name=" ") + with pytest.raises(FixtureNameMissingError) as exc_info: + to_fixture_pidinst_record(view, landing_page_url=_LANDING_PAGE, publisher=_PUBLISHER) + assert exc_info.value.fixture_id == _FIXTURE_ID + + +def test_to_fixture_pidinst_record_with_empty_owners_raises_owner_state_not_available_error() -> ( + None +): + view = replace(_build_minimal_fixture_view(), owners=()) + with pytest.raises(FixtureOwnerStateNotAvailableError) as exc_info: + to_fixture_pidinst_record(view, landing_page_url=_LANDING_PAGE, publisher=_PUBLISHER) + assert exc_info.value.fixture_id == _FIXTURE_ID + + +def test_to_record_with_empty_manufacturers_raises_manufacturer_state_not_available_error() -> None: + view = replace(_build_minimal_fixture_view(), manufacturers=()) + with pytest.raises(FixtureManufacturerStateNotAvailableError) as exc_info: + to_fixture_pidinst_record(view, landing_page_url=_LANDING_PAGE, publisher=_PUBLISHER) + assert exc_info.value.fixture_id == _FIXTURE_ID + + +def test_to_record_with_whitespace_landing_page_raises_landing_page_missing_error() -> None: + with pytest.raises(FixtureLandingPageMissingError): + to_fixture_pidinst_record( + _build_minimal_fixture_view(), + landing_page_url=" ", + publisher=_PUBLISHER, + ) + + +def test_to_record_with_owners_pairing_violation_raises_pidinst_record_invariant_error() -> None: + """Kernel `PidinstRecordInvariantError` propagates unwrapped from the intermediate Owner VO. + + Owner's `__post_init__` enforces the identifier / identifier_type + pairing invariant and raises `PidinstRecordInvariantError`. The + Fixture serializer surfaces it unwrapped (mirrors the Asset side; + no `FixturePidinstRecordInvariantError` sibling ships). + """ + bad_asset_owner = AssetOwner.__new__(AssetOwner) + object.__setattr__(bad_asset_owner, "name", AssetOwnerName("Bypassed")) + object.__setattr__(bad_asset_owner, "contact", AssetOwnerContact("bypass@example.org")) + object.__setattr__( + bad_asset_owner, "identifier", AssetOwnerIdentifier("https://ror.org/000000000") + ) + object.__setattr__(bad_asset_owner, "identifier_type", None) + view = replace(_build_minimal_fixture_view(), owners=(bad_asset_owner,)) + with pytest.raises(PidinstRecordInvariantError): + to_fixture_pidinst_record(view, landing_page_url=_LANDING_PAGE, publisher=_PUBLISHER) diff --git a/apps/api/tests/unit/equipment/test_fixture_pidinst_view.py b/apps/api/tests/unit/equipment/test_fixture_pidinst_view.py new file mode 100644 index 0000000000..cdc2c5c799 --- /dev/null +++ b/apps/api/tests/unit/equipment/test_fixture_pidinst_view.py @@ -0,0 +1,180 @@ +"""Construction-time tests for `FixturePidinstView` and `FixtureComponentRef`. + +Section 15.1 of the design memo. The two frozen dataclasses are plain +data substrates with no logic of their own; the serializer +(`to_fixture_pidinst_record`) exercises the field semantics. These tests +pin the frozen-dataclass posture, the full-required-fields constructor +shape, and the type-system stance that `publication_year` is a required +non-optional int. +""" + +from dataclasses import FrozenInstanceError +from uuid import UUID, uuid4 + +import pytest + +from cora.equipment._pidinst_types import ( + FixtureComponentRef, + FixturePidinstView, + Manufacturer, +) +from cora.equipment.aggregates.asset import ( + PersistentIdentifier, + PersistentIdentifierScheme, +) +from cora.equipment.aggregates.asset.state import ( + AssetOwner, + AssetOwnerName, +) + +pytestmark = [pytest.mark.unit, pytest.mark.timeout(60, method="thread")] + + +def _component( + *, + component_id: UUID | None = None, + scheme: PersistentIdentifierScheme | None = None, + value: str | None = None, + name: str = "Bound Asset A", +) -> FixtureComponentRef: + return FixtureComponentRef( + component_id=component_id if component_id is not None else uuid4(), + scheme=scheme, + value=value, + name=name, + ) + + +def _view(**overrides: object) -> FixturePidinstView: + base_kwargs: dict[str, object] = { + "fixture_id": uuid4(), + "name": "MCTOptics fixture", + "persistent_id": None, + "owners": (AssetOwner(name=AssetOwnerName(value="Advanced Photon Source")),), + "manufacturers": (Manufacturer(name="Aerotech"),), + "components": (_component(),), + "publication_year": 2026, + } + base_kwargs.update(overrides) + return FixturePidinstView(**base_kwargs) # type: ignore[arg-type] + + +def test_fixture_pidinst_view_with_all_required_fields_constructs() -> None: + fixture_id = uuid4() + component = _component(name="Rotary stage") + view = FixturePidinstView( + fixture_id=fixture_id, + name="MCTOptics fixture", + persistent_id=None, + owners=(AssetOwner(name=AssetOwnerName(value="Advanced Photon Source")),), + manufacturers=(Manufacturer(name="Aerotech"),), + components=(component,), + publication_year=2026, + ) + assert view.fixture_id == fixture_id + assert view.name == "MCTOptics fixture" + assert view.persistent_id is None + assert view.owners == (AssetOwner(name=AssetOwnerName(value="Advanced Photon Source")),) + assert view.manufacturers == (Manufacturer(name="Aerotech"),) + assert view.components == (component,) + assert view.publication_year == 2026 + + +def test_fixture_pidinst_view_with_persistent_id_set_constructs() -> None: + pid = PersistentIdentifier( + scheme=PersistentIdentifierScheme.DOI, + value="10.5281/zenodo.1234567", + ) + view = _view(persistent_id=pid) + assert view.persistent_id is pid + + +def test_fixture_pidinst_view_is_frozen() -> None: + view = _view() + with pytest.raises(FrozenInstanceError): + view.name = "Renamed" # type: ignore[misc] + + +def test_fixture_pidinst_view_without_publication_year_raises_type_error() -> None: + with pytest.raises(TypeError): + FixturePidinstView( # type: ignore[call-arg] + fixture_id=uuid4(), + name="MCTOptics fixture", + persistent_id=None, + owners=(AssetOwner(name=AssetOwnerName(value="Advanced Photon Source")),), + manufacturers=(Manufacturer(name="Aerotech"),), + components=(_component(),), + ) + + +def test_fixture_pidinst_view_equality_is_value_based() -> None: + fixture_id = uuid4() + component_id = uuid4() + a = _view( + fixture_id=fixture_id, + components=(_component(component_id=component_id, name="Stage"),), + ) + b = _view( + fixture_id=fixture_id, + components=(_component(component_id=component_id, name="Stage"),), + ) + assert a == b + + +def test_fixture_component_ref_with_all_required_fields_constructs() -> None: + component_id = uuid4() + ref = FixtureComponentRef( + component_id=component_id, + scheme=PersistentIdentifierScheme.DOI, + value="10.5281/zenodo.1234567", + name="Rotary stage", + ) + assert ref.component_id == component_id + assert ref.scheme is PersistentIdentifierScheme.DOI + assert ref.value == "10.5281/zenodo.1234567" + assert ref.name == "Rotary stage" + + +def test_fixture_component_ref_with_unminted_asset_constructs_with_none_scheme_and_value() -> None: + component_id = uuid4() + ref = FixtureComponentRef( + component_id=component_id, + scheme=None, + value=None, + name="Unminted detector", + ) + assert ref.scheme is None + assert ref.value is None + assert ref.component_id == component_id + assert ref.name == "Unminted detector" + + +def test_fixture_component_ref_is_frozen() -> None: + ref = _component() + with pytest.raises(FrozenInstanceError): + ref.name = "Renamed" # type: ignore[misc] + + +def test_fixture_component_ref_is_hashable_in_frozenset() -> None: + a = _component(name="Stage A") + b = _component(name="Stage B") + members = frozenset({a, b}) + assert a in members + assert b in members + + +def test_fixture_component_ref_equality_is_value_based() -> None: + component_id = uuid4() + a = FixtureComponentRef( + component_id=component_id, + scheme=PersistentIdentifierScheme.DOI, + value="10.5281/zenodo.1234567", + name="Rotary stage", + ) + b = FixtureComponentRef( + component_id=component_id, + scheme=PersistentIdentifierScheme.DOI, + value="10.5281/zenodo.1234567", + name="Rotary stage", + ) + assert a == b