feat: add read-only inventory API

This commit is contained in:
lussierj committed 2026-07-28 18:31:57 +00:00
1 parent 979885bbdd
commit 51aec1b404
7 files changed
+346 -6

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
"""Dependency construction for the Inventory domain."""
from app.inventory.repository import InventoryRepository
from app.inventory.service import InventoryService
def get_inventory_service() -> InventoryService:
return InventoryService(InventoryRepository())
+18
View File
@@ -84,3 +84,21 @@ class InventorySearchFilters(BaseModel):
tags: list[str] = Field(default_factory=list)
limit: int = Field(default=100, ge=1, le=500)
offset: int = Field(default=0, ge=0)
class InventoryListMeta(BaseModel):
limit: int
offset: int
returned: int
class InventoryMinerListResponse(BaseModel):
data: list[InventoryMinerSummary]
meta: InventoryListMeta
error: None = None
class InventoryMinerDetailResponse(BaseModel):
data: InventoryMinerDetail
meta: dict[str, object] = Field(default_factory=dict)
error: None = None
+71
View File
@@ -0,0 +1,71 @@
"""Read-only HTTP routes for the Inventory domain."""
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from app.identity import require_permission
from app.inventory.dependencies import get_inventory_service
from app.inventory.exceptions import InventoryValidationError, MinerNotFound
from app.inventory.models import (
InventoryListMeta,
InventoryMinerDetailResponse,
InventoryMinerListResponse,
InventorySearchFilters,
)
from app.inventory.service import InventoryService
router = APIRouter(prefix="/api/inventory/miners", tags=["inventory"])
require_inventory_read = require_permission("miners.view")
InventoryServiceDependency = Annotated[
InventoryService,
Depends(get_inventory_service),
]
InventoryReadUser = Annotated[dict, Depends(require_inventory_read)]
InventoryQuery = Annotated[InventorySearchFilters, Query()]
@router.get("", response_model=InventoryMinerListResponse)
def list_inventory_miners(
filters: InventoryQuery,
service: InventoryServiceDependency,
current_user: InventoryReadUser,
) -> InventoryMinerListResponse:
del current_user
try:
miners = service.search_miners(filters)
except InventoryValidationError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(exc),
) from exc
return InventoryMinerListResponse(
data=miners,
meta=InventoryListMeta(
limit=filters.limit,
offset=filters.offset,
returned=len(miners),
),
)
@router.get("/{miner_id}", response_model=InventoryMinerDetailResponse)
def get_inventory_miner(
miner_id: UUID,
service: InventoryServiceDependency,
current_user: InventoryReadUser,
) -> InventoryMinerDetailResponse:
del current_user
try:
miner = service.get_miner(miner_id)
except MinerNotFound as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Miner not found",
) from exc
return InventoryMinerDetailResponse(data=miner)
+2
View File
@@ -17,10 +17,12 @@ from app.identity import (
install_identity_middleware,
router as identity_router,
)
from app.inventory.router import router as inventory_router
app = FastAPI(title=settings.app_name)
app.include_router(identity_router)
app.include_router(inventory_router)
install_identity_middleware(app)
+1
View File
@@ -11,3 +11,4 @@ email-validator==2.3.0
python-multipart==0.0.20
asyncpg
pycryptodome==3.23.0
httpx==0.28.1
+10 -6
View File
@@ -281,11 +281,17 @@ class InventoryRepositoryIntegrationTests(unittest.TestCase):
class InventoryArchitectureTests(unittest.TestCase):
def test_inventory_package_has_no_http_layer_or_router(self):
import app.inventory as inventory
def test_inventory_domain_layers_have_no_http_dependency(self):
inventory_path = Path(__file__).parents[1] / "app" / "inventory"
for source_path in inventory_path.glob("*.py"):
domain_files = (
"dependencies.py",
"exceptions.py",
"models.py",
"repository.py",
"service.py",
)
for filename in domain_files:
source_path = inventory_path / filename
tree = ast.parse(source_path.read_text(encoding="utf-8"))
imported_modules = []
for node in ast.walk(tree):
@@ -298,8 +304,6 @@ class InventoryArchitectureTests(unittest.TestCase):
source_path,
)
self.assertFalse(hasattr(inventory, "router"))
if __name__ == "__main__":
unittest.main()
+236
View File
@@ -0,0 +1,236 @@
import ast
import unittest
from datetime import datetime, timezone
from pathlib import Path
from uuid import UUID, uuid4
from fastapi import FastAPI
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 (
InventoryValidationError,
MinerNotFound,
)
from app.inventory.models import (
InventoryMinerDetail,
InventoryMinerSummary,
InventorySearchFilters,
)
from app.inventory.router import router
class StubInventoryService:
def __init__(self):
self.search_result: list[InventoryMinerSummary] = []
self.detail_result: InventoryMinerDetail | None = None
self.search_filters: InventorySearchFilters | None = None
self.detail_miner_id: UUID | None = None
self.search_error: InventoryValidationError | None = None
def search_miners(
self,
filters: InventorySearchFilters,
) -> list[InventoryMinerSummary]:
self.search_filters = filters
if self.search_error is not None:
raise self.search_error
return self.search_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:
raise MinerNotFound(miner_id)
return self.detail_result
def miner_summary(miner_id: UUID | None = None) -> InventoryMinerSummary:
now = datetime.now(timezone.utc)
return InventoryMinerSummary(
id=miner_id or uuid4(),
asset_id="ASSET-001",
model="M50",
lifecycle_status="active",
runtime_status="unknown",
created_at=now,
updated_at=now,
)
def miner_detail(miner_id: UUID | None = None) -> InventoryMinerDetail:
summary = miner_summary(miner_id)
return InventoryMinerDetail(
**summary.model_dump(),
miner_sn="MINER-001",
control_board_sn="CB-001",
psu_serial="PSU-001",
)
class InventoryRouterTests(unittest.TestCase):
def setUp(self):
self.service = StubInventoryService()
self.app = FastAPI()
self.app.include_router(router)
self.app.dependency_overrides[get_inventory_service] = (
lambda: self.service
)
self.client = TestClient(self.app)
def tearDown(self):
self.client.close()
def authenticate(self, permissions=("miners.view",)):
self.app.dependency_overrides[get_current_user] = lambda: {
"id": str(uuid4()),
"username": "reader",
"role": "Read Only",
"permissions": list(permissions),
}
def test_list_requires_authentication(self):
response = self.client.get("/api/inventory/miners")
self.assertEqual(response.status_code, 401)
self.assertEqual(response.json()["detail"], "Login required")
def test_detail_requires_authentication(self):
response = self.client.get(f"/api/inventory/miners/{uuid4()}")
self.assertEqual(response.status_code, 401)
self.assertEqual(response.json()["detail"], "Login required")
def test_list_requires_miners_view_permission(self):
self.authenticate(permissions=())
response = self.client.get("/api/inventory/miners")
self.assertEqual(response.status_code, 403)
def test_authenticated_list_returns_typed_envelope(self):
self.authenticate()
self.service.search_result = [miner_summary()]
response = self.client.get("/api/inventory/miners")
self.assertEqual(response.status_code, 200)
body = response.json()
self.assertEqual(len(body["data"]), 1)
self.assertEqual(body["data"][0]["asset_id"], "ASSET-001")
self.assertEqual(
body["meta"],
{"limit": 100, "offset": 0, "returned": 1},
)
self.assertIsNone(body["error"])
def test_empty_list_returns_successful_empty_collection(self):
self.authenticate()
response = self.client.get("/api/inventory/miners")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["data"], [])
self.assertEqual(response.json()["meta"]["returned"], 0)
def test_search_filters_are_passed_to_service(self):
self.authenticate()
response = self.client.get(
"/api/inventory/miners",
params=[
("query", "M50"),
("asset_id", "ASSET-001"),
("lifecycle_status", "spare"),
("tags", "Priority"),
("tags", "Repair"),
("limit", "25"),
("offset", "50"),
],
)
self.assertEqual(response.status_code, 200)
filters = self.service.search_filters
self.assertIsNotNone(filters)
self.assertEqual(filters.query, "M50")
self.assertEqual(filters.asset_id, "ASSET-001")
self.assertEqual(filters.lifecycle_status, "spare")
self.assertEqual(filters.tags, ["Priority", "Repair"])
self.assertEqual(filters.limit, 25)
self.assertEqual(filters.offset, 50)
def test_invalid_lifecycle_filter_returns_client_error(self):
self.authenticate()
self.service.search_error = InventoryValidationError(
"Lifecycle status must be active, spare, or retired"
)
response = self.client.get(
"/api/inventory/miners",
params={"lifecycle_status": "broken"},
)
self.assertEqual(response.status_code, 422)
self.assertIn("Lifecycle status", response.json()["detail"])
def test_pagination_is_validated_and_bounded(self):
self.authenticate()
for params in (
{"limit": 0},
{"limit": 501},
{"offset": -1},
):
with self.subTest(params=params):
response = self.client.get(
"/api/inventory/miners",
params=params,
)
self.assertEqual(response.status_code, 422)
def test_existing_uuid_returns_inventory_detail(self):
self.authenticate()
expected = miner_detail()
self.service.detail_result = expected
response = self.client.get(
f"/api/inventory/miners/{expected.id}"
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["data"]["id"], str(expected.id))
self.assertEqual(response.json()["data"]["psu_serial"], "PSU-001")
self.assertEqual(self.service.detail_miner_id, expected.id)
def test_unknown_uuid_returns_not_found(self):
self.authenticate()
response = self.client.get(f"/api/inventory/miners/{uuid4()}")
self.assertEqual(response.status_code, 404)
self.assertEqual(response.json()["detail"], "Miner not found")
def test_malformed_uuid_returns_client_error(self):
self.authenticate()
response = self.client.get("/api/inventory/miners/not-a-uuid")
self.assertEqual(response.status_code, 422)
def test_router_has_no_sql_or_database_driver_imports(self):
router_path = (
Path(__file__).parents[1] / "app" / "inventory" / "router.py"
)
source = router_path.read_text(encoding="utf-8")
tree = ast.parse(source)
imported_modules = []
for node in ast.walk(tree):
if isinstance(node, ast.Import):
imported_modules.extend(alias.name for alias in node.names)
elif isinstance(node, ast.ImportFrom) and node.module:
imported_modules.append(node.module)
database_modules = ("psycopg", "asyncpg", "sqlalchemy")
self.assertFalse(
any(
module == name or module.startswith(f"{name}.")
for module in imported_modules
for name in database_modules
)
)
upper_source = source.upper()
for keyword in ("SELECT ", "INSERT ", "UPDATE ", "DELETE "):
self.assertNotIn(keyword, upper_source)
if __name__ == "__main__":
unittest.main()