feat: add repair timeline read APIs

This commit is contained in:
lussierj committed 2026-07-29 16:28:44 +00:00
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'"
)
+13
View File
@@ -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
View File
@@ -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)
+10
View File
@@ -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",
]
+8
View File
@@ -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."""
+48 -2
View File
@@ -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
+70
View File
@@ -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]
+49 -1
View File
@@ -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)
+57
View File
@@ -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
],
)
+804
View File
@@ -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()
+1 -1
View File
@@ -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"})])
+6 -3
View File
@@ -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
+183
View File
@@ -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.
+24 -1
View File
@@ -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" ]]
+171
View File
@@ -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."
+59
View File
@@ -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."