feat: add begin repair workflow

This commit is contained in:
lussierj committed 2026-07-29 14:50:46 +00:00
1 parent d738b8e27e
commit f6a2512e23
21 files changed
+3108 -5

No files matched your search

@@ -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')",
)
+2
View File
@@ -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"
+1 -1
View File
@@ -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):
+3 -1
View File
@@ -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:
+2
View File
@@ -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)
+37
View File
@@ -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",
]
+8
View File
@@ -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())
+58
View File
@@ -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."""
+65
View File
@@ -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
+225
View File
@@ -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"
)
+60
View File
@@ -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)
+103
View File
@@ -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"],
)
+737
View File
@@ -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()
+277
View File
@@ -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()