740 lines
25 KiB
Python
740 lines
25 KiB
Python
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("ALTER TABLE repair_events DISABLE TRIGGER trg_protect_repair_event_history")
|
|
cur.execute("DELETE FROM repair_events")
|
|
cur.execute("ALTER TABLE repair_events ENABLE TRIGGER trg_protect_repair_event_history")
|
|
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()
|