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

+255 -3
View File
@@ -1,13 +1,17 @@
import ast
import unittest
from copy import deepcopy
from contextlib import contextmanager
from datetime import datetime, timezone
from pathlib import Path
from uuid import UUID, uuid4
from psycopg.errors import ForeignKeyViolation, UniqueViolation
from app.inventory import (
DuplicateAssetID,
InventoryCreateRequest,
InventoryMetadataUpdateRequest,
InventoryRepository,
InventorySearchFilters,
InventoryService,
@@ -48,14 +52,26 @@ class FakeInventoryRepository:
def __init__(self):
self.rows = {}
self.created_values = None
self.asset_id_history = []
self.update_error = None
@contextmanager
def transaction(self):
yield self
rows_snapshot = deepcopy(self.rows)
history_snapshot = deepcopy(self.asset_id_history)
try:
yield self
except Exception:
self.rows = rows_snapshot
self.asset_id_history = history_snapshot
raise
def get_miner_by_id(self, miner_id):
return self.rows.get(miner_id)
def get_miner_by_id_for_update(self, miner_id):
return self.get_miner_by_id(miner_id)
def get_miner_by_asset_id(self, asset_id):
key = asset_id.strip().casefold()
return next(
@@ -86,6 +102,8 @@ class FakeInventoryRepository:
return row
def update_miner(self, miner_id, values):
if self.update_error is not None:
raise self.update_error
row = self.rows.get(miner_id)
if not row:
return None
@@ -93,6 +111,27 @@ class FakeInventoryRepository:
row["updated_at"] = datetime.now(timezone.utc)
return row
def add_asset_id_history(
self, miner_id, old_asset_id, new_asset_id, changed_by
):
history = {
"id": uuid4(),
"miner_id": miner_id,
"old_asset_id": old_asset_id,
"new_asset_id": new_asset_id,
"changed_at": datetime.now(timezone.utc),
"changed_by": changed_by,
}
self.asset_id_history.append(history)
return history
def list_asset_id_history(self, miner_id):
return [
row
for row in self.asset_id_history
if row["miner_id"] == miner_id
]
def add_tag(self, miner_id, tag_name):
now = datetime.now(timezone.utc)
tag = {
@@ -121,6 +160,7 @@ class InventoryServiceTests(unittest.TestCase):
def setUp(self):
self.repository = FakeInventoryRepository()
self.service = InventoryService(self.repository)
self.actor_user_id = uuid4()
def test_create_normalizes_asset_id_tags_and_never_seen(self):
created = self.service.create_miner(
@@ -167,6 +207,123 @@ class InventoryServiceTests(unittest.TestCase):
with self.assertRaises(InventoryValidationError):
self.service.normalize_tags(["valid", "\t\n"])
def test_metadata_update_preserves_unspecified_fields(self):
row = inventory_row(
asset_tag="ASSET-001",
miner_sn="MINER-OLD",
power_sn="PSU-OLD",
model="M50",
notes="Old notes",
)
self.repository.rows[row["id"]] = row
updated = self.service.update_metadata(
row["id"],
InventoryMetadataUpdateRequest(
model="M60",
notes=None,
),
actor_user_id=self.actor_user_id,
)
self.assertEqual(updated.id, row["id"])
self.assertEqual(updated.asset_id, "ASSET-001")
self.assertEqual(updated.miner_sn, "MINER-OLD")
self.assertEqual(updated.psu_serial, "PSU-OLD")
self.assertEqual(updated.model, "M60")
self.assertIsNone(updated.notes)
self.assertEqual(self.repository.asset_id_history, [])
def test_asset_id_change_preserves_uuid_and_creates_history(self):
row = inventory_row(asset_tag="ASSET-OLD")
self.repository.rows[row["id"]] = row
updated = self.service.update_metadata(
row["id"],
InventoryMetadataUpdateRequest(asset_id=" ASSET-NEW "),
actor_user_id=self.actor_user_id,
)
self.assertEqual(updated.id, row["id"])
self.assertEqual(updated.asset_id, "ASSET-NEW")
self.assertEqual(len(self.repository.asset_id_history), 1)
history = self.repository.asset_id_history[0]
self.assertEqual(history["miner_id"], row["id"])
self.assertEqual(history["old_asset_id"], "ASSET-OLD")
self.assertEqual(history["new_asset_id"], "ASSET-NEW")
self.assertEqual(history["changed_by"], self.actor_user_id)
def test_unchanged_normalized_asset_id_creates_no_history(self):
row = inventory_row(asset_tag="ASSET-001")
original_updated_at = row["updated_at"]
self.repository.rows[row["id"]] = row
updated = self.service.update_metadata(
row["id"],
InventoryMetadataUpdateRequest(asset_id=" ASSET-001 "),
actor_user_id=self.actor_user_id,
)
self.assertEqual(updated.asset_id, "ASSET-001")
self.assertEqual(updated.updated_at, original_updated_at)
self.assertEqual(self.repository.asset_id_history, [])
def test_duplicate_asset_id_leaves_miner_and_history_unchanged(self):
row = inventory_row(asset_tag="ASSET-OLD")
duplicate = inventory_row(asset_tag="ASSET-TAKEN")
self.repository.rows[row["id"]] = row
self.repository.rows[duplicate["id"]] = duplicate
with self.assertRaises(DuplicateAssetID):
self.service.update_metadata(
row["id"],
InventoryMetadataUpdateRequest(asset_id=" asset-taken "),
actor_user_id=self.actor_user_id,
)
self.assertEqual(
self.repository.rows[row["id"]]["asset_tag"],
"ASSET-OLD",
)
self.assertEqual(self.repository.asset_id_history, [])
def test_empty_or_invalid_metadata_update_does_not_write(self):
row = inventory_row(asset_tag="ASSET-OLD", model="M50")
self.repository.rows[row["id"]] = row
for request in (
InventoryMetadataUpdateRequest(),
InventoryMetadataUpdateRequest(asset_id=" ", model="M60"),
):
with self.subTest(request=request):
with self.assertRaises(InventoryValidationError):
self.service.update_metadata(
row["id"],
request,
actor_user_id=self.actor_user_id,
)
self.assertEqual(self.repository.rows[row["id"]]["model"], "M50")
self.assertEqual(self.repository.asset_id_history, [])
def test_uniqueness_race_becomes_duplicate_asset_id(self):
row = inventory_row(asset_tag="ASSET-OLD")
self.repository.rows[row["id"]] = row
self.repository.update_error = UniqueViolation("duplicate Asset ID")
with self.assertRaises(DuplicateAssetID):
self.service.update_metadata(
row["id"],
InventoryMetadataUpdateRequest(asset_id="ASSET-NEW"),
actor_user_id=self.actor_user_id,
)
self.assertEqual(
self.repository.rows[row["id"]]["asset_tag"],
"ASSET-OLD",
)
self.assertEqual(self.repository.asset_id_history, [])
def test_update_maps_product_names_without_repository_rules(self):
row = inventory_row(asset_tag="ASSET-OLD", power_sn="PSU-OLD")
self.repository.rows[row["id"]] = row
@@ -174,16 +331,34 @@ class InventoryServiceTests(unittest.TestCase):
updated = self.service.update_miner(
row["id"],
InventoryUpdateRequest(
asset_id=" ASSET-NEW ",
psu_serial=" PSU-NEW ",
lifecycle_status="retired",
),
)
self.assertEqual(updated.asset_id, "ASSET-NEW")
self.assertEqual(updated.asset_id, "ASSET-OLD")
self.assertEqual(updated.psu_serial, "PSU-NEW")
self.assertEqual(updated.lifecycle_status, "retired")
def test_legacy_update_rejects_asset_id_change_without_actor(self):
row = inventory_row(asset_tag="ASSET-OLD")
self.repository.rows[row["id"]] = row
with self.assertRaisesRegex(
InventoryValidationError,
"actor-attributed metadata update",
):
self.service.update_miner(
row["id"],
InventoryUpdateRequest(asset_id="ASSET-NEW"),
)
self.assertEqual(
self.repository.rows[row["id"]]["asset_tag"],
"ASSET-OLD",
)
self.assertEqual(self.repository.asset_id_history, [])
def test_search_validation_precedes_repository(self):
with self.assertRaises(InventoryValidationError):
self.service.search_miners(
@@ -197,11 +372,30 @@ class InventoryRepositoryIntegrationTests(unittest.TestCase):
cls.repository = InventoryRepository()
def setUp(self):
self.actor_user_id = UUID("00000000-0000-0000-0000-000000000951")
with self.repository.transaction() as repository:
with repository._connection.cursor() as cur:
cur.execute("DELETE FROM miner_asset_id_history")
cur.execute("DELETE FROM miner_tags")
cur.execute("DELETE FROM tags")
cur.execute("DELETE FROM miners")
cur.execute(
"""
INSERT INTO users (
id, username, display_name, password_hash, role_id
)
SELECT %s, %s, %s, %s, id
FROM roles
WHERE name = 'Admin'
ON CONFLICT (id) DO NOTHING
""",
(
self.actor_user_id,
"inventory-history-test",
"Inventory History Test",
"not-used",
),
)
def test_repository_crud_search_and_tags(self):
created = self.repository.create_miner(
@@ -262,6 +456,64 @@ class InventoryRepositoryIntegrationTests(unittest.TestCase):
[],
)
def test_repeated_asset_id_changes_preserve_chronological_history(self):
service = InventoryService(self.repository)
created = service.create_miner(
InventoryCreateRequest(asset_id="ASSET-FIRST")
)
second = service.update_metadata(
created.id,
InventoryMetadataUpdateRequest(asset_id="ASSET-SECOND"),
actor_user_id=self.actor_user_id,
)
third = service.update_metadata(
created.id,
InventoryMetadataUpdateRequest(asset_id="ASSET-THIRD"),
actor_user_id=self.actor_user_id,
)
self.assertEqual(created.id, second.id)
self.assertEqual(second.id, third.id)
history = self.repository.list_asset_id_history(created.id)
self.assertEqual(len(history), 2)
self.assertEqual(
[
(row["old_asset_id"], row["new_asset_id"])
for row in history
],
[
("ASSET-FIRST", "ASSET-SECOND"),
("ASSET-SECOND", "ASSET-THIRD"),
],
)
self.assertLessEqual(
history[0]["changed_at"],
history[1]["changed_at"],
)
def test_history_failure_rolls_back_asset_id_update(self):
service = InventoryService(self.repository)
created = service.create_miner(
InventoryCreateRequest(asset_id="ASSET-ROLLBACK-OLD")
)
with self.assertRaises(ForeignKeyViolation):
service.update_metadata(
created.id,
InventoryMetadataUpdateRequest(
asset_id="ASSET-ROLLBACK-NEW"
),
actor_user_id=uuid4(),
)
persisted = self.repository.get_miner_by_id(created.id)
self.assertEqual(persisted["asset_tag"], "ASSET-ROLLBACK-OLD")
self.assertEqual(
self.repository.list_asset_id_history(created.id),
[],
)
def test_transaction_rolls_back_repository_writes(self):
with self.assertRaises(RuntimeError):
with self.repository.transaction() as repository:
+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)