Add inventory domain foundation

This commit is contained in:
lussierj committed 2026-07-28 16:18:11 +00:00
1 parent d4df20b740
commit d1d02962da
8 files changed
+1135

No files matched your search

+33
View File
@@ -0,0 +1,33 @@
"""Inventory domain models, persistence, and business services."""
from app.inventory.exceptions import (
DuplicateAssetID,
InventoryError,
InventoryValidationError,
MinerNotFound,
)
from app.inventory.models import (
InventoryCreateRequest,
InventoryMinerDetail,
InventoryMinerSummary,
InventorySearchFilters,
InventoryTag,
InventoryUpdateRequest,
)
from app.inventory.repository import InventoryRepository
from app.inventory.service import InventoryService
__all__ = [
"DuplicateAssetID",
"InventoryCreateRequest",
"InventoryError",
"InventoryMinerDetail",
"InventoryMinerSummary",
"InventoryRepository",
"InventorySearchFilters",
"InventoryService",
"InventoryTag",
"InventoryUpdateRequest",
"InventoryValidationError",
"MinerNotFound",
]
+25
View File
@@ -0,0 +1,25 @@
"""Inventory domain exceptions."""
class InventoryError(Exception):
"""Base exception for inventory operations."""
class InventoryValidationError(InventoryError):
"""Raised when an inventory business rule is violated."""
class DuplicateAssetID(InventoryError):
"""Raised when an Asset ID is already assigned to another miner."""
def __init__(self, asset_id: str):
self.asset_id = asset_id
super().__init__(f"Asset ID already exists: {asset_id}")
class MinerNotFound(InventoryError):
"""Raised when an inventory miner cannot be found."""
def __init__(self, miner_id: object):
self.miner_id = miner_id
super().__init__(f"Miner not found: {miner_id}")
+86
View File
@@ -0,0 +1,86 @@
"""Pydantic contracts for the inventory domain."""
from datetime import datetime
from typing import Literal
from uuid import UUID
from pydantic import BaseModel, Field, IPvAnyAddress
LifecycleStatus = Literal["active", "spare", "retired"]
class InventoryTag(BaseModel):
id: UUID
name: str
created_at: datetime
updated_at: datetime
class InventoryMinerSummary(BaseModel):
id: UUID
asset_id: str | None = None
current_mac: str | None = None
current_ip: str | None = None
model: str | None = None
lifecycle_status: LifecycleStatus
runtime_status: str
site_id: UUID | None = None
room_id: UUID | None = None
tags: list[InventoryTag] = Field(default_factory=list)
created_at: datetime
updated_at: datetime
last_seen_at: datetime | None = None
class InventoryMinerDetail(InventoryMinerSummary):
hostname: str | None = None
miner_sn: str | None = None
control_board_sn: str | None = None
psu_serial: str | None = None
firmware_version: str | None = None
notes: str | None = None
first_seen_at: datetime | None = None
class InventoryCreateRequest(BaseModel):
asset_id: str = Field(min_length=1, max_length=120)
current_mac: str | None = Field(default=None, max_length=64)
current_ip: IPvAnyAddress | None = None
hostname: str | None = Field(default=None, max_length=255)
miner_sn: str | None = Field(default=None, max_length=255)
control_board_sn: str | None = Field(default=None, max_length=255)
psu_serial: str | None = Field(default=None, max_length=255)
model: str | None = Field(default=None, max_length=255)
firmware_version: str | None = Field(default=None, max_length=255)
lifecycle_status: str = "active"
site_id: UUID | None = None
room_id: UUID | None = None
notes: str | None = None
tags: list[str] = Field(default_factory=list)
class InventoryUpdateRequest(BaseModel):
asset_id: str | None = Field(default=None, max_length=120)
current_mac: str | None = Field(default=None, max_length=64)
current_ip: IPvAnyAddress | None = None
hostname: str | None = Field(default=None, max_length=255)
miner_sn: str | None = Field(default=None, max_length=255)
control_board_sn: str | None = Field(default=None, max_length=255)
psu_serial: str | None = Field(default=None, max_length=255)
model: str | None = Field(default=None, max_length=255)
firmware_version: str | None = Field(default=None, max_length=255)
lifecycle_status: str | None = None
site_id: UUID | None = None
room_id: UUID | None = None
notes: str | None = None
tags: list[str] | None = None
class InventorySearchFilters(BaseModel):
query: str | None = None
asset_id: str | None = None
lifecycle_status: str | None = None
tags: list[str] = Field(default_factory=list)
limit: int = Field(default=100, ge=1, le=500)
offset: int = Field(default=0, ge=0)
+337
View File
@@ -0,0 +1,337 @@
"""PostgreSQL persistence for the inventory domain."""
from collections.abc import Callable, Iterator, Mapping, Sequence
from contextlib import contextmanager
from typing import Any
from uuid import UUID
import psycopg
from psycopg.rows import dict_row
from app.config import settings
ConnectionFactory = Callable[[], psycopg.Connection]
TAG_TRIMMED_NAME_SQL = (
"regexp_replace(name, '^[[:space:]]+|[[:space:]]+$', '', 'g')"
)
MINER_SELECT = f"""
SELECT
miners.id,
miners.site_id,
miners.room_id,
miners.asset_tag,
miners.current_mac,
host(miners.current_ip) AS current_ip,
miners.hostname,
miners.miner_sn,
miners.control_board_sn,
miners.power_sn,
miners.model,
miners.firmware_version,
miners.lifecycle_status,
miners.status,
miners.notes,
miners.first_seen_at,
miners.last_seen_at,
miners.created_at,
miners.updated_at,
tag_data.tags
FROM miners
LEFT JOIN LATERAL (
SELECT COALESCE(
jsonb_agg(
jsonb_build_object(
'id', tags.id,
'name', tags.name,
'created_at', tags.created_at,
'updated_at', tags.updated_at
)
ORDER BY lower({TAG_TRIMMED_NAME_SQL}), tags.id
),
'[]'::jsonb
) AS tags
FROM miner_tags
JOIN tags ON tags.id = miner_tags.tag_id
WHERE miner_tags.miner_id = miners.id
) tag_data ON true
"""
MINER_WRITE_COLUMNS = {
"site_id",
"room_id",
"asset_tag",
"current_mac",
"current_ip",
"hostname",
"miner_sn",
"control_board_sn",
"power_sn",
"model",
"firmware_version",
"lifecycle_status",
"status",
"notes",
"first_seen_at",
"last_seen_at",
}
def default_connection_factory() -> psycopg.Connection:
return psycopg.connect(
host=settings.postgres_host,
port=settings.postgres_port,
dbname=settings.postgres_db,
user=settings.postgres_user,
password=settings.postgres_password,
connect_timeout=5,
)
class InventoryRepository:
"""Database-only inventory persistence with optional transaction binding."""
def __init__(
self,
connection_factory: ConnectionFactory | None = None,
*,
connection: psycopg.Connection | None = None,
):
self._connection_factory = connection_factory or default_connection_factory
self._connection = connection
@contextmanager
def transaction(self) -> Iterator["InventoryRepository"]:
if self._connection is not None:
yield self
return
connection = self._connection_factory()
transaction_repository = InventoryRepository(
self._connection_factory,
connection=connection,
)
try:
yield transaction_repository
connection.commit()
except Exception:
connection.rollback()
raise
finally:
connection.close()
@contextmanager
def _connection_scope(
self,
*,
write: bool = False,
) -> Iterator[psycopg.Connection]:
if self._connection is not None:
yield self._connection
return
connection = self._connection_factory()
try:
yield connection
if write:
connection.commit()
except Exception:
if write:
connection.rollback()
raise
finally:
connection.close()
@staticmethod
def _fetch_miner(cur, where_sql: str, params: Sequence[Any]) -> dict | None:
cur.execute(f"{MINER_SELECT} WHERE {where_sql}", params)
row = cur.fetchone()
return dict(row) if row else None
def get_miner_by_id(self, miner_id: UUID) -> dict | None:
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
return self._fetch_miner(cur, "miners.id = %s", (miner_id,))
def get_miner_by_asset_id(self, asset_id: str) -> dict | None:
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
return self._fetch_miner(
cur,
"lower(trim(miners.asset_tag)) = lower(trim(%s))",
(asset_id,),
)
def list_miners(self, *, limit: int = 100, offset: int = 0) -> list[dict]:
return self.search_miners(limit=limit, offset=offset)
def search_miners(
self,
*,
query: str | None = None,
asset_id: str | None = None,
lifecycle_status: str | None = None,
tag_names: Sequence[str] = (),
limit: int = 100,
offset: int = 0,
) -> list[dict]:
conditions: list[str] = []
params: list[Any] = []
if query:
pattern = f"%{query}%"
conditions.append(
"""
(
miners.asset_tag ILIKE %s
OR miners.current_mac ILIKE %s
OR host(miners.current_ip) ILIKE %s
OR miners.model ILIKE %s
OR miners.miner_sn ILIKE %s
OR miners.control_board_sn ILIKE %s
OR miners.power_sn ILIKE %s
OR miners.notes ILIKE %s
)
"""
)
params.extend([pattern] * 8)
if asset_id:
conditions.append("lower(trim(miners.asset_tag)) = lower(trim(%s))")
params.append(asset_id)
if lifecycle_status:
conditions.append("miners.lifecycle_status = %s")
params.append(lifecycle_status)
for tag_name in tag_names:
conditions.append(
f"""
EXISTS (
SELECT 1
FROM miner_tags search_miner_tags
JOIN tags search_tags ON search_tags.id = search_miner_tags.tag_id
WHERE search_miner_tags.miner_id = miners.id
AND lower({TAG_TRIMMED_NAME_SQL.replace("name", "search_tags.name")})
= %s
)
"""
)
params.append(tag_name)
where_sql = " AND ".join(conditions) if conditions else "true"
params.extend([limit, offset])
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
f"""
{MINER_SELECT}
WHERE {where_sql}
ORDER BY
lower(trim(miners.asset_tag)) NULLS LAST,
miners.created_at,
miners.id
LIMIT %s OFFSET %s
""",
params,
)
return [dict(row) for row in cur.fetchall()]
def create_miner(self, values: Mapping[str, Any]) -> dict:
clean_values = {
key: value for key, value in values.items() if key in MINER_WRITE_COLUMNS
}
columns = list(clean_values)
params = [clean_values[column] for column in columns]
with self._connection_scope(write=True) as connection:
with connection.cursor(row_factory=dict_row) as cur:
if columns:
placeholders = ", ".join(["%s"] * len(columns))
cur.execute(
f"""
INSERT INTO miners ({", ".join(columns)})
VALUES ({placeholders})
RETURNING id
""",
params,
)
else:
cur.execute("INSERT INTO miners DEFAULT VALUES RETURNING id")
miner_id = cur.fetchone()["id"]
return self._fetch_miner(cur, "miners.id = %s", (miner_id,))
def update_miner(self, miner_id: UUID, values: Mapping[str, Any]) -> dict | None:
clean_values = {
key: value for key, value in values.items() if key in MINER_WRITE_COLUMNS
}
if not clean_values:
return self.get_miner_by_id(miner_id)
assignments = [f"{column} = %s" for column in clean_values]
params = list(clean_values.values())
params.append(miner_id)
with self._connection_scope(write=True) as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
f"""
UPDATE miners
SET {", ".join(assignments)}, updated_at = now()
WHERE id = %s
RETURNING id
""",
params,
)
row = cur.fetchone()
if not row:
return None
return self._fetch_miner(cur, "miners.id = %s", (miner_id,))
def list_tags(self) -> list[dict]:
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
f"""
SELECT id, name, created_at, updated_at
FROM tags
ORDER BY lower({TAG_TRIMMED_NAME_SQL}), id
"""
)
return [dict(row) for row in cur.fetchall()]
def add_tag(self, miner_id: UUID, tag_name: str) -> dict:
with self._connection_scope(write=True) as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
f"""
INSERT INTO tags (name)
VALUES (%s)
ON CONFLICT (lower({TAG_TRIMMED_NAME_SQL}))
DO UPDATE SET updated_at = tags.updated_at
RETURNING id, name, created_at, updated_at
""",
(tag_name,),
)
tag = dict(cur.fetchone())
cur.execute(
"""
INSERT INTO miner_tags (miner_id, tag_id)
VALUES (%s, %s)
ON CONFLICT (miner_id, tag_id) DO NOTHING
""",
(miner_id, tag["id"]),
)
return tag
def remove_tag(self, miner_id: UUID, tag_id: UUID) -> bool:
with self._connection_scope(write=True) as connection:
with connection.cursor() as cur:
cur.execute(
"DELETE FROM miner_tags WHERE miner_id = %s AND tag_id = %s",
(miner_id, tag_id),
)
return cur.rowcount > 0
+290
View File
@@ -0,0 +1,290 @@
"""Business rules and orchestration for manual miner inventory."""
from collections.abc import Iterable
from typing import Any
from uuid import UUID
from psycopg.errors import UniqueViolation
from app.inventory.exceptions import (
DuplicateAssetID,
InventoryValidationError,
MinerNotFound,
)
from app.inventory.models import (
InventoryCreateRequest,
InventoryMinerDetail,
InventoryMinerSummary,
InventorySearchFilters,
InventoryTag,
InventoryUpdateRequest,
)
from app.inventory.repository import InventoryRepository
ALLOWED_LIFECYCLE_STATUSES = frozenset({"active", "spare", "retired"})
class InventoryService:
"""Inventory business rules independent of FastAPI and other transports."""
def __init__(self, repository: InventoryRepository):
self.repository = repository
@staticmethod
def normalize_asset_id(asset_id: str) -> str:
normalized = str(asset_id or "").strip()
if not normalized:
raise InventoryValidationError("Asset ID cannot be blank")
return normalized
@staticmethod
def validate_lifecycle_status(lifecycle_status: str) -> str:
normalized = str(lifecycle_status or "").strip().lower()
if normalized not in ALLOWED_LIFECYCLE_STATUSES:
allowed = ", ".join(sorted(ALLOWED_LIFECYCLE_STATUSES))
raise InventoryValidationError(
f"Lifecycle status must be one of: {allowed}"
)
return normalized
@staticmethod
def normalize_tags(tags: Iterable[str]) -> list[str]:
normalized: list[str] = []
seen: set[str] = set()
for raw_tag in tags:
tag = str(raw_tag or "").strip()
if not tag:
raise InventoryValidationError("Tag names cannot be blank")
key = tag.casefold()
if key not in seen:
normalized.append(tag)
seen.add(key)
return normalized
@staticmethod
def _clean_optional_text(value: str | None) -> str | None:
if value is None:
return None
cleaned = str(value).strip()
return cleaned or None
@staticmethod
def _tag_model(row: dict) -> InventoryTag:
return InventoryTag.model_validate(row)
@classmethod
def _summary_model(cls, row: dict) -> InventoryMinerSummary:
return InventoryMinerSummary(
id=row["id"],
asset_id=row.get("asset_tag"),
current_mac=row.get("current_mac"),
current_ip=row.get("current_ip"),
model=row.get("model"),
lifecycle_status=row["lifecycle_status"],
runtime_status=row["status"],
site_id=row.get("site_id"),
room_id=row.get("room_id"),
tags=[cls._tag_model(tag) for tag in row.get("tags") or []],
created_at=row["created_at"],
updated_at=row["updated_at"],
last_seen_at=row.get("last_seen_at"),
)
@classmethod
def _detail_model(cls, row: dict) -> InventoryMinerDetail:
summary = cls._summary_model(row)
return InventoryMinerDetail(
**summary.model_dump(),
hostname=row.get("hostname"),
miner_sn=row.get("miner_sn"),
control_board_sn=row.get("control_board_sn"),
psu_serial=row.get("power_sn"),
firmware_version=row.get("firmware_version"),
notes=row.get("notes"),
first_seen_at=row.get("first_seen_at"),
)
def get_miner(self, miner_id: UUID) -> InventoryMinerDetail:
row = self.repository.get_miner_by_id(miner_id)
if not row:
raise MinerNotFound(miner_id)
return self._detail_model(row)
def list_miners(
self,
*,
limit: int = 100,
offset: int = 0,
) -> list[InventoryMinerSummary]:
return [
self._summary_model(row)
for row in self.repository.list_miners(limit=limit, offset=offset)
]
def search_miners(
self,
filters: InventorySearchFilters,
) -> list[InventoryMinerSummary]:
lifecycle_status = None
if filters.lifecycle_status:
lifecycle_status = self.validate_lifecycle_status(
filters.lifecycle_status
)
asset_id = None
if filters.asset_id:
asset_id = self.normalize_asset_id(filters.asset_id)
tag_names = [
tag.casefold() for tag in self.normalize_tags(filters.tags)
]
return [
self._summary_model(row)
for row in self.repository.search_miners(
query=self._clean_optional_text(filters.query),
asset_id=asset_id,
lifecycle_status=lifecycle_status,
tag_names=tag_names,
limit=filters.limit,
offset=filters.offset,
)
]
def create_miner(
self,
request: InventoryCreateRequest,
) -> InventoryMinerDetail:
asset_id = self.normalize_asset_id(request.asset_id)
lifecycle_status = self.validate_lifecycle_status(
request.lifecycle_status
)
tags = self.normalize_tags(request.tags)
if self.repository.get_miner_by_asset_id(asset_id):
raise DuplicateAssetID(asset_id)
values: dict[str, Any] = {
"asset_tag": asset_id,
"current_mac": self._clean_optional_text(request.current_mac),
"current_ip": str(request.current_ip) if request.current_ip else None,
"hostname": self._clean_optional_text(request.hostname),
"miner_sn": self._clean_optional_text(request.miner_sn),
"control_board_sn": self._clean_optional_text(
request.control_board_sn
),
"power_sn": self._clean_optional_text(request.psu_serial),
"model": self._clean_optional_text(request.model),
"firmware_version": self._clean_optional_text(
request.firmware_version
),
"lifecycle_status": lifecycle_status,
"site_id": request.site_id,
"room_id": request.room_id,
"notes": self._clean_optional_text(request.notes),
"status": "unknown",
"first_seen_at": None,
"last_seen_at": None,
}
try:
with self.repository.transaction() as repository:
row = repository.create_miner(values)
for tag in tags:
repository.add_tag(row["id"], tag)
row = repository.get_miner_by_id(row["id"])
self._after_create(row)
except UniqueViolation as exc:
raise DuplicateAssetID(asset_id) from exc
return self._detail_model(row)
def update_miner(
self,
miner_id: UUID,
request: InventoryUpdateRequest,
) -> InventoryMinerDetail:
existing = self.repository.get_miner_by_id(miner_id)
if not existing:
raise MinerNotFound(miner_id)
values: dict[str, Any] = {}
fields_set = request.model_fields_set
if "asset_id" in fields_set:
if request.asset_id is None:
raise InventoryValidationError("Asset ID cannot be cleared")
asset_id = self.normalize_asset_id(request.asset_id)
duplicate = self.repository.get_miner_by_asset_id(asset_id)
if duplicate and duplicate["id"] != miner_id:
raise DuplicateAssetID(asset_id)
values["asset_tag"] = asset_id
else:
asset_id = existing["asset_tag"]
if "lifecycle_status" in fields_set:
if request.lifecycle_status is None:
raise InventoryValidationError(
"Lifecycle status cannot be cleared"
)
values["lifecycle_status"] = self.validate_lifecycle_status(
request.lifecycle_status
)
field_map = {
"current_mac": "current_mac",
"current_ip": "current_ip",
"hostname": "hostname",
"miner_sn": "miner_sn",
"control_board_sn": "control_board_sn",
"psu_serial": "power_sn",
"model": "model",
"firmware_version": "firmware_version",
"site_id": "site_id",
"room_id": "room_id",
"notes": "notes",
}
for model_field, database_column in field_map.items():
if model_field not in fields_set:
continue
value = getattr(request, model_field)
if model_field == "current_ip":
values[database_column] = str(value) if value else None
elif isinstance(value, str) or value is None:
values[database_column] = self._clean_optional_text(value)
else:
values[database_column] = value
desired_tags = None
if "tags" in fields_set:
desired_tags = self.normalize_tags(request.tags or [])
try:
with self.repository.transaction() as repository:
self._before_update(existing, values)
row = repository.update_miner(miner_id, values)
if desired_tags is not None:
existing_tags = {
str(tag["name"]).strip().casefold(): tag
for tag in row.get("tags") or []
}
desired_keys = {tag.casefold() for tag in desired_tags}
for key, tag in existing_tags.items():
if key not in desired_keys:
repository.remove_tag(miner_id, tag["id"])
for tag in desired_tags:
if tag.casefold() not in existing_tags:
repository.add_tag(miner_id, tag)
row = repository.get_miner_by_id(miner_id)
self._after_update(row)
except UniqueViolation as exc:
raise DuplicateAssetID(asset_id) from exc
return self._detail_model(row)
def _after_create(self, miner: dict) -> None:
"""Extension point for later inventory integrations."""
def _before_update(self, existing: dict, changes: dict[str, Any]) -> None:
"""Extension point for later inventory integrations."""
def _after_update(self, miner: dict) -> None:
"""Extension point for later inventory integrations."""
+1
View File
@@ -0,0 +1 @@
"""MinerOps backend tests."""
+305
View File
@@ -0,0 +1,305 @@
import ast
import unittest
from contextlib import contextmanager
from datetime import datetime, timezone
from pathlib import Path
from uuid import UUID, uuid4
from app.inventory import (
DuplicateAssetID,
InventoryCreateRequest,
InventoryRepository,
InventorySearchFilters,
InventoryService,
InventoryUpdateRequest,
InventoryValidationError,
)
def inventory_row(**overrides):
now = datetime.now(timezone.utc)
row = {
"id": uuid4(),
"site_id": None,
"room_id": None,
"asset_tag": "ASSET-001",
"current_mac": None,
"current_ip": None,
"hostname": None,
"miner_sn": None,
"control_board_sn": None,
"power_sn": None,
"model": None,
"firmware_version": None,
"lifecycle_status": "active",
"status": "unknown",
"notes": None,
"first_seen_at": None,
"last_seen_at": None,
"created_at": now,
"updated_at": now,
"tags": [],
}
row.update(overrides)
return row
class FakeInventoryRepository:
def __init__(self):
self.rows = {}
self.created_values = None
@contextmanager
def transaction(self):
yield self
def get_miner_by_id(self, miner_id):
return self.rows.get(miner_id)
def get_miner_by_asset_id(self, asset_id):
key = asset_id.strip().casefold()
return next(
(
row
for row in self.rows.values()
if str(row.get("asset_tag") or "").strip().casefold() == key
),
None,
)
def list_miners(self, *, limit=100, offset=0):
return list(self.rows.values())[offset : offset + limit]
def search_miners(self, **kwargs):
rows = list(self.rows.values())
lifecycle = kwargs.get("lifecycle_status")
if lifecycle:
rows = [
row for row in rows if row["lifecycle_status"] == lifecycle
]
return rows[kwargs.get("offset", 0) : kwargs.get("limit", 100)]
def create_miner(self, values):
self.created_values = dict(values)
row = inventory_row(**values)
self.rows[row["id"]] = row
return row
def update_miner(self, miner_id, values):
row = self.rows.get(miner_id)
if not row:
return None
row.update(values)
row["updated_at"] = datetime.now(timezone.utc)
return row
def add_tag(self, miner_id, tag_name):
now = datetime.now(timezone.utc)
tag = {
"id": uuid4(),
"name": tag_name,
"created_at": now,
"updated_at": now,
}
existing = {
item["name"].strip().casefold()
for item in self.rows[miner_id]["tags"]
}
if tag_name.casefold() not in existing:
self.rows[miner_id]["tags"].append(tag)
return tag
def remove_tag(self, miner_id, tag_id):
before = len(self.rows[miner_id]["tags"])
self.rows[miner_id]["tags"] = [
tag for tag in self.rows[miner_id]["tags"] if tag["id"] != tag_id
]
return len(self.rows[miner_id]["tags"]) != before
class InventoryServiceTests(unittest.TestCase):
def setUp(self):
self.repository = FakeInventoryRepository()
self.service = InventoryService(self.repository)
def test_create_normalizes_asset_id_tags_and_never_seen(self):
created = self.service.create_miner(
InventoryCreateRequest(
asset_id=" Mine-001 ",
psu_serial=" PSU-9 ",
tags=[" Priority ", "priority", "\tRepair\n"],
)
)
self.assertEqual(created.asset_id, "Mine-001")
self.assertEqual(created.psu_serial, "PSU-9")
self.assertEqual([tag.name for tag in created.tags], ["Priority", "Repair"])
self.assertIsNone(created.first_seen_at)
self.assertIsNone(created.last_seen_at)
self.assertIsNone(self.repository.created_values["first_seen_at"])
self.assertIsNone(self.repository.created_values["last_seen_at"])
def test_duplicate_asset_id_is_case_and_whitespace_insensitive(self):
row = inventory_row(asset_tag="Mine-001")
self.repository.rows[row["id"]] = row
with self.assertRaises(DuplicateAssetID):
self.service.create_miner(
InventoryCreateRequest(asset_id=" mine-001 ")
)
def test_lifecycle_validation_is_owned_by_service(self):
self.assertEqual(
self.service.validate_lifecycle_status(" SPARE "),
"spare",
)
with self.assertRaises(InventoryValidationError):
self.service.create_miner(
InventoryCreateRequest(
asset_id="ASSET-BAD",
lifecycle_status="warehouse",
)
)
def test_blank_asset_and_tags_are_rejected(self):
with self.assertRaises(InventoryValidationError):
self.service.normalize_asset_id(" ")
with self.assertRaises(InventoryValidationError):
self.service.normalize_tags(["valid", "\t\n"])
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
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.psu_serial, "PSU-NEW")
self.assertEqual(updated.lifecycle_status, "retired")
def test_search_validation_precedes_repository(self):
with self.assertRaises(InventoryValidationError):
self.service.search_miners(
InventorySearchFilters(lifecycle_status="broken")
)
class InventoryRepositoryIntegrationTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.repository = InventoryRepository()
def setUp(self):
with self.repository.transaction() as repository:
with repository._connection.cursor() as cur:
cur.execute("DELETE FROM miner_tags")
cur.execute("DELETE FROM tags")
cur.execute("DELETE FROM miners")
def test_repository_crud_search_and_tags(self):
created = self.repository.create_miner(
{
"asset_tag": "REPO-001",
"current_ip": "192.0.2.10",
"model": "M50",
"miner_sn": "MINER-001",
"control_board_sn": "CB-001",
"power_sn": "PSU-001",
"lifecycle_status": "active",
"status": "unknown",
"first_seen_at": None,
"last_seen_at": None,
}
)
miner_id = created["id"]
self.assertIsInstance(miner_id, UUID)
self.assertIsNone(created["first_seen_at"])
self.assertIsNone(created["last_seen_at"])
self.assertEqual(
self.repository.get_miner_by_asset_id(" repo-001 ")["id"],
miner_id,
)
self.assertEqual(self.repository.get_miner_by_id(miner_id)["model"], "M50")
self.assertEqual(len(self.repository.list_miners()), 1)
self.assertEqual(
self.repository.search_miners(query="CB-001")[0]["id"],
miner_id,
)
updated = self.repository.update_miner(
miner_id,
{"lifecycle_status": "spare", "notes": "Bench stock"},
)
self.assertEqual(updated["lifecycle_status"], "spare")
self.assertEqual(
self.repository.search_miners(lifecycle_status="spare")[0]["id"],
miner_id,
)
tag = self.repository.add_tag(miner_id, "Priority")
repeated = self.repository.add_tag(miner_id, " priority ")
self.assertEqual(tag["id"], repeated["id"])
self.assertEqual(len(self.repository.list_tags()), 1)
self.assertEqual(
self.repository.search_miners(tag_names=["priority"])[0]["id"],
miner_id,
)
self.assertEqual(
len(self.repository.get_miner_by_id(miner_id)["tags"]),
1,
)
self.assertTrue(self.repository.remove_tag(miner_id, tag["id"]))
self.assertEqual(
self.repository.get_miner_by_id(miner_id)["tags"],
[],
)
def test_transaction_rolls_back_repository_writes(self):
with self.assertRaises(RuntimeError):
with self.repository.transaction() as repository:
repository.create_miner(
{
"asset_tag": "ROLLBACK-001",
"status": "unknown",
"first_seen_at": None,
"last_seen_at": None,
}
)
raise RuntimeError("rollback")
self.assertIsNone(
self.repository.get_miner_by_asset_id("ROLLBACK-001")
)
class InventoryArchitectureTests(unittest.TestCase):
def test_inventory_package_has_no_http_layer_or_router(self):
import app.inventory as inventory
inventory_path = Path(__file__).parents[1] / "app" / "inventory"
for source_path in inventory_path.glob("*.py"):
tree = ast.parse(source_path.read_text(encoding="utf-8"))
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)
self.assertFalse(
any(module.startswith("fastapi") for module in imported_modules),
source_path,
)
self.assertFalse(hasattr(inventory, "router"))
if __name__ == "__main__":
unittest.main()
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${repo_root}"
project_name="${MINEROPS_INVENTORY_TEST_PROJECT:-minerops-inventory-domain-$$}"
compose=(
docker compose
--project-name "${project_name}"
--env-file .env.dev
-f compose.dev.yaml
)
cleanup() {
status=$?
if (( status != 0 )); then
echo
echo "Inventory domain tests failed. Recent PostgreSQL logs:"
"${compose[@]}" logs --no-color --tail 120 postgres || true
fi
"${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true
exit "${status}"
}
trap cleanup EXIT
if [[ ! -f .env.dev ]]; then
echo "Missing .env.dev. Run ./scripts/dev-env-init.sh first." >&2
exit 1
fi
echo "Starting disposable inventory database..."
"${compose[@]}" up --detach postgres
for attempt in $(seq 1 60); do
if "${compose[@]}" exec -T postgres sh -ec \
'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' >/dev/null 2>&1; then
break
fi
if (( attempt == 60 )); then
echo "PostgreSQL did not become ready." >&2
exit 1
fi
sleep 1
done
echo "Building inventory test image..."
"${compose[@]}" build backend
echo "Migrating disposable database..."
"${compose[@]}" run --rm --no-deps backend alembic upgrade head
echo "Running inventory repository and service tests..."
"${compose[@]}" run --rm --no-deps \
-v "${repo_root}/backend/tests:/app/tests:ro" \
backend python -m unittest discover -s /app/tests -v
echo "Inventory domain tests passed."