#!/usr/bin/env bash set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "${repo_root}" project_name="${MINEROPS_REPAIR_PARTS_MIGRATION_PROJECT:-minerops-repair-parts-migration-$$}" compose=(docker compose --project-name "${project_name}" --env-file .env.dev -f compose.dev.yaml) cleanup() { status=$? if (( status != 0 )); then "${compose[@]}" logs --no-color --tail 120 postgres || true fi "${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true exit "${status}" } trap cleanup EXIT run_alembic() { "${compose[@]}" run --rm --no-deps backend alembic "$@"; } psql_db() { "${compose[@]}" exec -T postgres sh -ec \ 'exec psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" "$@"' sh "$@" } [[ -f .env.dev ]] || { echo "Missing .env.dev" >&2; exit 1; } "${compose[@]}" up --detach postgres for attempt in $(seq 1 60); do "${compose[@]}" exec -T postgres sh -ec \ 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' >/dev/null 2>&1 && break (( attempt < 60 )) || { echo "PostgreSQL did not become ready" >&2; exit 1; } sleep 1 done "${compose[@]}" build backend echo "Upgrading from Task 004 parent..." run_alembic upgrade 0013_control_board_replacement [[ "$(run_alembic current 2>/dev/null)" == *"0013_control_board_replacement"* ]] run_alembic upgrade 0014_repair_parts_tracking [[ "$(run_alembic current 2>/dev/null)" == *"0014_repair_parts_tracking"* ]] psql_db <<'SQL' DO $$ DECLARE component_roles text[]; DECLARE create_roles text[]; DECLARE manage_roles text[]; BEGIN IF to_regclass('public.components') IS NULL OR to_regclass('public.repair_parts') IS NULL THEN RAISE EXCEPTION 'Task 005 tables are missing'; END IF; SELECT array_agg(r.name ORDER BY r.name) INTO component_roles FROM role_permissions rp JOIN roles r ON r.id = rp.role_id JOIN permissions p ON p.id = rp.permission_id WHERE p.key = 'components.read'; SELECT array_agg(r.name ORDER BY r.name) INTO create_roles FROM role_permissions rp JOIN roles r ON r.id = rp.role_id JOIN permissions p ON p.id = rp.permission_id WHERE p.key = 'components.create'; SELECT array_agg(r.name ORDER BY r.name) INTO manage_roles FROM role_permissions rp JOIN roles r ON r.id = rp.role_id JOIN permissions p ON p.id = rp.permission_id WHERE p.key = 'repairs.parts.manage'; IF component_roles <> ARRAY['Admin', 'Manager', 'Read Only', 'Technician'] THEN RAISE EXCEPTION 'Unexpected components.read roles: %', component_roles; END IF; IF create_roles <> ARRAY['Admin', 'Manager', 'Technician'] OR manage_roles <> ARRAY['Admin', 'Manager', 'Technician'] THEN RAISE EXCEPTION 'Unexpected Task 005 write roles: %, %', create_roles, manage_roles; END IF; IF NOT EXISTS ( SELECT 1 FROM pg_constraint WHERE conname = 'ck_repair_parts_entry_xor' ) OR NOT EXISTS ( SELECT 1 FROM pg_trigger WHERE tgname = 'trg_validate_repair_part_history' ) THEN RAISE EXCEPTION 'Task 005 constraints or triggers are missing'; END IF; END $$; SQL echo "Testing safe downgrade and re-upgrade..." run_alembic downgrade 0013_control_board_replacement [[ "$(run_alembic current 2>/dev/null)" == *"0013_control_board_replacement"* ]] absent="$(psql_db -Atc "SELECT to_regclass('public.components') IS NULL AND to_regclass('public.repair_parts') IS NULL")" [[ "${absent}" == "t" ]] run_alembic upgrade 0014_repair_parts_tracking echo "Testing registry, XOR, snapshots, voids, and immutability..." psql_db <<'SQL' INSERT INTO users (id, username, display_name, password_hash, role_id) SELECT '00000000-0000-0000-0000-000000001501', 'repair-parts-migration', 'Repair Parts Migration', 'unused', id FROM roles WHERE name = 'Admin'; INSERT INTO miners ( id, asset_tag, lifecycle_status, status, first_seen_at, last_seen_at ) VALUES ( '00000000-0000-0000-0000-000000001502', 'PARTS-MINER', 'in_repair', 'unknown', NULL, NULL ); INSERT INTO repairs (id, miner_id, status, reason, opened_by) VALUES ( '00000000-0000-0000-0000-000000001503', '00000000-0000-0000-0000-000000001502', 'OPEN', 'OTHER', '00000000-0000-0000-0000-000000001501' ); INSERT INTO repair_events (id, repair_id, sequence, event_type, created_by, payload) VALUES ( '00000000-0000-0000-0000-000000001504', '00000000-0000-0000-0000-000000001503', 1, 'REPAIR_STARTED', '00000000-0000-0000-0000-000000001501', '{"reason":"OTHER","from_lifecycle":"active","to_lifecycle":"in_repair"}' ); INSERT INTO components ( id, component_type, asset_id, manufacturer, manufacturer_serial, created_by ) VALUES ( '00000000-0000-4000-8000-000000001505', 'hashboard', 'HB-005', 'MicroBT', 'SER-005', '00000000-0000-0000-0000-000000001501' ); INSERT INTO repair_events (id, repair_id, sequence, event_type, created_by, payload) VALUES ( '00000000-0000-0000-0000-000000001506', '00000000-0000-0000-0000-000000001503', 2, 'REPAIR_PART_RECORDED', '00000000-0000-0000-0000-000000001501', '{"repair_part_id":"00000000-0000-4000-8000-000000001507","entry_kind":"tracked_component","component_id":"00000000-0000-4000-8000-000000001505","component_type":"hashboard","component_asset_id":"HB-005","component_manufacturer":"MicroBT","component_model":null,"component_manufacturer_serial":"SER-005","notes":null}' ); INSERT INTO repair_parts ( id, repair_id, component_id, component_type_snapshot, component_asset_id_snapshot, component_manufacturer_snapshot, component_model_snapshot, component_manufacturer_serial_snapshot, recorded_by, recorded_at, recorded_event_id ) SELECT '00000000-0000-4000-8000-000000001507', '00000000-0000-0000-0000-000000001503', '00000000-0000-4000-8000-000000001505', 'hashboard', 'HB-005', 'MicroBT', NULL, 'SER-005', '00000000-0000-0000-0000-000000001501', created_at, id FROM repair_events WHERE id = '00000000-0000-0000-0000-000000001506'; UPDATE components SET manufacturer = 'Updated Manufacturer' WHERE id = '00000000-0000-4000-8000-000000001505'; DO $$ BEGIN IF (SELECT component_manufacturer_snapshot FROM repair_parts WHERE id = '00000000-0000-4000-8000-000000001507') <> 'MicroBT' THEN RAISE EXCEPTION 'Tracked snapshot changed with live metadata'; END IF; BEGIN INSERT INTO repair_parts ( repair_id, consumable_description, quantity, unit, component_id, recorded_by, recorded_event_id ) VALUES ( '00000000-0000-0000-0000-000000001503', 'invalid', 1, 'each', '00000000-0000-4000-8000-000000001505', '00000000-0000-0000-0000-000000001501', '00000000-0000-0000-0000-000000001506' ); RAISE EXCEPTION 'XOR violation was accepted'; EXCEPTION WHEN check_violation OR raise_exception THEN NULL; END; END $$; INSERT INTO repair_events (id, repair_id, sequence, event_type, created_by, payload) VALUES ( '00000000-0000-0000-0000-000000001508', '00000000-0000-0000-0000-000000001503', 3, 'REPAIR_PART_VOIDED', '00000000-0000-0000-0000-000000001501', '{"repair_part_id":"00000000-0000-4000-8000-000000001507","reason":"Wrong repair"}' ); UPDATE repair_parts SET voided_by = '00000000-0000-0000-0000-000000001501', voided_at = (SELECT created_at FROM repair_events WHERE id = '00000000-0000-0000-0000-000000001508'), void_reason = 'Wrong repair', void_event_id = '00000000-0000-0000-0000-000000001508' WHERE id = '00000000-0000-4000-8000-000000001507'; DO $$ BEGIN BEGIN UPDATE repair_parts SET void_reason = 'Changed' WHERE id = '00000000-0000-4000-8000-000000001507'; RAISE EXCEPTION 'Second void mutation was accepted'; EXCEPTION WHEN raise_exception THEN NULL; END; BEGIN DELETE FROM repair_parts WHERE id = '00000000-0000-4000-8000-000000001507'; RAISE EXCEPTION 'Repair-part deletion was accepted'; EXCEPTION WHEN raise_exception THEN NULL; END; BEGIN DELETE FROM repair_events WHERE id = '00000000-0000-0000-0000-000000001508'; RAISE EXCEPTION 'Repair-event deletion was accepted'; EXCEPTION WHEN raise_exception THEN NULL; END; END $$; SQL if downgrade_output="$(run_alembic downgrade 0013_control_board_replacement 2>&1)"; then echo "Task 005 downgrade unexpectedly destroyed history" >&2 exit 1 fi [[ "${downgrade_output}" == *"Cannot downgrade Repair Parts Tracking"* ]] [[ "$(run_alembic current 2>/dev/null)" == *"0014_repair_parts_tracking"* ]] echo "Testing empty database through head..." "${compose[@]}" exec -T postgres sh -ec \ 'createdb -U "$POSTGRES_USER" minerops_task005_empty' "${compose[@]}" run --rm --no-deps -e POSTGRES_DB=minerops_task005_empty \ backend alembic upgrade head empty_revision="$( "${compose[@]}" exec -T postgres sh -ec \ 'psql -U "$POSTGRES_USER" -d minerops_task005_empty -Atc "SELECT version_num FROM alembic_version"' )" [[ "${empty_revision}" == "0014_repair_parts_tracking" ]] echo "Repair Parts Tracking migration tests passed."