feat(server): 完成 P3 后端API与回归(D2~D9)
- workflow: 任务详情/转审候选/列表分页字段(D5) - reimbursements: 列表API + pay凭证字段/幂等增强(D7) - invoice-pool: 列表/详情API + 上传pending_ocr(D9) - ocr/audit: mock provider + 回归脚本 + regression-all开关(D2/D3) - CI: 新增 ci-minimal / ci-regression 工作流(D4)
这个提交包含在:
@@ -2,17 +2,37 @@
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
RUN_AUDIT_REGRESSION="${RUN_AUDIT_REGRESSION:-1}"
|
||||
RUN_OCR_REGRESSION="${RUN_OCR_REGRESSION:-1}"
|
||||
|
||||
echo "=== [1/3] Smoke Test ==="
|
||||
echo "=== [1/5] Smoke Test ==="
|
||||
"$SCRIPT_DIR/smoke-test.sh"
|
||||
echo ""
|
||||
|
||||
echo "=== [2/3] Dedup Regression ==="
|
||||
echo "=== [2/5] Dedup Regression ==="
|
||||
"$SCRIPT_DIR/regression-dedup.sh"
|
||||
echo ""
|
||||
|
||||
echo "=== [3/3] Workflow Actions Regression ==="
|
||||
echo "=== [3/5] Workflow Actions Regression ==="
|
||||
"$SCRIPT_DIR/regression-workflow-actions.sh"
|
||||
echo ""
|
||||
|
||||
if [[ "$RUN_AUDIT_REGRESSION" == "1" ]]; then
|
||||
echo "=== [4/5] Audit Regression ==="
|
||||
"$SCRIPT_DIR/regression-audit.sh"
|
||||
echo ""
|
||||
else
|
||||
echo "=== [4/5] Audit Regression (skipped, RUN_AUDIT_REGRESSION=$RUN_AUDIT_REGRESSION) ==="
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if [[ "$RUN_OCR_REGRESSION" == "1" ]]; then
|
||||
echo "=== [5/5] OCR Regression ==="
|
||||
"$SCRIPT_DIR/regression-ocr.sh"
|
||||
echo ""
|
||||
else
|
||||
echo "=== [5/5] OCR Regression (skipped, RUN_OCR_REGRESSION=$RUN_OCR_REGRESSION) ==="
|
||||
echo ""
|
||||
fi
|
||||
|
||||
echo "[OK] All regressions passed"
|
||||
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
BASE_URL="${BASE_URL:-http://127.0.0.1:3000}"
|
||||
DEVICE_FINGERPRINT="${DEVICE_FINGERPRINT:-regression-audit}"
|
||||
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}"
|
||||
CEO_IDENTIFIER="${CEO_IDENTIFIER:-ceo@hyinda.com}"
|
||||
|
||||
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
|
||||
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
RUN_SUFFIX="$(date +%s)-$RANDOM"
|
||||
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
|
||||
if [[ -n "$body_file" && -f "$body_file" ]]; then
|
||||
cat "$body_file" >&2
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
assert_error_code() {
|
||||
local file_path="$1"
|
||||
local expected="$2"
|
||||
local name="$3"
|
||||
local actual
|
||||
actual="$(jq -r '.error.code // empty' "$file_path")"
|
||||
if [[ "$actual" != "$expected" ]]; then
|
||||
echo "[ERROR] $name expected error code $expected but got $actual" >&2
|
||||
cat "$file_path" >&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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
login_user() {
|
||||
local identifier="$1"
|
||||
local password="$2"
|
||||
local fingerprint="$3"
|
||||
local out_file="$4"
|
||||
|
||||
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: $fingerprint" \
|
||||
-d "{\"identifier\":\"$identifier\",\"password\":\"$password\"}")"
|
||||
|
||||
assert_status "$status" "200" "login $identifier" "$out_file"
|
||||
jq -er '.data.accessToken' "$out_file"
|
||||
}
|
||||
|
||||
auth_get() {
|
||||
local url="$1"
|
||||
local token="$2"
|
||||
local out_file="$3"
|
||||
|
||||
curl -sS -o "$out_file" -w "%{http_code}" \
|
||||
-X GET "$url" \
|
||||
-H "authorization: Bearer $token"
|
||||
}
|
||||
|
||||
get_me_id() {
|
||||
local token="$1"
|
||||
local out_file="$2"
|
||||
|
||||
local status
|
||||
status="$(auth_get "$BASE_URL/api/me" "$token" "$out_file")"
|
||||
assert_status "$status" "200" "get me" "$out_file"
|
||||
jq -er '.data.id' "$out_file"
|
||||
}
|
||||
|
||||
create_delegation() {
|
||||
local token="$1"
|
||||
local delegate_id="$2"
|
||||
local valid_from="$3"
|
||||
local valid_to="$4"
|
||||
local out_file="$5"
|
||||
|
||||
local status
|
||||
status="$(api_json_auth POST "$BASE_URL/api/delegations" "$token" "$out_file" "{\"delegateEmployeeId\":\"$delegate_id\",\"scope\":\"all\",\"validFrom\":\"$valid_from\",\"validTo\":\"$valid_to\"}")"
|
||||
assert_status "$status" "200" "create delegation" "$out_file"
|
||||
jq -er '.data.delegation.id' "$out_file"
|
||||
}
|
||||
|
||||
revoke_delegation() {
|
||||
local token="$1"
|
||||
local delegation_id="$2"
|
||||
local out_file="$3"
|
||||
|
||||
local status
|
||||
status="$(api_json_auth POST "$BASE_URL/api/delegations/$delegation_id/revoke" "$token" "$out_file" '{}')"
|
||||
assert_status "$status" "200" "revoke delegation" "$out_file"
|
||||
}
|
||||
|
||||
echo "[1/6] login users"
|
||||
EMPLOYEE_TOKEN="$(login_user "$EMPLOYEE_IDENTIFIER" "$EMPLOYEE_PASSWORD" "$DEVICE_FINGERPRINT-employee-$RUN_SUFFIX" "$TMP_DIR/login-employee.json")"
|
||||
MANAGER_TOKEN="$(login_user "$MANAGER_IDENTIFIER" "$MANAGER_PASSWORD" "$DEVICE_FINGERPRINT-manager-$RUN_SUFFIX" "$TMP_DIR/login-manager.json")"
|
||||
FINANCE_MGR_TOKEN="$(login_user "$FINANCE_MGR_IDENTIFIER" "$FINANCE_MGR_PASSWORD" "$DEVICE_FINGERPRINT-finance-mgr-$RUN_SUFFIX" "$TMP_DIR/login-finance-mgr.json")"
|
||||
CEO_TOKEN="$(login_user "$CEO_IDENTIFIER" "${CEO_PASSWORD:-CeoPassword1234}" "$DEVICE_FINGERPRINT-ceo-$RUN_SUFFIX" "$TMP_DIR/login-ceo.json")"
|
||||
|
||||
echo "[2/6] permission check: non-audit role forbidden"
|
||||
perm_status="$(auth_get "$BASE_URL/api/audit-logs?page=1&pageSize=5" "$EMPLOYEE_TOKEN" "$TMP_DIR/audit-perm.json")"
|
||||
assert_status "$perm_status" "403" "audit permission" "$TMP_DIR/audit-perm.json"
|
||||
assert_error_code "$TMP_DIR/audit-perm.json" "PERM_001" "audit permission"
|
||||
|
||||
echo "[3/6] create deterministic audit records"
|
||||
CEO_ID="$(get_me_id "$CEO_TOKEN" "$TMP_DIR/me-ceo.json")"
|
||||
VALID_FROM="$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")"
|
||||
VALID_TO="$(date -u -d "+1 day" +"%Y-%m-%dT%H:%M:%S.000Z")"
|
||||
DELEGATION_ID="$(create_delegation "$MANAGER_TOKEN" "$CEO_ID" "$VALID_FROM" "$VALID_TO" "$TMP_DIR/delegation-create.json")"
|
||||
revoke_delegation "$MANAGER_TOKEN" "$DELEGATION_ID" "$TMP_DIR/delegation-revoke.json"
|
||||
|
||||
echo "[4/6] pagination and base response"
|
||||
page_status="$(auth_get "$BASE_URL/api/audit-logs?page=1&pageSize=3" "$FINANCE_MGR_TOKEN" "$TMP_DIR/audit-page.json")"
|
||||
assert_status "$page_status" "200" "audit list page" "$TMP_DIR/audit-page.json"
|
||||
assert_jq "$TMP_DIR/audit-page.json" '.data.page == 1 and .data.pageSize == 3 and (.data.total|type=="number") and (.data.items|type=="array")' "audit list page"
|
||||
|
||||
echo "[5/6] action/targetType/date filters"
|
||||
filter_status="$(auth_get "$BASE_URL/api/audit-logs?action=DELEGATION_CREATE&targetType=delegation_grant&page=1&pageSize=20" "$FINANCE_MGR_TOKEN" "$TMP_DIR/audit-filter-action.json")"
|
||||
assert_status "$filter_status" "200" "audit filter action" "$TMP_DIR/audit-filter-action.json"
|
||||
assert_jq "$TMP_DIR/audit-filter-action.json" '.data.items | any(.action == "DELEGATION_CREATE" and .targetType == "delegation_grant")' "audit filter action"
|
||||
|
||||
range_status="$(auth_get "$BASE_URL/api/audit-logs?dateFrom=2026-01-01T00:00:00.000Z&dateTo=2026-12-31T23:59:59.000Z&page=1&pageSize=10" "$FINANCE_MGR_TOKEN" "$TMP_DIR/audit-filter-date.json")"
|
||||
assert_status "$range_status" "200" "audit filter date" "$TMP_DIR/audit-filter-date.json"
|
||||
assert_jq "$TMP_DIR/audit-filter-date.json" '.data.items|type=="array"' "audit filter date"
|
||||
|
||||
echo "[6/6] invalid query check"
|
||||
invalid_status="$(auth_get "$BASE_URL/api/audit-logs?page=0&pageSize=20" "$FINANCE_MGR_TOKEN" "$TMP_DIR/audit-invalid-page.json")"
|
||||
assert_status "$invalid_status" "400" "audit invalid page" "$TMP_DIR/audit-invalid-page.json"
|
||||
assert_error_code "$TMP_DIR/audit-invalid-page.json" "VALID_001" "audit invalid page"
|
||||
|
||||
date_invalid_status="$(auth_get "$BASE_URL/api/audit-logs?dateFrom=2026-02-10T00:00:00.000Z&dateTo=2026-02-01T00:00:00.000Z" "$FINANCE_MGR_TOKEN" "$TMP_DIR/audit-invalid-date.json")"
|
||||
assert_status "$date_invalid_status" "400" "audit invalid date" "$TMP_DIR/audit-invalid-date.json"
|
||||
assert_error_code "$TMP_DIR/audit-invalid-date.json" "VALID_001" "audit invalid date"
|
||||
assert_jq "$TMP_DIR/audit-invalid-date.json" '.error.details.dateTo == "must_be_after_dateFrom"' "audit invalid date"
|
||||
|
||||
echo "[OK] Audit regression passed"
|
||||
+477
@@ -0,0 +1,477 @@
|
||||
#!/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 || '[email protected]';
|
||||
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"
|
||||
在新工单中引用
屏蔽一个用户