feat: add repair timeline read APIs
This commit is contained in:
1 parent
f6a2512e23
commit
36a0caecd8
16 files changed
+1562
-9
No files matched your search
@@ -0,0 +1,55 @@
|
||||
"""add repair timeline read permission
|
||||
|
||||
Revision ID: 0011_repair_timeline
|
||||
Revises: 0010_begin_repair
|
||||
Create Date: 2026-07-29
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision: str = "0011_repair_timeline"
|
||||
down_revision: Union[str, None] = "0010_begin_repair"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
connection = op.get_bind()
|
||||
|
||||
connection.exec_driver_sql(
|
||||
"""
|
||||
INSERT INTO permissions (key, description)
|
||||
VALUES ('repairs.read', 'View repair details and timeline events')
|
||||
ON CONFLICT (key) DO UPDATE
|
||||
SET description = EXCLUDED.description
|
||||
"""
|
||||
)
|
||||
connection.exec_driver_sql(
|
||||
"""
|
||||
INSERT INTO role_permissions (role_id, permission_id)
|
||||
SELECT roles.id, permissions.id
|
||||
FROM roles
|
||||
JOIN permissions ON permissions.key = 'repairs.read'
|
||||
WHERE roles.name IN ('Admin', 'Manager', 'Technician', 'Read Only')
|
||||
ON CONFLICT DO NOTHING
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
connection = op.get_bind()
|
||||
|
||||
connection.exec_driver_sql(
|
||||
"""
|
||||
DELETE FROM role_permissions
|
||||
WHERE permission_id = (
|
||||
SELECT id FROM permissions WHERE key = 'repairs.read'
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.exec_driver_sql(
|
||||
"DELETE FROM permissions WHERE key = 'repairs.read'"
|
||||
)
|
||||
@@ -671,11 +671,24 @@ def machine_agent_request(path: str) -> bool:
|
||||
return path in exact
|
||||
|
||||
|
||||
def repair_timeline_request(path: str) -> bool:
|
||||
parts = path.split("/")
|
||||
if len(parts) == 4 and parts[:3] == ["", "api", "repairs"]:
|
||||
return parts[3] not in {"queue", "parts-summary"}
|
||||
return (
|
||||
len(parts) == 5
|
||||
and parts[:3] == ["", "api", "repairs"]
|
||||
and parts[4] == "events"
|
||||
)
|
||||
|
||||
|
||||
def required_api_permission(method: str, path: str) -> str | None:
|
||||
if path.startswith("/api/admin/"):
|
||||
return "users.manage"
|
||||
if method == "POST" and path == "/api/repairs":
|
||||
return "repairs.create"
|
||||
if method == "GET" and repair_timeline_request(path):
|
||||
return "repairs.read"
|
||||
if method == "GET":
|
||||
if path.startswith("/api/system/"):
|
||||
return "system_health.view"
|
||||
|
||||
+4
-1
@@ -24,7 +24,6 @@ from app.repairs.router import router as repairs_router
|
||||
app = FastAPI(title=settings.app_name)
|
||||
app.include_router(identity_router)
|
||||
app.include_router(inventory_router)
|
||||
app.include_router(repairs_router)
|
||||
install_identity_middleware(app)
|
||||
|
||||
|
||||
@@ -35671,3 +35670,7 @@ def minerops_repair_parts_summary_api(
|
||||
# MINEROPS_REPAIR_HISTORY_API_HELPERS_SCOPE_FIX_V1
|
||||
|
||||
# MINEROPS_REPAIR_HISTORY_DELEGATED_ACTIONS_V1
|
||||
|
||||
|
||||
# Register UUID-based Repair routes after legacy static Repair paths.
|
||||
app.include_router(repairs_router)
|
||||
@@ -8,13 +8,18 @@ from app.repairs.exceptions import (
|
||||
RepairConflict,
|
||||
RepairError,
|
||||
RepairMinerNotFound,
|
||||
RepairNotFound,
|
||||
RepairPersistenceError,
|
||||
)
|
||||
from app.repairs.models import (
|
||||
BeginRepairRequest,
|
||||
BeginRepairResult,
|
||||
RepairActor,
|
||||
RepairDetail,
|
||||
RepairReason,
|
||||
RepairStatus,
|
||||
RepairTimeline,
|
||||
RepairTimelineEvent,
|
||||
)
|
||||
from app.repairs.repository import RepairRepository
|
||||
from app.repairs.service import RepairService
|
||||
@@ -25,13 +30,18 @@ __all__ = [
|
||||
"BeginRepairResult",
|
||||
"IncompatibleMinerLifecycle",
|
||||
"InconsistentRepairState",
|
||||
"RepairActor",
|
||||
"RepairActorRequired",
|
||||
"RepairConflict",
|
||||
"RepairDetail",
|
||||
"RepairError",
|
||||
"RepairMinerNotFound",
|
||||
"RepairNotFound",
|
||||
"RepairPersistenceError",
|
||||
"RepairReason",
|
||||
"RepairRepository",
|
||||
"RepairService",
|
||||
"RepairStatus",
|
||||
"RepairTimeline",
|
||||
"RepairTimelineEvent",
|
||||
]
|
||||
@@ -13,6 +13,14 @@ class RepairMinerNotFound(RepairError):
|
||||
super().__init__(f"Miner not found: {miner_id}")
|
||||
|
||||
|
||||
class RepairNotFound(RepairError):
|
||||
"""Raised when the requested repair does not exist."""
|
||||
|
||||
def __init__(self, repair_id: object):
|
||||
self.repair_id = repair_id
|
||||
super().__init__(f"Repair not found: {repair_id}")
|
||||
|
||||
|
||||
class RepairConflict(RepairError):
|
||||
"""Base exception for a conflicting repair state."""
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"""Typed contracts for the Repairs domain."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
from typing import Any, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import AwareDatetime, BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
RepairReason = Literal[
|
||||
@@ -30,6 +30,7 @@ RepairStatus = Literal[
|
||||
]
|
||||
RepairEventType = Literal["REPAIR_STARTED", "INITIAL_NOTE"]
|
||||
RepairEligibleLifecycle = Literal["active", "spare"]
|
||||
RepairMinerLifecycle = Literal["active", "spare", "retired", "in_repair"]
|
||||
|
||||
|
||||
class BeginRepairRequest(BaseModel):
|
||||
@@ -63,3 +64,48 @@ class BeginRepairResponse(BaseModel):
|
||||
data: BeginRepairResult
|
||||
meta: dict[str, object] = Field(default_factory=dict)
|
||||
error: None = None
|
||||
|
||||
|
||||
class RepairActor(BaseModel):
|
||||
user_id: UUID
|
||||
display_name: str
|
||||
|
||||
|
||||
class RepairDetail(BaseModel):
|
||||
repair_id: UUID
|
||||
miner_id: UUID
|
||||
miner_asset_id: str | None = None
|
||||
status: RepairStatus
|
||||
reason: RepairReason
|
||||
opened_at: AwareDatetime
|
||||
opened_by: RepairActor
|
||||
closed_at: AwareDatetime | None = None
|
||||
closed_by: RepairActor | None = None
|
||||
miner_lifecycle: RepairMinerLifecycle
|
||||
latest_event_sequence: int | None = Field(default=None, ge=1)
|
||||
|
||||
|
||||
class RepairTimelineEvent(BaseModel):
|
||||
event_id: UUID
|
||||
sequence: int = Field(ge=1)
|
||||
event_type: RepairEventType
|
||||
actor: RepairActor
|
||||
occurred_at: AwareDatetime
|
||||
payload: dict[str, Any]
|
||||
|
||||
|
||||
class RepairTimeline(BaseModel):
|
||||
repair_id: UUID
|
||||
events: list[RepairTimelineEvent] = Field(default_factory=list)
|
||||
|
||||
|
||||
class RepairDetailResponse(BaseModel):
|
||||
data: RepairDetail
|
||||
meta: dict[str, object] = Field(default_factory=dict)
|
||||
error: None = None
|
||||
|
||||
|
||||
class RepairTimelineResponse(BaseModel):
|
||||
data: RepairTimeline
|
||||
meta: dict[str, object] = Field(default_factory=dict)
|
||||
error: None = None
|
||||
@@ -223,3 +223,73 @@ class RepairRepository:
|
||||
raise RepairPersistenceError(
|
||||
"Miner lifecycle transition did not update one record"
|
||||
)
|
||||
|
||||
def get_repair_detail(self, repair_id: UUID) -> dict | None:
|
||||
with self._connection_scope() as connection:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
repair.id AS repair_id,
|
||||
repair.miner_id,
|
||||
miner.asset_tag AS miner_asset_id,
|
||||
repair.status,
|
||||
repair.reason,
|
||||
repair.opened_at,
|
||||
opened_actor.id AS opened_by_user_id,
|
||||
opened_actor.display_name AS opened_by_display_name,
|
||||
repair.completed_at AS closed_at,
|
||||
closed_actor.id AS closed_by_user_id,
|
||||
closed_actor.display_name AS closed_by_display_name,
|
||||
miner.lifecycle_status AS miner_lifecycle,
|
||||
(
|
||||
SELECT max(event.sequence)
|
||||
FROM repair_events AS event
|
||||
WHERE event.repair_id = repair.id
|
||||
) AS latest_event_sequence
|
||||
FROM repairs AS repair
|
||||
JOIN miners AS miner ON miner.id = repair.miner_id
|
||||
JOIN users AS opened_actor
|
||||
ON opened_actor.id = repair.opened_by
|
||||
LEFT JOIN users AS closed_actor
|
||||
ON closed_actor.id = repair.completed_by
|
||||
WHERE repair.id = %s
|
||||
""",
|
||||
(repair_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def get_repair_events(
|
||||
self,
|
||||
repair_id: UUID,
|
||||
) -> list[dict] | None:
|
||||
with self._connection_scope() as connection:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
repair.id AS repair_id,
|
||||
event.id AS event_id,
|
||||
event.sequence,
|
||||
event.event_type,
|
||||
actor.id AS actor_user_id,
|
||||
actor.display_name AS actor_display_name,
|
||||
event.created_at AS occurred_at,
|
||||
event.payload
|
||||
FROM repairs AS repair
|
||||
LEFT JOIN (
|
||||
repair_events AS event
|
||||
JOIN users AS actor
|
||||
ON actor.id = event.created_by
|
||||
) ON event.repair_id = repair.id
|
||||
WHERE repair.id = %s
|
||||
ORDER BY event.sequence ASC
|
||||
""",
|
||||
(repair_id,),
|
||||
)
|
||||
rows = [dict(row) for row in cur.fetchall()]
|
||||
|
||||
if not rows:
|
||||
return None
|
||||
return [row for row in rows if row["event_id"] is not None]
|
||||
@@ -11,19 +11,27 @@ from app.repairs.exceptions import (
|
||||
RepairActorRequired,
|
||||
RepairConflict,
|
||||
RepairMinerNotFound,
|
||||
RepairNotFound,
|
||||
)
|
||||
from app.repairs.models import (
|
||||
BeginRepairRequest,
|
||||
BeginRepairResponse,
|
||||
RepairDetailResponse,
|
||||
RepairTimelineResponse,
|
||||
)
|
||||
from app.repairs.models import BeginRepairRequest, BeginRepairResponse
|
||||
from app.repairs.service import RepairService
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/repairs", tags=["repairs"])
|
||||
require_repair_create = require_permission("repairs.create")
|
||||
require_repair_read = require_permission("repairs.read")
|
||||
|
||||
RepairServiceDependency = Annotated[
|
||||
RepairService,
|
||||
Depends(get_repair_service),
|
||||
]
|
||||
RepairCreateUser = Annotated[dict, Depends(require_repair_create)]
|
||||
RepairReadUser = Annotated[dict, Depends(require_repair_read)]
|
||||
|
||||
|
||||
@router.post(
|
||||
@@ -58,3 +66,43 @@ def begin_repair(
|
||||
) from exc
|
||||
|
||||
return BeginRepairResponse(data=repair)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{repair_id}",
|
||||
response_model=RepairDetailResponse,
|
||||
)
|
||||
def get_repair(
|
||||
repair_id: UUID,
|
||||
service: RepairServiceDependency,
|
||||
current_user: RepairReadUser,
|
||||
) -> RepairDetailResponse:
|
||||
del current_user
|
||||
try:
|
||||
repair = service.get_repair(repair_id)
|
||||
except RepairNotFound as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Repair not found",
|
||||
) from exc
|
||||
return RepairDetailResponse(data=repair)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{repair_id}/events",
|
||||
response_model=RepairTimelineResponse,
|
||||
)
|
||||
def get_repair_events(
|
||||
repair_id: UUID,
|
||||
service: RepairServiceDependency,
|
||||
current_user: RepairReadUser,
|
||||
) -> RepairTimelineResponse:
|
||||
del current_user
|
||||
try:
|
||||
timeline = service.get_repair_timeline(repair_id)
|
||||
except RepairNotFound as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Repair not found",
|
||||
) from exc
|
||||
return RepairTimelineResponse(data=timeline)
|
||||
@@ -8,11 +8,16 @@ from app.repairs.exceptions import (
|
||||
InconsistentRepairState,
|
||||
RepairActorRequired,
|
||||
RepairMinerNotFound,
|
||||
RepairNotFound,
|
||||
)
|
||||
from app.repairs.models import (
|
||||
BeginRepairRequest,
|
||||
BeginRepairResult,
|
||||
InitialRepairNotePayload,
|
||||
RepairActor,
|
||||
RepairDetail,
|
||||
RepairTimeline,
|
||||
RepairTimelineEvent,
|
||||
RepairStartedPayload,
|
||||
)
|
||||
from app.repairs.repository import RepairRepository
|
||||
@@ -101,3 +106,55 @@ class RepairService:
|
||||
opened_at=repair["opened_at"],
|
||||
opened_by=repair["opened_by"],
|
||||
)
|
||||
|
||||
def get_repair(self, repair_id: UUID) -> RepairDetail:
|
||||
repair = self.repository.get_repair_detail(repair_id)
|
||||
if repair is None:
|
||||
raise RepairNotFound(repair_id)
|
||||
|
||||
closed_by = None
|
||||
if repair["closed_by_user_id"] is not None:
|
||||
closed_by = RepairActor(
|
||||
user_id=repair["closed_by_user_id"],
|
||||
display_name=repair["closed_by_display_name"],
|
||||
)
|
||||
|
||||
return RepairDetail(
|
||||
repair_id=repair["repair_id"],
|
||||
miner_id=repair["miner_id"],
|
||||
miner_asset_id=repair["miner_asset_id"],
|
||||
status=repair["status"],
|
||||
reason=repair["reason"],
|
||||
opened_at=repair["opened_at"],
|
||||
opened_by=RepairActor(
|
||||
user_id=repair["opened_by_user_id"],
|
||||
display_name=repair["opened_by_display_name"],
|
||||
),
|
||||
closed_at=repair["closed_at"],
|
||||
closed_by=closed_by,
|
||||
miner_lifecycle=repair["miner_lifecycle"],
|
||||
latest_event_sequence=repair["latest_event_sequence"],
|
||||
)
|
||||
|
||||
def get_repair_timeline(self, repair_id: UUID) -> RepairTimeline:
|
||||
events = self.repository.get_repair_events(repair_id)
|
||||
if events is None:
|
||||
raise RepairNotFound(repair_id)
|
||||
|
||||
return RepairTimeline(
|
||||
repair_id=repair_id,
|
||||
events=[
|
||||
RepairTimelineEvent(
|
||||
event_id=event["event_id"],
|
||||
sequence=event["sequence"],
|
||||
event_type=event["event_type"],
|
||||
actor=RepairActor(
|
||||
user_id=event["actor_user_id"],
|
||||
display_name=event["actor_display_name"],
|
||||
),
|
||||
occurred_at=event["occurred_at"],
|
||||
payload=event["payload"],
|
||||
)
|
||||
for event in events
|
||||
],
|
||||
)
|
||||
@@ -0,0 +1,804 @@
|
||||
import ast
|
||||
import inspect
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from psycopg.rows import dict_row
|
||||
from starlette.routing import Match
|
||||
|
||||
from app.identity import get_current_user, required_api_permission
|
||||
from app.repairs.dependencies import get_repair_service
|
||||
from app.repairs.exceptions import RepairNotFound
|
||||
from app.repairs.models import (
|
||||
BeginRepairRequest,
|
||||
BeginRepairResult,
|
||||
RepairActor,
|
||||
RepairDetail,
|
||||
RepairTimeline,
|
||||
RepairTimelineEvent,
|
||||
)
|
||||
from app.repairs.repository import RepairRepository
|
||||
from app.repairs.router import router
|
||||
from app.repairs.service import RepairService
|
||||
|
||||
|
||||
OPENED_AT = datetime(2026, 7, 29, 12, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def detail_result(
|
||||
repair_id: UUID | None = None,
|
||||
miner_id: UUID | None = None,
|
||||
) -> RepairDetail:
|
||||
return RepairDetail(
|
||||
repair_id=repair_id or uuid4(),
|
||||
miner_id=miner_id or uuid4(),
|
||||
miner_asset_id="ASSET-TIMELINE-001",
|
||||
status="OPEN",
|
||||
reason="INSPECTION",
|
||||
opened_at=OPENED_AT,
|
||||
opened_by=RepairActor(
|
||||
user_id=uuid4(),
|
||||
display_name="Timeline Technician",
|
||||
),
|
||||
closed_at=None,
|
||||
closed_by=None,
|
||||
miner_lifecycle="in_repair",
|
||||
latest_event_sequence=2,
|
||||
)
|
||||
|
||||
|
||||
def timeline_result(repair_id: UUID | None = None) -> RepairTimeline:
|
||||
return RepairTimeline(
|
||||
repair_id=repair_id or uuid4(),
|
||||
events=[
|
||||
RepairTimelineEvent(
|
||||
event_id=uuid4(),
|
||||
sequence=1,
|
||||
event_type="REPAIR_STARTED",
|
||||
actor=RepairActor(
|
||||
user_id=uuid4(),
|
||||
display_name="Timeline Technician",
|
||||
),
|
||||
occurred_at=OPENED_AT,
|
||||
payload={
|
||||
"reason": "INSPECTION",
|
||||
"from_lifecycle": "active",
|
||||
"to_lifecycle": "in_repair",
|
||||
},
|
||||
),
|
||||
RepairTimelineEvent(
|
||||
event_id=uuid4(),
|
||||
sequence=2,
|
||||
event_type="INITIAL_NOTE",
|
||||
actor=RepairActor(
|
||||
user_id=uuid4(),
|
||||
display_name="Timeline Manager",
|
||||
),
|
||||
occurred_at=OPENED_AT,
|
||||
payload={"note": "Inspect control board."},
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
class FakeTimelineRepository:
|
||||
def __init__(self):
|
||||
self.detail = None
|
||||
self.events = None
|
||||
self.detail_repair_id = None
|
||||
self.events_repair_id = None
|
||||
|
||||
def get_repair_detail(self, repair_id):
|
||||
self.detail_repair_id = repair_id
|
||||
return self.detail
|
||||
|
||||
def get_repair_events(self, repair_id):
|
||||
self.events_repair_id = repair_id
|
||||
return self.events
|
||||
|
||||
|
||||
class RepairTimelineServiceTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.repository = FakeTimelineRepository()
|
||||
self.service = RepairService(self.repository)
|
||||
self.repair_id = uuid4()
|
||||
self.miner_id = uuid4()
|
||||
self.opened_by = uuid4()
|
||||
self.closed_by = uuid4()
|
||||
|
||||
def detail_row(self):
|
||||
return {
|
||||
"repair_id": self.repair_id,
|
||||
"miner_id": self.miner_id,
|
||||
"miner_asset_id": "ASSET-SERVICE-001",
|
||||
"status": "COMPLETED",
|
||||
"reason": "PSU",
|
||||
"opened_at": OPENED_AT,
|
||||
"opened_by_user_id": self.opened_by,
|
||||
"opened_by_display_name": "Opening Technician",
|
||||
"closed_at": OPENED_AT + timedelta(hours=2),
|
||||
"closed_by_user_id": self.closed_by,
|
||||
"closed_by_display_name": "Closing Manager",
|
||||
"miner_lifecycle": "active",
|
||||
"latest_event_sequence": 3,
|
||||
}
|
||||
|
||||
def test_detail_maps_public_fields_and_actors(self):
|
||||
self.repository.detail = self.detail_row()
|
||||
|
||||
detail = self.service.get_repair(self.repair_id)
|
||||
|
||||
self.assertEqual(detail.repair_id, self.repair_id)
|
||||
self.assertEqual(detail.miner_id, self.miner_id)
|
||||
self.assertEqual(detail.miner_asset_id, "ASSET-SERVICE-001")
|
||||
self.assertEqual(detail.status, "COMPLETED")
|
||||
self.assertEqual(detail.reason, "PSU")
|
||||
self.assertEqual(detail.opened_by.user_id, self.opened_by)
|
||||
self.assertEqual(detail.closed_by.user_id, self.closed_by)
|
||||
self.assertEqual(detail.closed_by.display_name, "Closing Manager")
|
||||
self.assertEqual(detail.latest_event_sequence, 3)
|
||||
self.assertEqual(self.repository.detail_repair_id, self.repair_id)
|
||||
|
||||
def test_detail_preserves_nullable_asset_and_empty_sequence(self):
|
||||
row = self.detail_row()
|
||||
row.update(
|
||||
{
|
||||
"miner_asset_id": None,
|
||||
"status": "OPEN",
|
||||
"closed_at": None,
|
||||
"closed_by_user_id": None,
|
||||
"closed_by_display_name": None,
|
||||
"miner_lifecycle": "in_repair",
|
||||
"latest_event_sequence": None,
|
||||
}
|
||||
)
|
||||
self.repository.detail = row
|
||||
|
||||
detail = self.service.get_repair(self.repair_id)
|
||||
|
||||
self.assertIsNone(detail.miner_asset_id)
|
||||
self.assertIsNone(detail.closed_at)
|
||||
self.assertIsNone(detail.closed_by)
|
||||
self.assertIsNone(detail.latest_event_sequence)
|
||||
|
||||
def test_missing_detail_raises_domain_not_found(self):
|
||||
with self.assertRaises(RepairNotFound):
|
||||
self.service.get_repair(self.repair_id)
|
||||
|
||||
def test_timeline_maps_structured_events_without_reordering(self):
|
||||
actor_id = uuid4()
|
||||
self.repository.events = [
|
||||
{
|
||||
"event_id": uuid4(),
|
||||
"sequence": 1,
|
||||
"event_type": "REPAIR_STARTED",
|
||||
"actor_user_id": actor_id,
|
||||
"actor_display_name": "Timeline Actor",
|
||||
"occurred_at": OPENED_AT,
|
||||
"payload": {
|
||||
"reason": "OTHER",
|
||||
"from_lifecycle": "spare",
|
||||
"to_lifecycle": "in_repair",
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
timeline = self.service.get_repair_timeline(self.repair_id)
|
||||
|
||||
self.assertEqual(timeline.repair_id, self.repair_id)
|
||||
self.assertEqual(len(timeline.events), 1)
|
||||
self.assertEqual(timeline.events[0].sequence, 1)
|
||||
self.assertEqual(timeline.events[0].actor.user_id, actor_id)
|
||||
self.assertIsInstance(timeline.events[0].payload, dict)
|
||||
self.assertEqual(
|
||||
timeline.events[0].payload["from_lifecycle"],
|
||||
"spare",
|
||||
)
|
||||
|
||||
def test_empty_timeline_is_successful(self):
|
||||
self.repository.events = []
|
||||
timeline = self.service.get_repair_timeline(self.repair_id)
|
||||
self.assertEqual(timeline.events, [])
|
||||
|
||||
def test_missing_timeline_raises_domain_not_found(self):
|
||||
with self.assertRaises(RepairNotFound):
|
||||
self.service.get_repair_timeline(self.repair_id)
|
||||
|
||||
|
||||
class RepairTimelineRepositoryTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.repository = RepairRepository()
|
||||
|
||||
def setUp(self):
|
||||
self.opened_actor = uuid4()
|
||||
self.note_actor = uuid4()
|
||||
with self.repository._connection_scope(write=True) as connection:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute("DELETE FROM repair_events")
|
||||
cur.execute("DELETE FROM repairs")
|
||||
cur.execute("DELETE FROM miner_tags")
|
||||
cur.execute("DELETE FROM miner_asset_id_history")
|
||||
cur.execute("DELETE FROM miners")
|
||||
cur.execute(
|
||||
"DELETE FROM users WHERE username LIKE "
|
||||
"'repair-timeline-test-%'"
|
||||
)
|
||||
for actor_id, suffix, display_name in (
|
||||
(
|
||||
self.opened_actor,
|
||||
"opened",
|
||||
"Timeline Opening Technician",
|
||||
),
|
||||
(
|
||||
self.note_actor,
|
||||
"note",
|
||||
"Timeline Note Manager",
|
||||
),
|
||||
):
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO users (
|
||||
id,
|
||||
username,
|
||||
display_name,
|
||||
password_hash,
|
||||
role_id
|
||||
)
|
||||
SELECT %s, %s, %s, 'not-used', id
|
||||
FROM roles
|
||||
WHERE name = 'Admin'
|
||||
""",
|
||||
(
|
||||
actor_id,
|
||||
f"repair-timeline-test-{suffix}",
|
||||
display_name,
|
||||
),
|
||||
)
|
||||
|
||||
def create_miner(self, asset_id="ASSET-TIMELINE-DB"):
|
||||
with self.repository._connection_scope(write=True) as connection:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO miners (
|
||||
asset_tag,
|
||||
status,
|
||||
lifecycle_status,
|
||||
first_seen_at,
|
||||
last_seen_at
|
||||
)
|
||||
VALUES (%s, 'unknown', 'in_repair', NULL, NULL)
|
||||
RETURNING id
|
||||
""",
|
||||
(asset_id,),
|
||||
)
|
||||
return cur.fetchone()["id"]
|
||||
|
||||
def create_repair(self, miner_id):
|
||||
with self.repository._connection_scope(write=True) as connection:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO repairs (miner_id, reason, opened_by)
|
||||
VALUES (%s, 'INSPECTION', %s)
|
||||
RETURNING id
|
||||
""",
|
||||
(miner_id, self.opened_actor),
|
||||
)
|
||||
return cur.fetchone()["id"]
|
||||
|
||||
def insert_non_monotonic_events(self, repair_id):
|
||||
later = OPENED_AT + timedelta(hours=1)
|
||||
earlier = OPENED_AT
|
||||
with self.repository._connection_scope(write=True) as connection:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO repair_events (
|
||||
repair_id,
|
||||
sequence,
|
||||
event_type,
|
||||
created_at,
|
||||
created_by,
|
||||
payload
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
%s,
|
||||
1,
|
||||
'REPAIR_STARTED',
|
||||
%s,
|
||||
%s,
|
||||
%s::jsonb
|
||||
),
|
||||
(
|
||||
%s,
|
||||
2,
|
||||
'INITIAL_NOTE',
|
||||
%s,
|
||||
%s,
|
||||
%s::jsonb
|
||||
)
|
||||
""",
|
||||
(
|
||||
repair_id,
|
||||
later,
|
||||
self.opened_actor,
|
||||
'{"reason":"INSPECTION","from_lifecycle":'
|
||||
'"active","to_lifecycle":"in_repair"}',
|
||||
repair_id,
|
||||
earlier,
|
||||
self.note_actor,
|
||||
'{"note":"Timestamp is earlier than sequence one."}',
|
||||
),
|
||||
)
|
||||
|
||||
def counts(self):
|
||||
with self.repository._connection_scope() as connection:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
(SELECT count(*) FROM repairs),
|
||||
(SELECT count(*) FROM repair_events)
|
||||
"""
|
||||
)
|
||||
return cur.fetchone()
|
||||
|
||||
def test_detail_joins_asset_lifecycle_actor_and_latest_sequence(self):
|
||||
miner_id = self.create_miner()
|
||||
repair_id = self.create_repair(miner_id)
|
||||
self.insert_non_monotonic_events(repair_id)
|
||||
|
||||
detail = self.repository.get_repair_detail(repair_id)
|
||||
|
||||
self.assertEqual(detail["repair_id"], repair_id)
|
||||
self.assertEqual(detail["miner_id"], miner_id)
|
||||
self.assertEqual(detail["miner_asset_id"], "ASSET-TIMELINE-DB")
|
||||
self.assertEqual(detail["miner_lifecycle"], "in_repair")
|
||||
self.assertEqual(detail["opened_by_user_id"], self.opened_actor)
|
||||
self.assertEqual(
|
||||
detail["opened_by_display_name"],
|
||||
"Timeline Opening Technician",
|
||||
)
|
||||
self.assertIsNone(detail["closed_at"])
|
||||
self.assertIsNone(detail["closed_by_user_id"])
|
||||
self.assertEqual(detail["latest_event_sequence"], 2)
|
||||
|
||||
def test_events_order_by_sequence_not_timestamp_and_join_actors(self):
|
||||
miner_id = self.create_miner()
|
||||
repair_id = self.create_repair(miner_id)
|
||||
self.insert_non_monotonic_events(repair_id)
|
||||
|
||||
events = self.repository.get_repair_events(repair_id)
|
||||
|
||||
self.assertEqual([event["sequence"] for event in events], [1, 2])
|
||||
self.assertGreater(
|
||||
events[0]["occurred_at"],
|
||||
events[1]["occurred_at"],
|
||||
)
|
||||
self.assertEqual(
|
||||
[event["actor_display_name"] for event in events],
|
||||
[
|
||||
"Timeline Opening Technician",
|
||||
"Timeline Note Manager",
|
||||
],
|
||||
)
|
||||
self.assertIsInstance(events[0]["payload"], dict)
|
||||
self.assertEqual(
|
||||
events[1]["payload"],
|
||||
{"note": "Timestamp is earlier than sequence one."},
|
||||
)
|
||||
|
||||
def test_known_repair_with_no_events_returns_empty_timeline(self):
|
||||
repair_id = self.create_repair(self.create_miner())
|
||||
|
||||
self.assertEqual(self.repository.get_repair_events(repair_id), [])
|
||||
detail = self.repository.get_repair_detail(repair_id)
|
||||
self.assertIsNone(detail["latest_event_sequence"])
|
||||
|
||||
def test_missing_repair_is_distinct_from_empty_timeline(self):
|
||||
repair_id = uuid4()
|
||||
self.assertIsNone(self.repository.get_repair_detail(repair_id))
|
||||
self.assertIsNone(self.repository.get_repair_events(repair_id))
|
||||
|
||||
def test_reads_do_not_modify_repairs_or_events(self):
|
||||
repair_id = self.create_repair(self.create_miner())
|
||||
self.insert_non_monotonic_events(repair_id)
|
||||
before = self.counts()
|
||||
|
||||
self.repository.get_repair_detail(repair_id)
|
||||
self.repository.get_repair_events(repair_id)
|
||||
|
||||
self.assertEqual(self.counts(), before)
|
||||
|
||||
|
||||
class StubTimelineService:
|
||||
def __init__(self):
|
||||
self.detail = None
|
||||
self.timeline = None
|
||||
self.error = None
|
||||
self.detail_repair_id = None
|
||||
self.timeline_repair_id = None
|
||||
self.begin_called = False
|
||||
|
||||
def get_repair(self, repair_id):
|
||||
self.detail_repair_id = repair_id
|
||||
if self.error is not None:
|
||||
raise self.error
|
||||
return self.detail
|
||||
|
||||
def get_repair_timeline(self, repair_id):
|
||||
self.timeline_repair_id = repair_id
|
||||
if self.error is not None:
|
||||
raise self.error
|
||||
return self.timeline
|
||||
|
||||
def begin_repair(self, request: BeginRepairRequest, *, actor_user_id):
|
||||
self.begin_called = True
|
||||
return BeginRepairResult(
|
||||
repair_id=uuid4(),
|
||||
miner_id=request.miner_id,
|
||||
status="OPEN",
|
||||
reason=request.reason,
|
||||
opened_at=OPENED_AT,
|
||||
opened_by=actor_user_id,
|
||||
)
|
||||
|
||||
|
||||
class RepairTimelineRouterTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.service = StubTimelineService()
|
||||
self.app = FastAPI()
|
||||
self.app.include_router(router)
|
||||
self.app.dependency_overrides[get_repair_service] = (
|
||||
lambda: self.service
|
||||
)
|
||||
self.client = TestClient(self.app)
|
||||
|
||||
def tearDown(self):
|
||||
self.client.close()
|
||||
|
||||
def authenticate(
|
||||
self,
|
||||
role="Read Only",
|
||||
permissions=("repairs.read",),
|
||||
):
|
||||
self.app.dependency_overrides[get_current_user] = lambda: {
|
||||
"id": str(uuid4()),
|
||||
"username": role.lower().replace(" ", "-"),
|
||||
"role": role,
|
||||
"permissions": list(permissions),
|
||||
}
|
||||
|
||||
def test_timeline_routes_require_authentication(self):
|
||||
for path in (
|
||||
f"/api/repairs/{uuid4()}",
|
||||
f"/api/repairs/{uuid4()}/events",
|
||||
):
|
||||
with self.subTest(path=path):
|
||||
response = self.client.get(path)
|
||||
self.assertEqual(response.status_code, 401)
|
||||
|
||||
def test_timeline_routes_require_repairs_read(self):
|
||||
self.authenticate(permissions=("repairs.view",))
|
||||
for path in (
|
||||
f"/api/repairs/{uuid4()}",
|
||||
f"/api/repairs/{uuid4()}/events",
|
||||
):
|
||||
with self.subTest(path=path):
|
||||
response = self.client.get(path)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_all_default_roles_can_read_detail_and_events(self):
|
||||
repair_id = uuid4()
|
||||
self.service.detail = detail_result(repair_id)
|
||||
self.service.timeline = timeline_result(repair_id)
|
||||
for role in ("Admin", "Manager", "Technician", "Read Only"):
|
||||
with self.subTest(role=role):
|
||||
self.authenticate(role=role)
|
||||
self.assertEqual(
|
||||
self.client.get(
|
||||
f"/api/repairs/{repair_id}"
|
||||
).status_code,
|
||||
200,
|
||||
)
|
||||
self.assertEqual(
|
||||
self.client.get(
|
||||
f"/api/repairs/{repair_id}/events"
|
||||
).status_code,
|
||||
200,
|
||||
)
|
||||
|
||||
def test_malformed_repair_uuid_returns_validation_error(self):
|
||||
self.authenticate()
|
||||
for path in (
|
||||
"/api/repairs/not-a-uuid",
|
||||
"/api/repairs/not-a-uuid/events",
|
||||
):
|
||||
with self.subTest(path=path):
|
||||
response = self.client.get(path)
|
||||
self.assertEqual(response.status_code, 422)
|
||||
|
||||
def test_missing_repair_returns_not_found(self):
|
||||
self.authenticate()
|
||||
repair_id = uuid4()
|
||||
self.service.error = RepairNotFound(repair_id)
|
||||
for path in (
|
||||
f"/api/repairs/{repair_id}",
|
||||
f"/api/repairs/{repair_id}/events",
|
||||
):
|
||||
with self.subTest(path=path):
|
||||
response = self.client.get(path)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertEqual(
|
||||
response.json()["detail"],
|
||||
"Repair not found",
|
||||
)
|
||||
|
||||
def test_detail_returns_typed_public_envelope(self):
|
||||
self.authenticate()
|
||||
repair_id = uuid4()
|
||||
expected = detail_result(repair_id)
|
||||
self.service.detail = expected
|
||||
|
||||
response = self.client.get(f"/api/repairs/{repair_id}")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = response.json()
|
||||
self.assertEqual(body["data"]["repair_id"], str(repair_id))
|
||||
self.assertEqual(body["data"]["miner_asset_id"], "ASSET-TIMELINE-001")
|
||||
self.assertEqual(body["data"]["miner_lifecycle"], "in_repair")
|
||||
self.assertEqual(body["data"]["latest_event_sequence"], 2)
|
||||
self.assertEqual(
|
||||
body["data"]["opened_by"]["display_name"],
|
||||
"Timeline Technician",
|
||||
)
|
||||
self.assertIsNone(body["data"]["closed_at"])
|
||||
self.assertIsNone(body["data"]["closed_by"])
|
||||
self.assertEqual(body["meta"], {})
|
||||
self.assertIsNone(body["error"])
|
||||
|
||||
def test_events_return_structured_payload_in_service_order(self):
|
||||
self.authenticate()
|
||||
repair_id = uuid4()
|
||||
expected = timeline_result(repair_id)
|
||||
self.service.timeline = expected
|
||||
|
||||
response = self.client.get(
|
||||
f"/api/repairs/{repair_id}/events"
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = response.json()
|
||||
self.assertEqual(body["data"]["repair_id"], str(repair_id))
|
||||
self.assertEqual(
|
||||
[event["sequence"] for event in body["data"]["events"]],
|
||||
[1, 2],
|
||||
)
|
||||
self.assertIsInstance(body["data"]["events"][0]["payload"], dict)
|
||||
self.assertEqual(
|
||||
body["data"]["events"][1]["actor"]["display_name"],
|
||||
"Timeline Manager",
|
||||
)
|
||||
|
||||
def test_known_empty_timeline_returns_success(self):
|
||||
self.authenticate()
|
||||
repair_id = uuid4()
|
||||
self.service.timeline = RepairTimeline(
|
||||
repair_id=repair_id,
|
||||
events=[],
|
||||
)
|
||||
|
||||
response = self.client.get(
|
||||
f"/api/repairs/{repair_id}/events"
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json()["data"]["events"], [])
|
||||
|
||||
def test_read_only_cannot_begin_repair(self):
|
||||
self.authenticate(role="Read Only", permissions=("repairs.read",))
|
||||
response = self.client.post(
|
||||
"/api/repairs",
|
||||
json={"miner_id": str(uuid4()), "reason": "OTHER"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertFalse(self.service.begin_called)
|
||||
|
||||
def test_exactly_two_timeline_get_routes_are_registered(self):
|
||||
get_routes = {
|
||||
route.path
|
||||
for route in router.routes
|
||||
if hasattr(route, "methods") and "GET" in route.methods
|
||||
}
|
||||
self.assertEqual(
|
||||
get_routes,
|
||||
{
|
||||
"/api/repairs/{repair_id}",
|
||||
"/api/repairs/{repair_id}/events",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class RepairTimelineArchitectureTests(unittest.TestCase):
|
||||
@staticmethod
|
||||
def full_route_matches(app, method, path):
|
||||
scope = {
|
||||
"type": "http",
|
||||
"method": method,
|
||||
"path": path,
|
||||
"root_path": "",
|
||||
}
|
||||
return [
|
||||
route
|
||||
for route in app.routes
|
||||
if route.matches(scope)[0] is Match.FULL
|
||||
]
|
||||
|
||||
def test_permission_mapper_scopes_new_and_legacy_routes(self):
|
||||
repair_id = uuid4()
|
||||
self.assertEqual(
|
||||
required_api_permission(
|
||||
"GET",
|
||||
f"/api/repairs/{repair_id}",
|
||||
),
|
||||
"repairs.read",
|
||||
)
|
||||
self.assertEqual(
|
||||
required_api_permission(
|
||||
"GET",
|
||||
f"/api/repairs/{repair_id}/events",
|
||||
),
|
||||
"repairs.read",
|
||||
)
|
||||
self.assertEqual(
|
||||
required_api_permission("GET", "/api/repairs/queue"),
|
||||
"repairs.view",
|
||||
)
|
||||
self.assertEqual(
|
||||
required_api_permission(
|
||||
"GET",
|
||||
"/api/repairs/parts-summary",
|
||||
),
|
||||
"repairs.view",
|
||||
)
|
||||
self.assertEqual(
|
||||
required_api_permission("POST", "/api/repairs"),
|
||||
"repairs.create",
|
||||
)
|
||||
|
||||
def test_repair_router_is_registered_after_legacy_static_routes(self):
|
||||
main_path = Path(__file__).parents[1] / "app" / "main.py"
|
||||
source = main_path.read_text(encoding="utf-8")
|
||||
self.assertGreater(
|
||||
source.rfind("app.include_router(repairs_router)"),
|
||||
source.find('@app.get("/api/repairs/parts-summary")'),
|
||||
)
|
||||
|
||||
def test_live_application_repair_route_inventory(self):
|
||||
from app.main import app
|
||||
|
||||
routes = {
|
||||
(method, route.path, route.endpoint.__name__)
|
||||
for route in app.routes
|
||||
if route.path.startswith("/api/repairs")
|
||||
for method in route.methods
|
||||
}
|
||||
self.assertEqual(
|
||||
routes,
|
||||
{
|
||||
("GET", "/api/repairs/queue", "minerops_repair_queue_api"),
|
||||
(
|
||||
"POST",
|
||||
"/api/repairs/{miner_id}/note",
|
||||
"minerops_repair_add_note_api",
|
||||
),
|
||||
(
|
||||
"POST",
|
||||
"/api/repairs/{miner_id}/status",
|
||||
"minerops_repair_update_status_api",
|
||||
),
|
||||
(
|
||||
"POST",
|
||||
"/api/repairs/{miner_id}/state",
|
||||
"minerops_repair_update_state_api",
|
||||
),
|
||||
(
|
||||
"GET",
|
||||
"/api/repairs/parts-summary",
|
||||
"minerops_repair_parts_summary_api",
|
||||
),
|
||||
("POST", "/api/repairs", "begin_repair"),
|
||||
("GET", "/api/repairs/{repair_id}", "get_repair"),
|
||||
(
|
||||
"GET",
|
||||
"/api/repairs/{repair_id}/events",
|
||||
"get_repair_events",
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
def test_static_repair_routes_resolve_before_uuid_detail_route(self):
|
||||
from app.main import app
|
||||
|
||||
for path, endpoint_name in (
|
||||
("/api/repairs/queue", "minerops_repair_queue_api"),
|
||||
(
|
||||
"/api/repairs/parts-summary",
|
||||
"minerops_repair_parts_summary_api",
|
||||
),
|
||||
):
|
||||
with self.subTest(path=path):
|
||||
matches = self.full_route_matches(app, "GET", path)
|
||||
self.assertGreaterEqual(len(matches), 2)
|
||||
self.assertEqual(matches[0].path, path)
|
||||
self.assertEqual(matches[0].endpoint.__name__, endpoint_name)
|
||||
self.assertEqual(matches[1].endpoint.__name__, "get_repair")
|
||||
|
||||
def test_uuid_repair_routes_resolve_to_timeline_handlers(self):
|
||||
from app.main import app
|
||||
|
||||
repair_id = uuid4()
|
||||
for path, endpoint_name in (
|
||||
(f"/api/repairs/{repair_id}", "get_repair"),
|
||||
(
|
||||
f"/api/repairs/{repair_id}/events",
|
||||
"get_repair_events",
|
||||
),
|
||||
):
|
||||
with self.subTest(path=path):
|
||||
matches = self.full_route_matches(app, "GET", path)
|
||||
self.assertEqual(len(matches), 1)
|
||||
self.assertEqual(matches[0].endpoint.__name__, endpoint_name)
|
||||
|
||||
def test_repository_event_read_is_one_joined_ordered_query(self):
|
||||
source = inspect.getsource(RepairRepository.get_repair_events)
|
||||
self.assertEqual(source.count("cur.execute("), 1)
|
||||
self.assertIn("JOIN users AS actor", source)
|
||||
self.assertIn("ORDER BY event.sequence ASC", source)
|
||||
for keyword in ("INSERT ", "UPDATE ", "DELETE "):
|
||||
self.assertNotIn(keyword, source.upper())
|
||||
|
||||
def test_service_and_repository_have_no_fastapi_dependency(self):
|
||||
repairs_path = Path(__file__).parents[1] / "app" / "repairs"
|
||||
for filename in ("service.py", "repository.py"):
|
||||
tree = ast.parse(
|
||||
(repairs_path / filename).read_text(encoding="utf-8")
|
||||
)
|
||||
modules = []
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
modules.extend(alias.name for alias in node.names)
|
||||
elif isinstance(node, ast.ImportFrom) and node.module:
|
||||
modules.append(node.module)
|
||||
self.assertFalse(
|
||||
any(
|
||||
module == "fastapi"
|
||||
or module.startswith("fastapi.")
|
||||
for module in modules
|
||||
),
|
||||
filename,
|
||||
)
|
||||
|
||||
def test_timeline_has_no_mutation_route_or_repository_method(self):
|
||||
methods = {
|
||||
method
|
||||
for route in router.routes
|
||||
if hasattr(route, "methods")
|
||||
for method in route.methods
|
||||
}
|
||||
self.assertNotIn("PATCH", methods)
|
||||
self.assertNotIn("PUT", methods)
|
||||
self.assertNotIn("DELETE", methods)
|
||||
self.assertFalse(hasattr(RepairRepository, "update_repair_event"))
|
||||
self.assertFalse(hasattr(RepairRepository, "delete_repair_event"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -236,7 +236,7 @@ class RepairRouterTests(unittest.TestCase):
|
||||
routes = [
|
||||
(route.path, route.methods)
|
||||
for route in router.routes
|
||||
if hasattr(route, "methods")
|
||||
if hasattr(route, "methods") and "POST" in route.methods
|
||||
]
|
||||
self.assertEqual(routes, [("/api/repairs", {"POST"})])
|
||||
|
||||
|
||||
Reference in new issue
Block a user