feat: add inventory metadata updates
This commit is contained in:
1 parent
51aec1b404
commit
a87ab2709f
11 files changed
+1048
-12
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user