From f6a2512e2301ba844edd2499f7aabbf792b4ca4d Mon Sep 17 00:00:00 2001 From: JC Date: Wed, 29 Jul 2026 14:50:46 +0000 Subject: [PATCH] feat: add begin repair workflow --- backend/alembic/versions/0010_begin_repair.py | 322 ++++++++ backend/app/identity.py | 2 + backend/app/inventory/models.py | 2 +- backend/app/inventory/service.py | 4 +- backend/app/main.py | 2 + backend/app/repairs/__init__.py | 37 + backend/app/repairs/dependencies.py | 8 + backend/app/repairs/exceptions.py | 58 ++ backend/app/repairs/models.py | 65 ++ backend/app/repairs/repository.py | 225 ++++++ backend/app/repairs/router.py | 60 ++ backend/app/repairs/service.py | 103 +++ backend/tests/test_repairs_domain.py | 737 ++++++++++++++++++ backend/tests/test_repairs_router.py | 277 +++++++ docs/ROADMAP.md | 7 +- docs/tasks/001-begin-repair.md | 544 +++++++++++++ scripts/test-begin-repair-migration.sh | 556 +++++++++++++ scripts/test-dev-baseline.sh | 37 +- scripts/test-inventory-domain.sh | 4 +- scripts/test-repairs-domain.sh | 61 ++ sql/verify-development-schema.sql | 2 + 21 files changed, 3108 insertions(+), 5 deletions(-) create mode 100644 backend/alembic/versions/0010_begin_repair.py create mode 100644 backend/app/repairs/__init__.py create mode 100644 backend/app/repairs/dependencies.py create mode 100644 backend/app/repairs/exceptions.py create mode 100644 backend/app/repairs/models.py create mode 100644 backend/app/repairs/repository.py create mode 100644 backend/app/repairs/router.py create mode 100644 backend/app/repairs/service.py create mode 100644 backend/tests/test_repairs_domain.py create mode 100644 backend/tests/test_repairs_router.py create mode 100644 docs/tasks/001-begin-repair.md create mode 100755 scripts/test-begin-repair-migration.sh create mode 100755 scripts/test-repairs-domain.sh diff --git a/backend/alembic/versions/0010_begin_repair.py b/backend/alembic/versions/0010_begin_repair.py new file mode 100644 index 0000000..df31b97 --- /dev/null +++ b/backend/alembic/versions/0010_begin_repair.py @@ -0,0 +1,322 @@ +"""add begin repair workflow + +Revision ID: 0010_begin_repair +Revises: 0009_inventory_asset_id_history +Create Date: 2026-07-29 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + + +revision: str = "0010_begin_repair" +down_revision: Union[str, None] = "0009_inventory_asset_id_history" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +REPAIR_STATUSES = ( + "OPEN", + "IN_PROGRESS", + "WAITING_FOR_PARTS", + "READY_FOR_TEST", + "COMPLETED", + "CANCELLED", +) +ACTIVE_REPAIR_STATUSES = ( + "OPEN", + "IN_PROGRESS", + "WAITING_FOR_PARTS", + "READY_FOR_TEST", +) +REPAIR_REASONS = ( + "HARDWARE_FAILURE", + "LOW_HASHRATE", + "CONTROL_BOARD", + "HASHBOARD", + "PSU", + "FAN", + "OVERHEATING", + "NETWORKING", + "PREVENTIVE_MAINTENANCE", + "INSPECTION", + "OTHER", +) +REPAIR_EVENT_TYPES = ("REPAIR_STARTED", "INITIAL_NOTE") + + +def _quoted(values: Sequence[str]) -> str: + return ", ".join(f"'{value}'" for value in values) + + +def upgrade() -> None: + connection = op.get_bind() + + op.drop_constraint("ck_miners_lifecycle_status", "miners", type_="check") + op.create_check_constraint( + "ck_miners_lifecycle_status", + "miners", + "lifecycle_status IN ('active', 'spare', 'retired', 'in_repair')", + ) + + connection.exec_driver_sql( + """ + INSERT INTO permissions (key, description) + VALUES ('repairs.create', 'Begin repairs for miner assets') + ON CONFLICT (key) DO UPDATE + SET description = EXCLUDED.description + """ + ) + connection.exec_driver_sql( + """ + INSERT INTO role_permissions (role_id, permission_id) + SELECT roles.id, permissions.id + FROM roles + JOIN permissions ON permissions.key = 'repairs.create' + WHERE roles.name IN ('Admin', 'Manager', 'Technician') + ON CONFLICT DO NOTHING + """ + ) + + op.create_table( + "repairs", + sa.Column( + "id", + postgresql.UUID(as_uuid=True), + primary_key=True, + server_default=sa.text("gen_random_uuid()"), + ), + sa.Column( + "miner_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("miners.id", ondelete="RESTRICT"), + nullable=False, + ), + sa.Column( + "status", + sa.Text(), + nullable=False, + server_default=sa.text("'OPEN'"), + ), + sa.Column("reason", sa.Text(), nullable=False), + sa.Column( + "opened_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.Column( + "opened_by", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("users.id", ondelete="RESTRICT"), + nullable=False, + ), + sa.Column( + "completed_at", + sa.DateTime(timezone=True), + nullable=True, + ), + sa.Column( + "completed_by", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("users.id", ondelete="RESTRICT"), + nullable=True, + ), + sa.CheckConstraint( + f"status IN ({_quoted(REPAIR_STATUSES)})", + name="ck_repairs_status", + ), + sa.CheckConstraint( + f"reason IN ({_quoted(REPAIR_REASONS)})", + name="ck_repairs_reason", + ), + sa.CheckConstraint( + "(completed_at IS NULL AND completed_by IS NULL) OR " + "(completed_at IS NOT NULL AND completed_by IS NOT NULL)", + name="ck_repairs_completion_pair", + ), + sa.CheckConstraint( + "(status = 'COMPLETED') = (completed_at IS NOT NULL)", + name="ck_repairs_completed_state", + ), + ) + op.create_index( + "idx_repairs_miner_opened_at", + "repairs", + ["miner_id", "opened_at", "id"], + ) + op.create_index("idx_repairs_opened_by", "repairs", ["opened_by"]) + op.create_index("idx_repairs_completed_by", "repairs", ["completed_by"]) + op.create_index( + "uq_repairs_one_active_per_miner", + "repairs", + ["miner_id"], + unique=True, + postgresql_where=sa.text( + f"status IN ({_quoted(ACTIVE_REPAIR_STATUSES)})" + ), + ) + + op.create_table( + "repair_events", + sa.Column( + "id", + postgresql.UUID(as_uuid=True), + primary_key=True, + server_default=sa.text("gen_random_uuid()"), + ), + sa.Column( + "repair_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("repairs.id", ondelete="RESTRICT"), + nullable=False, + ), + sa.Column("sequence", sa.Integer(), nullable=False), + sa.Column("event_type", sa.Text(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.Column( + "created_by", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("users.id", ondelete="RESTRICT"), + nullable=False, + ), + sa.Column( + "payload", + postgresql.JSONB(), + nullable=False, + ), + sa.CheckConstraint( + f"event_type IN ({_quoted(REPAIR_EVENT_TYPES)})", + name="ck_repair_events_type", + ), + sa.CheckConstraint( + "sequence > 0", + name="ck_repair_events_sequence_positive", + ), + sa.CheckConstraint( + "jsonb_typeof(payload) = 'object'", + name="ck_repair_events_payload_object", + ), + sa.CheckConstraint( + "(" + "event_type = 'REPAIR_STARTED' " + "AND payload ?& ARRAY['reason', 'from_lifecycle', 'to_lifecycle'] " + f"AND payload ->> 'reason' IN ({_quoted(REPAIR_REASONS)}) " + "AND payload ->> 'from_lifecycle' IN ('active', 'spare') " + "AND payload ->> 'to_lifecycle' = 'in_repair'" + ") OR (" + "event_type = 'INITIAL_NOTE' " + "AND jsonb_typeof(payload -> 'note') = 'string' " + "AND trim(payload ->> 'note') <> ''" + ")", + name="ck_repair_events_payload_shape", + ), + sa.UniqueConstraint( + "repair_id", + "sequence", + name="uq_repair_events_repair_sequence", + ), + ) + op.create_index( + "idx_repair_events_repair_created", + "repair_events", + ["repair_id", "created_at", "sequence", "id"], + ) + op.create_index( + "idx_repair_events_created_by", + "repair_events", + ["created_by"], + ) + + +def downgrade() -> None: + connection = op.get_bind() + + connection.exec_driver_sql( + f""" + DO $$ + DECLARE + invalid_miner_ids TEXT; + BEGIN + SELECT string_agg(miner.id::text, ', ' ORDER BY miner.id::text) + INTO invalid_miner_ids + FROM miners AS miner + LEFT JOIN LATERAL ( + SELECT count(*) AS started_event_count + FROM repairs AS repair + JOIN repair_events AS event + ON event.repair_id = repair.id + AND event.event_type = 'REPAIR_STARTED' + WHERE repair.miner_id = miner.id + AND repair.status IN ({_quoted(ACTIVE_REPAIR_STATUSES)}) + ) AS history ON TRUE + WHERE miner.lifecycle_status = 'in_repair' + AND history.started_event_count <> 1; + + IF invalid_miner_ids IS NOT NULL THEN + RAISE EXCEPTION USING + MESSAGE = format( + 'Cannot downgrade Begin Repair: miners %s do not have exactly one active REPAIR_STARTED event', + invalid_miner_ids + ), + HINT = 'Repair the affected lifecycle history before downgrading; MinerOps will not invent a prior lifecycle value.'; + END IF; + END + $$; + """ + ) + connection.exec_driver_sql( + f""" + UPDATE miners AS miner + SET lifecycle_status = history.from_lifecycle, + updated_at = now() + FROM ( + SELECT + repair.miner_id, + event.payload ->> 'from_lifecycle' AS from_lifecycle + FROM repairs AS repair + JOIN repair_events AS event + ON event.repair_id = repair.id + AND event.event_type = 'REPAIR_STARTED' + WHERE repair.status IN ({_quoted(ACTIVE_REPAIR_STATUSES)}) + ) AS history + WHERE miner.id = history.miner_id + AND miner.lifecycle_status = 'in_repair' + """ + ) + + op.drop_index("idx_repair_events_created_by", table_name="repair_events") + op.drop_index("idx_repair_events_repair_created", table_name="repair_events") + op.drop_table("repair_events") + + op.drop_index("uq_repairs_one_active_per_miner", table_name="repairs") + op.drop_index("idx_repairs_completed_by", table_name="repairs") + op.drop_index("idx_repairs_opened_by", table_name="repairs") + op.drop_index("idx_repairs_miner_opened_at", table_name="repairs") + op.drop_table("repairs") + + connection.exec_driver_sql( + """ + DELETE FROM role_permissions + WHERE permission_id = ( + SELECT id FROM permissions WHERE key = 'repairs.create' + ) + """ + ) + connection.exec_driver_sql( + "DELETE FROM permissions WHERE key = 'repairs.create'" + ) + + op.drop_constraint("ck_miners_lifecycle_status", "miners", type_="check") + op.create_check_constraint( + "ck_miners_lifecycle_status", + "miners", + "lifecycle_status IN ('active', 'spare', 'retired')", + ) diff --git a/backend/app/identity.py b/backend/app/identity.py index 6a0f94c..333672c 100644 --- a/backend/app/identity.py +++ b/backend/app/identity.py @@ -674,6 +674,8 @@ def machine_agent_request(path: str) -> bool: def required_api_permission(method: str, path: str) -> str | None: if path.startswith("/api/admin/"): return "users.manage" + if method == "POST" and path == "/api/repairs": + return "repairs.create" if method == "GET": if path.startswith("/api/system/"): return "system_health.view" diff --git a/backend/app/inventory/models.py b/backend/app/inventory/models.py index b193911..2c5204e 100644 --- a/backend/app/inventory/models.py +++ b/backend/app/inventory/models.py @@ -7,7 +7,7 @@ from uuid import UUID from pydantic import BaseModel, ConfigDict, Field, IPvAnyAddress -LifecycleStatus = Literal["active", "spare", "retired"] +LifecycleStatus = Literal["active", "spare", "retired", "in_repair"] class InventoryTag(BaseModel): diff --git a/backend/app/inventory/service.py b/backend/app/inventory/service.py index f0706b8..2237811 100644 --- a/backend/app/inventory/service.py +++ b/backend/app/inventory/service.py @@ -24,7 +24,9 @@ from app.inventory.models import ( from app.inventory.repository import InventoryRepository -ALLOWED_LIFECYCLE_STATUSES = frozenset({"active", "spare", "retired"}) +ALLOWED_LIFECYCLE_STATUSES = frozenset( + {"active", "spare", "retired", "in_repair"} +) class InventoryService: diff --git a/backend/app/main.py b/backend/app/main.py index 2d8dc8c..037b697 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -18,11 +18,13 @@ from app.identity import ( router as identity_router, ) from app.inventory.router import router as inventory_router +from app.repairs.router import router as repairs_router app = FastAPI(title=settings.app_name) app.include_router(identity_router) app.include_router(inventory_router) +app.include_router(repairs_router) install_identity_middleware(app) diff --git a/backend/app/repairs/__init__.py b/backend/app/repairs/__init__.py new file mode 100644 index 0000000..7f2d3a7 --- /dev/null +++ b/backend/app/repairs/__init__.py @@ -0,0 +1,37 @@ +"""Repair workflow models, persistence, and business services.""" + +from app.repairs.exceptions import ( + ActiveRepairConflict, + IncompatibleMinerLifecycle, + InconsistentRepairState, + RepairActorRequired, + RepairConflict, + RepairError, + RepairMinerNotFound, + RepairPersistenceError, +) +from app.repairs.models import ( + BeginRepairRequest, + BeginRepairResult, + RepairReason, + RepairStatus, +) +from app.repairs.repository import RepairRepository +from app.repairs.service import RepairService + +__all__ = [ + "ActiveRepairConflict", + "BeginRepairRequest", + "BeginRepairResult", + "IncompatibleMinerLifecycle", + "InconsistentRepairState", + "RepairActorRequired", + "RepairConflict", + "RepairError", + "RepairMinerNotFound", + "RepairPersistenceError", + "RepairReason", + "RepairRepository", + "RepairService", + "RepairStatus", +] diff --git a/backend/app/repairs/dependencies.py b/backend/app/repairs/dependencies.py new file mode 100644 index 0000000..8fafe37 --- /dev/null +++ b/backend/app/repairs/dependencies.py @@ -0,0 +1,8 @@ +"""Dependency construction for the Repairs domain.""" + +from app.repairs.repository import RepairRepository +from app.repairs.service import RepairService + + +def get_repair_service() -> RepairService: + return RepairService(RepairRepository()) diff --git a/backend/app/repairs/exceptions.py b/backend/app/repairs/exceptions.py new file mode 100644 index 0000000..b570d65 --- /dev/null +++ b/backend/app/repairs/exceptions.py @@ -0,0 +1,58 @@ +"""Domain exceptions for repair workflows.""" + + +class RepairError(Exception): + """Base exception for repair operations.""" + + +class RepairMinerNotFound(RepairError): + """Raised when the requested miner does not exist.""" + + def __init__(self, miner_id: object): + self.miner_id = miner_id + super().__init__(f"Miner not found: {miner_id}") + + +class RepairConflict(RepairError): + """Base exception for a conflicting repair state.""" + + +class ActiveRepairConflict(RepairConflict): + """Raised when a miner already has an active repair.""" + + def __init__(self, miner_id: object): + self.miner_id = miner_id + super().__init__(f"Miner already has an active repair: {miner_id}") + + +class IncompatibleMinerLifecycle(RepairConflict): + """Raised when the miner lifecycle cannot enter repair.""" + + def __init__(self, miner_id: object, lifecycle_status: object): + self.miner_id = miner_id + self.lifecycle_status = lifecycle_status + super().__init__( + "Miner lifecycle is incompatible with beginning repair: " + f"{lifecycle_status}" + ) + + +class InconsistentRepairState(RepairConflict): + """Raised when lifecycle state and repair records disagree.""" + + def __init__(self, miner_id: object): + self.miner_id = miner_id + super().__init__( + "Miner is marked in repair but has no active repair record" + ) + + +class RepairActorRequired(RepairError): + """Raised when an operational workflow lacks an authenticated actor.""" + + def __init__(self): + super().__init__("An authenticated actor is required to begin repair") + + +class RepairPersistenceError(RepairError): + """Raised when a required repair persistence operation fails.""" diff --git a/backend/app/repairs/models.py b/backend/app/repairs/models.py new file mode 100644 index 0000000..bc96be4 --- /dev/null +++ b/backend/app/repairs/models.py @@ -0,0 +1,65 @@ +"""Typed contracts for the Repairs domain.""" + +from datetime import datetime +from typing import Literal +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + + +RepairReason = Literal[ + "HARDWARE_FAILURE", + "LOW_HASHRATE", + "CONTROL_BOARD", + "HASHBOARD", + "PSU", + "FAN", + "OVERHEATING", + "NETWORKING", + "PREVENTIVE_MAINTENANCE", + "INSPECTION", + "OTHER", +] +RepairStatus = Literal[ + "OPEN", + "IN_PROGRESS", + "WAITING_FOR_PARTS", + "READY_FOR_TEST", + "COMPLETED", + "CANCELLED", +] +RepairEventType = Literal["REPAIR_STARTED", "INITIAL_NOTE"] +RepairEligibleLifecycle = Literal["active", "spare"] + + +class BeginRepairRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + miner_id: UUID + reason: RepairReason + notes: str | None = Field(default=None, max_length=4000) + + +class RepairStartedPayload(BaseModel): + reason: RepairReason + from_lifecycle: RepairEligibleLifecycle + to_lifecycle: Literal["in_repair"] = "in_repair" + + +class InitialRepairNotePayload(BaseModel): + note: str = Field(min_length=1, max_length=4000) + + +class BeginRepairResult(BaseModel): + repair_id: UUID + miner_id: UUID + status: Literal["OPEN"] + reason: RepairReason + opened_at: datetime + opened_by: UUID + + +class BeginRepairResponse(BaseModel): + data: BeginRepairResult + meta: dict[str, object] = Field(default_factory=dict) + error: None = None diff --git a/backend/app/repairs/repository.py b/backend/app/repairs/repository.py new file mode 100644 index 0000000..215d51f --- /dev/null +++ b/backend/app/repairs/repository.py @@ -0,0 +1,225 @@ +"""PostgreSQL persistence for repair workflows.""" + +from collections.abc import Callable, Iterator, Mapping +from contextlib import contextmanager +from typing import Any +from uuid import UUID + +import psycopg +from psycopg.errors import UniqueViolation +from psycopg.rows import dict_row +from psycopg.types.json import Jsonb + +from app.config import settings +from app.repairs.exceptions import ( + ActiveRepairConflict, + RepairPersistenceError, +) + + +ConnectionFactory = Callable[[], psycopg.Connection] +ACTIVE_REPAIR_STATUSES = ( + "OPEN", + "IN_PROGRESS", + "WAITING_FOR_PARTS", + "READY_FOR_TEST", +) +ACTIVE_REPAIR_CONSTRAINT = "uq_repairs_one_active_per_miner" + + +def default_connection_factory() -> psycopg.Connection: + return psycopg.connect( + host=settings.postgres_host, + port=settings.postgres_port, + dbname=settings.postgres_db, + user=settings.postgres_user, + password=settings.postgres_password, + connect_timeout=5, + ) + + +class RepairRepository: + """Persistence boundary for the atomic Begin Repair workflow.""" + + def __init__( + self, + connection_factory: ConnectionFactory | None = None, + *, + connection: psycopg.Connection | None = None, + ): + self._connection_factory = connection_factory or default_connection_factory + self._connection = connection + + @contextmanager + def transaction(self) -> Iterator["RepairRepository"]: + if self._connection is not None: + yield self + return + + connection = self._connection_factory() + transaction_repository = RepairRepository( + self._connection_factory, + connection=connection, + ) + try: + yield transaction_repository + connection.commit() + except Exception: + connection.rollback() + raise + finally: + connection.close() + + @contextmanager + def _connection_scope( + self, + *, + write: bool = False, + ) -> Iterator[psycopg.Connection]: + if self._connection is not None: + yield self._connection + return + + connection = self._connection_factory() + try: + yield connection + if write: + connection.commit() + except Exception: + if write: + connection.rollback() + raise + finally: + connection.close() + + def lock_miner_for_update(self, miner_id: UUID) -> dict | None: + with self._connection_scope() as connection: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute( + """ + SELECT id, lifecycle_status + FROM miners + WHERE id = %s + FOR UPDATE + """, + (miner_id,), + ) + row = cur.fetchone() + return dict(row) if row else None + + def get_active_repair(self, miner_id: UUID) -> dict | None: + with self._connection_scope() as connection: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute( + """ + SELECT + id, + miner_id, + status, + reason, + opened_at, + opened_by, + completed_at, + completed_by + FROM repairs + WHERE miner_id = %s + AND status = ANY(%s) + ORDER BY opened_at, id + LIMIT 1 + """, + (miner_id, list(ACTIVE_REPAIR_STATUSES)), + ) + row = cur.fetchone() + return dict(row) if row else None + + def create_repair( + self, + miner_id: UUID, + reason: str, + opened_by: UUID, + ) -> dict: + with self._connection_scope(write=True) as connection: + with connection.cursor(row_factory=dict_row) as cur: + try: + cur.execute( + """ + INSERT INTO repairs (miner_id, reason, opened_by) + VALUES (%s, %s, %s) + RETURNING + id, + miner_id, + status, + reason, + opened_at, + opened_by, + completed_at, + completed_by + """, + (miner_id, reason, opened_by), + ) + except UniqueViolation as exc: + if exc.diag.constraint_name == ACTIVE_REPAIR_CONSTRAINT: + raise ActiveRepairConflict(miner_id) from exc + raise + return dict(cur.fetchone()) + + def create_event( + self, + repair_id: UUID, + *, + sequence: int, + event_type: str, + created_by: UUID, + payload: Mapping[str, Any], + ) -> dict: + with self._connection_scope(write=True) as connection: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute( + """ + INSERT INTO repair_events ( + repair_id, + sequence, + event_type, + created_by, + payload + ) + VALUES (%s, %s, %s, %s, %s) + RETURNING + id, + repair_id, + sequence, + event_type, + created_at, + created_by, + payload + """, + ( + repair_id, + sequence, + event_type, + created_by, + Jsonb(dict(payload)), + ), + ) + return dict(cur.fetchone()) + + def set_miner_lifecycle( + self, + miner_id: UUID, + lifecycle_status: str, + ) -> None: + with self._connection_scope(write=True) as connection: + with connection.cursor() as cur: + cur.execute( + """ + UPDATE miners + SET lifecycle_status = %s, + updated_at = now() + WHERE id = %s + """, + (lifecycle_status, miner_id), + ) + if cur.rowcount != 1: + raise RepairPersistenceError( + "Miner lifecycle transition did not update one record" + ) diff --git a/backend/app/repairs/router.py b/backend/app/repairs/router.py new file mode 100644 index 0000000..a369bbf --- /dev/null +++ b/backend/app/repairs/router.py @@ -0,0 +1,60 @@ +"""HTTP mapping for explicit repair workflows.""" + +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, status + +from app.identity import require_permission +from app.repairs.dependencies import get_repair_service +from app.repairs.exceptions import ( + RepairActorRequired, + RepairConflict, + RepairMinerNotFound, +) +from app.repairs.models import BeginRepairRequest, BeginRepairResponse +from app.repairs.service import RepairService + + +router = APIRouter(prefix="/api/repairs", tags=["repairs"]) +require_repair_create = require_permission("repairs.create") + +RepairServiceDependency = Annotated[ + RepairService, + Depends(get_repair_service), +] +RepairCreateUser = Annotated[dict, Depends(require_repair_create)] + + +@router.post( + "", + response_model=BeginRepairResponse, + status_code=status.HTTP_201_CREATED, +) +def begin_repair( + request: BeginRepairRequest, + service: RepairServiceDependency, + current_user: RepairCreateUser, +) -> BeginRepairResponse: + try: + repair = service.begin_repair( + request, + actor_user_id=UUID(str(current_user["id"])), + ) + except RepairMinerNotFound as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Miner not found", + ) from exc + except RepairConflict as exc: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=str(exc), + ) from exc + except RepairActorRequired as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=str(exc), + ) from exc + + return BeginRepairResponse(data=repair) diff --git a/backend/app/repairs/service.py b/backend/app/repairs/service.py new file mode 100644 index 0000000..af63ffa --- /dev/null +++ b/backend/app/repairs/service.py @@ -0,0 +1,103 @@ +"""Business rules and orchestration for repair workflows.""" + +from uuid import UUID + +from app.repairs.exceptions import ( + ActiveRepairConflict, + IncompatibleMinerLifecycle, + InconsistentRepairState, + RepairActorRequired, + RepairMinerNotFound, +) +from app.repairs.models import ( + BeginRepairRequest, + BeginRepairResult, + InitialRepairNotePayload, + RepairStartedPayload, +) +from app.repairs.repository import RepairRepository + + +ELIGIBLE_LIFECYCLE_STATUSES = frozenset({"active", "spare"}) +REPAIR_LIFECYCLE_STATUS = "in_repair" + + +class RepairService: + """Explicit operational repair workflows independent of FastAPI.""" + + def __init__(self, repository: RepairRepository): + self.repository = repository + + @staticmethod + def normalize_note(note: str | None) -> str | None: + if note is None: + return None + normalized = str(note).strip() + return normalized or None + + def begin_repair( + self, + request: BeginRepairRequest, + *, + actor_user_id: UUID | None, + ) -> BeginRepairResult: + if actor_user_id is None: + raise RepairActorRequired() + + note = self.normalize_note(request.notes) + + with self.repository.transaction() as repository: + miner = repository.lock_miner_for_update(request.miner_id) + if not miner: + raise RepairMinerNotFound(request.miner_id) + + lifecycle_status = str(miner["lifecycle_status"]) + active_repair = repository.get_active_repair(request.miner_id) + if active_repair: + raise ActiveRepairConflict(request.miner_id) + if lifecycle_status == REPAIR_LIFECYCLE_STATUS: + raise InconsistentRepairState(request.miner_id) + if lifecycle_status not in ELIGIBLE_LIFECYCLE_STATUSES: + raise IncompatibleMinerLifecycle( + request.miner_id, + lifecycle_status, + ) + + repair = repository.create_repair( + request.miner_id, + request.reason, + actor_user_id, + ) + started_payload = RepairStartedPayload( + reason=request.reason, + from_lifecycle=lifecycle_status, + ) + repository.create_event( + repair["id"], + sequence=1, + event_type="REPAIR_STARTED", + created_by=actor_user_id, + payload=started_payload.model_dump(mode="json"), + ) + if note is not None: + note_payload = InitialRepairNotePayload(note=note) + repository.create_event( + repair["id"], + sequence=2, + event_type="INITIAL_NOTE", + created_by=actor_user_id, + payload=note_payload.model_dump(mode="json"), + ) + repository.set_miner_lifecycle( + request.miner_id, + REPAIR_LIFECYCLE_STATUS, + ) + + return BeginRepairResult( + repair_id=repair["id"], + miner_id=repair["miner_id"], + status=repair["status"], + reason=repair["reason"], + opened_at=repair["opened_at"], + opened_by=repair["opened_by"], + ) diff --git a/backend/tests/test_repairs_domain.py b/backend/tests/test_repairs_domain.py new file mode 100644 index 0000000..cb472a5 --- /dev/null +++ b/backend/tests/test_repairs_domain.py @@ -0,0 +1,737 @@ +import ast +import unittest +from concurrent.futures import ThreadPoolExecutor +from contextlib import contextmanager +from copy import deepcopy +from datetime import datetime, timezone +from pathlib import Path +from threading import Barrier +from uuid import UUID, uuid4 + +from psycopg.errors import RaiseException +from psycopg.rows import dict_row + +from app.repairs import ( + ActiveRepairConflict, + BeginRepairRequest, + IncompatibleMinerLifecycle, + InconsistentRepairState, + RepairActorRequired, + RepairMinerNotFound, + RepairRepository, + RepairService, +) + + +def repair_row(miner_id, actor_user_id, reason="CONTROL_BOARD"): + return { + "id": uuid4(), + "miner_id": miner_id, + "status": "OPEN", + "reason": reason, + "opened_at": datetime.now(timezone.utc), + "opened_by": actor_user_id, + "completed_at": None, + "completed_by": None, + } + + +class FakeRepairRepository: + def __init__(self): + self.miners = {} + self.repairs = [] + self.events = [] + self.calls = [] + self.create_error = None + self.event_error = None + self.lifecycle_error = None + + @contextmanager + def transaction(self): + self.calls.append("transaction") + snapshot = deepcopy((self.miners, self.repairs, self.events)) + try: + yield self + except Exception: + self.miners, self.repairs, self.events = snapshot + raise + + def lock_miner_for_update(self, miner_id): + self.calls.append("lock_miner") + return self.miners.get(miner_id) + + def get_active_repair(self, miner_id): + self.calls.append("get_active_repair") + return next( + ( + repair + for repair in self.repairs + if repair["miner_id"] == miner_id + and repair["status"] + in { + "OPEN", + "IN_PROGRESS", + "WAITING_FOR_PARTS", + "READY_FOR_TEST", + } + ), + None, + ) + + def create_repair(self, miner_id, reason, opened_by): + self.calls.append("create_repair") + if self.create_error is not None: + raise self.create_error + row = repair_row(miner_id, opened_by, reason) + self.repairs.append(row) + return row + + def create_event( + self, + repair_id, + *, + sequence, + event_type, + created_by, + payload, + ): + self.calls.append(f"create_event:{event_type}") + if self.event_error is not None: + raise self.event_error + event = { + "id": uuid4(), + "repair_id": repair_id, + "sequence": sequence, + "event_type": event_type, + "created_at": datetime.now(timezone.utc), + "created_by": created_by, + "payload": dict(payload), + } + self.events.append(event) + return event + + def set_miner_lifecycle(self, miner_id, lifecycle_status): + self.calls.append("set_miner_lifecycle") + if self.lifecycle_error is not None: + raise self.lifecycle_error + self.miners[miner_id]["lifecycle_status"] = lifecycle_status + + +class RepairServiceTests(unittest.TestCase): + def setUp(self): + self.repository = FakeRepairRepository() + self.service = RepairService(self.repository) + self.actor_user_id = uuid4() + + def add_miner(self, lifecycle_status="active"): + miner_id = uuid4() + self.repository.miners[miner_id] = { + "id": miner_id, + "lifecycle_status": lifecycle_status, + } + return miner_id + + def test_begin_repair_creates_record_event_and_lifecycle_atomically(self): + miner_id = self.add_miner() + + result = self.service.begin_repair( + BeginRepairRequest( + miner_id=miner_id, + reason="CONTROL_BOARD", + ), + actor_user_id=self.actor_user_id, + ) + + self.assertEqual(result.miner_id, miner_id) + self.assertEqual(result.status, "OPEN") + self.assertEqual(result.reason, "CONTROL_BOARD") + self.assertEqual(result.opened_by, self.actor_user_id) + self.assertEqual(len(self.repository.repairs), 1) + self.assertEqual(len(self.repository.events), 1) + self.assertEqual( + self.repository.events[0]["event_type"], + "REPAIR_STARTED", + ) + self.assertEqual( + self.repository.events[0]["payload"], + { + "reason": "CONTROL_BOARD", + "from_lifecycle": "active", + "to_lifecycle": "in_repair", + }, + ) + self.assertEqual( + self.repository.miners[miner_id]["lifecycle_status"], + "in_repair", + ) + self.assertEqual( + self.repository.calls, + [ + "transaction", + "lock_miner", + "get_active_repair", + "create_repair", + "create_event:REPAIR_STARTED", + "set_miner_lifecycle", + ], + ) + + def test_begin_repair_normalizes_and_records_initial_note(self): + miner_id = self.add_miner("spare") + + self.service.begin_repair( + BeginRepairRequest( + miner_id=miner_id, + reason="INSPECTION", + notes=" Check fan harness. ", + ), + actor_user_id=self.actor_user_id, + ) + + self.assertEqual( + [event["event_type"] for event in self.repository.events], + ["REPAIR_STARTED", "INITIAL_NOTE"], + ) + self.assertEqual( + [event["sequence"] for event in self.repository.events], + [1, 2], + ) + self.assertEqual( + self.repository.events[0]["payload"], + { + "reason": "INSPECTION", + "from_lifecycle": "spare", + "to_lifecycle": "in_repair", + }, + ) + self.assertEqual( + self.repository.events[1]["payload"], + {"note": "Check fan harness."}, + ) + + def test_absent_and_blank_notes_create_no_note_event(self): + for note in (None, " \t\n "): + with self.subTest(note=note): + repository = FakeRepairRepository() + miner_id = uuid4() + repository.miners[miner_id] = { + "id": miner_id, + "lifecycle_status": "active", + } + RepairService(repository).begin_repair( + BeginRepairRequest( + miner_id=miner_id, + reason="OTHER", + notes=note, + ), + actor_user_id=self.actor_user_id, + ) + self.assertEqual(len(repository.events), 1) + + def test_unknown_miner_raises_not_found_without_writes(self): + with self.assertRaises(RepairMinerNotFound): + self.service.begin_repair( + BeginRepairRequest( + miner_id=uuid4(), + reason="OTHER", + ), + actor_user_id=self.actor_user_id, + ) + self.assertEqual(self.repository.repairs, []) + self.assertEqual(self.repository.events, []) + + def test_active_repair_conflict_prevents_partial_changes(self): + miner_id = self.add_miner() + self.repository.repairs.append( + repair_row(miner_id, self.actor_user_id) + ) + snapshot = deepcopy(self.repository.repairs) + + with self.assertRaises(ActiveRepairConflict): + self.service.begin_repair( + BeginRepairRequest( + miner_id=miner_id, + reason="PSU", + ), + actor_user_id=self.actor_user_id, + ) + + self.assertEqual(self.repository.repairs, snapshot) + self.assertEqual(self.repository.events, []) + self.assertEqual( + self.repository.miners[miner_id]["lifecycle_status"], + "active", + ) + + def test_in_repair_without_active_record_is_inconsistent(self): + miner_id = self.add_miner("in_repair") + with self.assertRaises(InconsistentRepairState): + self.service.begin_repair( + BeginRepairRequest( + miner_id=miner_id, + reason="OTHER", + ), + actor_user_id=self.actor_user_id, + ) + + def test_incompatible_lifecycle_is_rejected(self): + miner_id = self.add_miner("retired") + with self.assertRaises(IncompatibleMinerLifecycle): + self.service.begin_repair( + BeginRepairRequest( + miner_id=miner_id, + reason="OTHER", + ), + actor_user_id=self.actor_user_id, + ) + + def test_actor_is_required_before_transaction_starts(self): + miner_id = self.add_miner() + with self.assertRaises(RepairActorRequired): + self.service.begin_repair( + BeginRepairRequest( + miner_id=miner_id, + reason="OTHER", + ), + actor_user_id=None, + ) + self.assertEqual(self.repository.calls, []) + + def test_repository_domain_conflict_propagates(self): + miner_id = self.add_miner() + self.repository.create_error = ActiveRepairConflict(miner_id) + with self.assertRaises(ActiveRepairConflict): + self.service.begin_repair( + BeginRepairRequest( + miner_id=miner_id, + reason="OTHER", + ), + actor_user_id=self.actor_user_id, + ) + + def test_event_failure_rolls_back_every_change(self): + miner_id = self.add_miner() + self.repository.event_error = RuntimeError("event failed") + with self.assertRaisesRegex(RuntimeError, "event failed"): + self.service.begin_repair( + BeginRepairRequest( + miner_id=miner_id, + reason="OTHER", + ), + actor_user_id=self.actor_user_id, + ) + self.assertEqual(self.repository.repairs, []) + self.assertEqual(self.repository.events, []) + self.assertEqual( + self.repository.miners[miner_id]["lifecycle_status"], + "active", + ) + + def test_lifecycle_failure_rolls_back_repair_and_events(self): + miner_id = self.add_miner() + self.repository.lifecycle_error = RuntimeError("lifecycle failed") + with self.assertRaisesRegex(RuntimeError, "lifecycle failed"): + self.service.begin_repair( + BeginRepairRequest( + miner_id=miner_id, + reason="OTHER", + notes="Initial note", + ), + actor_user_id=self.actor_user_id, + ) + self.assertEqual(self.repository.repairs, []) + self.assertEqual(self.repository.events, []) + self.assertEqual( + self.repository.miners[miner_id]["lifecycle_status"], + "active", + ) + + +class RepairRepositoryIntegrationTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.repository = RepairRepository() + cls.actor_user_id = UUID( + "00000000-0000-0000-0000-000000001101" + ) + + def setUp(self): + with self.repository._connection_scope(write=True) as connection: + with connection.cursor() as cur: + cur.execute( + "DROP TRIGGER IF EXISTS test_repair_event_failure " + "ON repair_events" + ) + cur.execute( + "DROP TRIGGER IF EXISTS test_repair_lifecycle_failure " + "ON miners" + ) + cur.execute( + "DROP FUNCTION IF EXISTS test_fail_repair_event()" + ) + cur.execute( + "DROP FUNCTION IF EXISTS test_fail_repair_lifecycle()" + ) + cur.execute("DELETE FROM repair_events") + cur.execute("DELETE FROM repairs") + cur.execute("DELETE FROM miner_tags") + cur.execute("DELETE FROM miner_asset_id_history") + cur.execute("DELETE FROM miners") + cur.execute( + "DELETE FROM users " + "WHERE username = 'repair-domain-test'" + ) + cur.execute( + """ + INSERT INTO users ( + id, username, display_name, password_hash, role_id + ) + SELECT %s, %s, %s, %s, id + FROM roles + WHERE name = 'Admin' + """, + ( + self.actor_user_id, + "repair-domain-test", + "Repair Domain Test", + "not-used", + ), + ) + + def create_miner(self, lifecycle_status="active"): + with self.repository._connection_scope(write=True) as connection: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute( + """ + INSERT INTO miners ( + asset_tag, + status, + lifecycle_status, + first_seen_at, + last_seen_at + ) + VALUES (%s, 'unknown', %s, NULL, NULL) + RETURNING id + """, + (f"REPAIR-{uuid4()}", lifecycle_status), + ) + return cur.fetchone()["id"] + + def fetch_events(self, repair_id): + with self.repository._connection_scope() as connection: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute( + """ + SELECT * + FROM repair_events + WHERE repair_id = %s + ORDER BY sequence + """, + (repair_id,), + ) + return [dict(row) for row in cur.fetchall()] + + def count_repairs(self, miner_id): + with self.repository._connection_scope() as connection: + with connection.cursor() as cur: + cur.execute( + "SELECT COUNT(*) FROM repairs WHERE miner_id = %s", + (miner_id,), + ) + return cur.fetchone()[0] + + def lifecycle(self, miner_id): + with self.repository._connection_scope() as connection: + with connection.cursor() as cur: + cur.execute( + "SELECT lifecycle_status FROM miners WHERE id = %s", + (miner_id,), + ) + return cur.fetchone()[0] + + def test_begin_repair_persists_actor_reason_timestamp_and_started_event(self): + miner_id = self.create_miner() + before = datetime.now(timezone.utc) + + result = RepairService(self.repository).begin_repair( + BeginRepairRequest( + miner_id=miner_id, + reason="HASHBOARD", + ), + actor_user_id=self.actor_user_id, + ) + + self.assertGreaterEqual(result.opened_at, before) + self.assertEqual(result.opened_by, self.actor_user_id) + self.assertEqual(result.reason, "HASHBOARD") + self.assertEqual(result.status, "OPEN") + self.assertEqual(self.lifecycle(miner_id), "in_repair") + active = self.repository.get_active_repair(miner_id) + self.assertEqual(active["id"], result.repair_id) + events = self.fetch_events(result.repair_id) + self.assertEqual(len(events), 1) + self.assertEqual(events[0]["event_type"], "REPAIR_STARTED") + self.assertEqual(events[0]["sequence"], 1) + self.assertEqual(events[0]["created_by"], self.actor_user_id) + self.assertIsNotNone(events[0]["created_at"]) + self.assertEqual( + events[0]["payload"], + { + "reason": "HASHBOARD", + "from_lifecycle": "active", + "to_lifecycle": "in_repair", + }, + ) + + def test_initial_note_is_normalized_and_ordered_after_started_event(self): + miner_id = self.create_miner("spare") + result = RepairService(self.repository).begin_repair( + BeginRepairRequest( + miner_id=miner_id, + reason="INSPECTION", + notes=" Inspect harness. ", + ), + actor_user_id=self.actor_user_id, + ) + + events = self.fetch_events(result.repair_id) + self.assertEqual( + [(event["sequence"], event["event_type"]) for event in events], + [(1, "REPAIR_STARTED"), (2, "INITIAL_NOTE")], + ) + self.assertEqual( + events[0]["payload"], + { + "reason": "INSPECTION", + "from_lifecycle": "spare", + "to_lifecycle": "in_repair", + }, + ) + self.assertEqual(events[1]["payload"], {"note": "Inspect harness."}) + + def test_database_rejects_duplicate_active_repair(self): + miner_id = self.create_miner() + service = RepairService(self.repository) + service.begin_repair( + BeginRepairRequest(miner_id=miner_id, reason="FAN"), + actor_user_id=self.actor_user_id, + ) + + with self.assertRaises(ActiveRepairConflict): + self.repository.create_repair( + miner_id, + "PSU", + self.actor_user_id, + ) + self.assertEqual(self.count_repairs(miner_id), 1) + + def test_repairs_are_isolated_by_miner(self): + first_miner = self.create_miner() + second_miner = self.create_miner() + service = RepairService(self.repository) + + first = service.begin_repair( + BeginRepairRequest( + miner_id=first_miner, + reason="LOW_HASHRATE", + ), + actor_user_id=self.actor_user_id, + ) + second = service.begin_repair( + BeginRepairRequest( + miner_id=second_miner, + reason="NETWORKING", + ), + actor_user_id=self.actor_user_id, + ) + + self.assertNotEqual(first.repair_id, second.repair_id) + self.assertEqual( + self.repository.get_active_repair(first_miner)["id"], + first.repair_id, + ) + self.assertEqual( + self.repository.get_active_repair(second_miner)["id"], + second.repair_id, + ) + + def test_event_insertion_failure_rolls_back_repair_and_lifecycle(self): + miner_id = self.create_miner() + with self.repository._connection_scope(write=True) as connection: + with connection.cursor() as cur: + cur.execute( + """ + CREATE FUNCTION test_fail_repair_event() + RETURNS trigger AS $$ + BEGIN + RAISE EXCEPTION 'forced repair event failure'; + END; + $$ LANGUAGE plpgsql + """ + ) + cur.execute( + """ + CREATE TRIGGER test_repair_event_failure + BEFORE INSERT ON repair_events + FOR EACH ROW EXECUTE FUNCTION test_fail_repair_event() + """ + ) + + with self.assertRaises(RaiseException): + RepairService(self.repository).begin_repair( + BeginRepairRequest( + miner_id=miner_id, + reason="OTHER", + ), + actor_user_id=self.actor_user_id, + ) + + self.assertEqual(self.count_repairs(miner_id), 0) + self.assertEqual(self.lifecycle(miner_id), "active") + + def test_lifecycle_failure_rolls_back_repair_and_events(self): + miner_id = self.create_miner() + with self.repository._connection_scope(write=True) as connection: + with connection.cursor() as cur: + cur.execute( + """ + CREATE FUNCTION test_fail_repair_lifecycle() + RETURNS trigger AS $$ + BEGIN + IF NEW.lifecycle_status = 'in_repair' THEN + RAISE EXCEPTION 'forced lifecycle failure'; + END IF; + RETURN NEW; + END; + $$ LANGUAGE plpgsql + """ + ) + cur.execute( + """ + CREATE TRIGGER test_repair_lifecycle_failure + BEFORE UPDATE ON miners + FOR EACH ROW + EXECUTE FUNCTION test_fail_repair_lifecycle() + """ + ) + + with self.assertRaises(RaiseException): + RepairService(self.repository).begin_repair( + BeginRepairRequest( + miner_id=miner_id, + reason="OTHER", + notes="Must roll back.", + ), + actor_user_id=self.actor_user_id, + ) + + self.assertEqual(self.count_repairs(miner_id), 0) + self.assertEqual(self.lifecycle(miner_id), "active") + + def test_simultaneous_requests_create_only_one_active_repair(self): + miner_id = self.create_miner() + barrier = Barrier(2) + + def begin(reason): + barrier.wait() + return RepairService(RepairRepository()).begin_repair( + BeginRepairRequest(miner_id=miner_id, reason=reason), + actor_user_id=self.actor_user_id, + ) + + outcomes = [] + with ThreadPoolExecutor(max_workers=2) as executor: + futures = [ + executor.submit(begin, "PSU"), + executor.submit(begin, "FAN"), + ] + for future in futures: + try: + outcomes.append(future.result(timeout=10)) + except ActiveRepairConflict as exc: + outcomes.append(exc) + + self.assertEqual( + sum(not isinstance(item, Exception) for item in outcomes), + 1, + ) + self.assertEqual( + sum(isinstance(item, ActiveRepairConflict) for item in outcomes), + 1, + ) + self.assertEqual(self.count_repairs(miner_id), 1) + self.assertEqual(self.lifecycle(miner_id), "in_repair") + + def test_repairs_create_permission_matrix_and_idempotent_seed(self): + with self.repository._connection_scope(write=True) as connection: + with connection.cursor() as cur: + cur.execute( + """ + INSERT INTO permissions (key, description) + VALUES ( + 'repairs.create', + 'Begin repairs for miner assets' + ) + ON CONFLICT (key) DO UPDATE + SET description = EXCLUDED.description + """ + ) + cur.execute( + """ + INSERT INTO role_permissions (role_id, permission_id) + SELECT roles.id, permissions.id + FROM roles + JOIN permissions + ON permissions.key = 'repairs.create' + WHERE roles.name IN ( + 'Admin', 'Manager', 'Technician' + ) + ON CONFLICT DO NOTHING + """ + ) + cur.execute( + """ + SELECT roles.name + FROM role_permissions + JOIN roles ON roles.id = role_permissions.role_id + JOIN permissions + ON permissions.id = role_permissions.permission_id + WHERE permissions.key = 'repairs.create' + ORDER BY roles.name + """ + ) + role_names = [row[0] for row in cur.fetchall()] + + self.assertEqual( + role_names, + ["Admin", "Manager", "Technician"], + ) + + +class RepairArchitectureTests(unittest.TestCase): + def test_service_and_repository_have_no_fastapi_dependency(self): + repairs_path = Path(__file__).parents[1] / "app" / "repairs" + for filename in ("service.py", "repository.py"): + tree = ast.parse( + (repairs_path / filename).read_text(encoding="utf-8") + ) + imported_modules = [] + for node in ast.walk(tree): + if isinstance(node, ast.Import): + imported_modules.extend( + alias.name for alias in node.names + ) + elif isinstance(node, ast.ImportFrom) and node.module: + imported_modules.append(node.module) + self.assertFalse( + any( + module == "fastapi" + or module.startswith("fastapi.") + for module in imported_modules + ), + filename, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_repairs_router.py b/backend/tests/test_repairs_router.py new file mode 100644 index 0000000..bf4d121 --- /dev/null +++ b/backend/tests/test_repairs_router.py @@ -0,0 +1,277 @@ +import ast +import unittest +from datetime import datetime, timezone +from pathlib import Path +from uuid import UUID, uuid4 + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.identity import get_current_user +from app.repairs.dependencies import get_repair_service +from app.repairs.exceptions import ( + ActiveRepairConflict, + IncompatibleMinerLifecycle, + InconsistentRepairState, + RepairMinerNotFound, +) +from app.repairs.models import ( + BeginRepairRequest, + BeginRepairResult, +) +from app.repairs.router import router + + +class StubRepairService: + def __init__(self): + self.result = None + self.error = None + self.request = None + self.actor_user_id = None + + def begin_repair( + self, + request: BeginRepairRequest, + *, + actor_user_id: UUID, + ) -> BeginRepairResult: + self.request = request + self.actor_user_id = actor_user_id + if self.error is not None: + raise self.error + if self.result is None: + raise AssertionError("result was not configured") + return self.result + + +def repair_result( + miner_id=None, + actor_user_id=None, +) -> BeginRepairResult: + return BeginRepairResult( + repair_id=uuid4(), + miner_id=miner_id or uuid4(), + status="OPEN", + reason="CONTROL_BOARD", + opened_at=datetime(2026, 7, 29, 12, 0, tzinfo=timezone.utc), + opened_by=actor_user_id or uuid4(), + ) + + +class RepairRouterTests(unittest.TestCase): + def setUp(self): + self.service = StubRepairService() + self.app = FastAPI() + self.app.include_router(router) + self.app.dependency_overrides[get_repair_service] = ( + lambda: self.service + ) + self.client = TestClient(self.app) + self.actor_user_id = uuid4() + + def tearDown(self): + self.client.close() + + def authenticate(self, permissions=("repairs.create",)): + self.app.dependency_overrides[get_current_user] = lambda: { + "id": str(self.actor_user_id), + "username": "technician", + "role": "Technician", + "permissions": list(permissions), + } + + def test_begin_repair_requires_authentication(self): + response = self.client.post( + "/api/repairs", + json={ + "miner_id": str(uuid4()), + "reason": "CONTROL_BOARD", + }, + ) + self.assertEqual(response.status_code, 401) + self.assertEqual(response.json()["detail"], "Login required") + + def test_begin_repair_requires_repairs_create(self): + self.authenticate(permissions=("repairs.view", "repairs.manage")) + response = self.client.post( + "/api/repairs", + json={ + "miner_id": str(uuid4()), + "reason": "CONTROL_BOARD", + }, + ) + self.assertEqual(response.status_code, 403) + self.assertIsNone(self.service.request) + + def test_malformed_miner_uuid_returns_validation_error(self): + self.authenticate() + response = self.client.post( + "/api/repairs", + json={ + "miner_id": "not-a-uuid", + "reason": "CONTROL_BOARD", + }, + ) + self.assertEqual(response.status_code, 422) + self.assertIsNone(self.service.request) + + def test_invalid_reason_returns_validation_error(self): + self.authenticate() + response = self.client.post( + "/api/repairs", + json={ + "miner_id": str(uuid4()), + "reason": "FREE_FORM_REASON", + }, + ) + self.assertEqual(response.status_code, 422) + self.assertIsNone(self.service.request) + + def test_unknown_miner_returns_not_found(self): + self.authenticate() + miner_id = uuid4() + self.service.error = RepairMinerNotFound(miner_id) + response = self.client.post( + "/api/repairs", + json={ + "miner_id": str(miner_id), + "reason": "OTHER", + }, + ) + self.assertEqual(response.status_code, 404) + self.assertEqual(response.json()["detail"], "Miner not found") + + def test_active_repair_returns_conflict(self): + self.authenticate() + miner_id = uuid4() + self.service.error = ActiveRepairConflict(miner_id) + response = self.client.post( + "/api/repairs", + json={ + "miner_id": str(miner_id), + "reason": "PSU", + }, + ) + self.assertEqual(response.status_code, 409) + self.assertIn("active repair", response.json()["detail"]) + + def test_lifecycle_conflicts_return_conflict(self): + self.authenticate() + miner_id = uuid4() + for error in ( + IncompatibleMinerLifecycle(miner_id, "retired"), + InconsistentRepairState(miner_id), + ): + with self.subTest(error=error): + self.service.error = error + response = self.client.post( + "/api/repairs", + json={ + "miner_id": str(miner_id), + "reason": "OTHER", + }, + ) + self.assertEqual(response.status_code, 409) + + def test_success_returns_typed_envelope_and_authenticated_actor(self): + self.authenticate() + miner_id = uuid4() + expected = repair_result(miner_id, self.actor_user_id) + self.service.result = expected + + response = self.client.post( + "/api/repairs", + json={ + "miner_id": str(miner_id), + "reason": "CONTROL_BOARD", + "notes": "Miner will not boot.", + }, + ) + + self.assertEqual(response.status_code, 201) + body = response.json() + self.assertEqual(body["data"]["repair_id"], str(expected.repair_id)) + self.assertEqual(body["data"]["miner_id"], str(miner_id)) + self.assertEqual(body["data"]["status"], "OPEN") + self.assertEqual(body["data"]["reason"], "CONTROL_BOARD") + self.assertEqual(body["data"]["opened_by"], str(self.actor_user_id)) + self.assertEqual( + body["data"]["opened_at"], + "2026-07-29T12:00:00Z", + ) + self.assertEqual(body["meta"], {}) + self.assertIsNone(body["error"]) + self.assertEqual(self.service.actor_user_id, self.actor_user_id) + self.assertEqual( + self.service.request.notes, + "Miner will not boot.", + ) + + def test_client_cannot_override_server_fields_or_actor(self): + self.authenticate() + base = { + "miner_id": str(uuid4()), + "reason": "OTHER", + } + forbidden = { + "repair_id": str(uuid4()), + "status": "COMPLETED", + "opened_at": "2026-01-01T00:00:00Z", + "opened_by": str(uuid4()), + "event_id": str(uuid4()), + "event_timestamp": "2026-01-01T00:00:00Z", + "event_actor": str(uuid4()), + "lifecycle_status": "retired", + } + for field, value in forbidden.items(): + with self.subTest(field=field): + response = self.client.post( + "/api/repairs", + json={**base, field: value}, + ) + self.assertEqual(response.status_code, 422) + self.assertIsNone(self.service.request) + + def test_router_registers_exactly_one_post_route(self): + routes = [ + (route.path, route.methods) + for route in router.routes + if hasattr(route, "methods") + ] + self.assertEqual(routes, [("/api/repairs", {"POST"})]) + + def test_router_has_no_sql_driver_or_transaction_management(self): + router_path = ( + Path(__file__).parents[1] / "app" / "repairs" / "router.py" + ) + source = router_path.read_text(encoding="utf-8") + tree = ast.parse(source) + imported_modules = [] + for node in ast.walk(tree): + if isinstance(node, ast.Import): + imported_modules.extend(alias.name for alias in node.names) + elif isinstance(node, ast.ImportFrom) and node.module: + imported_modules.append(node.module) + + database_modules = ("psycopg", "asyncpg", "sqlalchemy") + self.assertFalse( + any( + module == name or module.startswith(f"{name}.") + for module in imported_modules + for name in database_modules + ) + ) + upper_source = source.upper() + for keyword in ( + "SELECT ", + "INSERT ", + "UPDATE ", + "DELETE ", + ".COMMIT(", + ".ROLLBACK(", + ): + self.assertNotIn(keyword, upper_source) + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 70d5eb6..783db9e 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -92,13 +92,17 @@ location changes. ### Phase 3: Operations -**Status: Planned direction.** +**Status: In progress.** This phase develops day-to-day technician and operations workflows around the durable asset record. Repairs, maintenance, work orders, parts usage, equipment movement, testing, and return-to-service history become coherent operational records rather than disconnected actions. +The Begin Repair workflow is complete. An authorized actor can open a repair +against a durable miner UUID, record sequenced starting events, and move the +miner into repair atomically. Later repair lifecycle workflows remain planned. + Technician experience is a priority. Responsive and QR-assisted workflows may reduce lookup and data-entry effort where they reinforce the same asset record. Existing repair and location behavior should move incrementally into clear @@ -209,6 +213,7 @@ Major completed foundations include: - Authentication, login gating, and role foundation - Manual Inventory schema and backend domain foundation - Focused Inventory and migration test foundations +- Begin Repair operational workflow - MinerOps branding package and project README - Architecture guide - Product guide diff --git a/docs/tasks/001-begin-repair.md b/docs/tasks/001-begin-repair.md new file mode 100644 index 0000000..652afa5 --- /dev/null +++ b/docs/tasks/001-begin-repair.md @@ -0,0 +1,544 @@ +# Task 001: Begin Repair + +## Status + +Ready for implementation + +## Phase + +Phase 3: Operations + +## Goal + +Implement the first explicit operational workflow in MinerOps: beginning a repair for a miner. + +This workflow creates permanent repair history. It is not generic CRUD and must not be implemented as a generic miner update. + +## Endpoint + +```http +POST /api/repairs +``` + +## Permission + +Introduce and require: + +```text +repairs.create +``` + +Do not reuse `miners.update`. + +Register the permission using the repository's existing permission and role-seeding conventions. + +At minimum, administrators must retain access. Do not grant this permission to viewer or auditor roles. + +## Request + +Use a typed request model equivalent to: + +```json +{ + "miner_id": "UUID", + "reason": "CONTROL_BOARD", + "notes": "Miner not booting after power outage." +} +``` + +### Fields + +#### `miner_id` + +- Required +- Durable internal miner UUID +- Must reference an existing miner + +#### `reason` + +- Required +- Must use the constrained repair-reason values below + +#### `notes` + +- Optional +- Trim surrounding whitespace using existing normalization conventions +- Treat whitespace-only notes as absent unless established project validation conventions require rejection +- Store the initial note as a repair timeline event, not as a mutable column on the repair record + +Do not accept client-provided values for: + +- repair ID +- repair status +- opened or started timestamp +- opened or started actor +- miner lifecycle +- event IDs +- event timestamps +- event actors + +## Repair Reasons + +Support exactly these initial values: + +- `HARDWARE_FAILURE` +- `LOW_HASHRATE` +- `CONTROL_BOARD` +- `HASHBOARD` +- `PSU` +- `FAN` +- `OVERHEATING` +- `NETWORKING` +- `PREVENTIVE_MAINTENANCE` +- `INSPECTION` +- `OTHER` + +Use the project's existing enum or check-constraint conventions. Do not substitute a free-form reason. + +## Repair Statuses + +The schema must anticipate: + +- `OPEN` +- `IN_PROGRESS` +- `WAITING_FOR_PARTS` +- `READY_FOR_TEST` +- `COMPLETED` +- `CANCELLED` + +This endpoint creates repairs only with status: + +```text +OPEN +``` + +Active repair statuses are: + +- `OPEN` +- `IN_PROGRESS` +- `WAITING_FOR_PARTS` +- `READY_FOR_TEST` + +## Workflow + +Beginning a repair must atomically: + +1. Confirm the miner exists. +2. Lock the miner row. +3. Confirm the miner is eligible to enter repair. +4. Confirm the miner does not already have an active repair. +5. Create the repair record. +6. Create a `REPAIR_STARTED` timeline event. +7. Create an `INITIAL_NOTE` timeline event when a non-empty note was supplied. +8. Change the miner lifecycle to `IN_REPAIR`. +9. Commit all changes together. + +If any step fails, none of the changes may persist. + +## Business Rules + +### One active repair + +A miner may have no more than one active repair. + +This rule must be protected by the database against concurrent requests. Do not rely only on a service-level pre-check. + +A likely implementation is a PostgreSQL partial unique index on `miner_id` for active statuses, but inspect existing migration and database conventions before choosing the mechanism. + +Duplicate active-repair attempts return: + +```text +409 Conflict +``` + +### Miner lifecycle + +Beginning a repair moves the miner lifecycle to: + +```text +IN_REPAIR +``` + +Inspect the current lifecycle values and transition conventions before implementing. + +Reject incompatible or terminal lifecycle states with a domain-specific conflict mapped to: + +```text +409 Conflict +``` + +If a miner is already `IN_REPAIR` but has no active repair, treat that as inconsistent state. Reject it rather than silently creating ambiguous history. + +Do not add another repair-status field to the miner. + +### Actor attribution + +The authenticated user's UUID is mandatory. + +Pass the actor UUID explicitly from the router to the service. + +Do not: + +- derive the actor from request data +- permit actorless repair creation +- allow the client to override the actor + +### Timestamps + +All timestamps must be generated by the server or database. + +Do not accept client timestamps. + +Initial events must have deterministic ordering. If timestamps can match, use a stable secondary order consistent with repository conventions. + +## Database + +Create one Alembic migration after the current head. + +Use Alembic only. Do not use runtime table creation or ORM auto-create behaviour. + +### `repairs` + +Required logical fields: + +- `id`: UUID primary key +- `miner_id`: foreign key to miners +- `status` +- `reason` +- `opened_at` or `started_at` +- `opened_by` or `started_by` +- `completed_at`: nullable +- `completed_by`: nullable + +Inspect existing naming conventions and choose either `opened_*` or `started_*`. Use the chosen terminology consistently across schema, models, service, API, and tests. + +Add appropriate: + +- foreign keys +- actor references +- indexes +- status constraints +- reason constraints +- server-generated timestamps +- database-backed active-repair uniqueness + +### `repair_events` + +Required logical fields: + +- `id`: UUID primary key +- `repair_id`: foreign key to repairs +- `event_type` +- `created_at` +- `created_by` +- `payload` + +Initial supported event types: + +- `REPAIR_STARTED` +- `INITIAL_NOTE` + +#### `REPAIR_STARTED` payload + +Preserve relevant starting context, including at least: + +- repair reason +- miner lifecycle transition where practical + +#### `INITIAL_NOTE` payload + +Preserve the normalized submitted note. + +Use JSON only if it fits existing repository conventions or is clearly the best design. Do not store unvalidated, inconsistent payloads merely for convenience. + +## Architecture + +Preserve: + +```text +Router + ↓ +RepairService + ↓ +RepairRepository + ↓ +PostgreSQL +``` + +The service may coordinate inventory persistence through an existing repository abstraction or a clean shared transaction boundary. + +Do not bypass repository boundaries merely to simplify the transaction. + +### Router responsibilities + +- authentication +- authorization +- request validation +- HTTP status mapping +- response envelope + +The router must contain: + +- no SQL +- no database-driver imports +- no transaction management +- no business rules + +### Service responsibilities + +- workflow orchestration +- business rules +- transaction coordination +- domain exception propagation or translation +- no FastAPI imports + +### Repository responsibilities + +- persistence +- row locking +- parameterized SQL +- no FastAPI imports + +Individual repository operations must not commit in a way that breaks atomic workflow orchestration. + +## Response + +Return: + +```text +201 Created +``` + +Reuse the existing typed response envelope. + +The response must contain at least: + +- `repair_id` +- `miner_id` +- `status` +- `reason` +- `opened_at` or `started_at` +- `opened_by` or `started_by` + +Use terminology consistent with the schema. + +Do not include the full repair timeline in this response. + +## Error Behaviour + +Use existing response-envelope and error conventions. + +### `401 Unauthorized` + +- user is not authenticated + +### `403 Forbidden` + +- user lacks `repairs.create` + +### `404 Not Found` + +- miner does not exist + +### `409 Conflict` + +- miner already has an active repair +- miner lifecycle is incompatible with beginning repair +- miner is in an inconsistent repair state + +### `422 Unprocessable Entity` + +- malformed UUID +- invalid reason +- invalid request body + +Do not expose raw database errors. + +## Out of Scope + +Do not implement: + +- repair retrieval +- repair listing +- repair editing +- repair deletion +- repair cancellation +- repair completion +- return to service +- adding later notes +- diagnostics +- parts +- photos +- attachments +- uploads +- testing workflows +- control-board replacement +- notifications +- generic repair PATCH endpoints +- alert pausing unless an existing clean abstraction makes it unavoidable + +Exactly one new API route is permitted in this task. + +## Tests + +Follow existing test conventions. + +### Migration tests + +Cover: + +- upgrade from previous head +- downgrade +- re-upgrade +- migration at head +- empty-database migration +- table constraints +- foreign keys +- indexes +- active-repair uniqueness +- supported status values +- supported reason values +- supported event types +- nullable completion fields +- actor requirements + +### Repository tests + +Cover: + +- repair creation +- `REPAIR_STARTED` event creation +- optional `INITIAL_NOTE` event creation +- no note event for absent or normalized-empty notes +- miner lifecycle transition +- actor persistence +- reason persistence +- timestamp persistence +- active-repair detection +- database uniqueness under duplicate active-repair attempts +- miner isolation +- rollback when event insertion fails +- rollback when lifecycle update fails +- incompatible lifecycle rejection +- concurrency or row-lock protection where practical + +### Service tests + +Cover: + +- successful Begin Repair +- successful Begin Repair with an initial note +- unknown miner +- active-repair conflict +- incompatible lifecycle conflict +- actor required +- orchestration behaviour +- domain exception propagation +- no partial persistence when orchestration fails + +### Router tests + +Cover: + +- authentication required +- `repairs.create` required +- malformed miner UUID +- invalid reason +- unknown miner +- active-repair conflict +- lifecycle conflict +- successful creation returns `201` +- response envelope +- typed response fields +- actor comes from authenticated principal +- client cannot override actor +- client cannot override status or timestamps +- router contains no SQL +- router contains no database-driver imports + +### Permission tests + +Cover: + +- `repairs.create` registration +- administrator access +- unauthorized roles are not granted access accidentally +- idempotent permission seeding + +## Documentation + +After implementation and validation pass: + +- Update `docs/ROADMAP.md` to mark Begin Repair complete. +- Update architecture or decision documentation only when the implementation introduces a genuinely new architectural decision. + +If a new ADR is warranted, explain concisely: + +- why repair is an operational entity +- why active-repair uniqueness is database-enforced +- why lifecycle transition and timeline creation are atomic + +Avoid documentation churn for principles already documented. + +## Validation + +Run: + +- Python compilation +- shell syntax validation +- migration upgrade from previous head +- migration downgrade +- migration re-upgrade +- migration head verification +- empty-database migration +- focused repair suite +- focused inventory regression suite +- permission and role tests +- duplicate active-repair or concurrency tests +- full disposable development baseline +- router SQL and database-driver audit +- service and repository FastAPI import audit +- route-count audit +- `git diff --check` +- `git status --short` + +## Final Review Checklist + +Confirm: + +- exactly one endpoint was added +- no generic repair CRUD was added +- active-repair uniqueness is enforced by the database +- transaction boundaries prevent partial persistence +- authenticated actor UUID is explicit and mandatory +- timestamps are server-controlled +- initial notes are timeline events +- no unrelated changes are present + +## Implementation Report + +Report: + +- implementation summary +- files created +- files modified +- migration revision and parent revision +- tables and constraints +- endpoint +- permission +- request model +- response model +- repair reasons +- repair statuses +- event types +- lifecycle behaviour +- actor attribution +- transaction design +- concurrency protection +- tests added +- validation results +- git diff summary +- `git status --short` + +Do not commit. +Do not push. diff --git a/scripts/test-begin-repair-migration.sh b/scripts/test-begin-repair-migration.sh new file mode 100755 index 0000000..a43dc3e --- /dev/null +++ b/scripts/test-begin-repair-migration.sh @@ -0,0 +1,556 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "${repo_root}" + +project_name="${MINEROPS_REPAIR_MIGRATION_PROJECT:-minerops-repair-migration-$$}" +empty_database="minerops_repair_empty" +compose=( + docker compose + --project-name "${project_name}" + --env-file .env.dev + -f compose.dev.yaml +) + +cleanup() { + status=$? + if (( status != 0 )); then + echo + echo "Begin Repair migration test failed. Recent PostgreSQL logs:" + "${compose[@]}" logs --no-color --tail 120 postgres || true + fi + "${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true + exit "${status}" +} + +trap cleanup EXIT + +if [[ ! -f .env.dev ]]; then + echo "Missing .env.dev. Run ./scripts/dev-env-init.sh first." >&2 + exit 1 +fi + +run_alembic() { + "${compose[@]}" run --rm --no-deps backend alembic "$@" +} + +echo "Starting disposable PostgreSQL..." +"${compose[@]}" up --detach postgres +for attempt in $(seq 1 60); do + if "${compose[@]}" exec -T postgres sh -ec \ + 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \ + >/dev/null 2>&1; then + break + fi + if (( attempt == 60 )); then + echo "PostgreSQL did not become ready." >&2 + exit 1 + fi + sleep 1 +done + +echo "Building migration image..." +"${compose[@]}" build backend + +echo "Upgrading the previous head to Begin Repair..." +run_alembic upgrade 0009_inventory_asset_id_history +previous_revision="$(run_alembic current 2>/dev/null)" +[[ "${previous_revision}" == *"0009_inventory_asset_id_history"* ]] +run_alembic upgrade head +current_revision="$(run_alembic current 2>/dev/null)" +[[ "${current_revision}" == *"0010_begin_repair"* ]] + +echo "Checking repair schema, constraints, permissions, and lifecycle..." +"${compose[@]}" exec -T postgres sh -ec \ + 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \ + <<'SQL' +DO $$ +DECLARE + v_actor UUID := '00000000-0000-0000-0000-000000001001'; + v_miner UUID := '00000000-0000-0000-0000-000000001002'; + v_other_miner UUID := '00000000-0000-0000-0000-000000001003'; + v_invalid_miner UUID := '00000000-0000-0000-0000-000000001004'; + v_repair UUID; + v_other_repair UUID; + v_role_id UUID; + v_count INTEGER; +BEGIN + IF to_regclass('public.repairs') IS NULL + OR to_regclass('public.repair_events') IS NULL THEN + RAISE EXCEPTION 'Repair workflow tables were not created'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'repairs' + AND column_name IN ( + 'id', 'miner_id', 'status', 'reason', 'opened_at', 'opened_by' + ) + AND is_nullable <> 'NO' + ) OR EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'repairs' + AND column_name IN ('completed_at', 'completed_by') + AND is_nullable <> 'YES' + ) THEN + RAISE EXCEPTION 'Repair column nullability is incorrect'; + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM pg_indexes + WHERE schemaname = 'public' + AND tablename = 'repairs' + AND indexname = 'uq_repairs_one_active_per_miner' + AND indexdef LIKE '%UNIQUE%' + AND indexdef LIKE '%WHERE (status = ANY%' + ) THEN + RAISE EXCEPTION 'Active repair partial unique index is missing'; + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM pg_indexes + WHERE schemaname = 'public' + AND tablename = 'repair_events' + AND indexname = 'idx_repair_events_repair_created' + AND indexdef LIKE '%repair_id, created_at, sequence, id%' + ) THEN + RAISE EXCEPTION 'Repair event chronological index is missing'; + END IF; + + IF (SELECT COUNT(*) FROM permissions WHERE key = 'repairs.create') <> 1 THEN + RAISE EXCEPTION 'repairs.create permission was not registered once'; + END IF; + + SELECT COUNT(*) + INTO v_count + FROM role_permissions + JOIN roles ON roles.id = role_permissions.role_id + JOIN permissions ON permissions.id = role_permissions.permission_id + WHERE permissions.key = 'repairs.create' + AND roles.name IN ('Admin', 'Manager', 'Technician'); + IF v_count <> 3 THEN + RAISE EXCEPTION 'Expected Admin, Manager, and Technician repair access'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM role_permissions + JOIN roles ON roles.id = role_permissions.role_id + JOIN permissions ON permissions.id = role_permissions.permission_id + WHERE permissions.key = 'repairs.create' + AND roles.name IN ('Read Only', 'Viewer', 'Auditor') + ) THEN + RAISE EXCEPTION 'A read-only role received repairs.create'; + END IF; + + INSERT INTO permissions (key, description) + VALUES ('repairs.create', 'Begin repairs for miner assets') + ON CONFLICT (key) DO UPDATE + SET description = EXCLUDED.description; + INSERT INTO role_permissions (role_id, permission_id) + SELECT roles.id, permissions.id + FROM roles + JOIN permissions ON permissions.key = 'repairs.create' + WHERE roles.name IN ('Admin', 'Manager', 'Technician') + ON CONFLICT DO NOTHING; + IF (SELECT COUNT(*) FROM permissions WHERE key = 'repairs.create') <> 1 THEN + RAISE EXCEPTION 'Permission seeding is not idempotent'; + END IF; + + SELECT id INTO v_role_id FROM roles WHERE name = 'Admin'; + INSERT INTO users ( + id, username, display_name, password_hash, role_id + ) VALUES ( + v_actor, + 'begin-repair-migration-test', + 'Begin Repair Migration Test', + 'not-used', + v_role_id + ); + INSERT INTO miners (id, asset_tag, status, lifecycle_status) + VALUES + (v_miner, 'REPAIR-MIGRATION-001', 'unknown', 'active'), + (v_other_miner, 'REPAIR-MIGRATION-002', 'unknown', 'spare'), + (v_invalid_miner, 'REPAIR-MIGRATION-003', 'unknown', 'active'); + + INSERT INTO repairs (miner_id, reason, opened_by) + VALUES (v_miner, 'CONTROL_BOARD', v_actor) + RETURNING id INTO v_repair; + INSERT INTO repair_events ( + repair_id, sequence, event_type, created_by, payload + ) VALUES + ( + v_repair, + 1, + 'REPAIR_STARTED', + v_actor, + '{"reason":"CONTROL_BOARD","from_lifecycle":"active","to_lifecycle":"in_repair"}' + ), + ( + v_repair, + 2, + 'INITIAL_NOTE', + v_actor, + '{"note":"Migration test note."}' + ); + INSERT INTO repairs (miner_id, reason, opened_by) + VALUES (v_other_miner, 'FAN', v_actor) + RETURNING id INTO v_other_repair; + INSERT INTO repair_events ( + repair_id, sequence, event_type, created_by, payload + ) VALUES ( + v_other_repair, + 1, + 'REPAIR_STARTED', + v_actor, + '{"reason":"FAN","from_lifecycle":"spare","to_lifecycle":"in_repair"}' + ); + UPDATE miners + SET lifecycle_status = 'in_repair' + WHERE id IN (v_miner, v_other_miner); + + IF EXISTS ( + SELECT 1 + FROM repairs + WHERE id = v_repair + AND ( + status <> 'OPEN' + OR completed_at IS NOT NULL + OR completed_by IS NOT NULL + OR opened_at IS NULL + ) + ) THEN + RAISE EXCEPTION 'New repair defaults or completion fields are invalid'; + END IF; + IF ( + SELECT array_agg(event_type ORDER BY sequence) + FROM repair_events + WHERE repair_id = v_repair + ) <> ARRAY['REPAIR_STARTED', 'INITIAL_NOTE'] THEN + RAISE EXCEPTION 'Initial event ordering is incorrect'; + END IF; + IF ( + SELECT payload ->> 'from_lifecycle' + FROM repair_events + WHERE repair_id = v_other_repair + AND event_type = 'REPAIR_STARTED' + ) <> 'spare' THEN + RAISE EXCEPTION 'Spare lifecycle history was not preserved'; + END IF; + + BEGIN + INSERT INTO repairs (miner_id, reason, opened_by) + VALUES (v_miner, 'PSU', v_actor); + RAISE EXCEPTION 'A duplicate active repair was accepted'; + EXCEPTION WHEN unique_violation THEN + NULL; + END; + + + BEGIN + INSERT INTO repairs (miner_id, status, reason, opened_by) + VALUES (v_invalid_miner, 'BROKEN', 'OTHER', v_actor); + RAISE EXCEPTION 'Unsupported repair status was accepted'; + EXCEPTION WHEN check_violation THEN + NULL; + END; + + BEGIN + INSERT INTO repairs (miner_id, reason, opened_by) + VALUES (v_invalid_miner, 'NOT_A_REASON', v_actor); + RAISE EXCEPTION 'Unsupported repair reason was accepted'; + EXCEPTION WHEN check_violation THEN + NULL; + END; + + BEGIN + INSERT INTO repair_events ( + repair_id, sequence, event_type, created_by, payload + ) VALUES ( + v_repair, 3, 'UNKNOWN_EVENT', v_actor, '{}'::jsonb + ); + RAISE EXCEPTION 'Unsupported repair event type was accepted'; + EXCEPTION WHEN check_violation THEN + NULL; + END; + + BEGIN + INSERT INTO repair_events ( + repair_id, sequence, event_type, created_by, payload + ) VALUES ( + v_repair, 3, 'INITIAL_NOTE', v_actor, '{"note":" "}'::jsonb + ); + RAISE EXCEPTION 'Blank initial note payload was accepted'; + EXCEPTION WHEN check_violation THEN + NULL; + END; + + BEGIN + INSERT INTO repair_events ( + repair_id, sequence, event_type, created_by, payload + ) VALUES ( + v_repair, 2, 'INITIAL_NOTE', v_actor, '{"note":"duplicate"}'::jsonb + ); + RAISE EXCEPTION 'Duplicate event sequence was accepted'; + EXCEPTION WHEN unique_violation THEN + NULL; + END; + + BEGIN + INSERT INTO repairs (miner_id, reason, opened_by) + VALUES (gen_random_uuid(), 'OTHER', v_actor); + RAISE EXCEPTION 'Unknown miner repair was accepted'; + EXCEPTION WHEN foreign_key_violation THEN + NULL; + END; + + BEGIN + INSERT INTO repairs (miner_id, reason, opened_by) + VALUES (v_invalid_miner, 'OTHER', gen_random_uuid()); + RAISE EXCEPTION 'Repair with an unknown actor was accepted'; + EXCEPTION WHEN foreign_key_violation THEN + NULL; + END; + + BEGIN + INSERT INTO repairs (miner_id, reason, opened_by) + VALUES (v_invalid_miner, 'OTHER', NULL); + RAISE EXCEPTION 'Repair without an actor was accepted'; + EXCEPTION WHEN not_null_violation THEN + NULL; + END; + + BEGIN + INSERT INTO repair_events ( + repair_id, sequence, event_type, created_by, payload + ) VALUES ( + v_repair, + 3, + 'INITIAL_NOTE', + NULL, + '{"note":"missing actor"}'::jsonb + ); + RAISE EXCEPTION 'Repair event without an actor was accepted'; + EXCEPTION WHEN not_null_violation THEN + NULL; + END; + + BEGIN + INSERT INTO repair_events ( + repair_id, sequence, event_type, created_by, payload + ) VALUES ( + gen_random_uuid(), + 1, + 'INITIAL_NOTE', + v_actor, + '{"note":"unknown repair"}'::jsonb + ); + RAISE EXCEPTION 'Event for an unknown repair was accepted'; + EXCEPTION WHEN foreign_key_violation THEN + NULL; + END; + + BEGIN + INSERT INTO repair_events ( + repair_id, sequence, event_type, created_by, payload + ) VALUES ( + v_repair, + 3, + 'INITIAL_NOTE', + gen_random_uuid(), + '{"note":"unknown actor"}'::jsonb + ); + RAISE EXCEPTION 'Repair event with an unknown actor was accepted'; + EXCEPTION WHEN foreign_key_violation THEN + NULL; + END; + + BEGIN + UPDATE miners SET lifecycle_status = 'unknown' WHERE id = v_miner; + RAISE EXCEPTION 'Unsupported miner lifecycle was accepted'; + EXCEPTION WHEN check_violation THEN + NULL; + END; +END +$$; + +INSERT INTO miners (id, asset_tag, status, lifecycle_status) +SELECT + gen_random_uuid(), + 'REPAIR-STATUS-' || status, + 'unknown', + 'active' +FROM ( + VALUES + ('OPEN'), + ('IN_PROGRESS'), + ('WAITING_FOR_PARTS'), + ('READY_FOR_TEST'), + ('COMPLETED'), + ('CANCELLED') +) AS supported(status); + +INSERT INTO repairs ( + miner_id, + status, + reason, + opened_by, + completed_at, + completed_by +) +SELECT + miners.id, + supported.status, + 'OTHER', + actor.id, + CASE WHEN supported.status = 'COMPLETED' THEN now() END, + CASE WHEN supported.status = 'COMPLETED' THEN actor.id END +FROM ( + VALUES + ('OPEN'), + ('IN_PROGRESS'), + ('WAITING_FOR_PARTS'), + ('READY_FOR_TEST'), + ('COMPLETED'), + ('CANCELLED') +) AS supported(status) +JOIN miners ON miners.asset_tag = 'REPAIR-STATUS-' || supported.status +CROSS JOIN ( + SELECT id FROM users WHERE username = 'begin-repair-migration-test' +) actor; + +INSERT INTO miners (id, asset_tag, status, lifecycle_status) +SELECT + gen_random_uuid(), + 'REPAIR-REASON-' || reason, + 'unknown', + 'active' +FROM ( + VALUES + ('HARDWARE_FAILURE'), + ('LOW_HASHRATE'), + ('CONTROL_BOARD'), + ('HASHBOARD'), + ('PSU'), + ('FAN'), + ('OVERHEATING'), + ('NETWORKING'), + ('PREVENTIVE_MAINTENANCE'), + ('INSPECTION'), + ('OTHER') +) AS supported(reason); + +INSERT INTO repairs ( + miner_id, + status, + reason, + opened_by, + completed_at, + completed_by +) +SELECT + miners.id, + 'COMPLETED', + supported.reason, + actor.id, + now(), + actor.id +FROM ( + VALUES + ('HARDWARE_FAILURE'), + ('LOW_HASHRATE'), + ('CONTROL_BOARD'), + ('HASHBOARD'), + ('PSU'), + ('FAN'), + ('OVERHEATING'), + ('NETWORKING'), + ('PREVENTIVE_MAINTENANCE'), + ('INSPECTION'), + ('OTHER') +) AS supported(reason) +JOIN miners ON miners.asset_tag = 'REPAIR-REASON-' || supported.reason +CROSS JOIN ( + SELECT id FROM users WHERE username = 'begin-repair-migration-test' +) actor; +SQL + + +echo "Checking downgrade refuses missing lifecycle history..." +"${compose[@]}" exec -T postgres sh -ec \ + 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL' +INSERT INTO miners (id, asset_tag, status, lifecycle_status) +VALUES ( + '00000000-0000-0000-0000-000000001999', + 'REPAIR-MIGRATION-INCONSISTENT', + 'unknown', + 'in_repair' +); +SQL +if downgrade_error="$(run_alembic downgrade 0009_inventory_asset_id_history 2>&1)"; then + echo "Downgrade accepted missing lifecycle history." >&2 + exit 1 +fi +[[ "${downgrade_error}" == *"do not have exactly one active REPAIR_STARTED event"* ]] +failed_revision="$(run_alembic current 2>/dev/null)" +[[ "${failed_revision}" == *"0010_begin_repair"* ]] +"${compose[@]}" exec -T postgres sh -ec \ + 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL' +DELETE FROM miners +WHERE id = '00000000-0000-0000-0000-000000001999'; +SQL +echo "Downgrading and re-upgrading Begin Repair..." +run_alembic downgrade 0009_inventory_asset_id_history +downgrade_state="$( + "${compose[@]}" exec -T postgres sh -ec \ + 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" -At' \ + <<'SQL' +SELECT + to_regclass('public.repairs') IS NULL + AND to_regclass('public.repair_events') IS NULL + AND NOT EXISTS ( + SELECT 1 FROM permissions WHERE key = 'repairs.create' + ) + AND NOT EXISTS ( + SELECT 1 FROM miners WHERE lifecycle_status = 'in_repair' + ) + AND ( + SELECT lifecycle_status + FROM miners + WHERE asset_tag = 'REPAIR-MIGRATION-001' + ) = 'active' + AND ( + SELECT lifecycle_status + FROM miners + WHERE asset_tag = 'REPAIR-MIGRATION-002' + ) = 'spare'; +SQL +)" +[[ "${downgrade_state}" == "t" ]] +run_alembic upgrade head +reupgraded_revision="$(run_alembic current 2>/dev/null)" +[[ "${reupgraded_revision}" == *"0010_begin_repair"* ]] + +echo "Migrating an empty database from base through head..." +"${compose[@]}" exec -T postgres sh -ec \ + 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d postgres \ + -c "CREATE DATABASE minerops_repair_empty"' >/dev/null +"${compose[@]}" run --rm --no-deps \ + -e "POSTGRES_DB=${empty_database}" \ + backend alembic upgrade head + +empty_revision="$( + "${compose[@]}" run --rm --no-deps \ + -e "POSTGRES_DB=${empty_database}" \ + backend alembic current 2>/dev/null +)" +[[ "${empty_revision}" == *"0010_begin_repair"* ]] + +echo "Begin Repair migration test passed." diff --git a/scripts/test-dev-baseline.sh b/scripts/test-dev-baseline.sh index 383f1cb..1382623 100755 --- a/scripts/test-dev-baseline.sh +++ b/scripts/test-dev-baseline.sh @@ -88,7 +88,7 @@ health_json="$(cat "${health_file}")" echo "Checking migrations and required schema..." current_revision="$("${compose[@]}" exec -T backend alembic current 2>/dev/null)" -[[ "${current_revision}" == *"0009_inventory_asset_id_history"* ]] +[[ "${current_revision}" == *"0010_begin_repair"* ]] missing_tables="$( "${compose[@]}" exec -T postgres sh -c \ @@ -162,6 +162,41 @@ done [[ "$(status_for "${readonly_cookie}" GET /locations)" == "200" ]] [[ "$(status_for "${readonly_cookie}" POST /api/rooms '{"name":"Must Not Be Created"}')" == "403" ]] +echo "Checking Begin Repair permission and workflow..." +"${compose[@]}" exec -T postgres sh -ec \ + 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL' +INSERT INTO miners ( + id, + asset_tag, + status, + lifecycle_status, + first_seen_at, + last_seen_at +) +VALUES ( + '00000000-0000-0000-0000-000000000981', + 'SMOKE-REPAIR-001', + 'unknown', + 'active', + NULL, + NULL +); +SQL +[[ "$( + status_for "${readonly_cookie}" POST /api/repairs \ + '{"miner_id":"00000000-0000-0000-0000-000000000981","reason":"CONTROL_BOARD"}' +)" == "403" ]] +begin_repair_response="$(curl --fail --silent -b "${technician_cookie}" \ + -H 'Content-Type: application/json' \ + --data '{"miner_id":"00000000-0000-0000-0000-000000000981","reason":"CONTROL_BOARD","notes":"Smoke test repair."}' \ + "${base_url}/api/repairs")" +[[ "${begin_repair_response}" == *'"status":"OPEN"'* ]] +[[ "${begin_repair_response}" == *'"reason":"CONTROL_BOARD"'* ]] +[[ "$( + status_for "${technician_cookie}" POST /api/repairs \ + '{"miner_id":"00000000-0000-0000-0000-000000000981","reason":"CONTROL_BOARD"}' +)" == "409" ]] + room_status="$(status_for "${technician_cookie}" POST /api/rooms '{"name":"Role Matrix Test Room"}')" [[ "${room_status}" == "200" || "${room_status}" == "201" ]] diff --git a/scripts/test-inventory-domain.sh b/scripts/test-inventory-domain.sh index 526432b..b0ca167 100755 --- a/scripts/test-inventory-domain.sh +++ b/scripts/test-inventory-domain.sh @@ -53,6 +53,8 @@ echo "Migrating disposable database..." echo "Running inventory repository and service tests..." "${compose[@]}" run --rm --no-deps \ -v "${repo_root}/backend/tests:/app/tests:ro" \ - backend python -m unittest discover -s /app/tests -v + backend python -m unittest -v \ + tests.test_inventory_domain \ + tests.test_inventory_router echo "Inventory domain tests passed." diff --git a/scripts/test-repairs-domain.sh b/scripts/test-repairs-domain.sh new file mode 100755 index 0000000..57ef8d9 --- /dev/null +++ b/scripts/test-repairs-domain.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "${repo_root}" + +project_name="${MINEROPS_REPAIR_TEST_PROJECT:-minerops-repair-domain-$$}" +compose=( + docker compose + --project-name "${project_name}" + --env-file .env.dev + -f compose.dev.yaml +) + +cleanup() { + status=$? + if (( status != 0 )); then + echo + echo "Repairs domain tests failed. Recent PostgreSQL logs:" + "${compose[@]}" logs --no-color --tail 120 postgres || true + fi + "${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true + exit "${status}" +} + +trap cleanup EXIT + +if [[ ! -f .env.dev ]]; then + echo "Missing .env.dev. Run ./scripts/dev-env-init.sh first." >&2 + exit 1 +fi + +echo "Starting disposable Repairs database..." +"${compose[@]}" up --detach postgres +for attempt in $(seq 1 60); do + if "${compose[@]}" exec -T postgres sh -ec \ + 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \ + >/dev/null 2>&1; then + break + fi + if (( attempt == 60 )); then + echo "PostgreSQL did not become ready." >&2 + exit 1 + fi + sleep 1 +done + +echo "Building Repairs test image..." +"${compose[@]}" build backend + +echo "Migrating disposable database..." +"${compose[@]}" run --rm --no-deps backend alembic upgrade head + +echo "Running Repairs repository, service, and router tests..." +"${compose[@]}" run --rm --no-deps \ + -v "${repo_root}/backend/tests:/app/tests:ro" \ + backend python -m unittest -v \ + tests.test_repairs_domain \ + tests.test_repairs_router + +echo "Repairs domain tests passed." diff --git a/sql/verify-development-schema.sql b/sql/verify-development-schema.sql index 9ee8e2c..ba67376 100644 --- a/sql/verify-development-schema.sql +++ b/sql/verify-development-schema.sql @@ -7,6 +7,8 @@ WITH expected(table_name) AS ( ('tags'), ('miner_tags'), ('miner_asset_id_history'), + ('repairs'), + ('repair_events'), ('miner_status_snapshots'), ('miner_status_hourly_rollups'), ('miner_status_daily_rollups'),