#!/usr/bin/env bash set -euo pipefail BASE_URL="${BASE_URL:-http://127.0.0.1:3000}" DEVICE_FINGERPRINT="${DEVICE_FINGERPRINT:-regression-dedup}" EMPLOYEE_IDENTIFIER="${EMPLOYEE_IDENTIFIER:-employee@hyinda.com}" EMPLOYEE_PASSWORD="${EMPLOYEE_PASSWORD:-EmployeePassword1234}" 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 if ! command -v node >/dev/null 2>&1; then echo "[ERROR] node 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)" TMP_INDEX_CREATED=0 RUN_SUFFIX="$(date +%s%N)-$RANDOM" DEDUP_CODE="RCODE-$RUN_SUFFIX" DEDUP_NUMBER="RNO-$RUN_SUFFIX" cleanup() { if [[ "$TMP_INDEX_CREATED" == "1" ]]; then drop_tmp_non_dedup_index || true fi 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 if [[ -n "$body_file" && -f "$body_file" ]]; then cat "$body_file" >&2 fi exit 1 fi } api_json() { local method="$1" local url="$2" local out_file="$3" local body="${4:-}" 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 } api_json_with_token() { 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 } upload_file() { local file_path="$1" local out_file="$2" curl -sS -o "$out_file" -w "%{http_code}" \ -X POST "$BASE_URL/api/invoice-pool/upload" \ -H "authorization: Bearer $TOKEN" \ -F "file=@$file_path" } create_reimbursement() { local out_file="$1" local status status="$(api_json POST "$BASE_URL/api/reimbursements" "$out_file" '{}')" assert_status "$status" "200" "create reimbursement" "$out_file" jq -er '.data.id' "$out_file" } set_invoice_code_number() { local invoice_id="$1" local invoice_code="$2" local invoice_number="$3" DATABASE_URL="$DATABASE_URL" node - "$invoice_id" "$invoice_code" "$invoice_number" <<'NODE' const invoiceId = process.argv[2]; const invoiceCode = process.argv[3]; const invoiceNumber = process.argv[4]; const { createPrismaClient } = require('./src/prismaClient'); (async () => { const { prisma, pool } = createPrismaClient(); await prisma.$connect(); try { await prisma.invoicePool.update({ where: { id: invoiceId }, data: { invoice_code: invoiceCode, invoice_number: invoiceNumber, }, }); } finally { await prisma.$disconnect(); await pool.end(); } })().catch((error) => { console.error(error); process.exit(1); }); NODE } set_reimbursement_status_db() { local reimbursement_id="$1" local status="$2" DATABASE_URL="$DATABASE_URL" REIMBURSEMENT_ID="$reimbursement_id" STATUS="$status" node <<'NODE' const { createPrismaClient } = require('./src/prismaClient'); (async () => { const reimbursementId = process.env.REIMBURSEMENT_ID; const status = process.env.STATUS; const { prisma, pool } = createPrismaClient(); await prisma.$connect(); try { await prisma.reimbursementForm.update({ where: { id: reimbursementId }, data: { status }, }); } finally { await prisma.$disconnect(); await pool.end(); } })().catch((error) => { console.error(error); process.exit(1); }); NODE } create_tmp_non_dedup_index() { local reimbursement_id="$1" DATABASE_URL="$DATABASE_URL" REIMBURSEMENT_ID="$reimbursement_id" node <<'NODE' const { createPrismaClient } = require('./src/prismaClient'); (async () => { const reimbursementId = process.env.REIMBURSEMENT_ID; if (!reimbursementId) { throw new Error('REIMBURSEMENT_ID is required'); } const { prisma, pool } = createPrismaClient(); await prisma.$connect(); try { await prisma.$executeRawUnsafe('DROP INDEX IF EXISTS "tmp_unique_linked_reim"'); await prisma.$executeRawUnsafe( `CREATE UNIQUE INDEX "tmp_unique_linked_reim" ON "invoice_pool" ("linked_reimbursement_id") WHERE "linked_reimbursement_id" = '${reimbursementId}'`, ); } finally { await prisma.$disconnect(); await pool.end(); } })().catch((error) => { console.error(error); process.exit(1); }); NODE } drop_tmp_non_dedup_index() { DATABASE_URL="$DATABASE_URL" node <<'NODE' const { createPrismaClient } = require('./src/prismaClient'); (async () => { const { prisma, pool } = createPrismaClient(); await prisma.$connect(); try { await prisma.$executeRawUnsafe('DROP INDEX IF EXISTS "tmp_unique_linked_reim"'); } finally { await prisma.$disconnect(); await pool.end(); } })().catch((error) => { console.error(error); process.exit(1); }); NODE } assert_error_code() { local file_path="$1" local expected="$2" local actual actual="$(jq -r '.error.code // empty' "$file_path")" if [[ "$actual" != "$expected" ]]; then echo "[ERROR] expected error code $expected but got $actual" >&2 cat "$file_path" >&2 exit 1 fi } echo "[1/7] Login employee" login_code="$(curl -sS -o "$TMP_DIR/login.json" -w "%{http_code}" \ -X POST "$BASE_URL/api/auth/login" \ -H "content-type: application/json" \ -H "x-device-fingerprint: $DEVICE_FINGERPRINT" \ -d "{\"identifier\":\"$EMPLOYEE_IDENTIFIER\",\"password\":\"$EMPLOYEE_PASSWORD\"}")" assert_status "$login_code" "200" "login" "$TMP_DIR/login.json" TOKEN="$(jq -er '.data.accessToken' "$TMP_DIR/login.json")" echo "[1.1/7] Login finance_mgr" login_fm_code="$(curl -sS -o "$TMP_DIR/login-fm.json" -w "%{http_code}" \ -X POST "$BASE_URL/api/auth/login" \ -H "content-type: application/json" \ -H "x-device-fingerprint: $DEVICE_FINGERPRINT-finance-mgr" \ -d "{\"identifier\":\"$FINANCE_MGR_IDENTIFIER\",\"password\":\"$FINANCE_MGR_PASSWORD\"}")" assert_status "$login_fm_code" "200" "login finance_mgr" "$TMP_DIR/login-fm.json" TOKEN_FM="$(jq -er '.data.accessToken' "$TMP_DIR/login-fm.json")" echo "[2/7] Dedup by file_sha256 => VALID_003" cat > "$TMP_DIR/same-file.pdf" < bind ok" override_status="$(api_json_with_token POST "$BASE_URL/api/invoice-pool/$IID_B/override-duplicate" "$TOKEN_FM" "$TMP_DIR/override-file.json" "{\"refFormNumber\":\"REF-$RUN_SUFFIX\",\"reason\":\"regression override file_sha256\"}")" assert_status "$override_status" "200" "override duplicate (file_sha256)" "$TMP_DIR/override-file.json" rebind_status="$(api_json POST "$BASE_URL/api/invoice-pool/$IID_B/bind" "$TMP_DIR/bB2.json" "{\"reimbursementId\":\"$RID_B\"}")" assert_status "$rebind_status" "200" "bind after override (file_sha256)" "$TMP_DIR/bB2.json" echo "[3/7] Dedup by invoice_code+invoice_number => VALID_003" cat > "$TMP_DIR/code-1.pdf" < "$TMP_DIR/code-2.pdf" < bind ok" override_status="$(api_json_with_token POST "$BASE_URL/api/invoice-pool/$IID_D/override-duplicate" "$TOKEN_FM" "$TMP_DIR/override-code.json" "{\"refFormNumber\":\"REF-$RUN_SUFFIX\",\"reason\":\"regression override code+number\"}")" assert_status "$override_status" "200" "override duplicate (code+number)" "$TMP_DIR/override-code.json" rebind_status="$(api_json POST "$BASE_URL/api/invoice-pool/$IID_D/bind" "$TMP_DIR/bD2.json" "{\"reimbursementId\":\"$RID_D\"}")" assert_status "$rebind_status" "200" "bind after override (code+number)" "$TMP_DIR/bD2.json" echo "[3.2/7] override blocked when original reimbursement approved => VALID_004" BLOCK_CODE="BCODE-$RUN_SUFFIX" BLOCK_NUMBER="BNO-$RUN_SUFFIX" cat > "$TMP_DIR/block-1.pdf" < "$TMP_DIR/block-2.pdf" < "$TMP_DIR/non-dedup-1.pdf" < "$TMP_DIR/non-dedup-2.pdf" <&2 cat "$TMP_DIR/bE2.json" >&2 exit 1 fi echo "[5/7] Validate non-dedup result is server-side failure path" if [[ "$status" != "500" ]]; then echo "[ERROR] expected HTTP 500 for temporary non-dedup unique index violation, got $status" >&2 cat "$TMP_DIR/bE2.json" >&2 exit 1 fi echo "[6/7] Ensure non-dedup response is not VALID_003" unexpected_code="$(jq -r '.error.code // empty' "$TMP_DIR/bE2.json")" if [[ "$unexpected_code" == "VALID_003" ]]; then echo "[ERROR] unexpected VALID_003 for non-dedup conflict" >&2 cat "$TMP_DIR/bE2.json" >&2 exit 1 fi drop_tmp_non_dedup_index TMP_INDEX_CREATED=0 echo "[7/7] Done" echo "[OK] Dedup regression passed"