#!/usr/bin/env bash set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT_DIR" if ! command -v node >/dev/null 2>&1; then echo "[ERROR] node not found" >&2 exit 1 fi if [[ -z "${DATABASE_URL:-}" && -f ".env" ]]; then set -a # shellcheck disable=SC1091 source .env set +a fi if [[ -z "${DATABASE_URL:-}" ]]; then echo "[ERROR] DATABASE_URL is required (export it or provide server/.env)" >&2 exit 1 fi EMPLOYEE_IDENTIFIER="${EMPLOYEE_IDENTIFIER:-employee@hyinda.com}" RUN_SUFFIX="$(date +%s)-$RANDOM" TMP_DIR="$(mktemp -d)" cleanup() { rm -rf "$TMP_DIR" } trap cleanup EXIT run_ocr_once() { OCR_PROVIDER=mock OCR_MAX_ATTEMPTS=3 npm run --silent job:ocr-once } create_pending_invoice() { local marker="$1" DATABASE_URL="$DATABASE_URL" EMPLOYEE_IDENTIFIER="$EMPLOYEE_IDENTIFIER" RUN_SUFFIX="$RUN_SUFFIX" MARKER="$marker" node <<'NODE' const fs = require('node:fs/promises'); const path = require('node:path'); const crypto = require('node:crypto'); const { createPrismaClient } = require('./src/prismaClient'); const { getConfig } = require('./src/config'); (async () => { const marker = process.env.MARKER || 'MOCK_OK'; const runSuffix = process.env.RUN_SUFFIX || 'default'; const email = process.env.EMPLOYEE_IDENTIFIER || 'employee@hyinda.com'; const config = getConfig(); const { prisma, pool } = createPrismaClient(); await prisma.$connect(); try { const owner = await prisma.employee.findUnique({ where: { email }, select: { id: true }, }); if (!owner) { throw new Error(`employee not found: ${email}`); } const relPath = path.join('ocr-regression', runSuffix, `${crypto.randomUUID()}.txt`); const absPath = path.resolve(config.storageDir, relPath); await fs.mkdir(path.dirname(absPath), { recursive: true }); await fs.writeFile(absPath, marker, 'utf8'); const fileSha256 = crypto.createHash('sha256').update(marker).digest('hex'); const created = await prisma.invoicePool.create({ data: { owner_employee_id: owner.id, uploaded_by_employee_id: owner.id, storage_path: relPath, original_name: `ocr-${runSuffix}.txt`, mime_type: 'text/plain', file_size: Buffer.byteLength(marker), file_sha256: fileSha256, status: 'pending_ocr', ocr_type: 'vat', created_by: owner.id, updated_by: owner.id, }, select: { id: true }, }); process.stdout.write(`${created.id}\n`); } finally { await prisma.$disconnect(); await pool.end(); } })().catch((error) => { console.error(error); process.exit(1); }); NODE } set_invoice_status_bound() { local invoice_id="$1" DATABASE_URL="$DATABASE_URL" INVOICE_ID="$invoice_id" node <<'NODE' const { createPrismaClient } = require('./src/prismaClient'); (async () => { const invoiceId = process.env.INVOICE_ID; const { prisma, pool } = createPrismaClient(); await prisma.$connect(); try { const invoice = await prisma.invoicePool.findUnique({ where: { id: invoiceId }, select: { owner_employee_id: true, }, }); if (!invoice) { throw new Error(`invoice not found: ${invoiceId}`); } const owner = await prisma.employee.findUnique({ where: { id: invoice.owner_employee_id }, select: { id: true, department_id: true, }, }); if (!owner) { throw new Error(`invoice owner not found: ${invoice.owner_employee_id}`); } const reimbursement = await prisma.reimbursementForm.create({ data: { submitter_id: owner.id, owner_id: owner.id, department_id: owner.department_id, reimbursement_date: new Date('2026-02-08T00:00:00.000Z'), description: 'regression-ocr synthetic reimbursement', status: 'reviewing', created_by: owner.id, updated_by: owner.id, }, select: { id: true, }, }); await prisma.invoicePool.update({ where: { id: invoiceId }, data: { status: 'bound', linked_reimbursement_id: reimbursement.id, updated_by: owner.id, }, }); } finally { await prisma.$disconnect(); await pool.end(); } })().catch((error) => { console.error(error); process.exit(1); }); NODE } assert_available_invoice() { local invoice_id="$1" local scenario="$2" DATABASE_URL="$DATABASE_URL" INVOICE_ID="$invoice_id" SCENARIO="$scenario" node <<'NODE' const { createPrismaClient } = require('./src/prismaClient'); (async () => { const invoiceId = process.env.INVOICE_ID; const scenario = process.env.SCENARIO; const { prisma, pool } = createPrismaClient(); await prisma.$connect(); try { const invoice = await prisma.invoicePool.findUnique({ where: { id: invoiceId }, select: { status: true, invoice_code: true, invoice_number: true, ocr_attempts: true, ocr_last_error: true, ocr_locked_at: true, ocr_lock_owner: true, }, }); if (!invoice || invoice.status !== 'available') { console.error(`[ERROR] ${scenario} expected status=available but got ${invoice?.status ?? 'N/A'}`); process.exit(1); } if (!invoice.invoice_code || !invoice.invoice_number) { console.error(`[ERROR] ${scenario} expected invoice_code and invoice_number to be filled`); process.exit(1); } if (invoice.ocr_locked_at || invoice.ocr_lock_owner) { console.error(`[ERROR] ${scenario} expected lock released`); process.exit(1); } if ((invoice.ocr_attempts ?? 0) < 1) { console.error(`[ERROR] ${scenario} expected ocr_attempts >= 1 but got ${invoice.ocr_attempts}`); process.exit(1); } } finally { await prisma.$disconnect(); await pool.end(); } })().catch((error) => { console.error(error); process.exit(1); }); NODE } assert_duplicate_failed_invoice() { local invoice_id="$1" local scenario="$2" DATABASE_URL="$DATABASE_URL" INVOICE_ID="$invoice_id" SCENARIO="$scenario" node <<'NODE' const { createPrismaClient } = require('./src/prismaClient'); (async () => { const invoiceId = process.env.INVOICE_ID; const scenario = process.env.SCENARIO; const { prisma, pool } = createPrismaClient(); await prisma.$connect(); try { const invoice = await prisma.invoicePool.findUnique({ where: { id: invoiceId }, select: { status: true, ocr_last_error: true, ocr_attempts: true, ocr_locked_at: true, ocr_lock_owner: true, }, }); if (!invoice || invoice.status !== 'ocr_failed') { console.error(`[ERROR] ${scenario} expected status=ocr_failed but got ${invoice?.status ?? 'N/A'}`); process.exit(1); } if (invoice.ocr_last_error !== 'duplicate_invoice_code_number') { console.error(`[ERROR] ${scenario} expected ocr_last_error=duplicate_invoice_code_number but got ${invoice.ocr_last_error}`); process.exit(1); } if ((invoice.ocr_attempts ?? 0) < 1) { console.error(`[ERROR] ${scenario} expected ocr_attempts >= 1 but got ${invoice.ocr_attempts}`); process.exit(1); } if (invoice.ocr_locked_at || invoice.ocr_lock_owner) { console.error(`[ERROR] ${scenario} expected lock released`); process.exit(1); } } finally { await prisma.$disconnect(); await pool.end(); } })().catch((error) => { console.error(error); process.exit(1); }); NODE } assert_pending_attempt() { local invoice_id="$1" local expected_attempts="$2" local scenario="$3" DATABASE_URL="$DATABASE_URL" INVOICE_ID="$invoice_id" EXPECTED_ATTEMPTS="$expected_attempts" SCENARIO="$scenario" node <<'NODE' const { createPrismaClient } = require('./src/prismaClient'); (async () => { const invoiceId = process.env.INVOICE_ID; const expectedAttempts = Number(process.env.EXPECTED_ATTEMPTS); const scenario = process.env.SCENARIO; const { prisma, pool } = createPrismaClient(); await prisma.$connect(); try { const invoice = await prisma.invoicePool.findUnique({ where: { id: invoiceId }, select: { status: true, ocr_attempts: true, ocr_last_error: true, ocr_locked_at: true, ocr_lock_owner: true, }, }); if (!invoice || invoice.status !== 'pending_ocr') { console.error(`[ERROR] ${scenario} expected status=pending_ocr but got ${invoice?.status ?? 'N/A'}`); process.exit(1); } if (invoice.ocr_attempts !== expectedAttempts) { console.error(`[ERROR] ${scenario} expected attempts=${expectedAttempts} but got ${invoice.ocr_attempts}`); process.exit(1); } if (invoice.ocr_last_error !== 'mock_ocr_failed') { console.error(`[ERROR] ${scenario} expected ocr_last_error=mock_ocr_failed but got ${invoice.ocr_last_error}`); process.exit(1); } if (invoice.ocr_locked_at || invoice.ocr_lock_owner) { console.error(`[ERROR] ${scenario} expected lock released`); process.exit(1); } } finally { await prisma.$disconnect(); await pool.end(); } })().catch((error) => { console.error(error); process.exit(1); }); NODE } assert_terminal_failed_attempt() { local invoice_id="$1" local expected_attempts="$2" local scenario="$3" DATABASE_URL="$DATABASE_URL" INVOICE_ID="$invoice_id" EXPECTED_ATTEMPTS="$expected_attempts" SCENARIO="$scenario" node <<'NODE' const { createPrismaClient } = require('./src/prismaClient'); (async () => { const invoiceId = process.env.INVOICE_ID; const expectedAttempts = Number(process.env.EXPECTED_ATTEMPTS); const scenario = process.env.SCENARIO; const { prisma, pool } = createPrismaClient(); await prisma.$connect(); try { const invoice = await prisma.invoicePool.findUnique({ where: { id: invoiceId }, select: { status: true, ocr_attempts: true, ocr_last_error: true, ocr_completed_at: true, ocr_locked_at: true, ocr_lock_owner: true, }, }); if (!invoice || invoice.status !== 'ocr_failed') { console.error(`[ERROR] ${scenario} expected status=ocr_failed but got ${invoice?.status ?? 'N/A'}`); process.exit(1); } if (invoice.ocr_attempts !== expectedAttempts) { console.error(`[ERROR] ${scenario} expected attempts=${expectedAttempts} but got ${invoice.ocr_attempts}`); process.exit(1); } if (invoice.ocr_last_error !== 'mock_ocr_failed') { console.error(`[ERROR] ${scenario} expected ocr_last_error=mock_ocr_failed but got ${invoice.ocr_last_error}`); process.exit(1); } if (!invoice.ocr_completed_at) { console.error(`[ERROR] ${scenario} expected ocr_completed_at not null`); process.exit(1); } if (invoice.ocr_locked_at || invoice.ocr_lock_owner) { console.error(`[ERROR] ${scenario} expected lock released`); process.exit(1); } } finally { await prisma.$disconnect(); await pool.end(); } })().catch((error) => { console.error(error); process.exit(1); }); NODE } assert_attempt_count() { local invoice_id="$1" local expected_attempts="$2" local scenario="$3" DATABASE_URL="$DATABASE_URL" INVOICE_ID="$invoice_id" EXPECTED_ATTEMPTS="$expected_attempts" SCENARIO="$scenario" node <<'NODE' const { createPrismaClient } = require('./src/prismaClient'); (async () => { const invoiceId = process.env.INVOICE_ID; const expectedAttempts = Number(process.env.EXPECTED_ATTEMPTS); const scenario = process.env.SCENARIO; const { prisma, pool } = createPrismaClient(); await prisma.$connect(); try { const invoice = await prisma.invoicePool.findUnique({ where: { id: invoiceId }, select: { status: true, ocr_attempts: true, }, }); if (!invoice || invoice.status !== 'available') { console.error(`[ERROR] ${scenario} expected status=available but got ${invoice?.status ?? 'N/A'}`); process.exit(1); } if (invoice.ocr_attempts !== expectedAttempts) { console.error(`[ERROR] ${scenario} expected attempts=${expectedAttempts} but got ${invoice.ocr_attempts}`); process.exit(1); } } finally { await prisma.$disconnect(); await pool.end(); } })().catch((error) => { console.error(error); process.exit(1); }); NODE } echo "[1/4] pending_ocr -> available" INVOICE_OK="$(create_pending_invoice "MOCK_OK:${RUN_SUFFIX}")" run_ocr_once >"$TMP_DIR/ocr-ok.log" assert_available_invoice "$INVOICE_OK" "ocr pending->available" echo "[2/4] duplicate invoice -> ocr_failed" INVOICE_BASE="$(create_pending_invoice "MOCK_DUP:${RUN_SUFFIX}")" run_ocr_once >"$TMP_DIR/ocr-dup-base.log" assert_available_invoice "$INVOICE_BASE" "ocr duplicate base" set_invoice_status_bound "$INVOICE_BASE" INVOICE_DUP="$(create_pending_invoice "MOCK_DUP:${RUN_SUFFIX}")" run_ocr_once >"$TMP_DIR/ocr-dup-second.log" assert_duplicate_failed_invoice "$INVOICE_DUP" "ocr duplicate second" echo "[3/4] failed retry until terminal" INVOICE_FAIL="$(create_pending_invoice "MOCK_OCR_FAIL:${RUN_SUFFIX}")" run_ocr_once >"$TMP_DIR/ocr-fail-1.log" assert_pending_attempt "$INVOICE_FAIL" "1" "ocr fail attempt 1" run_ocr_once >"$TMP_DIR/ocr-fail-2.log" assert_pending_attempt "$INVOICE_FAIL" "2" "ocr fail attempt 2" run_ocr_once >"$TMP_DIR/ocr-fail-3.log" assert_terminal_failed_attempt "$INVOICE_FAIL" "3" "ocr fail terminal" echo "[4/4] concurrency lock semantics" INVOICE_CONCURRENT="$(create_pending_invoice "MOCK_CONCURRENT:${RUN_SUFFIX}")" ( OCR_PROVIDER=mock OCR_BATCH_SIZE=1 OCR_MAX_ATTEMPTS=3 npm run --silent job:ocr-once >"$TMP_DIR/ocr-concurrent-a.log" ) & PID_A=$! ( OCR_PROVIDER=mock OCR_BATCH_SIZE=1 OCR_MAX_ATTEMPTS=3 npm run --silent job:ocr-once >"$TMP_DIR/ocr-concurrent-b.log" ) & PID_B=$! wait "$PID_A" wait "$PID_B" assert_available_invoice "$INVOICE_CONCURRENT" "ocr concurrency available" assert_attempt_count "$INVOICE_CONCURRENT" "1" "ocr concurrency attempts" echo "[OK] OCR regression passed"