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: