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,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