#!/usr/bin/env bash set -euo pipefail BASE_URL="${BASE_URL:-http://127.0.0.1:3000}" DEVICE_FINGERPRINT="${DEVICE_FINGERPRINT:-regression-procurement}" EMPLOYEE_IDENTIFIER="${EMPLOYEE_IDENTIFIER:-employee@hyinda.com}" EMPLOYEE_PASSWORD="${EMPLOYEE_PASSWORD:-EmployeePassword1234}" MANAGER_IDENTIFIER="${MANAGER_IDENTIFIER:-manager@hyinda.com}" MANAGER_PASSWORD="${MANAGER_PASSWORD:-ManagerPassword1234}" FINANCE_MGR_IDENTIFIER="${FINANCE_MGR_IDENTIFIER:-finance_mgr@hyinda.com}" FINANCE_MGR_PASSWORD="${FINANCE_MGR_PASSWORD:-FinanceMgrPassword1234}" if ! command -v curl >/dev/null 2>&1; then echo "[ERROR] curl not found" >&2 exit 1 fi if ! command -v jq >/dev/null 2>&1; then echo "[ERROR] jq not found" >&2 exit 1 fi ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT_DIR" # shellcheck source=scripts/lib/load-env-defaults.sh source "$ROOT_DIR/scripts/lib/load-env-defaults.sh" load_env_defaults ".env" if [[ -z "${DATABASE_URL:-}" ]]; then echo "[ERROR] DATABASE_URL is required (export it or provide server/.env)" >&2 exit 1 fi TMP_DIR="$(mktemp -d)" cleanup() { rm -rf "$TMP_DIR" } trap cleanup EXIT assert_status() { local actual="$1" local expected="$2" local name="$3" local body_file="${4:-}" if [[ "$actual" != "$expected" ]]; then echo "[ERROR] $name expected HTTP $expected but got $actual" >&2 [[ -n "$body_file" && -f "$body_file" ]] && cat "$body_file" >&2 exit 1 fi } assert_jq() { local file_path="$1" local jq_expr="$2" local name="$3" if ! jq -e "$jq_expr" "$file_path" >/dev/null; then echo "[ERROR] $name assertion failed: $jq_expr" >&2 cat "$file_path" >&2 exit 1 fi } login_user() { local identifier="$1" local password="$2" local out_file="$3" local status status="$(curl -sS -o "$out_file" -w "%{http_code}" \ -X POST "$BASE_URL/api/auth/login" \ -H "content-type: application/json" \ -H "x-device-fingerprint: $DEVICE_FINGERPRINT" \ -d "{\"identifier\":\"$identifier\",\"password\":\"$password\"}")" assert_status "$status" "200" "login $identifier" "$out_file" jq -er '.data.accessToken' "$out_file" } api_json_auth() { local method="$1" local url="$2" local token="$3" local out_file="$4" local body="${5:-}" if [[ -n "$body" ]]; then curl -sS -o "$out_file" -w "%{http_code}" \ -X "$method" "$url" \ -H "authorization: Bearer $token" \ -H "content-type: application/json" \ -d "$body" else curl -sS -o "$out_file" -w "%{http_code}" \ -X "$method" "$url" \ -H "authorization: Bearer $token" fi } resolve_fixture_ids() { DATABASE_URL="$DATABASE_URL" node - <<'NODE' const { createPrismaClient } = require('./src/prismaClient'); const { resolveApplicableBudgetPoolByContext } = require('./src/services/budgetPool'); (async () => { const { prisma, pool } = createPrismaClient(); await prisma.$connect(); try { const employee = await prisma.employee.findUnique({ where: { email: process.env.SEED_EMPLOYEE_EMAIL ?? 'employee@hyinda.com' }, select: { department_id: true, legal_entity_id: true }, }); const costCenter = await prisma.costCenter.findFirst({ where: { enabled: true, archived_at: null, legal_entity_id: employee.legal_entity_id, }, orderBy: { created_at: 'asc' }, select: { id: true }, }); const supplier = await prisma.supplier.findFirst({ where: { enabled: true, archived_at: null, OR: [{ legal_entity_id: employee.legal_entity_id }, { legal_entity_id: null }], }, orderBy: { created_at: 'asc' }, select: { id: true }, }); const documentCategory = await prisma.documentCategory.findFirst({ where: { enabled: true, archived_at: null, entity_type: 'purchase_request', }, orderBy: { created_at: 'asc' }, select: { id: true }, }); const budgetPool = await resolveApplicableBudgetPoolByContext(prisma, { departmentId: employee.department_id, legalEntityId: employee.legal_entity_id, costCenterId: costCenter?.id ?? null, effectiveDate: new Date(), }); process.stdout.write([ employee.legal_entity_id, costCenter?.id ?? '', supplier?.id ?? '', documentCategory?.id ?? '', budgetPool?.id ?? '', ].join('\n')); } finally { await prisma.$disconnect(); await pool.end(); } })().catch((error) => { console.error(error); process.exit(1); }); NODE } assert_procurement_budget_state() { local approved_id="$1" local withdrawn_id="$2" local budget_pool_id="$3" DATABASE_URL="$DATABASE_URL" node - "$approved_id" "$withdrawn_id" "$budget_pool_id" <<'NODE' const [approvedId, withdrawnId, budgetPoolId] = process.argv.slice(2); const { createPrismaClient } = require('./src/prismaClient'); (async () => { const { prisma, pool } = createPrismaClient(); await prisma.$connect(); try { const approvedEntries = await prisma.budgetLedgerEntry.findMany({ where: { entity_type: 'purchase_request', entity_id: approvedId }, orderBy: { created_at: 'asc' }, select: { kind: true, amount: true, budget_pool_id: true }, }); const withdrawnEntries = await prisma.budgetLedgerEntry.findMany({ where: { entity_type: 'purchase_request', entity_id: withdrawnId }, orderBy: { created_at: 'asc' }, select: { kind: true, amount: true, budget_pool_id: true }, }); const archive = await prisma.purchaseOrderArchive.findUnique({ where: { purchase_request_id: approvedId }, select: { po_no: true, contract_no: true }, }); const poolState = budgetPoolId ? await prisma.budgetPool.findUnique({ where: { id: budgetPoolId }, select: { used_amount: true }, }) : null; if (approvedEntries.length !== 2 || approvedEntries[0]?.kind !== 'reserve' || approvedEntries[1]?.kind !== 'confirm') { console.error('[ERROR] approved purchase budget ledger mismatch', approvedEntries); process.exit(1); } if (withdrawnEntries.length !== 2 || withdrawnEntries[0]?.kind !== 'reserve' || withdrawnEntries[1]?.kind !== 'release') { console.error('[ERROR] withdrawn purchase budget ledger mismatch', withdrawnEntries); process.exit(1); } if (!archive || archive.po_no !== 'PO-P17B-001' || archive.contract_no !== 'CT-P17B-001') { console.error('[ERROR] purchase order archive mismatch', archive); process.exit(1); } if (poolState && Number(poolState.used_amount) < 0) { console.error('[ERROR] budget pool used_amount should not be negative', poolState); process.exit(1); } } finally { await prisma.$disconnect(); await pool.end(); } })().catch((error) => { console.error(error); process.exit(1); }); NODE } echo "[1/8] login baseline users" EMPLOYEE_TOKEN="$(login_user "$EMPLOYEE_IDENTIFIER" "$EMPLOYEE_PASSWORD" "$TMP_DIR/employee-login.json")" MANAGER_TOKEN="$(login_user "$MANAGER_IDENTIFIER" "$MANAGER_PASSWORD" "$TMP_DIR/manager-login.json")" FINANCE_MGR_TOKEN="$(login_user "$FINANCE_MGR_IDENTIFIER" "$FINANCE_MGR_PASSWORD" "$TMP_DIR/finance-mgr-login.json")" echo "[2/8] employee can read procurement reference master-data" master_status="$(api_json_auth GET "$BASE_URL/api/master-data/legal-entities?enabled=true" "$EMPLOYEE_TOKEN" "$TMP_DIR/legal-entities.json")" assert_status "$master_status" "200" "employee master-data read" "$TMP_DIR/legal-entities.json" assert_jq "$TMP_DIR/legal-entities.json" '.data.items | length > 0' "employee master-data non-empty" mapfile -t FIXTURE_IDS < <(resolve_fixture_ids) LEGAL_ENTITY_ID="${FIXTURE_IDS[0]}" COST_CENTER_ID="${FIXTURE_IDS[1]}" SUPPLIER_ID="${FIXTURE_IDS[2]}" DOCUMENT_CATEGORY_ID="${FIXTURE_IDS[3]}" BUDGET_POOL_ID="${FIXTURE_IDS[4]}" echo "[3/8] create + submit purchase request => reserve budget" create_status="$(api_json_auth POST "$BASE_URL/api/purchase-requests" "$EMPLOYEE_TOKEN" "$TMP_DIR/create.json" '{}')" assert_status "$create_status" "200" "create purchase request" "$TMP_DIR/create.json" PURCHASE_ID_APPROVED="$(jq -er '.data.purchaseRequest.id' "$TMP_DIR/create.json")" PATCH_BODY="$(jq -nc --arg le "$LEGAL_ENTITY_ID" --arg cc "$COST_CENTER_ID" --arg sp "$SUPPLIER_ID" --arg dc "$DOCUMENT_CATEGORY_ID" '{title:"procurement regression approved", legalEntityId:$le, costCenterId:$cc, supplierId:$sp, documentCategoryId:$dc, items:[{description:"Laptop", specification:"14 inch", quantity:"2.00", unitPrice:"100.00", isAssetCandidate:true}] }')" patch_status="$(api_json_auth PATCH "$BASE_URL/api/purchase-requests/$PURCHASE_ID_APPROVED" "$EMPLOYEE_TOKEN" "$TMP_DIR/patch.json" "$PATCH_BODY")" assert_status "$patch_status" "200" "patch purchase request" "$TMP_DIR/patch.json" submit_status="$(api_json_auth POST "$BASE_URL/api/purchase-requests/$PURCHASE_ID_APPROVED/submit" "$EMPLOYEE_TOKEN" "$TMP_DIR/submit.json" '{}')" assert_status "$submit_status" "200" "submit purchase request" "$TMP_DIR/submit.json" assert_jq "$TMP_DIR/submit.json" '.data.status == "reviewing" and (.data.budgetReservation.applied == true)' "submit reserve budget" TASK_ID="$(jq -er '.data.currentTaskId' "$TMP_DIR/submit.json")" echo "[4/8] manager approve => confirm budget" approve_status="$(api_json_auth POST "$BASE_URL/api/workflow/tasks/$TASK_ID/approve" "$MANAGER_TOKEN" "$TMP_DIR/approve.json" '{"comment":"approve procurement regression"}')" assert_status "$approve_status" "200" "approve purchase request" "$TMP_DIR/approve.json" assert_jq "$TMP_DIR/approve.json" '.data.outcome == "approved" and .data.purchaseRequestStatus == "approved"' "approve purchase request response" echo "[5/8] only procurement.manage can archive PO/contract" archive_forbidden_status="$(api_json_auth POST "$BASE_URL/api/purchase-requests/$PURCHASE_ID_APPROVED/archive-order" "$EMPLOYEE_TOKEN" "$TMP_DIR/archive-forbidden.json" '{"poNo":"PO-FAIL"}')" assert_status "$archive_forbidden_status" "403" "archive order forbidden for employee" "$TMP_DIR/archive-forbidden.json" archive_status="$(api_json_auth POST "$BASE_URL/api/purchase-requests/$PURCHASE_ID_APPROVED/archive-order" "$FINANCE_MGR_TOKEN" "$TMP_DIR/archive.json" '{"poNo":"PO-P17B-001","contractNo":"CT-P17B-001","archivedFiles":[]}')" assert_status "$archive_status" "200" "archive order" "$TMP_DIR/archive.json" assert_jq "$TMP_DIR/archive.json" '.data.archiveOrder.poNo == "PO-P17B-001" and .data.archiveOrder.contractNo == "CT-P17B-001"' "archive order response" echo "[6/8] archive order readable by purchase participants" archive_get_status="$(api_json_auth GET "$BASE_URL/api/purchase-requests/$PURCHASE_ID_APPROVED/archive-order" "$EMPLOYEE_TOKEN" "$TMP_DIR/archive-get.json")" assert_status "$archive_get_status" "200" "get archive order" "$TMP_DIR/archive-get.json" assert_jq "$TMP_DIR/archive-get.json" '.data.archiveOrder.poNo == "PO-P17B-001"' "get archive order response" echo "[7/8] submit + withdraw purchase request => release budget" create2_status="$(api_json_auth POST "$BASE_URL/api/purchase-requests" "$EMPLOYEE_TOKEN" "$TMP_DIR/create2.json" '{}')" assert_status "$create2_status" "200" "create purchase request 2" "$TMP_DIR/create2.json" PURCHASE_ID_WITHDRAWN="$(jq -er '.data.purchaseRequest.id' "$TMP_DIR/create2.json")" PATCH2_BODY="$(jq -nc --arg le "$LEGAL_ENTITY_ID" --arg cc "$COST_CENTER_ID" --arg sp "$SUPPLIER_ID" --arg dc "$DOCUMENT_CATEGORY_ID" '{title:"procurement regression withdrawn", legalEntityId:$le, costCenterId:$cc, supplierId:$sp, documentCategoryId:$dc, items:[{description:"Desk", specification:null, quantity:"1.00", unitPrice:"50.00", isAssetCandidate:false}] }')" patch2_status="$(api_json_auth PATCH "$BASE_URL/api/purchase-requests/$PURCHASE_ID_WITHDRAWN" "$EMPLOYEE_TOKEN" "$TMP_DIR/patch2.json" "$PATCH2_BODY")" assert_status "$patch2_status" "200" "patch purchase request 2" "$TMP_DIR/patch2.json" submit2_status="$(api_json_auth POST "$BASE_URL/api/purchase-requests/$PURCHASE_ID_WITHDRAWN/submit" "$EMPLOYEE_TOKEN" "$TMP_DIR/submit2.json" '{}')" assert_status "$submit2_status" "200" "submit purchase request 2" "$TMP_DIR/submit2.json" withdraw_status="$(api_json_auth POST "$BASE_URL/api/purchase-requests/$PURCHASE_ID_WITHDRAWN/withdraw" "$EMPLOYEE_TOKEN" "$TMP_DIR/withdraw2.json" '{}')" assert_status "$withdraw_status" "200" "withdraw purchase request" "$TMP_DIR/withdraw2.json" assert_jq "$TMP_DIR/withdraw2.json" '.data.status == "draft" and (.data.budgetRelease.applied == true)' "withdraw release budget" echo "[8/8] verify budget ledger + archive DB state" assert_procurement_budget_state "$PURCHASE_ID_APPROVED" "$PURCHASE_ID_WITHDRAWN" "$BUDGET_POOL_ID" echo "[OK] Procurement regression passed" echo "- approved purchaseRequestId: $PURCHASE_ID_APPROVED" echo "- withdrawn purchaseRequestId: $PURCHASE_ID_WITHDRAWN"