feat: add control board replacement workflow
This commit is contained in:
1 parent
62c91a2c3e
commit
bcf1b4e3cf
16 files changed
+2122
-279
No files matched your search
@@ -0,0 +1,804 @@
|
||||
import ast
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from threading import Barrier
|
||||
from unittest.mock import patch
|
||||
import unittest
|
||||
from contextlib import contextmanager
|
||||
from copy import deepcopy
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from psycopg.errors import CheckViolation, RaiseException, UniqueViolation
|
||||
from psycopg.rows import dict_row
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.identity import get_current_user, required_api_permission
|
||||
from app.repairs.dependencies import get_repair_service
|
||||
from app.repairs.exceptions import (
|
||||
ControlBoardReplacementConflict,
|
||||
InvalidMacAddress,
|
||||
MacOwnershipConflict,
|
||||
RepairMinerNotFound,
|
||||
RepairMinerNotInRepair,
|
||||
)
|
||||
from app.repairs.models import (
|
||||
BeginRepairRequest,
|
||||
ControlBoardReplacementRequest,
|
||||
ControlBoardReplacementResult,
|
||||
)
|
||||
from app.repairs.repository import RepairRepository
|
||||
from app.repairs.router import control_board_router
|
||||
from app.repairs.service import RepairService
|
||||
|
||||
|
||||
NOW = datetime(2026, 8, 2, 12, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def request(**overrides):
|
||||
values = {
|
||||
"new_mac_address": "02:AA:BB:CC:DD:02",
|
||||
"new_control_board_serial": " CB-NEW ",
|
||||
"board_model": " Model X ",
|
||||
"board_revision": " Rev B ",
|
||||
"firmware_version": " 1.2.3 ",
|
||||
"removed_board_disposition": "sent_for_repair",
|
||||
"replacement_reason": "failed",
|
||||
"notes": " Board would not boot. ",
|
||||
}
|
||||
values.update(overrides)
|
||||
return ControlBoardReplacementRequest(**values)
|
||||
|
||||
|
||||
class FakeReplacementRepository:
|
||||
def __init__(self):
|
||||
self.miner_id = uuid4()
|
||||
self.repair_id = uuid4()
|
||||
self.actor_id = uuid4()
|
||||
self.miner = {
|
||||
"id": self.miner_id,
|
||||
"asset_tag": "MINER-004",
|
||||
"lifecycle_status": "in_repair",
|
||||
"current_mac": "02:AA:BB:CC:DD:01",
|
||||
"control_board_sn": "CB-OLD",
|
||||
"firmware_version": "1.0.0",
|
||||
}
|
||||
self.repair = {"id": self.repair_id, "miner_id": self.miner_id}
|
||||
self.installations = []
|
||||
self.events = [{"sequence": 1}]
|
||||
self.identities = []
|
||||
self.calls = []
|
||||
self.fail_at = None
|
||||
self.owner = None
|
||||
|
||||
@contextmanager
|
||||
def transaction(self):
|
||||
self.calls.append("transaction")
|
||||
snapshot = deepcopy(
|
||||
(self.miner, self.installations, self.events, self.identities)
|
||||
)
|
||||
try:
|
||||
yield self
|
||||
except Exception:
|
||||
self.miner, self.installations, self.events, self.identities = snapshot
|
||||
raise
|
||||
|
||||
def _fail(self, name):
|
||||
self.calls.append(name)
|
||||
if self.fail_at == name:
|
||||
raise RuntimeError(name)
|
||||
|
||||
def lock_miner_for_control_board_replacement(self, miner_id):
|
||||
self._fail("lock_miner")
|
||||
return self.miner if miner_id == self.miner_id else None
|
||||
|
||||
def lock_active_repair_for_miner(self, miner_id):
|
||||
self._fail("lock_repair")
|
||||
return self.repair if miner_id == self.miner_id else None
|
||||
|
||||
def lock_current_control_board(self, miner_id):
|
||||
self._fail("lock_board")
|
||||
current = [row for row in self.installations if row["removed_at"] is None]
|
||||
return current[0] if current else None
|
||||
|
||||
def create_legacy_control_board(self, miner):
|
||||
self._fail("create_legacy_board")
|
||||
row = {
|
||||
"id": uuid4(), "miner_id": miner["id"],
|
||||
"mac_address": miner["current_mac"],
|
||||
"board_serial": miner["control_board_sn"],
|
||||
"board_model": None, "board_revision": None,
|
||||
"removed_at": None,
|
||||
}
|
||||
self.installations.append(row)
|
||||
return row
|
||||
|
||||
def ensure_network_identity_history(self, miner_id, **values):
|
||||
self._fail("ensure_old_identity")
|
||||
self.identities.append({"miner_id": miner_id, "is_current": False, **values})
|
||||
|
||||
def claim_mac_ownership(self, normalized_mac, miner_id, actor_user_id):
|
||||
self._fail("claim_mac")
|
||||
if self.owner is not None and self.owner != miner_id:
|
||||
raise MacOwnershipConflict(normalized_mac)
|
||||
self.owner = miner_id
|
||||
|
||||
def close_control_board(self, installation_id, **values):
|
||||
self._fail("close_board")
|
||||
row = next(row for row in self.installations if row["id"] == installation_id)
|
||||
row.update(removed_at=NOW, **values)
|
||||
return row
|
||||
|
||||
def create_control_board_installation(self, **values):
|
||||
self._fail("create_board")
|
||||
row = {"id": uuid4(), "removed_at": None, **values, "installed_at": NOW}
|
||||
self.installations.append(row)
|
||||
return row
|
||||
|
||||
def set_current_network_identity(self, miner_id, **values):
|
||||
self._fail("set_identity")
|
||||
self.identities.append({"miner_id": miner_id, **values})
|
||||
return self.identities[-1]
|
||||
|
||||
def set_current_control_board(self, miner_id, *, mac_address, board_serial):
|
||||
self._fail("set_projection")
|
||||
self.miner.update(current_mac=mac_address, control_board_sn=board_serial)
|
||||
|
||||
def get_next_event_sequence(self, repair_id):
|
||||
self._fail("next_sequence")
|
||||
return max(event["sequence"] for event in self.events) + 1
|
||||
|
||||
def create_event(self, repair_id, **values):
|
||||
self._fail("create_event")
|
||||
row = {"id": uuid4(), "repair_id": repair_id, "created_at": NOW, **values}
|
||||
self.events.append(row)
|
||||
return row
|
||||
|
||||
|
||||
class ControlBoardReplacementServiceTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.repository = FakeReplacementRepository()
|
||||
self.service = RepairService(self.repository)
|
||||
|
||||
def replace(self, replacement=None):
|
||||
return self.service.replace_control_board(
|
||||
self.repository.miner_id,
|
||||
replacement or request(),
|
||||
actor_user_id=self.repository.actor_id,
|
||||
)
|
||||
|
||||
def test_replacement_is_atomic_and_preserves_durable_identity(self):
|
||||
miner_id = self.repository.miner["id"]
|
||||
asset_id = self.repository.miner["asset_tag"]
|
||||
result = self.replace()
|
||||
|
||||
self.assertEqual(result.miner_id, miner_id)
|
||||
self.assertEqual(result.asset_id, asset_id)
|
||||
self.assertEqual(result.current_mac, "02:AA:BB:CC:DD:02")
|
||||
self.assertEqual(result.control_board_serial, "CB-NEW")
|
||||
self.assertEqual(self.repository.miner["id"], miner_id)
|
||||
self.assertEqual(self.repository.miner["asset_tag"], asset_id)
|
||||
self.assertEqual(len([x for x in self.repository.installations if x["removed_at"] is None]), 1)
|
||||
event = self.repository.events[-1]
|
||||
self.assertEqual(event["event_type"], "CONTROL_BOARD_REPLACED")
|
||||
self.assertEqual(event["sequence"], 2)
|
||||
self.assertEqual(event["created_by"], self.repository.actor_id)
|
||||
self.assertEqual(event["payload"]["old_mac_address"], "02:AA:BB:CC:DD:01")
|
||||
self.assertEqual(event["payload"]["old_control_board_serial"], "CB-OLD")
|
||||
self.assertEqual(event["payload"]["notes"], "Board would not boot.")
|
||||
|
||||
def test_mac_normalization_and_prohibited_values(self):
|
||||
self.assertEqual(
|
||||
self.service.normalize_mac_address("02-aA-bb-cc-dd-02"),
|
||||
("02:AA:BB:CC:DD:02", "02aabbccdd02"),
|
||||
)
|
||||
for value in (
|
||||
"bad", "00:00:00:00:00:00", "FF:FF:FF:FF:FF:FF",
|
||||
"01:00:5E:00:00:01",
|
||||
):
|
||||
with self.subTest(value=value), self.assertRaises(InvalidMacAddress):
|
||||
self.service.normalize_mac_address(value)
|
||||
|
||||
def test_conflicting_states_do_not_write(self):
|
||||
cases = ("active", "spare", "retired")
|
||||
for lifecycle in cases:
|
||||
with self.subTest(lifecycle=lifecycle):
|
||||
repository = FakeReplacementRepository()
|
||||
repository.miner["lifecycle_status"] = lifecycle
|
||||
with self.assertRaises(RepairMinerNotInRepair):
|
||||
RepairService(repository).replace_control_board(
|
||||
repository.miner_id, request(),
|
||||
actor_user_id=repository.actor_id,
|
||||
)
|
||||
self.assertEqual(repository.events, [{"sequence": 1}])
|
||||
|
||||
def test_same_and_historically_owned_mac_conflict(self):
|
||||
with self.assertRaises(ControlBoardReplacementConflict):
|
||||
self.replace(request(new_mac_address="02:AA:BB:CC:DD:01"))
|
||||
repository = FakeReplacementRepository()
|
||||
repository.owner = uuid4()
|
||||
with self.assertRaises(MacOwnershipConflict):
|
||||
RepairService(repository).replace_control_board(
|
||||
repository.miner_id, request(), actor_user_id=repository.actor_id
|
||||
)
|
||||
|
||||
def test_missing_miner_and_active_repair_conflict(self):
|
||||
with self.assertRaises(RepairMinerNotFound):
|
||||
self.service.replace_control_board(
|
||||
uuid4(), request(), actor_user_id=self.repository.actor_id
|
||||
)
|
||||
self.repository.repair = None
|
||||
with self.assertRaises(ControlBoardReplacementConflict):
|
||||
self.replace()
|
||||
|
||||
def test_each_meaningful_write_failure_rolls_back(self):
|
||||
for step in (
|
||||
"claim_mac", "close_board", "create_board", "set_identity",
|
||||
"set_projection", "create_event",
|
||||
):
|
||||
with self.subTest(step=step):
|
||||
repository = FakeReplacementRepository()
|
||||
repository.fail_at = step
|
||||
original = deepcopy(repository.miner)
|
||||
with self.assertRaises(RuntimeError):
|
||||
RepairService(repository).replace_control_board(
|
||||
repository.miner_id, request(),
|
||||
actor_user_id=repository.actor_id,
|
||||
)
|
||||
self.assertEqual(repository.miner, original)
|
||||
self.assertEqual(repository.installations, [])
|
||||
self.assertEqual(repository.events, [{"sequence": 1}])
|
||||
|
||||
|
||||
class StubReplacementService:
|
||||
def __init__(self):
|
||||
self.error = None
|
||||
self.result = None
|
||||
self.request = None
|
||||
self.actor = None
|
||||
|
||||
def replace_control_board(self, miner_id, request, *, actor_user_id):
|
||||
self.request = request
|
||||
self.actor = actor_user_id
|
||||
if self.error:
|
||||
raise self.error
|
||||
return self.result
|
||||
|
||||
|
||||
class ControlBoardReplacementRouterTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.service = StubReplacementService()
|
||||
self.app = FastAPI()
|
||||
self.app.include_router(control_board_router)
|
||||
self.app.dependency_overrides[get_repair_service] = lambda: self.service
|
||||
self.client = TestClient(self.app)
|
||||
self.actor = uuid4()
|
||||
self.miner = uuid4()
|
||||
|
||||
def tearDown(self):
|
||||
self.client.close()
|
||||
|
||||
def authenticate(self, permissions=("miners.replace_control_board",), role="Technician"):
|
||||
self.app.dependency_overrides[get_current_user] = lambda: {
|
||||
"id": str(self.actor), "role": role,
|
||||
"permissions": list(permissions),
|
||||
}
|
||||
|
||||
def post(self, body=None):
|
||||
return self.client.post(
|
||||
f"/api/miners/{self.miner}/replace-control-board",
|
||||
json=(request().model_dump(mode="json") if body is None else body),
|
||||
)
|
||||
|
||||
def test_authentication_and_permission_are_required(self):
|
||||
self.assertEqual(self.post().status_code, 401)
|
||||
self.authenticate(permissions=("miners.update",))
|
||||
self.assertEqual(self.post().status_code, 403)
|
||||
|
||||
def test_success_uses_typed_envelope_and_authenticated_actor(self):
|
||||
self.authenticate()
|
||||
self.service.result = ControlBoardReplacementResult(
|
||||
miner_id=self.miner, asset_id="MINER-004", repair_id=uuid4(),
|
||||
installation_id=uuid4(), event_id=uuid4(), event_sequence=4,
|
||||
current_mac="02:AA:BB:CC:DD:02", control_board_serial="CB-NEW",
|
||||
replaced_at=NOW, replaced_by=self.actor,
|
||||
)
|
||||
response = self.post()
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json()["data"]["miner_id"], str(self.miner))
|
||||
self.assertEqual(response.json()["meta"], {})
|
||||
self.assertIsNone(response.json()["error"])
|
||||
self.assertEqual(self.service.actor, self.actor)
|
||||
|
||||
def test_validation_and_domain_error_mapping(self):
|
||||
self.authenticate()
|
||||
self.assertEqual(self.post({"new_mac_address": "bad"}).status_code, 422)
|
||||
self.service.error = RepairMinerNotFound(self.miner)
|
||||
self.assertEqual(self.post().status_code, 404)
|
||||
self.service.error = ControlBoardReplacementConflict("conflict")
|
||||
self.assertEqual(self.post().status_code, 409)
|
||||
self.service.error = InvalidMacAddress()
|
||||
self.assertEqual(self.post().status_code, 422)
|
||||
|
||||
def test_route_and_permission_mapping_are_exact(self):
|
||||
routes = [
|
||||
(route.path, route.methods) for route in control_board_router.routes
|
||||
if hasattr(route, "methods")
|
||||
]
|
||||
self.assertEqual(routes, [
|
||||
("/api/miners/{miner_id}/replace-control-board", {"POST"})
|
||||
])
|
||||
path = f"/api/miners/{self.miner}/replace-control-board"
|
||||
self.assertEqual(
|
||||
required_api_permission("POST", path),
|
||||
"miners.replace_control_board",
|
||||
)
|
||||
|
||||
def test_allowed_roles_and_read_only_matrix(self):
|
||||
self.service.result = ControlBoardReplacementResult(
|
||||
miner_id=self.miner, asset_id="MINER-004", repair_id=uuid4(),
|
||||
installation_id=uuid4(), event_id=uuid4(), event_sequence=2,
|
||||
current_mac="02:AA:BB:CC:DD:02", control_board_serial=None,
|
||||
replaced_at=NOW, replaced_by=self.actor,
|
||||
)
|
||||
for role in ("Admin", "Manager", "Technician"):
|
||||
with self.subTest(role=role):
|
||||
self.authenticate(role=role)
|
||||
self.assertEqual(self.post().status_code, 200)
|
||||
self.authenticate(permissions=(), role="Read Only")
|
||||
self.assertEqual(self.post().status_code, 403)
|
||||
|
||||
def test_request_contract_rejects_invalid_shapes(self):
|
||||
self.authenticate()
|
||||
valid = request().model_dump(mode="json")
|
||||
invalid_bodies = []
|
||||
missing_serial = dict(valid)
|
||||
missing_serial.pop("new_control_board_serial")
|
||||
invalid_bodies.append(missing_serial)
|
||||
for field, value in (
|
||||
("new_mac_address", ""),
|
||||
("notes", "x" * 4001),
|
||||
("replacement_reason", "invented"),
|
||||
("removed_board_disposition", "invented"),
|
||||
):
|
||||
body = dict(valid)
|
||||
body[field] = value
|
||||
invalid_bodies.append(body)
|
||||
unknown = dict(valid)
|
||||
unknown["created_by"] = str(self.actor)
|
||||
invalid_bodies.append(unknown)
|
||||
for body in invalid_bodies:
|
||||
with self.subTest(body=body):
|
||||
self.assertEqual(self.post(body).status_code, 422)
|
||||
|
||||
|
||||
def test_router_has_no_sql_driver_or_transaction_management(self):
|
||||
path = Path(__file__).parents[1] / "app" / "repairs" / "router.py"
|
||||
source = path.read_text(encoding="utf-8")
|
||||
tree = ast.parse(source)
|
||||
modules = []
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
modules.extend(alias.name for alias in node.names)
|
||||
elif isinstance(node, ast.ImportFrom) and node.module:
|
||||
modules.append(node.module)
|
||||
self.assertFalse(any(name.startswith(("psycopg", "sqlalchemy", "asyncpg")) for name in modules))
|
||||
for keyword in ("SELECT ", "INSERT ", "UPDATE ", "DELETE ", ".COMMIT(", ".ROLLBACK("):
|
||||
self.assertNotIn(keyword, source.upper())
|
||||
|
||||
|
||||
class LegacyChangeMacCompatibilityTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
from app import main as main_module
|
||||
|
||||
self.main = main_module
|
||||
self.service = StubReplacementService()
|
||||
self.actor = uuid4()
|
||||
self.miner = uuid4()
|
||||
self.service.result = ControlBoardReplacementResult(
|
||||
miner_id=self.miner, asset_id="MINER-004", repair_id=uuid4(),
|
||||
installation_id=uuid4(), event_id=uuid4(), event_sequence=3,
|
||||
current_mac="02:AA:BB:CC:DD:02", control_board_serial="CB-NEW",
|
||||
replaced_at=NOW, replaced_by=self.actor,
|
||||
)
|
||||
|
||||
def legacy_request(self, **overrides):
|
||||
values = {
|
||||
"new_mac": "02:AA:BB:CC:DD:02",
|
||||
"component_serial": "CB-NEW",
|
||||
"description": "Replaced during repair.",
|
||||
"replacement_reason": "failed",
|
||||
"removed_board_disposition": "sent_for_repair",
|
||||
}
|
||||
values.update(overrides)
|
||||
return self.main.MinerMacChangeRequest(**values)
|
||||
|
||||
def call(self, legacy_request):
|
||||
with patch.object(self.main, "RepairRepository"), patch.object(
|
||||
self.main, "RepairService", return_value=self.service
|
||||
):
|
||||
return self.main.change_miner_mac_address(
|
||||
str(self.miner), legacy_request, {"id": str(self.actor)}
|
||||
)
|
||||
|
||||
def test_alias_uses_same_permission_and_domain_workflow(self):
|
||||
path = f"/api/miners/{self.miner}/change-mac"
|
||||
self.assertEqual(
|
||||
required_api_permission("POST", path),
|
||||
"miners.replace_control_board",
|
||||
)
|
||||
response = self.call(self.legacy_request())
|
||||
self.assertEqual(self.service.actor, self.actor)
|
||||
self.assertEqual(self.service.request.replacement_reason, "failed")
|
||||
self.assertEqual(
|
||||
self.service.request.removed_board_disposition, "sent_for_repair"
|
||||
)
|
||||
self.assertEqual(
|
||||
response["data"]["miner"]["current_mac"],
|
||||
"02:AA:BB:CC:DD:02",
|
||||
)
|
||||
self.assertIn("control_board_replacement", response["data"])
|
||||
self.assertNotIn("maintenance_event", response["data"])
|
||||
|
||||
def test_alias_does_not_fabricate_required_workflow_fields(self):
|
||||
for changes in (
|
||||
{"replacement_reason": None},
|
||||
{"removed_board_disposition": None},
|
||||
{"new_ip": "192.0.2.4"},
|
||||
{"downtime_minutes": 10},
|
||||
):
|
||||
with self.subTest(changes=changes), self.assertRaises(HTTPException) as caught:
|
||||
self.call(self.legacy_request(**changes))
|
||||
self.assertEqual(caught.exception.status_code, 422)
|
||||
|
||||
def test_alias_maps_domain_conflicts_without_internal_error(self):
|
||||
self.service.error = ControlBoardReplacementConflict("Miner has no active repair")
|
||||
with self.assertRaises(HTTPException) as caught:
|
||||
self.call(self.legacy_request())
|
||||
self.assertEqual(caught.exception.status_code, 409)
|
||||
|
||||
|
||||
class ControlBoardReplacementDatabaseTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.repository = RepairRepository()
|
||||
|
||||
def setUp(self):
|
||||
self.actor = uuid4()
|
||||
with self.repository._connection_scope(write=True) as connection:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute("DROP TRIGGER IF EXISTS test_board_event_failure ON repair_events")
|
||||
cur.execute("DROP FUNCTION IF EXISTS test_fail_board_event()")
|
||||
cur.execute(
|
||||
"TRUNCATE miner_control_board_installations, "
|
||||
"miner_network_identities, miner_mac_ownerships"
|
||||
)
|
||||
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 LIKE 'board-test-%'")
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO users (
|
||||
id, username, display_name, password_hash, role_id
|
||||
)
|
||||
SELECT %s, %s, 'Board Test Actor', 'unused', id
|
||||
FROM roles WHERE name = 'Admin'
|
||||
""",
|
||||
(self.actor, f"board-test-{self.actor}"),
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
with self.repository._connection_scope(write=True) as connection:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute("DROP TRIGGER IF EXISTS test_board_event_failure ON repair_events")
|
||||
cur.execute("DROP FUNCTION IF EXISTS test_fail_board_event()")
|
||||
cur.execute(
|
||||
"TRUNCATE miner_control_board_installations, "
|
||||
"miner_network_identities, miner_mac_ownerships"
|
||||
)
|
||||
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 LIKE 'board-test-%'")
|
||||
|
||||
|
||||
def create_repairing_miner(self, mac_address, serial):
|
||||
asset_id = f"BOARD-{uuid4()}"
|
||||
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, current_mac, control_board_sn, status,
|
||||
lifecycle_status, first_seen_at, last_seen_at
|
||||
) VALUES (%s, %s, %s, 'unknown', 'active', NULL, NULL)
|
||||
RETURNING id
|
||||
""",
|
||||
(asset_id, mac_address, serial),
|
||||
)
|
||||
miner_id = cur.fetchone()["id"]
|
||||
repair = RepairService(self.repository).begin_repair(
|
||||
BeginRepairRequest(miner_id=miner_id, reason="CONTROL_BOARD"),
|
||||
actor_user_id=self.actor,
|
||||
)
|
||||
return miner_id, asset_id, repair.repair_id
|
||||
|
||||
def replace(self, miner_id, mac_address="02:AA:BB:CC:EE:02"):
|
||||
return RepairService(self.repository).replace_control_board(
|
||||
miner_id, request(new_mac_address=mac_address),
|
||||
actor_user_id=self.actor,
|
||||
)
|
||||
|
||||
def fetchall(self, sql, values=()):
|
||||
with self.repository._connection_scope() as connection:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(sql, values)
|
||||
return [dict(row) for row in cur.fetchall()]
|
||||
|
||||
def test_database_success_preserves_identity_and_authoritative_history(self):
|
||||
miner_id, asset_id, repair_id = self.create_repairing_miner(
|
||||
"02:AA:BB:CC:EE:01", "CB-OLD"
|
||||
)
|
||||
result = self.replace(miner_id)
|
||||
miner = self.fetchall(
|
||||
"SELECT * FROM miners WHERE id = %s", (miner_id,)
|
||||
)[0]
|
||||
boards = self.fetchall(
|
||||
"SELECT * FROM miner_control_board_installations "
|
||||
"WHERE miner_id = %s ORDER BY created_at, id", (miner_id,)
|
||||
)
|
||||
identities = self.fetchall(
|
||||
"SELECT * FROM miner_network_identities "
|
||||
"WHERE miner_id = %s ORDER BY is_current, mac_address", (miner_id,)
|
||||
)
|
||||
events = self.fetchall(
|
||||
"SELECT * FROM repair_events WHERE repair_id = %s "
|
||||
"AND event_type = 'CONTROL_BOARD_REPLACED'", (repair_id,)
|
||||
)
|
||||
self.assertEqual(result.miner_id, miner_id)
|
||||
self.assertEqual(miner["asset_tag"], asset_id)
|
||||
self.assertEqual(miner["current_mac"], "02:AA:BB:CC:EE:02")
|
||||
self.assertEqual(len(boards), 2)
|
||||
self.assertEqual(sum(row["removed_at"] is None for row in boards), 1)
|
||||
legacy = next(row for row in boards if row["record_origin"] == "legacy_projection")
|
||||
current = next(row for row in boards if row["removed_at"] is None)
|
||||
self.assertIsNone(legacy["installed_at"])
|
||||
self.assertIsNone(legacy["installed_by"])
|
||||
self.assertEqual(current["record_origin"], "replacement")
|
||||
self.assertEqual(current["installed_by"], self.actor)
|
||||
self.assertEqual(current["normalized_mac"], "02aabbccee02")
|
||||
self.assertEqual(len(identities), 2)
|
||||
self.assertEqual(sum(row["is_current"] for row in identities), 1)
|
||||
self.assertEqual(
|
||||
next(row for row in identities if row["is_current"])["mac_address"],
|
||||
"02:AA:BB:CC:EE:02",
|
||||
)
|
||||
self.assertTrue(any(row["mac_address"] == "02:AA:BB:CC:EE:01" for row in identities))
|
||||
self.assertEqual(len(events), 1)
|
||||
self.assertEqual(events[0]["created_by"], self.actor)
|
||||
self.assertEqual(events[0]["sequence"], result.event_sequence)
|
||||
|
||||
def test_database_allows_omitted_optional_notes(self):
|
||||
miner_id, _, repair_id = self.create_repairing_miner(
|
||||
"02:AA:BB:CC:EA:01", "CB-OLD"
|
||||
)
|
||||
RepairService(self.repository).replace_control_board(
|
||||
miner_id,
|
||||
request(new_mac_address="02:AA:BB:CC:EA:02", notes=None),
|
||||
actor_user_id=self.actor,
|
||||
)
|
||||
event = self.fetchall(
|
||||
"SELECT payload FROM repair_events WHERE repair_id = %s "
|
||||
"AND event_type = 'CONTROL_BOARD_REPLACED'",
|
||||
(repair_id,),
|
||||
)[0]
|
||||
self.assertIn("notes", event["payload"])
|
||||
self.assertIsNone(event["payload"]["notes"])
|
||||
|
||||
def test_database_constraints_prevent_drift_and_history_mutation(self):
|
||||
miner_id, _, repair_id = self.create_repairing_miner(
|
||||
"02:AA:BB:CC:ED:01", "CB-OLD"
|
||||
)
|
||||
self.replace(miner_id, "02:AA:BB:CC:ED:02")
|
||||
|
||||
with self.assertRaises(UniqueViolation):
|
||||
with self.repository._connection_scope(write=True) as connection:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO miner_control_board_installations (
|
||||
miner_id, record_origin, board_serial
|
||||
) VALUES (%s, 'legacy_projection', 'SECOND')
|
||||
""",
|
||||
(miner_id,),
|
||||
)
|
||||
with self.assertRaises(CheckViolation):
|
||||
with self.repository._connection_scope(write=True) as connection:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE miners SET control_board_sn = 'DRIFT' "
|
||||
"WHERE id = %s",
|
||||
(miner_id,),
|
||||
)
|
||||
with self.assertRaises(CheckViolation):
|
||||
with self.repository._connection_scope(write=True) as connection:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE miner_network_identities SET is_current = false "
|
||||
"WHERE miner_id = %s AND is_current",
|
||||
(miner_id,),
|
||||
)
|
||||
with self.assertRaises(RaiseException):
|
||||
with self.repository._connection_scope(write=True) as connection:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"DELETE FROM miner_network_identities "
|
||||
"WHERE miner_id = %s",
|
||||
(miner_id,),
|
||||
)
|
||||
with self.assertRaises(RaiseException):
|
||||
with self.repository._connection_scope(write=True) as connection:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"DELETE FROM miner_control_board_installations "
|
||||
"WHERE miner_id = %s",
|
||||
(miner_id,),
|
||||
)
|
||||
for statement in (
|
||||
"UPDATE miner_mac_ownerships SET miner_id = gen_random_uuid() "
|
||||
"WHERE normalized_mac = '02aabbcced02'",
|
||||
"DELETE FROM miner_mac_ownerships "
|
||||
"WHERE normalized_mac = '02aabbcced02'",
|
||||
):
|
||||
with self.assertRaises(RaiseException):
|
||||
with self.repository._connection_scope(write=True) as connection:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(statement)
|
||||
with self.assertRaises(UniqueViolation):
|
||||
with self.repository._connection_scope(write=True) as connection:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO repair_events (
|
||||
repair_id, sequence, event_type, created_by, payload
|
||||
)
|
||||
SELECT repair_id, sequence, event_type, created_by, payload
|
||||
FROM repair_events
|
||||
WHERE repair_id = %s ORDER BY sequence LIMIT 1
|
||||
""",
|
||||
(repair_id,),
|
||||
)
|
||||
|
||||
def test_database_rejects_prohibited_component_mac(self):
|
||||
miner_id, _, _ = self.create_repairing_miner(
|
||||
"02:AA:BB:CC:EC:01", "CB-OLD"
|
||||
)
|
||||
for mac_address, normalized_mac in (
|
||||
("01:00:5E:00:00:01", "01005e000001"),
|
||||
("00:00:00:00:00:00", "000000000000"),
|
||||
("FF:FF:FF:FF:FF:FF", "ffffffffffff"),
|
||||
("02-AA-BB-CC-EC-02", "02aabbccec02"),
|
||||
):
|
||||
with self.subTest(mac_address=mac_address), self.assertRaises(CheckViolation):
|
||||
with self.repository._connection_scope(write=True) as connection:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO miner_control_board_installations (
|
||||
miner_id, record_origin, mac_address, normalized_mac
|
||||
) VALUES (%s, 'legacy_projection', %s, %s)
|
||||
""",
|
||||
(miner_id, mac_address, normalized_mac),
|
||||
)
|
||||
|
||||
|
||||
def test_event_failure_rolls_back_every_replacement_write(self):
|
||||
miner_id, _, repair_id = self.create_repairing_miner(
|
||||
"02:AA:BB:CC:EF:01", "CB-OLD"
|
||||
)
|
||||
with self.repository._connection_scope(write=True) as connection:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE FUNCTION test_fail_board_event() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
IF NEW.event_type = 'CONTROL_BOARD_REPLACED' THEN
|
||||
RAISE EXCEPTION 'forced replacement event failure';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$ LANGUAGE plpgsql
|
||||
"""
|
||||
)
|
||||
cur.execute(
|
||||
"CREATE TRIGGER test_board_event_failure BEFORE INSERT ON "
|
||||
"repair_events FOR EACH ROW EXECUTE FUNCTION test_fail_board_event()"
|
||||
)
|
||||
with self.assertRaises(RaiseException):
|
||||
self.replace(miner_id, "02:AA:BB:CC:EF:02")
|
||||
miner = self.fetchall("SELECT * FROM miners WHERE id = %s", (miner_id,))[0]
|
||||
self.assertEqual(miner["current_mac"], "02:AA:BB:CC:EF:01")
|
||||
self.assertEqual(self.fetchall(
|
||||
"SELECT * FROM miner_control_board_installations WHERE miner_id = %s",
|
||||
(miner_id,),
|
||||
), [])
|
||||
self.assertEqual(self.fetchall(
|
||||
"SELECT * FROM miner_network_identities WHERE miner_id = %s",
|
||||
(miner_id,),
|
||||
), [])
|
||||
self.assertEqual(len(self.fetchall(
|
||||
"SELECT * FROM repair_events WHERE repair_id = %s", (repair_id,)
|
||||
)), 1)
|
||||
owners = self.fetchall(
|
||||
"SELECT normalized_mac FROM miner_mac_ownerships WHERE miner_id = %s",
|
||||
(miner_id,),
|
||||
)
|
||||
self.assertEqual(owners, [{"normalized_mac": "02aabbccef01"}])
|
||||
|
||||
def test_concurrent_replacements_on_one_miner_succeed_once(self):
|
||||
miner_id, _, repair_id = self.create_repairing_miner(
|
||||
"02:AA:BB:CC:F0:01", "CB-OLD"
|
||||
)
|
||||
barrier = Barrier(2)
|
||||
|
||||
def replace(mac_address):
|
||||
barrier.wait()
|
||||
return self.replace(miner_id, mac_address)
|
||||
|
||||
outcomes = []
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
futures = [
|
||||
executor.submit(replace, "02:AA:BB:CC:F0:02"),
|
||||
executor.submit(replace, "02:AA:BB:CC:F0:04"),
|
||||
]
|
||||
for future in futures:
|
||||
try:
|
||||
outcomes.append(future.result(timeout=15))
|
||||
except ControlBoardReplacementConflict as exc:
|
||||
outcomes.append(exc)
|
||||
self.assertEqual(sum(not isinstance(item, Exception) for item in outcomes), 1)
|
||||
self.assertEqual(sum(isinstance(item, Exception) for item in outcomes), 1)
|
||||
self.assertEqual(len(self.fetchall(
|
||||
"SELECT * FROM miner_control_board_installations "
|
||||
"WHERE miner_id = %s AND removed_at IS NULL", (miner_id,)
|
||||
)), 1)
|
||||
self.assertEqual(len(self.fetchall(
|
||||
"SELECT * FROM repair_events WHERE repair_id = %s "
|
||||
"AND event_type = 'CONTROL_BOARD_REPLACED'", (repair_id,)
|
||||
)), 1)
|
||||
|
||||
def test_two_miners_cannot_concurrently_claim_one_mac(self):
|
||||
first, _, _ = self.create_repairing_miner("02:AA:BB:CC:F1:01", "CB-1")
|
||||
second, _, _ = self.create_repairing_miner("02:AA:BB:CC:F1:02", "CB-2")
|
||||
barrier = Barrier(2)
|
||||
|
||||
def replace(miner_id):
|
||||
barrier.wait()
|
||||
return self.replace(miner_id, "02:AA:BB:CC:F1:04")
|
||||
|
||||
outcomes = []
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
futures = [executor.submit(replace, first), executor.submit(replace, second)]
|
||||
for future in futures:
|
||||
try:
|
||||
outcomes.append(future.result(timeout=15))
|
||||
except ControlBoardReplacementConflict as exc:
|
||||
outcomes.append(exc)
|
||||
self.assertEqual(sum(not isinstance(item, Exception) for item in outcomes), 1)
|
||||
self.assertEqual(sum(isinstance(item, Exception) for item in outcomes), 1)
|
||||
owner = self.fetchall(
|
||||
"SELECT miner_id FROM miner_mac_ownerships "
|
||||
"WHERE normalized_mac = '02aabbccf104'"
|
||||
)
|
||||
self.assertEqual(len(owner), 1)
|
||||
|
||||
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user