feat: add inventory metadata updates

This commit is contained in:
lussierj committed 2026-07-28 19:30:51 +00:00
1 parent 51aec1b404
commit a87ab2709f
11 files changed
+1048 -12

No files matched your search

+251
View File
@@ -10,10 +10,13 @@ from fastapi.testclient import TestClient
from app.identity import get_current_user
from app.inventory.dependencies import get_inventory_service
from app.inventory.exceptions import (
DuplicateAssetID,
InventoryValidationError,
MinerNotFound,
)
from app.inventory.models import (
InventoryCreateRequest,
InventoryMetadataUpdateRequest,
InventoryMinerDetail,
InventoryMinerSummary,
InventorySearchFilters,
@@ -28,6 +31,14 @@ class StubInventoryService:
self.search_filters: InventorySearchFilters | None = None
self.detail_miner_id: UUID | None = None
self.search_error: InventoryValidationError | None = None
self.create_result: InventoryMinerDetail | None = None
self.create_error: Exception | None = None
self.create_request: InventoryCreateRequest | None = None
self.update_result: InventoryMinerDetail | None = None
self.update_error: Exception | None = None
self.update_miner_id: UUID | None = None
self.update_request: InventoryMetadataUpdateRequest | None = None
self.update_actor_user_id: UUID | None = None
def search_miners(
self,
@@ -38,6 +49,33 @@ class StubInventoryService:
raise self.search_error
return self.search_result
def create_miner(
self,
request: InventoryCreateRequest,
) -> InventoryMinerDetail:
self.create_request = request
if self.create_error is not None:
raise self.create_error
if self.create_result is None:
raise AssertionError("create_result was not configured")
return self.create_result
def update_metadata(
self,
miner_id: UUID,
request: InventoryMetadataUpdateRequest,
*,
actor_user_id: UUID,
) -> InventoryMinerDetail:
self.update_miner_id = miner_id
self.update_request = request
self.update_actor_user_id = actor_user_id
if self.update_error is not None:
raise self.update_error
if self.update_result is None:
raise AssertionError("update_result was not configured")
return self.update_result
def get_miner(self, miner_id: UUID) -> InventoryMinerDetail:
self.detail_miner_id = miner_id
if self.detail_result is None or self.detail_result.id != miner_id:
@@ -89,6 +127,219 @@ class InventoryRouterTests(unittest.TestCase):
"permissions": list(permissions),
}
def test_create_requires_authentication(self):
response = self.client.post(
"/api/inventory/miners",
json={"asset_id": "ASSET-NEW"},
)
self.assertEqual(response.status_code, 401)
self.assertEqual(response.json()["detail"], "Login required")
def test_create_requires_miners_update_permission(self):
self.authenticate(permissions=("miners.view",))
response = self.client.post(
"/api/inventory/miners",
json={"asset_id": "ASSET-NEW"},
)
self.assertEqual(response.status_code, 403)
self.assertIsNone(self.service.create_request)
def test_create_returns_new_miner_in_typed_envelope(self):
self.authenticate(permissions=("miners.update",))
created = miner_detail()
self.service.create_result = created
response = self.client.post(
"/api/inventory/miners",
json={
"asset_id": " ASSET-NEW ",
"lifecycle_status": "spare",
"site_id": str(uuid4()),
"tags": ["Priority", "Repair"],
},
)
self.assertEqual(response.status_code, 201)
self.assertEqual(response.json()["data"]["id"], str(created.id))
self.assertEqual(response.json()["meta"], {})
self.assertIsNone(response.json()["error"])
request = self.service.create_request
self.assertIsNotNone(request)
self.assertEqual(request.asset_id, " ASSET-NEW ")
self.assertEqual(request.lifecycle_status, "spare")
self.assertEqual(request.tags, ["Priority", "Repair"])
def test_duplicate_asset_id_returns_conflict(self):
self.authenticate(permissions=("miners.update",))
self.service.create_error = DuplicateAssetID("ASSET-001")
response = self.client.post(
"/api/inventory/miners",
json={"asset_id": "ASSET-001"},
)
self.assertEqual(response.status_code, 409)
self.assertEqual(
response.json()["detail"],
"Asset ID already exists: ASSET-001",
)
def test_invalid_create_body_returns_validation_error(self):
self.authenticate(permissions=("miners.update",))
for body in (
{},
{"asset_id": "A" * 121},
{"asset_id": "ASSET-001", "site_id": "not-a-uuid"},
):
with self.subTest(body=body):
response = self.client.post(
"/api/inventory/miners",
json=body,
)
self.assertEqual(response.status_code, 422)
self.assertIsNone(self.service.create_request)
def test_create_maps_domain_validation_error(self):
self.authenticate(permissions=("miners.update",))
self.service.create_error = InventoryValidationError(
"Tag names cannot be blank"
)
response = self.client.post(
"/api/inventory/miners",
json={"asset_id": "ASSET-001", "tags": [" "]},
)
self.assertEqual(response.status_code, 422)
self.assertEqual(
response.json()["detail"],
"Tag names cannot be blank",
)
def test_patch_requires_authentication(self):
response = self.client.patch(
f"/api/inventory/miners/{uuid4()}",
json={"model": "M60"},
)
self.assertEqual(response.status_code, 401)
self.assertEqual(response.json()["detail"], "Login required")
def test_patch_requires_miners_update_permission(self):
self.authenticate(permissions=("miners.view",))
response = self.client.patch(
f"/api/inventory/miners/{uuid4()}",
json={"model": "M60"},
)
self.assertEqual(response.status_code, 403)
self.assertIsNone(self.service.update_request)
def test_patch_unknown_miner_returns_not_found(self):
self.authenticate(permissions=("miners.update",))
miner_id = uuid4()
self.service.update_error = MinerNotFound(miner_id)
response = self.client.patch(
f"/api/inventory/miners/{miner_id}",
json={"model": "M60"},
)
self.assertEqual(response.status_code, 404)
self.assertEqual(response.json()["detail"], "Miner not found")
def test_patch_malformed_uuid_returns_validation_error(self):
self.authenticate(permissions=("miners.update",))
response = self.client.patch(
"/api/inventory/miners/not-a-uuid",
json={"model": "M60"},
)
self.assertEqual(response.status_code, 422)
self.assertIsNone(self.service.update_request)
def test_empty_patch_maps_domain_validation_error(self):
self.authenticate(permissions=("miners.update",))
self.service.update_error = InventoryValidationError(
"At least one metadata field must be provided"
)
response = self.client.patch(
f"/api/inventory/miners/{uuid4()}",
json={},
)
self.assertEqual(response.status_code, 422)
self.assertEqual(
response.json()["detail"],
"At least one metadata field must be provided",
)
def test_patch_rejects_unknown_and_operational_fields(self):
self.authenticate(permissions=("miners.update",))
for body in (
{"unknown_field": "value"},
{"lifecycle_status": "retired"},
{"current_ip": "192.0.2.10"},
{"current_mac": "AA:BB:CC:DD:EE:FF"},
{"control_board_sn": "CB-NEW"},
{"site_id": str(uuid4())},
{"room_id": str(uuid4())},
{"tags": ["Priority"]},
{"firmware_version": "1.2.3"},
{"hostname": "miner-host"},
):
with self.subTest(body=body):
response = self.client.patch(
f"/api/inventory/miners/{uuid4()}",
json=body,
)
self.assertEqual(response.status_code, 422)
self.assertIsNone(self.service.update_request)
def test_valid_metadata_patch_returns_typed_envelope(self):
self.authenticate(permissions=("miners.update",))
updated = miner_detail()
self.service.update_result = updated
response = self.client.patch(
f"/api/inventory/miners/{updated.id}",
json={
"asset_id": " ASSET-NEW ",
"miner_sn": "MINER-NEW",
"psu_serial": None,
"model": "M60",
"notes": "Bench verified",
},
)
self.assertEqual(response.status_code, 200)
body = response.json()
self.assertEqual(body["data"]["id"], str(updated.id))
self.assertEqual(body["meta"], {})
self.assertIsNone(body["error"])
self.assertEqual(self.service.update_miner_id, updated.id)
self.assertIsInstance(self.service.update_actor_user_id, UUID)
request = self.service.update_request
self.assertIsNotNone(request)
self.assertEqual(
request.model_fields_set,
{"asset_id", "miner_sn", "psu_serial", "model", "notes"},
)
self.assertIsNone(request.psu_serial)
def test_patch_duplicate_asset_id_returns_conflict(self):
self.authenticate(permissions=("miners.update",))
self.service.update_error = DuplicateAssetID("ASSET-TAKEN")
response = self.client.patch(
f"/api/inventory/miners/{uuid4()}",
json={"asset_id": "ASSET-TAKEN"},
)
self.assertEqual(response.status_code, 409)
self.assertEqual(
response.json()["detail"],
"Asset ID already exists: ASSET-TAKEN",
)
def test_list_requires_authentication(self):
response = self.client.get("/api/inventory/miners")
self.assertEqual(response.status_code, 401)