From 36a0caecd826d3f40a978b599a7e56d01f23bf88 Mon Sep 17 00:00:00 2001 From: JC Date: Wed, 29 Jul 2026 16:28:44 +0000 Subject: [PATCH] feat: add repair timeline read APIs --- .../alembic/versions/0011_repair_timeline.py | 55 ++ backend/app/identity.py | 13 + backend/app/main.py | 5 +- backend/app/repairs/__init__.py | 10 + backend/app/repairs/exceptions.py | 8 + backend/app/repairs/models.py | 50 +- backend/app/repairs/repository.py | 70 ++ backend/app/repairs/router.py | 50 +- backend/app/repairs/service.py | 57 ++ backend/tests/test_repair_timeline.py | 804 ++++++++++++++++++ backend/tests/test_repairs_router.py | 2 +- docs/ROADMAP.md | 9 +- docs/tasks/002-repair-timeline.md | 183 ++++ scripts/test-dev-baseline.sh | 25 +- scripts/test-repair-timeline-migration.sh | 171 ++++ scripts/test-repair-timeline.sh | 59 ++ 16 files changed, 1562 insertions(+), 9 deletions(-) create mode 100644 backend/alembic/versions/0011_repair_timeline.py create mode 100644 backend/tests/test_repair_timeline.py create mode 100644 docs/tasks/002-repair-timeline.md create mode 100755 scripts/test-repair-timeline-migration.sh create mode 100755 scripts/test-repair-timeline.sh diff --git a/backend/alembic/versions/0011_repair_timeline.py b/backend/alembic/versions/0011_repair_timeline.py new file mode 100644 index 0000000..2acacd1 --- /dev/null +++ b/backend/alembic/versions/0011_repair_timeline.py @@ -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'" + ) diff --git a/backend/app/identity.py b/backend/app/identity.py index 333672c..5330a6e 100644 --- a/backend/app/identity.py +++ b/backend/app/identity.py @@ -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" diff --git a/backend/app/main.py b/backend/app/main.py index 037b697..8f17469 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/repairs/__init__.py b/backend/app/repairs/__init__.py index 7f2d3a7..365f364 100644 --- a/backend/app/repairs/__init__.py +++ b/backend/app/repairs/__init__.py @@ -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", ] diff --git a/backend/app/repairs/exceptions.py b/backend/app/repairs/exceptions.py index b570d65..9b7938e 100644 --- a/backend/app/repairs/exceptions.py +++ b/backend/app/repairs/exceptions.py @@ -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.""" diff --git a/backend/app/repairs/models.py b/backend/app/repairs/models.py index bc96be4..5129618 100644 --- a/backend/app/repairs/models.py +++ b/backend/app/repairs/models.py @@ -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 diff --git a/backend/app/repairs/repository.py b/backend/app/repairs/repository.py index 215d51f..4013a47 100644 --- a/backend/app/repairs/repository.py +++ b/backend/app/repairs/repository.py @@ -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] diff --git a/backend/app/repairs/router.py b/backend/app/repairs/router.py index a369bbf..5ae9ec7 100644 --- a/backend/app/repairs/router.py +++ b/backend/app/repairs/router.py @@ -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) diff --git a/backend/app/repairs/service.py b/backend/app/repairs/service.py index af63ffa..e841377 100644 --- a/backend/app/repairs/service.py +++ b/backend/app/repairs/service.py @@ -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 + ], + ) diff --git a/backend/tests/test_repair_timeline.py b/backend/tests/test_repair_timeline.py new file mode 100644 index 0000000..7a6a605 --- /dev/null +++ b/backend/tests/test_repair_timeline.py @@ -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() diff --git a/backend/tests/test_repairs_router.py b/backend/tests/test_repairs_router.py index bf4d121..cc0f111 100644 --- a/backend/tests/test_repairs_router.py +++ b/backend/tests/test_repairs_router.py @@ -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"})]) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 783db9e..deb62ca 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -99,9 +99,11 @@ durable asset record. Repairs, maintenance, work orders, parts usage, equipment movement, testing, and return-to-service history become coherent operational records rather than disconnected actions. -The Begin Repair workflow is complete. An authorized actor can open a repair -against a durable miner UUID, record sequenced starting events, and move the -miner into repair atomically. Later repair lifecycle workflows remain planned. +The Begin Repair and read-only Repair Timeline workflows are complete. An +authorized actor can open a repair against a durable miner UUID, record +sequenced starting events, and move the miner into repair atomically. Authorized +readers can inspect the repair and its ordered immutable events. Later repair +lifecycle workflows remain planned. Technician experience is a priority. Responsive and QR-assisted workflows may reduce lookup and data-entry effort where they reinforce the same asset record. @@ -214,6 +216,7 @@ Major completed foundations include: - Manual Inventory schema and backend domain foundation - Focused Inventory and migration test foundations - Begin Repair operational workflow +- Read-only Repair Timeline API - MinerOps branding package and project README - Architecture guide - Product guide diff --git a/docs/tasks/002-repair-timeline.md b/docs/tasks/002-repair-timeline.md new file mode 100644 index 0000000..786596a --- /dev/null +++ b/docs/tasks/002-repair-timeline.md @@ -0,0 +1,183 @@ +# Task 002: Repair Timeline + +## Objective + +Add read-only APIs for viewing one repair and its immutable timeline. + +## Scope + +Add exactly these two endpoints: + +```http +GET /api/repairs/{repair_id} +GET /api/repairs/{repair_id}/events +``` + +Do not add any other repair endpoints. + +## Permissions + +Add: + +```text +repairs.read +``` + +Grant it to Admin, Manager, Technician, and Read Only. Existing `repairs.create` access remains unchanged. + +## Architecture + +Preserve: + +```text +Router -> Service -> Repository -> PostgreSQL +``` + +Routers contain no SQL or transaction logic. Services contain no FastAPI dependencies. Repositories contain no FastAPI imports. Reads must not modify data. + +## GET /api/repairs/{repair_id} + +Return: + +```json +{ + "repair_id": "uuid", + "miner_id": "uuid", + "miner_asset_id": "string", + "status": "OPEN", + "reason": "INSPECTION", + "opened_at": "timestamp", + "opened_by": { + "user_id": "uuid", + "display_name": "string" + }, + "closed_at": null, + "closed_by": null, + "miner_lifecycle": "in_repair", + "latest_event_sequence": 2 +} +``` + +Requirements: + +- Return 404 when the repair does not exist. +- Return the current miner lifecycle. +- Resolve actor identity using existing user conventions. +- Obtain `latest_event_sequence` from authoritative event data. +- Do not infer missing history or expose internal-only fields. + +## GET /api/repairs/{repair_id}/events + +Return: + +```json +{ + "repair_id": "uuid", + "events": [ + { + "event_id": "uuid", + "sequence": 1, + "event_type": "REPAIR_STARTED", + "actor": { + "user_id": "uuid", + "display_name": "string" + }, + "occurred_at": "timestamp", + "payload": { + "reason": "INSPECTION", + "from_lifecycle": "active", + "to_lifecycle": "in_repair" + } + } + ] +} +``` + +Requirements: + +- Return 404 when the repair does not exist. +- Return all events ordered strictly by database sequence ascending. +- Do not order only by timestamp. +- Preserve payload as structured JSON. +- Avoid N+1 actor lookups. + +## Timeline Invariants + +Events are immutable. This task must not add support for editing, deleting, replacing, or reordering timeline events. Corrections must be represented by new events. + +## Models and Validation + +- Use UUID types for identifiers. +- Use timezone-aware datetime types. +- Reuse existing constrained status, reason, lifecycle, and event types where practical. +- Represent payloads as structured JSON, not serialized strings. +- Avoid exposing unexpected internal fields. + +## Repository Requirements + +Add focused read methods for repair detail and ordered repair events. Queries should include miner asset ID, current lifecycle, actor display data, and latest event sequence. Do not add broad list or search methods. + +## Service Requirements + +The service must validate existence, coordinate repository reads, map missing repairs to the domain-standard not-found exception, and contain no SQL or FastAPI dependencies. + +## Router Requirements + +Expose exactly the two routes in this specification, require `repairs.read`, follow existing exception patterns, and contain no SQL, commits, or transaction management. + +## Migration + +Create the next Alembic revision only if required to add `repairs.read` and role assignments. Preserve existing permissions and provide a complete downgrade. Do not alter repair tables unless a material incompatibility is found; report that conflict before deviating. + +## Tests + +Cover at least: + +- 401 unauthenticated +- 403 unauthorized +- Read Only can read detail and events +- Admin, Manager, and Technician can read +- `repairs.create` behavior remains unchanged +- existing repair detail +- missing repair 404 +- asset ID, lifecycle, actor, nullable closed fields, and latest sequence +- sequence ordering even when timestamps are equal or non-monotonic +- structured JSON payload +- actor data +- explicit empty-timeline behavior +- Begin Repair regression +- Inventory regression +- migration upgrade, downgrade, re-upgrade, and empty database through head +- exactly two new routes +- architecture audits + +## Validation + +Run: + +- Python compilation +- shell syntax validation +- focused timeline tests +- existing Begin Repair tests +- inventory regression tests +- migration validation +- disposable development baseline +- router SQL/driver/transaction audit +- service/repository FastAPI audit +- exact route-count audit +- `git diff --check` + +## Documentation + +Update `docs/ROADMAP.md` only after implementation and validation pass. Do not add an ADR unless a genuinely new architectural decision is required. + +## Explicitly Out of Scope + +Do not implement repair listing, search, pagination, adding or editing notes, deleting events, completion, return to service, cancellation, assignment, diagnostics, parts, photos, control-board replacement, generic CRUD, or UI changes. + +## Completion Report + +Report files created and modified, migration revision if any, routes, permission changes, tests and validation, and final `git status --short`. + +Do not commit. +Do not push. diff --git a/scripts/test-dev-baseline.sh b/scripts/test-dev-baseline.sh index 1382623..f9ccfb7 100755 --- a/scripts/test-dev-baseline.sh +++ b/scripts/test-dev-baseline.sh @@ -88,7 +88,7 @@ health_json="$(cat "${health_file}")" echo "Checking migrations and required schema..." current_revision="$("${compose[@]}" exec -T backend alembic current 2>/dev/null)" -[[ "${current_revision}" == *"0010_begin_repair"* ]] +[[ "${current_revision}" == *"0011_repair_timeline"* ]] missing_tables="$( "${compose[@]}" exec -T postgres sh -c \ @@ -197,6 +197,29 @@ begin_repair_response="$(curl --fail --silent -b "${technician_cookie}" \ '{"miner_id":"00000000-0000-0000-0000-000000000981","reason":"CONTROL_BOARD"}' )" == "409" ]] +smoke_repair_id="$( + printf '%s' "${begin_repair_response}" \ + | python3 -c 'import json, sys; print(json.load(sys.stdin)["data"]["repair_id"])' +)" +[[ -n "${smoke_repair_id}" ]] +readonly_repair_detail="$( + curl --fail --silent -b "${readonly_cookie}" \ + "${base_url}/api/repairs/${smoke_repair_id}" +)" +[[ "${readonly_repair_detail}" == *'"miner_asset_id":"SMOKE-REPAIR-001"'* ]] +[[ "${readonly_repair_detail}" == *'"miner_lifecycle":"in_repair"'* ]] +[[ "${readonly_repair_detail}" == *'"latest_event_sequence":2'* ]] +readonly_repair_events="$( + curl --fail --silent -b "${readonly_cookie}" \ + "${base_url}/api/repairs/${smoke_repair_id}/events" +)" +[[ "${readonly_repair_events}" == *'"sequence":1'* ]] +[[ "${readonly_repair_events}" == *'"sequence":2'* ]] +[[ "${readonly_repair_events}" == *'"event_type":"REPAIR_STARTED"'* ]] +[[ "${readonly_repair_events}" == *'"event_type":"INITIAL_NOTE"'* ]] +[[ "$(status_for "${technician_cookie}" GET /api/repairs/queue)" == "200" ]] +[[ "$(status_for "${technician_cookie}" GET /api/repairs/parts-summary)" == "200" ]] + room_status="$(status_for "${technician_cookie}" POST /api/rooms '{"name":"Role Matrix Test Room"}')" [[ "${room_status}" == "200" || "${room_status}" == "201" ]] diff --git a/scripts/test-repair-timeline-migration.sh b/scripts/test-repair-timeline-migration.sh new file mode 100755 index 0000000..08dd87c --- /dev/null +++ b/scripts/test-repair-timeline-migration.sh @@ -0,0 +1,171 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "${repo_root}" + +project_name="${MINEROPS_TIMELINE_MIGRATION_PROJECT:-minerops-timeline-migration-$$}" +empty_database="minerops_timeline_empty" +compose=( + docker compose + --project-name "${project_name}" + --env-file .env.dev + -f compose.dev.yaml +) + +cleanup() { + status=$? + if (( status != 0 )); then + echo + echo "Repair Timeline migration test 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 + +run_alembic() { + "${compose[@]}" run --rm --no-deps backend alembic "$@" +} + +echo "Starting disposable PostgreSQL..." +"${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 migration image..." +"${compose[@]}" build backend + +echo "Upgrading the previous head to Repair Timeline..." +run_alembic upgrade 0010_begin_repair +previous_revision="$(run_alembic current 2>/dev/null)" +[[ "${previous_revision}" == *"0010_begin_repair"* ]] +run_alembic upgrade head +current_revision="$(run_alembic current 2>/dev/null)" +[[ "${current_revision}" == *"0011_repair_timeline"* ]] + +echo "Checking repairs.read permission and role assignments..." +"${compose[@]}" exec -T postgres sh -ec \ + 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \ + <<'SQL' +DO $$ +DECLARE + v_read_roles TEXT[]; + v_create_roles TEXT[]; +BEGIN + IF to_regclass('public.repairs') IS NULL + OR to_regclass('public.repair_events') IS NULL THEN + RAISE EXCEPTION 'Repair tables changed during timeline migration'; + END IF; + + IF (SELECT count(*) FROM permissions WHERE key = 'repairs.read') <> 1 THEN + RAISE EXCEPTION 'repairs.read permission was not registered once'; + END IF; + + SELECT array_agg(roles.name ORDER BY roles.name) + INTO v_read_roles + FROM role_permissions + JOIN roles ON roles.id = role_permissions.role_id + JOIN permissions ON permissions.id = role_permissions.permission_id + WHERE permissions.key = 'repairs.read'; + IF v_read_roles <> ARRAY[ + 'Admin', 'Manager', 'Read Only', 'Technician' + ] THEN + RAISE EXCEPTION 'Unexpected repairs.read role matrix: %', v_read_roles; + END IF; + + SELECT array_agg(roles.name ORDER BY roles.name) + INTO v_create_roles + FROM role_permissions + JOIN roles ON roles.id = role_permissions.role_id + JOIN permissions ON permissions.id = role_permissions.permission_id + WHERE permissions.key = 'repairs.create'; + IF v_create_roles <> ARRAY['Admin', 'Manager', 'Technician'] THEN + RAISE EXCEPTION + 'repairs.create role matrix changed: %', + v_create_roles; + END IF; + + INSERT INTO permissions (key, description) + VALUES ('repairs.read', 'View repair details and timeline events') + ON CONFLICT (key) DO UPDATE + SET description = EXCLUDED.description; + 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; + + IF (SELECT count(*) FROM permissions WHERE key = 'repairs.read') <> 1 + OR ( + SELECT count(*) + FROM role_permissions + JOIN permissions + ON permissions.id = role_permissions.permission_id + WHERE permissions.key = 'repairs.read' + ) <> 4 THEN + RAISE EXCEPTION 'repairs.read seeding is not idempotent'; + END IF; +END +$$; +SQL + +echo "Downgrading and re-upgrading Repair Timeline..." +run_alembic downgrade 0010_begin_repair +downgrade_state="$( + "${compose[@]}" exec -T postgres sh -ec \ + 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" -At' \ + <<'SQL' +SELECT + NOT EXISTS ( + SELECT 1 FROM permissions WHERE key = 'repairs.read' + ) + AND EXISTS ( + SELECT 1 FROM permissions WHERE key = 'repairs.create' + ) + AND to_regclass('public.repairs') IS NOT NULL + AND to_regclass('public.repair_events') IS NOT NULL; +SQL +)" +[[ "${downgrade_state}" == "t" ]] +downgraded_revision="$(run_alembic current 2>/dev/null)" +[[ "${downgraded_revision}" == *"0010_begin_repair"* ]] + +run_alembic upgrade head +reupgraded_revision="$(run_alembic current 2>/dev/null)" +[[ "${reupgraded_revision}" == *"0011_repair_timeline"* ]] + +echo "Migrating an empty database from base through head..." +"${compose[@]}" exec -T postgres sh -ec \ + 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d postgres \ + -c "CREATE DATABASE minerops_timeline_empty"' >/dev/null +"${compose[@]}" run --rm --no-deps \ + -e "POSTGRES_DB=${empty_database}" \ + backend alembic upgrade head + +empty_revision="$( + "${compose[@]}" run --rm --no-deps \ + -e "POSTGRES_DB=${empty_database}" \ + backend alembic current 2>/dev/null +)" +[[ "${empty_revision}" == *"0011_repair_timeline"* ]] + +echo "Repair Timeline migration test passed." diff --git a/scripts/test-repair-timeline.sh b/scripts/test-repair-timeline.sh new file mode 100755 index 0000000..402792c --- /dev/null +++ b/scripts/test-repair-timeline.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "${repo_root}" + +project_name="${MINEROPS_TIMELINE_TEST_PROJECT:-minerops-repair-timeline-$$}" +compose=( + docker compose + --project-name "${project_name}" + --env-file .env.dev + -f compose.dev.yaml +) + +cleanup() { + status=$? + if (( status != 0 )); then + echo + echo "Repair Timeline 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 Repair Timeline 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 Repair Timeline test image..." +"${compose[@]}" build backend + +echo "Migrating disposable database..." +"${compose[@]}" run --rm --no-deps backend alembic upgrade head + +echo "Running Repair Timeline repository, service, and router tests..." +"${compose[@]}" run --rm --no-deps \ + -v "${repo_root}/backend/tests:/app/tests:ro" \ + backend python -m unittest -v tests.test_repair_timeline + +echo "Repair Timeline tests passed."