From d1d02962da20f4dc5ff9861f181c0e97362e6537 Mon Sep 17 00:00:00 2001 From: JC Date: Tue, 28 Jul 2026 16:18:11 +0000 Subject: [PATCH] Add inventory domain foundation --- backend/app/inventory/__init__.py | 33 +++ backend/app/inventory/exceptions.py | 25 ++ backend/app/inventory/models.py | 86 +++++++ backend/app/inventory/repository.py | 337 +++++++++++++++++++++++++ backend/app/inventory/service.py | 290 +++++++++++++++++++++ backend/tests/__init__.py | 1 + backend/tests/test_inventory_domain.py | 305 ++++++++++++++++++++++ scripts/test-inventory-domain.sh | 58 +++++ 8 files changed, 1135 insertions(+) create mode 100644 backend/app/inventory/__init__.py create mode 100644 backend/app/inventory/exceptions.py create mode 100644 backend/app/inventory/models.py create mode 100644 backend/app/inventory/repository.py create mode 100644 backend/app/inventory/service.py create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/test_inventory_domain.py create mode 100755 scripts/test-inventory-domain.sh diff --git a/backend/app/inventory/__init__.py b/backend/app/inventory/__init__.py new file mode 100644 index 0000000..95a5c01 --- /dev/null +++ b/backend/app/inventory/__init__.py @@ -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", +] diff --git a/backend/app/inventory/exceptions.py b/backend/app/inventory/exceptions.py new file mode 100644 index 0000000..3bc9ebf --- /dev/null +++ b/backend/app/inventory/exceptions.py @@ -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}") diff --git a/backend/app/inventory/models.py b/backend/app/inventory/models.py new file mode 100644 index 0000000..c534dfa --- /dev/null +++ b/backend/app/inventory/models.py @@ -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) diff --git a/backend/app/inventory/repository.py b/backend/app/inventory/repository.py new file mode 100644 index 0000000..f20b256 --- /dev/null +++ b/backend/app/inventory/repository.py @@ -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 diff --git a/backend/app/inventory/service.py b/backend/app/inventory/service.py new file mode 100644 index 0000000..13fa37f --- /dev/null +++ b/backend/app/inventory/service.py @@ -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.""" diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..387c337 --- /dev/null +++ b/backend/tests/__init__.py @@ -0,0 +1 @@ +"""MinerOps backend tests.""" diff --git a/backend/tests/test_inventory_domain.py b/backend/tests/test_inventory_domain.py new file mode 100644 index 0000000..cac02a8 --- /dev/null +++ b/backend/tests/test_inventory_domain.py @@ -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() diff --git a/scripts/test-inventory-domain.sh b/scripts/test-inventory-domain.sh new file mode 100755 index 0000000..526432b --- /dev/null +++ b/scripts/test-inventory-domain.sh @@ -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."