feat: add begin repair workflow
This commit is contained in:
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')",
|
||||
)
|
||||
@@ -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"
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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())
|
||||
@@ -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."""
|
||||
@@ -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
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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"],
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user