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: def required_api_permission(method: str, path: str) -> str | None:
if path.startswith("/api/admin/"): if path.startswith("/api/admin/"):
return "users.manage" return "users.manage"
if method == "POST" and path == "/api/repairs":
return "repairs.create"
if method == "GET": if method == "GET":
if path.startswith("/api/system/"): if path.startswith("/api/system/"):
return "system_health.view" return "system_health.view"
+1 -1
View File
@@ -7,7 +7,7 @@ from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, IPvAnyAddress from pydantic import BaseModel, ConfigDict, Field, IPvAnyAddress
LifecycleStatus = Literal["active", "spare", "retired"] LifecycleStatus = Literal["active", "spare", "retired", "in_repair"]
class InventoryTag(BaseModel): class InventoryTag(BaseModel):
+3 -1
View File
@@ -24,7 +24,9 @@ from app.inventory.models import (
from app.inventory.repository import InventoryRepository from app.inventory.repository import InventoryRepository
ALLOWED_LIFECYCLE_STATUSES = frozenset({"active", "spare", "retired"}) ALLOWED_LIFECYCLE_STATUSES = frozenset(
{"active", "spare", "retired", "in_repair"}
)
class InventoryService: class InventoryService:
+2
View File
@@ -18,11 +18,13 @@ from app.identity import (
router as identity_router, router as identity_router,
) )
from app.inventory.router import router as inventory_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 = FastAPI(title=settings.app_name)
app.include_router(identity_router) app.include_router(identity_router)
app.include_router(inventory_router) app.include_router(inventory_router)
app.include_router(repairs_router)
install_identity_middleware(app) 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()
+6 -1
View File
@@ -92,13 +92,17 @@ location changes.
### Phase 3: Operations ### Phase 3: Operations
**Status: Planned direction.** **Status: In progress.**
This phase develops day-to-day technician and operations workflows around the This phase develops day-to-day technician and operations workflows around the
durable asset record. Repairs, maintenance, work orders, parts usage, equipment durable asset record. Repairs, maintenance, work orders, parts usage, equipment
movement, testing, and return-to-service history become coherent operational movement, testing, and return-to-service history become coherent operational
records rather than disconnected actions. 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 Technician experience is a priority. Responsive and QR-assisted workflows may
reduce lookup and data-entry effort where they reinforce the same asset record. reduce lookup and data-entry effort where they reinforce the same asset record.
Existing repair and location behavior should move incrementally into clear Existing repair and location behavior should move incrementally into clear
@@ -209,6 +213,7 @@ Major completed foundations include:
- Authentication, login gating, and role foundation - Authentication, login gating, and role foundation
- Manual Inventory schema and backend domain foundation - Manual Inventory schema and backend domain foundation
- Focused Inventory and migration test foundations - Focused Inventory and migration test foundations
- Begin Repair operational workflow
- MinerOps branding package and project README - MinerOps branding package and project README
- Architecture guide - Architecture guide
- Product guide - Product guide
+544
View File
@@ -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.
+556
View File
@@ -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."
+36 -1
View File
@@ -88,7 +88,7 @@ health_json="$(cat "${health_file}")"
echo "Checking migrations and required schema..." echo "Checking migrations and required schema..."
current_revision="$("${compose[@]}" exec -T backend alembic current 2>/dev/null)" 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="$( missing_tables="$(
"${compose[@]}" exec -T postgres sh -c \ "${compose[@]}" exec -T postgres sh -c \
@@ -162,6 +162,41 @@ done
[[ "$(status_for "${readonly_cookie}" GET /locations)" == "200" ]] [[ "$(status_for "${readonly_cookie}" GET /locations)" == "200" ]]
[[ "$(status_for "${readonly_cookie}" POST /api/rooms '{"name":"Must Not Be Created"}')" == "403" ]] [[ "$(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="$(status_for "${technician_cookie}" POST /api/rooms '{"name":"Role Matrix Test Room"}')"
[[ "${room_status}" == "200" || "${room_status}" == "201" ]] [[ "${room_status}" == "200" || "${room_status}" == "201" ]]
+3 -1
View File
@@ -53,6 +53,8 @@ echo "Migrating disposable database..."
echo "Running inventory repository and service tests..." echo "Running inventory repository and service tests..."
"${compose[@]}" run --rm --no-deps \ "${compose[@]}" run --rm --no-deps \
-v "${repo_root}/backend/tests:/app/tests:ro" \ -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." echo "Inventory domain tests passed."
+61
View File
@@ -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."
+2
View File
@@ -7,6 +7,8 @@ WITH expected(table_name) AS (
('tags'), ('tags'),
('miner_tags'), ('miner_tags'),
('miner_asset_id_history'), ('miner_asset_id_history'),
('repairs'),
('repair_events'),
('miner_status_snapshots'), ('miner_status_snapshots'),
('miner_status_hourly_rollups'), ('miner_status_hourly_rollups'),
('miner_status_daily_rollups'), ('miner_status_daily_rollups'),