diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 3187edc67..5e64dd2aa 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -15517,7 +15517,7 @@ } } }, - "description": "Assembly is Deprecated (cannot instantiate), OR a referenced Asset is Decommissioned (lifecycle disallows attachment), OR a concurrent write to the new Fixture stream conflicted (optimistic concurrency; essentially impossible with UUIDv7 ids)." + "description": "Assembly is Deprecated (cannot instantiate), OR a referenced Asset is Decommissioned (lifecycle disallows attachment), OR a referenced Asset is not currently installed in any Mount (install_asset first), OR a concurrent write to the new Fixture stream conflicted (optimistic concurrency; essentially impossible with UUIDv7 ids)." }, "422": { "description": "Request body failed schema validation OR Idempotency-Key was reused with a different request body." diff --git a/apps/api/src/cora/equipment/aggregates/assembly/__init__.py b/apps/api/src/cora/equipment/aggregates/assembly/__init__.py index 066c2c216..0527042e4 100644 --- a/apps/api/src/cora/equipment/aggregates/assembly/__init__.py +++ b/apps/api/src/cora/equipment/aggregates/assembly/__init__.py @@ -49,6 +49,7 @@ FixtureAssetFamilyMismatchError, FixtureAssetNotAttachableError, FixtureAssetNotFoundError, + FixtureAssetNotInstalledError, FixtureMappingIncompleteError, FixtureParameterOverridesInvalidError, InvalidAssemblyNameError, @@ -84,6 +85,7 @@ "FixtureAssetFamilyMismatchError", "FixtureAssetNotAttachableError", "FixtureAssetNotFoundError", + "FixtureAssetNotInstalledError", "FixtureMappingIncompleteError", "FixtureParameterOverridesInvalidError", "InvalidAssemblyNameError", diff --git a/apps/api/src/cora/equipment/aggregates/assembly/state.py b/apps/api/src/cora/equipment/aggregates/assembly/state.py index a31d315c3..e9b48ecfc 100644 --- a/apps/api/src/cora/equipment/aggregates/assembly/state.py +++ b/apps/api/src/cora/equipment/aggregates/assembly/state.py @@ -315,6 +315,27 @@ def __init__(self, asset_id: UUID, current_lifecycle: str) -> None: self.current_lifecycle = current_lifecycle +class FixtureAssetNotInstalledError(Exception): + """A referenced Asset is not currently installed in any Mount. + + Fires when `proj_equipment_asset_location` carries no row for the + Asset at register_fixture time, i.e., the Asset exists but has + not been physically racked. A Fixture should materialize only + equipment that is already on the floor, so the + install-then-register-fixture choreography is the contract. + + Carries the sorted-first offending `asset_id` for deterministic + error responses. + """ + + def __init__(self, asset_id: UUID) -> None: + super().__init__( + f"Asset {asset_id} cannot be bound into a Fixture: not currently " + f"installed in any Mount; install_asset first" + ) + self.asset_id = asset_id + + class FixtureMappingIncompleteError(Exception): """`register_fixture`'s slot_asset_bindings does not satisfy the required cardinality of one or more slots. diff --git a/apps/api/src/cora/equipment/features/register_fixture/context.py b/apps/api/src/cora/equipment/features/register_fixture/context.py index 1a2325c53..f10d8b085 100644 --- a/apps/api/src/cora/equipment/features/register_fixture/context.py +++ b/apps/api/src/cora/equipment/features/register_fixture/context.py @@ -23,6 +23,18 @@ genesis and cannot be amended). Empty dict (default) means no lifecycle info was loaded; the decider skips the guard entirely (useful for decider unit tests that exercise other invariants). + +`mount_id_by_asset_id` maps each referenced asset_id to the Mount +currently holding it (sourced from `proj_equipment_asset_location`), +or `None` when the Asset is not currently installed. The whole field +is `None` when the handler ran without a pool (test path) and the +orphan guard is disabled entirely; this matches the +install_asset / decommission_asset projection-precondition +short-circuit convention. When non-None and an entry maps to +`None`, the decider raises `FixtureAssetNotInstalledError` carrying +the sorted-first orphan id: a Fixture should snapshot only +equipment already on the floor, so install-then-register is +the contract. """ from dataclasses import dataclass, field @@ -34,7 +46,7 @@ @dataclass(frozen=True) class RegisterFixtureContext: - """Snapshot of Assembly + Asset existence + lifecycle checks.""" + """Snapshot of Assembly + Asset existence + lifecycle + install checks.""" assembly_state: Assembly | None family_ids_by_asset_id: dict[UUID, frozenset[UUID] | None] = field( @@ -43,3 +55,4 @@ class RegisterFixtureContext: lifecycle_by_asset_id: dict[UUID, AssetLifecycle | None] = field( default_factory=dict[UUID, AssetLifecycle | None] ) + mount_id_by_asset_id: dict[UUID, UUID | None] | None = None diff --git a/apps/api/src/cora/equipment/features/register_fixture/decider.py b/apps/api/src/cora/equipment/features/register_fixture/decider.py index 7cbc0a730..8d7b44278 100644 --- a/apps/api/src/cora/equipment/features/register_fixture/decider.py +++ b/apps/api/src/cora/equipment/features/register_fixture/decider.py @@ -23,6 +23,11 @@ -> FixtureAssetNotAttachableError carrying the sorted-first offending asset_id (mirrors AssetCannotAttachToFixtureError at attach-time). + - Every referenced Asset must currently be installed in some Mount + (when the handler loaded asset_location info) + -> FixtureAssetNotInstalledError carrying the sorted-first + orphan id. A Fixture should materialize only equipment that is + already on the floor; install_asset is a hard precondition. - Each TemplateSlot's cardinality is satisfied by the count of bindings carrying its slot_name -> FixtureMappingIncompleteError carrying the offending @@ -55,6 +60,7 @@ FixtureAssetFamilyMismatchError, FixtureAssetNotAttachableError, FixtureAssetNotFoundError, + FixtureAssetNotInstalledError, FixtureMappingIncompleteError, FixtureParameterOverridesInvalidError, SlotCardinality, @@ -130,6 +136,11 @@ def decide( -> FixtureAssetNotAttachableError carrying the sorted-first offending asset_id (mirrors AssetCannotAttachToFixtureError at attach-time). + - Every referenced Asset must currently be installed in some Mount + (when the handler loaded asset_location info) + -> FixtureAssetNotInstalledError carrying the sorted-first + orphan id. A Fixture should materialize only equipment that is + already on the floor; install_asset is a hard precondition. - Each TemplateSlot's cardinality must be satisfied by the count of bindings carrying its slot_name -> FixtureMappingIncompleteError carrying the offending @@ -189,6 +200,24 @@ def decide( AssetLifecycle.DECOMMISSIONED.value, ) + # Cross-aggregate guard: every referenced Asset must currently be + # installed in some Mount. A Fixture should snapshot only equipment + # already racked on the floor, so the install-then-register-fixture + # choreography is the contract. + # `mount_id_by_asset_id is None` means the handler ran without a + # pool (test path) and the guard is disabled entirely. + if context.mount_id_by_asset_id is not None: + orphan_asset_ids = sorted( + ( + asset_id + for asset_id, mount_id in context.mount_id_by_asset_id.items() + if mount_id is None + ), + key=str, + ) + if orphan_asset_ids: + raise FixtureAssetNotInstalledError(orphan_asset_ids[0]) + slots_by_name = {slot.slot_name.value: slot for slot in assembly.required_slots} binding_counts: Counter[str] = Counter( binding.slot_name for binding in command.slot_asset_bindings diff --git a/apps/api/src/cora/equipment/features/register_fixture/handler.py b/apps/api/src/cora/equipment/features/register_fixture/handler.py index 3cb95dd87..70ade56d8 100644 --- a/apps/api/src/cora/equipment/features/register_fixture/handler.py +++ b/apps/api/src/cora/equipment/features/register_fixture/handler.py @@ -38,6 +38,7 @@ from cora.equipment.features.register_fixture.command import RegisterFixture from cora.equipment.features.register_fixture.context import RegisterFixtureContext from cora.equipment.features.register_fixture.decider import decide +from cora.equipment.projections.asset_location import load_asset_location from cora.infrastructure.event_envelope import to_new_event from cora.infrastructure.kernel import Kernel from cora.infrastructure.logging import get_logger @@ -130,15 +131,36 @@ async def handler( now = deps.clock.now() asset_ids = _referenced_asset_ids(command) - # Split gather across the two aggregate types so pyright keeps - # `assembly_state` narrowed to `Assembly | None` and each item - # in `assets` narrowed to `Asset | None`; a single gather across - # both would widen everything to the union. - assembly_state_task = asyncio.create_task( - load_assembly(deps.event_store, command.assembly_id) + # All three I/O streams run concurrently inside a TaskGroup so + # that a failure in any one of them cancels the siblings before + # the handler returns. Without this discipline, a load_asset + # raise would leak the assembly_state and mount_ids work as + # "task exception never retrieved" warnings and tie up pool + # connections after the request had already errored out. + # Per-asset tasks keep each result narrowed (Asset | None, + # Assembly | None) for pyright; the asset_location stream is + # only scheduled when deps.pool is set (pool=None short-circuit + # preserves the permissive default for the pool-less test path; + # matches install_asset / decommission_asset shape). In + # production deps.pool is always set, so every referenced + # asset_id gets an entry in mount_id_by_asset_id (mount_id when + # installed, None when orphan). + pool = deps.pool + async with asyncio.TaskGroup() as tg: + assembly_task = tg.create_task(load_assembly(deps.event_store, command.assembly_id)) + asset_tasks = [tg.create_task(load_asset(deps.event_store, aid)) for aid in asset_ids] + mount_id_tasks: list[asyncio.Task[UUID | None]] | None = ( + [tg.create_task(load_asset_location(pool, aid)) for aid in asset_ids] + if pool is not None + else None + ) + + assembly_state = assembly_task.result() + assets = [t.result() for t in asset_tasks] + mount_ids: list[UUID | None] | None = ( + [t.result() for t in mount_id_tasks] if mount_id_tasks is not None else None ) - assets = await asyncio.gather(*(load_asset(deps.event_store, aid) for aid in asset_ids)) - assembly_state = await assembly_state_task + family_ids_by_asset_id: dict[UUID, frozenset[UUID] | None] = { aid: (asset.family_ids if asset is not None else None) for aid, asset in zip(asset_ids, assets, strict=True) @@ -147,10 +169,14 @@ async def handler( aid: (asset.lifecycle if asset is not None else None) for aid, asset in zip(asset_ids, assets, strict=True) } + mount_id_by_asset_id: dict[UUID, UUID | None] | None = ( + dict(zip(asset_ids, mount_ids, strict=True)) if mount_ids is not None else None + ) context = RegisterFixtureContext( assembly_state=assembly_state, family_ids_by_asset_id=family_ids_by_asset_id, lifecycle_by_asset_id=lifecycle_by_asset_id, + mount_id_by_asset_id=mount_id_by_asset_id, ) # Decider raises FixtureAlreadyExistsError defensively when diff --git a/apps/api/src/cora/equipment/features/register_fixture/route.py b/apps/api/src/cora/equipment/features/register_fixture/route.py index c241049b6..7e5135e86 100644 --- a/apps/api/src/cora/equipment/features/register_fixture/route.py +++ b/apps/api/src/cora/equipment/features/register_fixture/route.py @@ -119,9 +119,11 @@ def _get_handler(request: Request) -> IdempotentHandler: "description": ( "Assembly is Deprecated (cannot instantiate), OR a " "referenced Asset is Decommissioned (lifecycle disallows " - "attachment), OR a concurrent write to the new Fixture " - "stream conflicted (optimistic concurrency; essentially " - "impossible with UUIDv7 ids)." + "attachment), OR a referenced Asset is not currently " + "installed in any Mount (install_asset first), OR a " + "concurrent write to the new Fixture stream conflicted " + "(optimistic concurrency; essentially impossible with " + "UUIDv7 ids)." ), }, status.HTTP_422_UNPROCESSABLE_CONTENT: { diff --git a/apps/api/src/cora/equipment/routes.py b/apps/api/src/cora/equipment/routes.py index e1a757ac8..55ec4c689 100644 --- a/apps/api/src/cora/equipment/routes.py +++ b/apps/api/src/cora/equipment/routes.py @@ -48,6 +48,7 @@ FixtureAssetFamilyMismatchError, FixtureAssetNotAttachableError, FixtureAssetNotFoundError, + FixtureAssetNotInstalledError, FixtureMappingIncompleteError, FixtureParameterOverridesInvalidError, InvalidAssemblyNameError, @@ -562,6 +563,7 @@ def register_equipment_routes(app: FastAPI) -> None: AssetAttachedToDifferentFixtureError, AssetCannotUpdatePartitionRuleError, FixtureAssetNotAttachableError, + FixtureAssetNotInstalledError, ): app.add_exception_handler(cannot_transition_cls, _handle_cannot_transition) for pidinst_state_cls in ( diff --git a/apps/api/tests/integration/_equipment_helpers.py b/apps/api/tests/integration/_equipment_helpers.py index 401d9ca4a..5e6df977a 100644 --- a/apps/api/tests/integration/_equipment_helpers.py +++ b/apps/api/tests/integration/_equipment_helpers.py @@ -2,7 +2,10 @@ The Mount/Frame PG integration test files share an identical `placement(parent_frame_id)` constructor and projection-drain wrapper; -hoisted here so per-file boilerplate stays short. +hoisted here so per-file boilerplate stays short. `seed_installed_asset` +is the install-then-register-fixture choreography helper used by every +Fixture-touching integration test (register_fixture requires every +bound Asset to be currently installed in some Mount). Per-file helpers that vary (the `_seed_*` family, scenario-specific fixtures) stay local to each test file. Only the genuinely-identical @@ -14,6 +17,7 @@ from __future__ import annotations from typing import TYPE_CHECKING +from uuid import UUID, uuid4 from cora.equipment._projections import register_equipment_projections from cora.equipment.aggregates._placement import ( @@ -21,14 +25,35 @@ ReferenceSurface, UnitSystem, ) +from cora.equipment.aggregates.asset import AssetLevel +from cora.equipment.features.activate_asset import ActivateAsset +from cora.equipment.features.activate_asset import bind as bind_activate_asset +from cora.equipment.features.install_asset import InstallAsset +from cora.equipment.features.install_asset import bind as bind_install_asset +from cora.equipment.features.register_asset import RegisterAsset +from cora.equipment.features.register_asset import bind as bind_register_asset +from cora.equipment.features.register_frame import RegisterFrame +from cora.equipment.features.register_frame import bind as bind_register_frame +from cora.equipment.features.register_mount import RegisterMount +from cora.equipment.features.register_mount import bind as bind_register_mount from cora.infrastructure.projection import ProjectionRegistry, drain_projections +from tests.integration._helpers import build_postgres_deps if TYPE_CHECKING: - from uuid import UUID + from datetime import datetime import asyncpg +# Helper-internal principal / correlation ids. They intentionally +# differ from per-test `_PRINCIPAL_ID` / `_CORRELATION_ID` constants: +# tests that need the seeded events to share a principal / correlation +# id with their own commands should run their own setup inline rather +# than call this helper. +_SEED_PRINCIPAL_ID = UUID("01900000-0000-7000-8000-000000000099") +_SEED_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000aa") + + def placement(parent_frame_id: UUID) -> Placement: """A minimal Placement adequate for any slot in PG integration tests. @@ -68,4 +93,145 @@ async def drain_equipment_projections( await drain_projections(pool, registry, deadline_seconds=deadline_seconds) -__all__ = ["drain_equipment_projections", "placement"] +async def seed_installed_asset( + pool: asyncpg.Pool, + *, + now: datetime, + slot_code: str, + asset_name: str | None = None, + asset_level: AssetLevel = AssetLevel.DEVICE, +) -> tuple[UUID, UUID, UUID]: + """Register Frame + Mount + Asset, activate Asset, install Asset; drain. + + Returns (frame_id, mount_id, asset_id). Drains projections after + activate (so install_asset's preconditions pass) and after install + (so the asset_location projection row is visible to downstream + register_fixture calls that need to pass the orphan guard). + + Shared by every register_fixture / attach_asset_to_fixture / + detach_asset_from_fixture integration test that needs a real + Fixture-able Asset (register_fixture rejects bindings whose Asset + is not currently installed in some Mount). Tests that need + fine-grained control over the individual ids (specific + FixedIdGenerator values) keep their own inline setup. + + The helper uses its own per-call `build_postgres_deps` so it + consumes ids from a fresh FixedIdGenerator each time; the caller's + outer deps's id pool only needs to budget for the work after + seeding (define_family, add_asset_family, define_assembly, + register_fixture, etc.). + """ + frame_id, mount_id, asset_id = uuid4(), uuid4(), uuid4() + asset_name = asset_name if asset_name is not None else f"specimen-{slot_code}" + + deps = build_postgres_deps(pool, now=now, ids=[frame_id, uuid4()]) + await bind_register_frame(deps)( + RegisterFrame(name=f"frame-{slot_code}", parent_frame_id=None, placement=None), + principal_id=_SEED_PRINCIPAL_ID, + correlation_id=_SEED_CORRELATION_ID, + ) + + deps = build_postgres_deps(pool, now=now, ids=[mount_id, uuid4()]) + await bind_register_mount(deps)( + RegisterMount( + slot_code=slot_code, + parent_mount_id=None, + placement=placement(frame_id), + drawing=None, + ), + principal_id=_SEED_PRINCIPAL_ID, + correlation_id=_SEED_CORRELATION_ID, + ) + + deps = build_postgres_deps(pool, now=now, ids=[asset_id, uuid4()]) + await bind_register_asset(deps)( + RegisterAsset(name=asset_name, level=asset_level, parent_id=uuid4()), + principal_id=_SEED_PRINCIPAL_ID, + correlation_id=_SEED_CORRELATION_ID, + ) + deps = build_postgres_deps(pool, now=now, ids=[uuid4()]) + await bind_activate_asset(deps)( + ActivateAsset(asset_id=asset_id), + principal_id=_SEED_PRINCIPAL_ID, + correlation_id=_SEED_CORRELATION_ID, + ) + await drain_equipment_projections(pool) + + deps = build_postgres_deps(pool, now=now, ids=[uuid4()]) + await bind_install_asset(deps)( + InstallAsset(mount_id=mount_id, asset_id=asset_id), + principal_id=_SEED_PRINCIPAL_ID, + correlation_id=_SEED_CORRELATION_ID, + ) + await drain_equipment_projections(pool) + + return frame_id, mount_id, asset_id + + +async def install_existing_asset_into_fresh_mount( + pool: asyncpg.Pool, + *, + now: datetime, + asset_id: UUID, + slot_code: str, +) -> tuple[UUID, UUID]: + """Activate the given pre-registered Asset, then register a fresh + Frame + Mount and install the Asset; drain. Returns (frame_id, + mount_id). + + Companion to `seed_installed_asset`: use this when the test + already needed to register the Asset itself (e.g., to bind a + model_id or seed an owner) and now needs to satisfy the INV-4 + install-required guard before calling `register_fixture`. + + Activates the Asset because `install_asset` rejects non-Active + Assets (`AssetNotInstallableError`). Tests that have already + activated the Asset should not call this helper; activate is a + strict-not-idempotent transition. + """ + frame_id, mount_id = uuid4(), uuid4() + + deps = build_postgres_deps(pool, now=now, ids=[frame_id, uuid4()]) + await bind_register_frame(deps)( + RegisterFrame(name=f"frame-{slot_code}", parent_frame_id=None, placement=None), + principal_id=_SEED_PRINCIPAL_ID, + correlation_id=_SEED_CORRELATION_ID, + ) + + deps = build_postgres_deps(pool, now=now, ids=[mount_id, uuid4()]) + await bind_register_mount(deps)( + RegisterMount( + slot_code=slot_code, + parent_mount_id=None, + placement=placement(frame_id), + drawing=None, + ), + principal_id=_SEED_PRINCIPAL_ID, + correlation_id=_SEED_CORRELATION_ID, + ) + + deps = build_postgres_deps(pool, now=now, ids=[uuid4()]) + await bind_activate_asset(deps)( + ActivateAsset(asset_id=asset_id), + principal_id=_SEED_PRINCIPAL_ID, + correlation_id=_SEED_CORRELATION_ID, + ) + await drain_equipment_projections(pool) + + deps = build_postgres_deps(pool, now=now, ids=[uuid4()]) + await bind_install_asset(deps)( + InstallAsset(mount_id=mount_id, asset_id=asset_id), + principal_id=_SEED_PRINCIPAL_ID, + correlation_id=_SEED_CORRELATION_ID, + ) + await drain_equipment_projections(pool) + + return frame_id, mount_id + + +__all__ = [ + "drain_equipment_projections", + "install_existing_asset_into_fresh_mount", + "placement", + "seed_installed_asset", +] diff --git a/apps/api/tests/integration/equipment/test_assign_fixture_persistent_id_tool.py b/apps/api/tests/integration/equipment/test_assign_fixture_persistent_id_tool.py index 94219c43c..d98386f40 100644 --- a/apps/api/tests/integration/equipment/test_assign_fixture_persistent_id_tool.py +++ b/apps/api/tests/integration/equipment/test_assign_fixture_persistent_id_tool.py @@ -72,7 +72,10 @@ from cora.equipment.features.register_fixture import RegisterFixture from cora.equipment.ports.doi_minter import PersistentIdentifierMintError from cora.infrastructure.kernel import Kernel -from tests.integration._equipment_helpers import drain_equipment_projections +from tests.integration._equipment_helpers import ( + drain_equipment_projections, + install_existing_asset_into_fresh_mount, +) from tests.integration._helpers import build_postgres_deps from tests.integration.equipment.conftest import RaisingDoiMinter @@ -185,6 +188,11 @@ async def _seed_fixture(db_pool: asyncpg.Pool) -> UUID: principal_id=_PRINCIPAL_ID, correlation_id=_CORRELATION_ID, ) + # INV-4: a Fixture's bindings must be installed in a Mount. + # Activate + install before the later register_fixture call. + await install_existing_asset_into_fresh_mount( + db_pool, now=_NOW, asset_id=asset_id, slot_code=f"02-BM-pidinst-{asset_id}" + ) await add_asset_family.bind(deps)( AddAssetFamily(asset_id=asset_id, family_id=family_id), principal_id=_PRINCIPAL_ID, diff --git a/apps/api/tests/integration/equipment/test_get_fixture_pidinst_after_assign.py b/apps/api/tests/integration/equipment/test_get_fixture_pidinst_after_assign.py index 142d308bf..fd5182fd8 100644 --- a/apps/api/tests/integration/equipment/test_get_fixture_pidinst_after_assign.py +++ b/apps/api/tests/integration/equipment/test_get_fixture_pidinst_after_assign.py @@ -65,7 +65,10 @@ 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._equipment_helpers import ( + drain_equipment_projections, + install_existing_asset_into_fresh_mount, +) from tests.integration._helpers import build_postgres_deps pytestmark = pytest.mark.timeout(60, method="thread") @@ -184,6 +187,11 @@ async def _seed_asset_with_owner_and_model( principal_id=_PRINCIPAL_ID, correlation_id=_CORRELATION_ID, ) + # INV-4: a Fixture's bindings must be installed in a Mount. + # Activate + install before the later register_fixture call. + await install_existing_asset_into_fresh_mount( + db_pool, now=_NOW, asset_id=asset_id, slot_code=f"02-BM-pidinst-{asset_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]) 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 index 5c2cd10a2..ba12501f5 100644 --- 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 @@ -67,7 +67,10 @@ 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._equipment_helpers import ( + drain_equipment_projections, + install_existing_asset_into_fresh_mount, +) from tests.integration._helpers import build_postgres_deps pytestmark = pytest.mark.timeout(60, method="thread") @@ -198,6 +201,14 @@ async def _seed_asset_with_owner_and_model( principal_id=_PRINCIPAL_ID, correlation_id=_CORRELATION_ID, ) + # INV-4: a Fixture's bindings must be installed in a Mount. + # Activate + install before the later register_fixture call so the + # orphan guard does not fire. slot_code suffix uses the asset's + # own uuid to keep slot_code unique across helper calls within a + # single test run. + await install_existing_asset_into_fresh_mount( + db_pool, now=_NOW, asset_id=asset_id, slot_code=f"02-BM-pidinst-{asset_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]) 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 index 6edf66d9e..da86e8acc 100644 --- a/apps/api/tests/integration/equipment/test_get_fixture_pidinst_route.py +++ b/apps/api/tests/integration/equipment/test_get_fixture_pidinst_route.py @@ -75,7 +75,10 @@ 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._equipment_helpers import ( + drain_equipment_projections, + install_existing_asset_into_fresh_mount, +) from tests.integration._helpers import build_postgres_deps pytestmark = pytest.mark.timeout(60, method="thread") @@ -183,6 +186,11 @@ async def _seed_asset( principal_id=_PRINCIPAL_ID, correlation_id=_CORRELATION_ID, ) + # INV-4: a Fixture's bindings must be installed in a Mount. + # Activate + install before the later register_fixture call. + await install_existing_asset_into_fresh_mount( + db_pool, now=_NOW, asset_id=asset_id, slot_code=f"02-BM-pidinst-{asset_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() 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 index 82ebc5e34..3963a68c0 100644 --- a/apps/api/tests/integration/equipment/test_get_fixture_pidinst_tool.py +++ b/apps/api/tests/integration/equipment/test_get_fixture_pidinst_tool.py @@ -63,7 +63,10 @@ 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._equipment_helpers import ( + drain_equipment_projections, + install_existing_asset_into_fresh_mount, +) from tests.integration._helpers import build_postgres_deps pytestmark = pytest.mark.timeout(60, method="thread") @@ -161,6 +164,11 @@ async def _seed_minted_fixture(db_pool: asyncpg.Pool) -> UUID: principal_id=_PRINCIPAL_ID, correlation_id=_CORRELATION_ID, ) + # INV-4: a Fixture's bindings must be installed in a Mount. + # Activate + install before the later register_fixture call. + await install_existing_asset_into_fresh_mount( + db_pool, now=_NOW, asset_id=asset_id, slot_code=f"02-BM-pidinst-{asset_id}" + ) await add_asset_family.bind(deps)( AddAssetFamily(asset_id=asset_id, family_id=family_id), principal_id=_PRINCIPAL_ID, diff --git a/apps/api/tests/integration/test_attach_asset_to_fixture_handler_postgres.py b/apps/api/tests/integration/test_attach_asset_to_fixture_handler_postgres.py index 4bc3ad49a..1c6d4c695 100644 --- a/apps/api/tests/integration/test_attach_asset_to_fixture_handler_postgres.py +++ b/apps/api/tests/integration/test_attach_asset_to_fixture_handler_postgres.py @@ -12,29 +12,26 @@ import pytest from cora.equipment.aggregates.assembly import SlotCardinality, SlotName, TemplateSlot -from cora.equipment.aggregates.asset import AssetLevel, load_asset +from cora.equipment.aggregates.asset import load_asset from cora.equipment.aggregates.fixture import SlotAssetBinding from cora.equipment.features import ( add_asset_family, attach_asset_to_fixture, define_assembly, define_family, - register_asset, register_fixture, ) from cora.equipment.features.add_asset_family import AddAssetFamily from cora.equipment.features.attach_asset_to_fixture import AttachAssetToFixture from cora.equipment.features.define_assembly import DefineAssembly from cora.equipment.features.define_family import DefineFamily -from cora.equipment.features.register_asset import RegisterAsset from cora.equipment.features.register_fixture import RegisterFixture +from tests.integration._equipment_helpers import seed_installed_asset from tests.integration._helpers import build_postgres_deps _NOW = datetime(2026, 6, 3, 14, 0, 0, tzinfo=UTC) _FAMILY_ID = UUID("01900000-0000-7000-8000-00000054cb01") _FAMILY_EVENT_ID = UUID("01900000-0000-7000-8000-00000054cb0e") -_ASSET_ID = UUID("01900000-0000-7000-8000-00000054cb02") -_ASSET_EVENT_ID = UUID("01900000-0000-7000-8000-00000054cb0f") _ADD_FAMILY_EVENT_ID = UUID("01900000-0000-7000-8000-00000054cb10") _ASSEMBLY_ID = UUID("01900000-0000-7000-8000-00000054cb03") _ASSEMBLY_DEFINED_EVENT_ID = UUID("01900000-0000-7000-8000-00000054cb1e") @@ -43,21 +40,24 @@ _ATTACH_EVENT_ID = UUID("01900000-0000-7000-8000-00000054cb3e") _PRINCIPAL_ID = UUID("01900000-0000-7000-8000-000000000099") _CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000dd") -_PARENT_ID = UUID("01900000-0000-7000-8000-000000000fff") @pytest.mark.integration async def test_attach_asset_to_fixture_sets_back_reference_in_postgres( db_pool: asyncpg.Pool, ) -> None: + # Pre-seed: Frame + Mount + Asset, with the Asset installed so it + # passes register_fixture's install-required guard. + _, _, asset_id = await seed_installed_asset( + db_pool, now=_NOW, slot_code="02-BM-attach", asset_name="Cam-1" + ) + deps = build_postgres_deps( db_pool, now=_NOW, ids=[ _FAMILY_ID, _FAMILY_EVENT_ID, - _ASSET_ID, - _ASSET_EVENT_ID, _ADD_FAMILY_EVENT_ID, _ASSEMBLY_ID, _ASSEMBLY_DEFINED_EVENT_ID, @@ -72,11 +72,6 @@ async def test_attach_asset_to_fixture_sets_back_reference_in_postgres( principal_id=_PRINCIPAL_ID, correlation_id=_CORRELATION_ID, ) - asset_id = await register_asset.bind(deps)( - RegisterAsset(name="Cam-1", level=AssetLevel.DEVICE, parent_id=_PARENT_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, @@ -118,8 +113,10 @@ async def test_attach_asset_to_fixture_sets_back_reference_in_postgres( # Asset stream now has Registered + AddFamily + AttachedToFixture. asset_events, asset_version = await deps.event_store.load("Asset", asset_id) - assert asset_version == 3 - attach_event = asset_events[2] + # Asset stream: Registered + Activated + AddFamily + AttachedToFixture + # (the Activated event comes from seed_installed_asset's setup). + assert asset_version == 4 + attach_event = asset_events[3] assert attach_event.event_type == "AssetAttachedToFixture" assert attach_event.event_id == _ATTACH_EVENT_ID assert attach_event.payload == { diff --git a/apps/api/tests/integration/test_decommission_asset_handler_postgres.py b/apps/api/tests/integration/test_decommission_asset_handler_postgres.py index 29f18d5fe..e3d88add5 100644 --- a/apps/api/tests/integration/test_decommission_asset_handler_postgres.py +++ b/apps/api/tests/integration/test_decommission_asset_handler_postgres.py @@ -53,7 +53,11 @@ from cora.equipment.features.register_fixture import RegisterFixture from cora.equipment.features.register_frame import RegisterFrame from cora.equipment.features.register_mount import RegisterMount -from tests.integration._equipment_helpers import drain_equipment_projections, placement +from tests.integration._equipment_helpers import ( + drain_equipment_projections, + placement, + seed_installed_asset, +) from tests.integration._helpers import build_postgres_deps _NOW = datetime(2026, 5, 10, 12, 0, 0, tzinfo=UTC) @@ -208,18 +212,23 @@ async def test_decommission_asset_rejects_when_still_bound_to_fixture( carries `fixture_id` cannot be decommissioned; operator must `detach_asset_from_fixture` first. Verifies the guard fires end-to-end against the real Asset stream fold. + + Setup chain (register_fixture requires every bound Asset to be + currently installed): seed_installed_asset (Frame + Mount + Asset + + activate + install) -> define_family + add_asset_family + -> define_assembly + register_fixture -> attach_asset_to_fixture + -> decommission_asset (rejected by AssetHasFixtureBindingError). """ - deps = build_postgres_deps(db_pool, now=_NOW, ids=[uuid4() for _ in range(10)]) + _, _, asset_id = await seed_installed_asset( + db_pool, now=_NOW, slot_code="02-BM-decom-fix", asset_name="Cam-1" + ) + + deps = build_postgres_deps(db_pool, now=_NOW, ids=[uuid4() for _ in range(8)]) family_id = await define_family.bind(deps)( DefineFamily(name="Camera", affordances=frozenset()), principal_id=_PRINCIPAL_ID, correlation_id=_CORRELATION_ID, ) - asset_id = await register_asset.bind(deps)( - RegisterAsset(name="Cam-1", level=AssetLevel.DEVICE, parent_id=uuid4()), - 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, @@ -267,10 +276,12 @@ async def test_decommission_asset_rejects_when_still_bound_to_fixture( assert exc_info.value.asset_id == asset_id assert exc_info.value.fixture_id == fixture_id - # The Asset stream is unchanged after the rejection. + # The Asset stream is unchanged after the rejection (AssetActivated + # comes from seed_installed_asset). events, _ = await deps.event_store.load("Asset", asset_id) assert [e.event_type for e in events] == [ "AssetRegistered", + "AssetActivated", "AssetFamilyAdded", "AssetAttachedToFixture", ] diff --git a/apps/api/tests/integration/test_detach_asset_from_fixture_handler_postgres.py b/apps/api/tests/integration/test_detach_asset_from_fixture_handler_postgres.py index d01fcf578..d34656310 100644 --- a/apps/api/tests/integration/test_detach_asset_from_fixture_handler_postgres.py +++ b/apps/api/tests/integration/test_detach_asset_from_fixture_handler_postgres.py @@ -12,7 +12,7 @@ import pytest from cora.equipment.aggregates.assembly import SlotCardinality, SlotName, TemplateSlot -from cora.equipment.aggregates.asset import AssetLevel, load_asset +from cora.equipment.aggregates.asset import load_asset from cora.equipment.aggregates.fixture import SlotAssetBinding from cora.equipment.features import ( add_asset_family, @@ -20,7 +20,6 @@ define_assembly, define_family, detach_asset_from_fixture, - register_asset, register_fixture, ) from cora.equipment.features.add_asset_family import AddAssetFamily @@ -28,15 +27,13 @@ from cora.equipment.features.define_assembly import DefineAssembly from cora.equipment.features.define_family import DefineFamily from cora.equipment.features.detach_asset_from_fixture import DetachAssetFromFixture -from cora.equipment.features.register_asset import RegisterAsset from cora.equipment.features.register_fixture import RegisterFixture +from tests.integration._equipment_helpers import seed_installed_asset from tests.integration._helpers import build_postgres_deps _NOW = datetime(2026, 6, 4, 14, 0, 0, tzinfo=UTC) _FAMILY_ID = UUID("01900000-0000-7000-8000-00000054cc01") _FAMILY_EVENT_ID = UUID("01900000-0000-7000-8000-00000054cc0e") -_ASSET_ID = UUID("01900000-0000-7000-8000-00000054cc02") -_ASSET_EVENT_ID = UUID("01900000-0000-7000-8000-00000054cc0f") _ADD_FAMILY_EVENT_ID = UUID("01900000-0000-7000-8000-00000054cc10") _ASSEMBLY_ID = UUID("01900000-0000-7000-8000-00000054cc03") _ASSEMBLY_DEFINED_EVENT_ID = UUID("01900000-0000-7000-8000-00000054cc1e") @@ -46,21 +43,23 @@ _DETACH_EVENT_ID = UUID("01900000-0000-7000-8000-00000054cc4e") _PRINCIPAL_ID = UUID("01900000-0000-7000-8000-000000000099") _CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000dd") -_PARENT_ID = UUID("01900000-0000-7000-8000-000000000fff") @pytest.mark.integration async def test_detach_asset_from_fixture_clears_back_reference_in_postgres( db_pool: asyncpg.Pool, ) -> None: + # Pre-seed: Frame + Mount + Asset, installed (passes register_fixture's install-required guard). + _, _, asset_id = await seed_installed_asset( + db_pool, now=_NOW, slot_code="02-BM-detach", asset_name="Cam-1" + ) + deps = build_postgres_deps( db_pool, now=_NOW, ids=[ _FAMILY_ID, _FAMILY_EVENT_ID, - _ASSET_ID, - _ASSET_EVENT_ID, _ADD_FAMILY_EVENT_ID, _ASSEMBLY_ID, _ASSEMBLY_DEFINED_EVENT_ID, @@ -76,11 +75,6 @@ async def test_detach_asset_from_fixture_clears_back_reference_in_postgres( principal_id=_PRINCIPAL_ID, correlation_id=_CORRELATION_ID, ) - asset_id = await register_asset.bind(deps)( - RegisterAsset(name="Cam-1", level=AssetLevel.DEVICE, parent_id=_PARENT_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, @@ -127,8 +121,10 @@ async def test_detach_asset_from_fixture_clears_back_reference_in_postgres( # Asset stream: Registered + AddFamily + AttachedToFixture + DetachedFromFixture. asset_events, asset_version = await deps.event_store.load("Asset", asset_id) - assert asset_version == 4 - detach_event = asset_events[3] + # Asset stream: Registered + Activated + AddFamily + AttachedToFixture + + # DetachedFromFixture (Activated comes from seed_installed_asset). + assert asset_version == 5 + detach_event = asset_events[4] assert detach_event.event_type == "AssetDetachedFromFixture" assert detach_event.event_id == _DETACH_EVENT_ID assert detach_event.payload == { diff --git a/apps/api/tests/integration/test_list_fixtures_handler_postgres.py b/apps/api/tests/integration/test_list_fixtures_handler_postgres.py index a674c76b3..2d657547e 100644 --- a/apps/api/tests/integration/test_list_fixtures_handler_postgres.py +++ b/apps/api/tests/integration/test_list_fixtures_handler_postgres.py @@ -15,7 +15,6 @@ from cora.equipment._projections import register_equipment_projections from cora.equipment.aggregates.assembly import SlotCardinality, SlotName, TemplateSlot -from cora.equipment.aggregates.asset import AssetLevel from cora.equipment.aggregates.fixture import SlotAssetBinding from cora.equipment.features.add_asset_family import AddAssetFamily from cora.equipment.features.add_asset_family import bind as bind_add_family @@ -25,18 +24,16 @@ from cora.equipment.features.define_family import bind as bind_define_family from cora.equipment.features.list_fixtures import ListFixtures from cora.equipment.features.list_fixtures import bind as bind_list_fixtures -from cora.equipment.features.register_asset import RegisterAsset -from cora.equipment.features.register_asset import bind as bind_register_asset from cora.equipment.features.register_fixture import RegisterFixture from cora.equipment.features.register_fixture import bind as bind_register_fixture from cora.infrastructure.kernel import Kernel from cora.infrastructure.projection import ProjectionRegistry, drain_projections +from tests.integration._equipment_helpers import seed_installed_asset from tests.integration._helpers import build_postgres_deps _NOW = datetime(2026, 6, 4, 14, 0, 0, tzinfo=UTC) _PRINCIPAL_ID = UUID("01900000-0000-7000-8000-000000000099") _CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000bb") -_PARENT_ID = UUID("01900000-0000-7000-8000-000000000fff") def _build_deps(db_pool: asyncpg.Pool, ids: list[UUID]) -> Kernel: @@ -51,21 +48,29 @@ async def _drain(db_pool: asyncpg.Pool) -> None: async def _seed_fixture( deps: Kernel, + db_pool: asyncpg.Pool, *, asset_name: str = "Cam", assembly_name: str = "MCTOptics", ) -> tuple[UUID, UUID, UUID]: - """Returns (family_id, assembly_id, fixture_id).""" + """Returns (family_id, assembly_id, fixture_id). + + Pre-seeds Frame + Mount + Asset via the shared seed_installed_asset + helper (uuid4 ids; bypasses the outer FixedIdGenerator) so the + bound Asset is mount-installed before register_fixture (the + install-required guard). The outer deps's id pool only needs to + budget for the four post-seed commands: define_family, + add_asset_family, define_assembly, register_fixture. + """ + _, _, asset_id = await seed_installed_asset( + db_pool, now=_NOW, slot_code=f"02-BM-{asset_name}", asset_name=asset_name + ) + family_id = await bind_define_family(deps)( DefineFamily(name=f"Camera-{asset_name}", affordances=frozenset()), principal_id=_PRINCIPAL_ID, correlation_id=_CORRELATION_ID, ) - asset_id = await bind_register_asset(deps)( - RegisterAsset(name=asset_name, level=AssetLevel.DEVICE, parent_id=_PARENT_ID), - principal_id=_PRINCIPAL_ID, - correlation_id=_CORRELATION_ID, - ) await bind_add_family(deps)( AddAssetFamily(asset_id=asset_id, family_id=family_id), principal_id=_PRINCIPAL_ID, @@ -109,7 +114,7 @@ async def test_list_fixtures_returns_registered_fixture( db_pool, ids=[UUID(f"01900000-0000-7000-8000-00000054ee{i:02x}") for i in range(20)], ) - _, assembly_id, fixture_id = await _seed_fixture(deps) + _, assembly_id, fixture_id = await _seed_fixture(deps, db_pool) await _drain(db_pool) page = await bind_list_fixtures(deps)( ListFixtures(limit=20), @@ -132,8 +137,10 @@ async def test_list_fixtures_filter_by_assembly_id( db_pool, ids=[UUID(f"01900000-0000-7000-8000-00000054ef{i:02x}") for i in range(40)], ) - _, assembly_a, fixture_a = await _seed_fixture(deps, asset_name="CamA", assembly_name="A") - _, _, fixture_b = await _seed_fixture(deps, asset_name="CamB", assembly_name="B") + _, assembly_a, fixture_a = await _seed_fixture( + deps, db_pool, asset_name="CamA", assembly_name="A" + ) + _, _, fixture_b = await _seed_fixture(deps, db_pool, asset_name="CamB", assembly_name="B") await _drain(db_pool) page = await bind_list_fixtures(deps)( ListFixtures(limit=50, assembly_id=assembly_a), @@ -155,7 +162,7 @@ async def test_list_fixtures_filter_by_surface_id( db_pool, ids=[UUID(f"01900000-0000-7000-8000-00000055f1{i:02x}") for i in range(40)], ) - _, _, fixture_id = await _seed_fixture(deps) + _, _, fixture_id = await _seed_fixture(deps, db_pool) await _drain(db_pool) # Fetch all to discover the surface_id our test fixture landed on. all_page = await bind_list_fixtures(deps)( @@ -184,7 +191,7 @@ async def test_list_fixtures_filter_by_content_hash( db_pool, ids=[UUID(f"01900000-0000-7000-8000-00000054f0{i:02x}") for i in range(40)], ) - _, _, fixture_id = await _seed_fixture(deps) + _, _, fixture_id = await _seed_fixture(deps, db_pool) await _drain(db_pool) # Fetch all fixtures and pull the content_hash for our fixture. all_page = await bind_list_fixtures(deps)( diff --git a/apps/api/tests/integration/test_register_fixture_handler_postgres.py b/apps/api/tests/integration/test_register_fixture_handler_postgres.py index dd9048ff1..664499d6e 100644 --- a/apps/api/tests/integration/test_register_fixture_handler_postgres.py +++ b/apps/api/tests/integration/test_register_fixture_handler_postgres.py @@ -15,6 +15,7 @@ from cora.equipment.aggregates.assembly import ( FixtureAssetNotAttachableError, + FixtureAssetNotInstalledError, SlotCardinality, SlotName, TemplateSlot, @@ -35,13 +36,12 @@ from cora.equipment.features.define_family import DefineFamily from cora.equipment.features.register_asset import RegisterAsset from cora.equipment.features.register_fixture import RegisterFixture +from tests.integration._equipment_helpers import seed_installed_asset from tests.integration._helpers import build_postgres_deps _NOW = datetime(2026, 6, 3, 14, 0, 0, tzinfo=UTC) _FAMILY_ID = UUID("01900000-0000-7000-8000-00000054ca01") _FAMILY_EVENT_ID = UUID("01900000-0000-7000-8000-00000054ca0e") -_ASSET_ID = UUID("01900000-0000-7000-8000-00000054ca02") -_ASSET_EVENT_ID = UUID("01900000-0000-7000-8000-00000054ca0f") _ADD_FAMILY_EVENT_ID = UUID("01900000-0000-7000-8000-00000054ca10") _ASSEMBLY_ID = UUID("01900000-0000-7000-8000-00000054ca03") _ASSEMBLY_DEFINED_EVENT_ID = UUID("01900000-0000-7000-8000-00000054ca1e") @@ -49,21 +49,27 @@ _FIXTURE_EVENT_ID = UUID("01900000-0000-7000-8000-00000054ca2e") _PRINCIPAL_ID = UUID("01900000-0000-7000-8000-000000000099") _CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000dd") -_PARENT_ID = UUID("01900000-0000-7000-8000-000000000fff") @pytest.mark.integration async def test_register_fixture_appends_genesis_event_to_postgres( db_pool: asyncpg.Pool, ) -> None: + # A Fixture's bindings must be currently installed in some Mount. + # Seed Frame + Mount + Asset and install the Asset BEFORE + # register_fixture; the helper bypasses this test's outer + # FixedIdGenerator so the pre-allocated id pool only needs to + # budget for the work after seeding. + _, _, asset_id = await seed_installed_asset( + db_pool, now=_NOW, slot_code="02-BM-fix-genesis", asset_name="Camera-1" + ) + deps = build_postgres_deps( db_pool, now=_NOW, ids=[ _FAMILY_ID, _FAMILY_EVENT_ID, - _ASSET_ID, - _ASSET_EVENT_ID, _ADD_FAMILY_EVENT_ID, _ASSEMBLY_ID, _ASSEMBLY_DEFINED_EVENT_ID, @@ -77,11 +83,6 @@ async def test_register_fixture_appends_genesis_event_to_postgres( 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), - 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, @@ -135,8 +136,10 @@ async def test_register_fixture_appends_genesis_event_to_postgres( assert registered.metadata == {"command": "RegisterFixture"} assert registered.occurred_at == _NOW + # Asset stream: registered + activated + add_family (UNCHANGED by Fixture). + # The activated event comes from seed_installed_asset. asset_events, asset_version = await deps.event_store.load("Asset", asset_id) - assert asset_version == 2 # registered + add_family; UNCHANGED by Fixture + assert asset_version == 3 assembly_events, assembly_version = await deps.event_store.load("Assembly", assembly_id) assert assembly_version == 1 # defined only; UNCHANGED _ = asset_events @@ -209,3 +212,65 @@ async def test_register_fixture_rejects_decommissioned_asset_with_not_attachable ) assert exc_info.value.asset_id == asset_id assert exc_info.value.current_lifecycle == AssetLifecycle.DECOMMISSIONED.value + + +@pytest.mark.integration +async def test_register_fixture_rejects_orphan_asset_with_not_installed_error( + db_pool: asyncpg.Pool, +) -> None: + """Install-required guard end-to-end: a registered Asset that is + NOT installed in any Mount cannot be bound into a Fixture. Operator + must install_asset first so the choreography becomes + install -> register_fixture, never register_fixture -> install. + + The asset_location projection is the back-lookup; the handler + loads it before the pure decider via a concurrent gather started + alongside the per-Asset load_asset gather and the assembly_state + task (three concurrent I/O streams). + """ + deps = build_postgres_deps(db_pool, now=_NOW, ids=[uuid4() for _ in range(8)]) + family_id = await define_family.bind(deps)( + DefineFamily(name="OrphanCamera", affordances=frozenset()), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + asset_id = await register_asset.bind(deps)( + RegisterAsset(name="OrphanCam-1", level=AssetLevel.DEVICE, parent_id=uuid4()), + 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, + ) + assembly_id = await define_assembly.bind(deps)( + DefineAssembly( + name="OrphanRig", + 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, + ) + + with pytest.raises(FixtureAssetNotInstalledError) as exc_info: + 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, + ) + assert exc_info.value.asset_id == asset_id diff --git a/apps/api/tests/unit/equipment/test_register_fixture_decider.py b/apps/api/tests/unit/equipment/test_register_fixture_decider.py index 841e1bb8a..830826094 100644 --- a/apps/api/tests/unit/equipment/test_register_fixture_decider.py +++ b/apps/api/tests/unit/equipment/test_register_fixture_decider.py @@ -14,6 +14,7 @@ FixtureAssetFamilyMismatchError, FixtureAssetNotAttachableError, FixtureAssetNotFoundError, + FixtureAssetNotInstalledError, FixtureMappingIncompleteError, FixtureParameterOverridesInvalidError, SlotCardinality, @@ -360,6 +361,153 @@ def test_decide_skips_lifecycle_guard_when_dict_is_empty() -> None: assert isinstance(events[0], FixtureRegistered) +@pytest.mark.unit +def test_decide_rejects_orphan_bound_asset_with_not_installed_error() -> None: + """Cross-aggregate guard: every bound Asset must currently + be installed in some Mount. mount_id_by_asset_id with a None entry + says 'the projection has no row for this asset_id' + -> FixtureAssetNotInstalledError. Fires AFTER the lifecycle check + (Decommissioned is a more fundamental constraint) and BEFORE + cardinality / family-mismatch / parameter-overrides. + """ + assembly_id = uuid4() + family_id = uuid4() + slot = _slot("camera", required_family_ids=frozenset({family_id})) + asset_id = uuid4() + context = RegisterFixtureContext( + assembly_state=_assembly(assembly_id, slots=frozenset({slot})), + family_ids_by_asset_id={asset_id: frozenset({family_id})}, + lifecycle_by_asset_id={asset_id: AssetLifecycle.ACTIVE}, + mount_id_by_asset_id={asset_id: None}, + ) + command = RegisterFixture( + assembly_id=assembly_id, + slot_asset_bindings=frozenset( + {SlotAssetBinding(slot_name="camera", asset_id=asset_id)}, + ), + ) + with pytest.raises(FixtureAssetNotInstalledError) as exc_info: + register_fixture.decide( + state=None, + command=command, + context=context, + now=_NOW, + new_id=uuid4(), + ) + assert exc_info.value.asset_id == asset_id + + +@pytest.mark.unit +def test_decide_orphan_error_carries_sorted_first_when_multiple_orphans() -> None: + """With multiple orphan bindings, FixtureAssetNotInstalledError must + carry the sorted-by-str-of-UUID first id (the deterministic + invariant). Uses concrete UUIDs whose dict insertion order does NOT + match their stringified sort order, so a regression to e.g. + `next(iter(...))` would catch the wrong id and fail. + """ + assembly_id = uuid4() + family_id = uuid4() + slot = TemplateSlot( + slot_name=SlotName("camera"), + required_family_ids=frozenset({family_id}), + cardinality=SlotCardinality.ZERO_OR_MORE, + ) + # Concrete ids chosen so the dict's first-inserted entry sorts LAST + # by str(UUID). Sorted-by-str order: 11..., 22..., 33.... + asset_late = UUID("33333333-3333-7333-9333-333333333333") + asset_mid = UUID("22222222-2222-7222-9222-222222222222") + asset_early = UUID("11111111-1111-7111-9111-111111111111") + asset_ids = (asset_late, asset_mid, asset_early) + context = RegisterFixtureContext( + assembly_state=_assembly(assembly_id, slots=frozenset({slot})), + family_ids_by_asset_id={aid: frozenset({family_id}) for aid in asset_ids}, + lifecycle_by_asset_id={aid: AssetLifecycle.ACTIVE for aid in asset_ids}, + mount_id_by_asset_id={aid: None for aid in asset_ids}, + ) + command = RegisterFixture( + assembly_id=assembly_id, + slot_asset_bindings=frozenset( + SlotAssetBinding(slot_name="camera", asset_id=aid) for aid in asset_ids + ), + ) + with pytest.raises(FixtureAssetNotInstalledError) as exc_info: + register_fixture.decide( + state=None, + command=command, + context=context, + now=_NOW, + new_id=uuid4(), + ) + assert exc_info.value.asset_id == asset_early + + +@pytest.mark.unit +def test_decide_skips_orphan_guard_when_mount_id_dict_is_none() -> None: + """Pool-None test path: handler ran without a database pool, so + mount_id_by_asset_id is None and the orphan guard is disabled. + Mirrors install_asset / decommission_asset projection-precondition + short-circuit pattern. + """ + assembly_id = uuid4() + family_id = uuid4() + slot = _slot("camera", required_family_ids=frozenset({family_id})) + asset_id = uuid4() + context = RegisterFixtureContext( + assembly_state=_assembly(assembly_id, slots=frozenset({slot})), + family_ids_by_asset_id={asset_id: frozenset({family_id})}, + lifecycle_by_asset_id={asset_id: AssetLifecycle.ACTIVE}, + mount_id_by_asset_id=None, + ) + command = RegisterFixture( + assembly_id=assembly_id, + slot_asset_bindings=frozenset( + {SlotAssetBinding(slot_name="camera", asset_id=asset_id)}, + ), + ) + events = register_fixture.decide( + state=None, + command=command, + context=context, + now=_NOW, + new_id=uuid4(), + ) + assert len(events) == 1 + assert isinstance(events[0], FixtureRegistered) + + +@pytest.mark.unit +def test_decide_decommissioned_guard_fires_before_orphan_guard() -> None: + """Deterministic ordering when both guards would apply: the + Decommissioned-lifecycle check fires first because lifecycle is + the more fundamental constraint (an installed Decommissioned + Asset is rarer in practice but still wrong). + """ + assembly_id = uuid4() + family_id = uuid4() + slot = _slot("camera", required_family_ids=frozenset({family_id})) + asset_id = uuid4() + context = RegisterFixtureContext( + assembly_state=_assembly(assembly_id, slots=frozenset({slot})), + family_ids_by_asset_id={asset_id: frozenset({family_id})}, + lifecycle_by_asset_id={asset_id: AssetLifecycle.DECOMMISSIONED}, + mount_id_by_asset_id={asset_id: None}, + ) + command = RegisterFixture( + assembly_id=assembly_id, + slot_asset_bindings=frozenset( + {SlotAssetBinding(slot_name="camera", asset_id=asset_id)}, + ), + ) + with pytest.raises(FixtureAssetNotAttachableError): + register_fixture.decide( + state=None, + command=command, + context=context, + now=_NOW, + new_id=uuid4(), + ) + + @pytest.mark.unit def test_decide_is_pure_same_inputs_yield_same_events() -> None: assembly_id = uuid4() diff --git a/apps/api/tests/unit/equipment/test_register_fixture_decider_properties.py b/apps/api/tests/unit/equipment/test_register_fixture_decider_properties.py index 32bb7e913..da310e520 100644 --- a/apps/api/tests/unit/equipment/test_register_fixture_decider_properties.py +++ b/apps/api/tests/unit/equipment/test_register_fixture_decider_properties.py @@ -13,10 +13,12 @@ AssemblyName, AssemblyNotFoundError, AssemblyStatus, + FixtureAssetNotInstalledError, SlotCardinality, SlotName, TemplateSlot, ) +from cora.equipment.aggregates.asset import AssetLifecycle from cora.equipment.aggregates.fixture import ( FixtureRegistered, SlotAssetBinding, @@ -125,6 +127,51 @@ def test_decide_zero_or_more_slot_accepts_any_binding_count( assert isinstance(events[0], FixtureRegistered) +@pytest.mark.unit +@given( + orphan_ids=st.lists(st.uuids(), min_size=2, max_size=5, unique=True).map(frozenset), + now=aware_datetimes(), +) +def test_decide_orphan_error_always_carries_sorted_first_id( + orphan_ids: frozenset[UUID], + now: datetime, +) -> None: + """When several bindings are orphans, the raised + `FixtureAssetNotInstalledError.asset_id` is the smallest by + `str(UUID)` regardless of dict/frozenset iteration order. A + regression to `next(iter(orphans))` would fail this property + because frozenset iteration is not stringified-order. + """ + assembly_id = uuid4() + family_id = uuid4() + slot = TemplateSlot( + slot_name=SlotName("camera"), + required_family_ids=frozenset({family_id}), + cardinality=SlotCardinality.ZERO_OR_MORE, + ) + context = RegisterFixtureContext( + assembly_state=_assembly(assembly_id, slots=frozenset({slot})), + family_ids_by_asset_id={aid: frozenset({family_id}) for aid in orphan_ids}, + lifecycle_by_asset_id={aid: AssetLifecycle.ACTIVE for aid in orphan_ids}, + mount_id_by_asset_id={aid: None for aid in orphan_ids}, + ) + command = RegisterFixture( + assembly_id=assembly_id, + slot_asset_bindings=frozenset( + SlotAssetBinding(slot_name="camera", asset_id=aid) for aid in orphan_ids + ), + ) + with pytest.raises(FixtureAssetNotInstalledError) as exc_info: + register_fixture.decide( + state=None, + command=command, + context=context, + now=now, + new_id=uuid4(), + ) + assert exc_info.value.asset_id == sorted(orphan_ids, key=str)[0] + + @pytest.mark.unit @given(now=aware_datetimes(), new_id=st.uuids()) def test_decide_is_pure_same_inputs_yield_same_events( diff --git a/docs/architecture/modules/equipment/index.md b/docs/architecture/modules/equipment/index.md index cd38545fe..57bcd7a86 100644 --- a/docs/architecture/modules/equipment/index.md +++ b/docs/architecture/modules/equipment/index.md @@ -226,7 +226,7 @@ stateDiagram-v2 : `presents_as_family_id` references a Family that exists in the Equipment module (verified at decide time via a cross-aggregate load). Every `TemplateWire.source_slot` and `TemplateWire.target_slot` matches a `TemplateSlot.slot_name` declared on the same Assembly. The decider captures the canonical `content_hash` from the new structural content; re-versioning with identical content yields the same hash by design. `register_fixture` -: Every required slot in the Assembly is covered by exactly one `SlotAssetBinding` whose `asset_id` references an Asset whose `family_ids` includes the slot's required Family. The Assembly is in `Defined` or `Versioned` (not `Deprecated`). The Fixture's `surface_id` is read from the caller's authenticated Trust Surface; the Fixture is bound to that Surface for authorization scoping. +: Every required slot in the Assembly is covered by exactly one `SlotAssetBinding` whose `asset_id` references an Asset whose `family_ids` includes the slot's required Family. The Assembly is in `Defined` or `Versioned` (not `Deprecated`). Every bound Asset must not be `Decommissioned` (a terminal lifecycle disallows attachment) and must currently be installed in some Mount (so the choreography is `install_asset` -> `register_fixture`, never the reverse). The Fixture's `surface_id` is read from the caller's authenticated Trust Surface; the Fixture is bound to that Surface for authorization scoping. `add_model_family` / `remove_model_family` : The Model is not Deprecated. The Family id is not already present (or is present, for remove). The referenced Family must exist in the Family event stream (verified at decide time via load). @@ -459,7 +459,7 @@ The seven aggregates expose fifty slices end to end. : `AssemblyAlreadyExists` (define only), `AssemblyNotFound`, `AssemblyCannotVersion` / `AssemblyCannotDeprecate`, `FamilyNotFoundForAssembly` (define / version, when `presents_as_family_id` or a slot's `required_family_ids` references a missing Family), `WireReferencesUnknownSlot` (define / version, when a TemplateWire endpoint cites a slot the same Assembly does not declare), `Unauthorized` `RegisterFixture` -: `AssemblyNotFound`, `AssemblyCannotInstantiate` (Assembly is Deprecated), `FixtureAlreadyExists`, `FixtureAssetNotFound` (a binding references a missing Asset), `FixtureAssetFamilyMismatch` (a binding's Asset does not include the slot's required Family in its `family_ids`), `FixtureMappingIncomplete` (a required slot has no covering binding), `Unauthorized` +: `AssemblyNotFound`, `AssemblyCannotInstantiate` (Assembly is Deprecated), `FixtureAlreadyExists`, `FixtureAssetNotFound` (a binding references a missing Asset), `FixtureAssetNotAttachable` (a binding's Asset is Decommissioned), `FixtureAssetNotInstalled` (a binding's Asset is not currently installed in any Mount), `FixtureAssetFamilyMismatch` (a binding's Asset does not include the slot's required Family in its `family_ids`), `FixtureMappingIncomplete` (a required slot has no covering binding), `Unauthorized` `GetFamily` / `GetModel` / `GetAsset` / `GetAssetIntegrationView` / `GetFixture` : `NotFound`