1343 行
56 KiB
Bash
可执行文件
1343 行
56 KiB
Bash
可执行文件
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
BASE_URL="${BASE_URL:-http://127.0.0.1:3000}"
|
|
DEVICE_FINGERPRINT="${DEVICE_FINGERPRINT:-regression-workflow-actions}"
|
|
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_IDENTIFIER="${FINANCE_IDENTIFIER:-finance@hyinda.com}"
|
|
FINANCE_PASSWORD="${FINANCE_PASSWORD:-FinancePassword1234}"
|
|
CEO_IDENTIFIER="${CEO_IDENTIFIER:-ceo@hyinda.com}"
|
|
CEO_PASSWORD="${CEO_PASSWORD:-CeoPassword1234}"
|
|
RUN_PAY_CONCURRENCY_CHECK="${RUN_PAY_CONCURRENCY_CHECK:-0}"
|
|
RUN_DELEGATION_CONCURRENCY_CHECK="${RUN_DELEGATION_CONCURRENCY_CHECK:-0}"
|
|
|
|
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)"
|
|
RUN_SUFFIX="$(date +%s)-$RANDOM"
|
|
EMPLOYEE_ID=""
|
|
ORIGINAL_EMPLOYEE_MANAGER_ID="__NULL__"
|
|
EMPLOYEE_MANAGER_PATCHED=0
|
|
TMP_APPROVAL_RULE_ID=""
|
|
|
|
cleanup() {
|
|
if [[ -n "$TMP_APPROVAL_RULE_ID" ]]; then
|
|
delete_approval_rule "$TMP_APPROVAL_RULE_ID" || true
|
|
fi
|
|
|
|
if [[ "$EMPLOYEE_MANAGER_PATCHED" == "1" ]] && [[ -n "$EMPLOYEE_ID" ]]; then
|
|
set_employee_direct_manager "$EMPLOYEE_ID" "$ORIGINAL_EMPLOYEE_MANAGER_ID" || 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
|
|
}
|
|
|
|
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_error_detail() {
|
|
local file_path="$1"
|
|
local jq_path="$2"
|
|
local expected="$3"
|
|
local name="$4"
|
|
local actual
|
|
actual="$(jq -r "$jq_path // empty" "$file_path")"
|
|
if [[ "$actual" != "$expected" ]]; then
|
|
echo "[ERROR] $name expected $jq_path=$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
|
|
}
|
|
|
|
upload_invoice() {
|
|
local token="$1"
|
|
local file_path="$2"
|
|
local out_file="$3"
|
|
|
|
curl -sS -o "$out_file" -w "%{http_code}" \
|
|
-X POST "$BASE_URL/api/invoice-pool/upload" \
|
|
-H "authorization: Bearer $token" \
|
|
-F "file=@$file_path"
|
|
}
|
|
|
|
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"
|
|
}
|
|
|
|
create_reimbursement() {
|
|
local token="$1"
|
|
local out_file="$2"
|
|
|
|
local status
|
|
status="$(api_json_auth POST "$BASE_URL/api/reimbursements" "$token" "$out_file" '{}')"
|
|
assert_status "$status" "200" "create reimbursement" "$out_file"
|
|
jq -er '.data.id' "$out_file"
|
|
}
|
|
|
|
submit_reimbursement() {
|
|
local token="$1"
|
|
local reimbursement_id="$2"
|
|
local out_file="$3"
|
|
|
|
local status
|
|
status="$(api_json_auth POST "$BASE_URL/api/reimbursements/$reimbursement_id/submit" "$token" "$out_file" '{}')"
|
|
assert_status "$status" "200" "submit reimbursement $reimbursement_id" "$out_file"
|
|
}
|
|
|
|
pay_reimbursement() {
|
|
local token="$1"
|
|
local reimbursement_id="$2"
|
|
local out_file="$3"
|
|
local body="${4:-}"
|
|
|
|
if [[ -z "$body" ]]; then
|
|
body='{}'
|
|
fi
|
|
|
|
api_json_auth POST "$BASE_URL/api/reimbursements/$reimbursement_id/pay" "$token" "$out_file" "$body"
|
|
}
|
|
|
|
transfer_task() {
|
|
local token="$1"
|
|
local task_id="$2"
|
|
local to_approver_id="$3"
|
|
local reason="$4"
|
|
local out_file="$5"
|
|
|
|
api_json_auth POST "$BASE_URL/api/workflow/tasks/$task_id/transfer" "$token" "$out_file" "{\"toApproverId\":\"$to_approver_id\",\"reason\":\"$reason\"}"
|
|
}
|
|
|
|
withdraw_reimbursement() {
|
|
local token="$1"
|
|
local reimbursement_id="$2"
|
|
local comment="$3"
|
|
local out_file="$4"
|
|
|
|
local status
|
|
status="$(api_json_auth POST "$BASE_URL/api/reimbursements/$reimbursement_id/withdraw" "$token" "$out_file" "{\"comment\":\"$comment\"}")"
|
|
assert_status "$status" "200" "withdraw reimbursement $reimbursement_id" "$out_file"
|
|
}
|
|
|
|
create_delegation() {
|
|
local token="$1"
|
|
local delegate_id="$2"
|
|
local scope="$3"
|
|
local valid_from="$4"
|
|
local valid_to="$5"
|
|
local out_file="$6"
|
|
|
|
api_json_auth POST "$BASE_URL/api/delegations" "$token" "$out_file" "{\"delegateEmployeeId\":\"$delegate_id\",\"scope\":\"$scope\",\"validFrom\":\"$valid_from\",\"validTo\":\"$valid_to\"}"
|
|
}
|
|
|
|
revoke_delegation() {
|
|
local token="$1"
|
|
local delegation_id="$2"
|
|
local out_file="$3"
|
|
|
|
api_json_auth POST "$BASE_URL/api/delegations/$delegation_id/revoke" "$token" "$out_file" '{}'
|
|
}
|
|
|
|
create_and_bind_invoice() {
|
|
local token="$1"
|
|
local reimbursement_id="$2"
|
|
local label="$3"
|
|
|
|
local file_path="$TMP_DIR/${label}-${RUN_SUFFIX}-${RANDOM}.pdf"
|
|
local upload_body="$TMP_DIR/${label}-upload.json"
|
|
local bind_body="$TMP_DIR/${label}-bind.json"
|
|
|
|
printf '%%PDF-1.4\n%s\n' "$label-$RUN_SUFFIX-$(date +%s%N)-$RANDOM" > "$file_path"
|
|
|
|
local status
|
|
status="$(upload_invoice "$token" "$file_path" "$upload_body")"
|
|
assert_status "$status" "200" "upload invoice $label" "$upload_body"
|
|
|
|
local invoice_id
|
|
invoice_id="$(jq -er '.data.invoicePoolId' "$upload_body")"
|
|
|
|
status="$(api_json_auth POST "$BASE_URL/api/invoice-pool/$invoice_id/bind" "$token" "$bind_body" "{\"reimbursementId\":\"$reimbursement_id\"}")"
|
|
assert_status "$status" "200" "bind invoice $label" "$bind_body"
|
|
|
|
echo "$invoice_id"
|
|
}
|
|
|
|
set_reimbursement_total_amount() {
|
|
local reimbursement_id="$1"
|
|
local total_amount="$2"
|
|
|
|
DATABASE_URL="$DATABASE_URL" node - "$reimbursement_id" "$total_amount" <<'NODE'
|
|
const reimbursementId = process.argv[2];
|
|
const totalAmount = process.argv[3];
|
|
const { createPrismaClient } = require('./src/prismaClient');
|
|
|
|
(async () => {
|
|
const { prisma, pool } = createPrismaClient();
|
|
await prisma.$connect();
|
|
try {
|
|
await prisma.reimbursementForm.update({
|
|
where: { id: reimbursementId },
|
|
data: { total_amount: totalAmount },
|
|
});
|
|
} finally {
|
|
await prisma.$disconnect();
|
|
await pool.end();
|
|
}
|
|
})().catch((error) => {
|
|
console.error(error);
|
|
process.exit(1);
|
|
});
|
|
NODE
|
|
}
|
|
|
|
set_reimbursement_status() {
|
|
local reimbursement_id="$1"
|
|
local status="$2"
|
|
|
|
DATABASE_URL="$DATABASE_URL" node - "$reimbursement_id" "$status" <<'NODE'
|
|
const reimbursementId = process.argv[2];
|
|
const status = process.argv[3];
|
|
const { createPrismaClient } = require('./src/prismaClient');
|
|
|
|
(async () => {
|
|
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
|
|
}
|
|
|
|
set_employee_direct_manager() {
|
|
local employee_id="$1"
|
|
local manager_id="$2"
|
|
|
|
DATABASE_URL="$DATABASE_URL" node - "$employee_id" "$manager_id" <<'NODE'
|
|
const employeeId = process.argv[2];
|
|
const managerIdRaw = process.argv[3];
|
|
const managerId = managerIdRaw === '__NULL__' ? null : managerIdRaw;
|
|
const { createPrismaClient } = require('./src/prismaClient');
|
|
|
|
(async () => {
|
|
const { prisma, pool } = createPrismaClient();
|
|
await prisma.$connect();
|
|
try {
|
|
await prisma.employee.update({
|
|
where: { id: employeeId },
|
|
data: { direct_manager_id: managerId },
|
|
});
|
|
} finally {
|
|
await prisma.$disconnect();
|
|
await pool.end();
|
|
}
|
|
})().catch((error) => {
|
|
console.error(error);
|
|
process.exit(1);
|
|
});
|
|
NODE
|
|
}
|
|
|
|
create_temp_manager_only_rule() {
|
|
local name="$1"
|
|
local min_amount="$2"
|
|
local max_amount="$3"
|
|
local priority="$4"
|
|
|
|
DATABASE_URL="$DATABASE_URL" node - "$name" "$min_amount" "$max_amount" "$priority" <<'NODE'
|
|
const [name, minAmount, maxAmount, priorityRaw] = process.argv.slice(2);
|
|
const priority = Number(priorityRaw);
|
|
const { createPrismaClient } = require('./src/prismaClient');
|
|
|
|
(async () => {
|
|
const { prisma, pool } = createPrismaClient();
|
|
await prisma.$connect();
|
|
try {
|
|
const rule = await prisma.approvalRule.create({
|
|
data: {
|
|
name,
|
|
min_amount: minAmount,
|
|
max_amount: maxAmount,
|
|
level1_role: 'manager',
|
|
level2_role: null,
|
|
level3_role: null,
|
|
enabled: true,
|
|
priority,
|
|
},
|
|
select: { id: true },
|
|
});
|
|
|
|
process.stdout.write(rule.id);
|
|
} finally {
|
|
await prisma.$disconnect();
|
|
await pool.end();
|
|
}
|
|
})().catch((error) => {
|
|
console.error(error);
|
|
process.exit(1);
|
|
});
|
|
NODE
|
|
}
|
|
|
|
delete_approval_rule() {
|
|
local rule_id="$1"
|
|
|
|
DATABASE_URL="$DATABASE_URL" node - "$rule_id" <<'NODE'
|
|
const ruleId = process.argv[2];
|
|
const { createPrismaClient } = require('./src/prismaClient');
|
|
|
|
(async () => {
|
|
const { prisma, pool } = createPrismaClient();
|
|
await prisma.$connect();
|
|
try {
|
|
await prisma.approvalRule.deleteMany({ where: { id: ruleId } });
|
|
} finally {
|
|
await prisma.$disconnect();
|
|
await pool.end();
|
|
}
|
|
})().catch((error) => {
|
|
console.error(error);
|
|
process.exit(1);
|
|
});
|
|
NODE
|
|
}
|
|
|
|
assert_instance_status() {
|
|
local instance_id="$1"
|
|
local expected_status="$2"
|
|
local scenario="$3"
|
|
|
|
DATABASE_URL="$DATABASE_URL" node - "$instance_id" "$expected_status" "$scenario" <<'NODE'
|
|
const [instanceId, expectedStatus, scenario] = process.argv.slice(2);
|
|
const { createPrismaClient } = require('./src/prismaClient');
|
|
|
|
(async () => {
|
|
const { prisma, pool } = createPrismaClient();
|
|
await prisma.$connect();
|
|
try {
|
|
const instance = await prisma.workflowInstance.findUnique({
|
|
where: { id: instanceId },
|
|
select: { id: true, current_status: true },
|
|
});
|
|
|
|
if (!instance) {
|
|
console.error(`[ERROR] ${scenario} workflow instance not found: ${instanceId}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
if (instance.current_status !== expectedStatus) {
|
|
console.error(
|
|
`[ERROR] ${scenario} expected workflow instance status ${expectedStatus} but got ${instance.current_status}`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
} finally {
|
|
await prisma.$disconnect();
|
|
await pool.end();
|
|
}
|
|
})().catch((error) => {
|
|
console.error(error);
|
|
process.exit(1);
|
|
});
|
|
NODE
|
|
}
|
|
|
|
assert_reimbursement_status_db() {
|
|
local reimbursement_id="$1"
|
|
local expected_status="$2"
|
|
local scenario="$3"
|
|
|
|
DATABASE_URL="$DATABASE_URL" node - "$reimbursement_id" "$expected_status" "$scenario" <<'NODE'
|
|
const [reimbursementId, expectedStatus, scenario] = process.argv.slice(2);
|
|
const { createPrismaClient } = require('./src/prismaClient');
|
|
|
|
(async () => {
|
|
const { prisma, pool } = createPrismaClient();
|
|
await prisma.$connect();
|
|
try {
|
|
const reimbursement = await prisma.reimbursementForm.findUnique({
|
|
where: { id: reimbursementId },
|
|
select: { id: true, status: true },
|
|
});
|
|
|
|
if (!reimbursement) {
|
|
console.error(`[ERROR] ${scenario} reimbursement not found: ${reimbursementId}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
if (reimbursement.status !== expectedStatus) {
|
|
console.error(
|
|
`[ERROR] ${scenario} expected reimbursement status ${expectedStatus} but got ${reimbursement.status}`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
} finally {
|
|
await prisma.$disconnect();
|
|
await pool.end();
|
|
}
|
|
})().catch((error) => {
|
|
console.error(error);
|
|
process.exit(1);
|
|
});
|
|
NODE
|
|
}
|
|
|
|
assert_skip_history_record() {
|
|
local instance_id="$1"
|
|
local expected_step_id="$2"
|
|
local expected_reason="$3"
|
|
local scenario="$4"
|
|
|
|
DATABASE_URL="$DATABASE_URL" node - "$instance_id" "$expected_step_id" "$expected_reason" "$scenario" <<'NODE'
|
|
const [instanceId, expectedStepId, expectedReason, scenario] = process.argv.slice(2);
|
|
const { createPrismaClient } = require('./src/prismaClient');
|
|
|
|
(async () => {
|
|
const { prisma, pool } = createPrismaClient();
|
|
await prisma.$connect();
|
|
try {
|
|
const history = await prisma.workflowHistory.findFirst({
|
|
where: {
|
|
instance_id: instanceId,
|
|
action: 'skip',
|
|
},
|
|
orderBy: { created_at: 'asc' },
|
|
select: { payload: true },
|
|
});
|
|
|
|
if (!history) {
|
|
console.error(`[ERROR] ${scenario} expected skip history record but none found`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const payload = history.payload ?? {};
|
|
const actualStepId = payload.step_id ?? null;
|
|
const actualReason = payload.reason ?? null;
|
|
if (actualStepId !== expectedStepId || actualReason !== expectedReason) {
|
|
console.error(
|
|
`[ERROR] ${scenario} expected skip payload step_id=${expectedStepId}, reason=${expectedReason} but got step_id=${actualStepId}, reason=${actualReason}`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
} finally {
|
|
await prisma.$disconnect();
|
|
await pool.end();
|
|
}
|
|
})().catch((error) => {
|
|
console.error(error);
|
|
process.exit(1);
|
|
});
|
|
NODE
|
|
}
|
|
|
|
assert_task_step_and_role() {
|
|
local task_id="$1"
|
|
local expected_step_id="$2"
|
|
local expected_role="$3"
|
|
local scenario="$4"
|
|
|
|
DATABASE_URL="$DATABASE_URL" node - "$task_id" "$expected_step_id" "$expected_role" "$scenario" <<'NODE'
|
|
const [taskId, expectedStepId, expectedRole, scenario] = process.argv.slice(2);
|
|
const { createPrismaClient } = require('./src/prismaClient');
|
|
|
|
(async () => {
|
|
const { prisma, pool } = createPrismaClient();
|
|
await prisma.$connect();
|
|
try {
|
|
const task = await prisma.workflowTask.findUnique({
|
|
where: { id: taskId },
|
|
select: {
|
|
id: true,
|
|
step_id: true,
|
|
assignee_role: true,
|
|
assignee_id: true,
|
|
},
|
|
});
|
|
|
|
if (!task) {
|
|
console.error(`[ERROR] ${scenario} task not found: ${taskId}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
if (task.step_id !== expectedStepId) {
|
|
console.error(
|
|
`[ERROR] ${scenario} expected step_id ${expectedStepId} but got ${task.step_id}`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
if (task.assignee_role !== expectedRole || task.assignee_id !== null) {
|
|
console.error(
|
|
`[ERROR] ${scenario} expected role assignee ${expectedRole} with null assignee_id but got assignee_role=${task.assignee_role}, assignee_id=${task.assignee_id}`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
} finally {
|
|
await prisma.$disconnect();
|
|
await pool.end();
|
|
}
|
|
})().catch((error) => {
|
|
console.error(error);
|
|
process.exit(1);
|
|
});
|
|
NODE
|
|
}
|
|
|
|
assert_no_workflow_instance() {
|
|
local reimbursement_id="$1"
|
|
local scenario="$2"
|
|
|
|
DATABASE_URL="$DATABASE_URL" node - "$reimbursement_id" "$scenario" <<'NODE'
|
|
const [reimbursementId, scenario] = process.argv.slice(2);
|
|
const { createPrismaClient } = require('./src/prismaClient');
|
|
|
|
(async () => {
|
|
const { prisma, pool } = createPrismaClient();
|
|
await prisma.$connect();
|
|
try {
|
|
const count = await prisma.workflowInstance.count({
|
|
where: {
|
|
entity_type: 'reimbursement',
|
|
entity_id: reimbursementId,
|
|
},
|
|
});
|
|
|
|
if (count !== 0) {
|
|
console.error(`[ERROR] ${scenario} expected no workflow instance but found ${count}`);
|
|
process.exit(1);
|
|
}
|
|
} finally {
|
|
await prisma.$disconnect();
|
|
await pool.end();
|
|
}
|
|
})().catch((error) => {
|
|
console.error(error);
|
|
process.exit(1);
|
|
});
|
|
NODE
|
|
}
|
|
|
|
assert_invoices_archived_used_complete() {
|
|
local reimbursement_id="$1"
|
|
local scenario="$2"
|
|
|
|
DATABASE_URL="$DATABASE_URL" node - "$reimbursement_id" "$scenario" <<'NODE'
|
|
const [reimbursementId, scenario] = process.argv.slice(2);
|
|
const { createPrismaClient } = require('./src/prismaClient');
|
|
|
|
(async () => {
|
|
const { prisma, pool } = createPrismaClient();
|
|
await prisma.$connect();
|
|
try {
|
|
const invoices = await prisma.invoicePool.findMany({
|
|
where: {
|
|
linked_reimbursement_id: reimbursementId,
|
|
},
|
|
select: {
|
|
id: true,
|
|
status: true,
|
|
archived_reason: true,
|
|
archived_at: true,
|
|
},
|
|
});
|
|
|
|
if (!invoices.length) {
|
|
console.error(`[ERROR] ${scenario} expected linked invoices but found none`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const invalid = invoices.find(
|
|
(invoice) =>
|
|
invoice.status !== 'archived' ||
|
|
invoice.archived_reason !== 'used_complete' ||
|
|
invoice.archived_at == null,
|
|
);
|
|
|
|
if (invalid) {
|
|
console.error(
|
|
`[ERROR] ${scenario} invoice ${invalid.id} expected archived/used_complete/archived_at but got status=${invalid.status}, reason=${invalid.archived_reason}, archived_at=${invalid.archived_at}`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
} finally {
|
|
await prisma.$disconnect();
|
|
await pool.end();
|
|
}
|
|
})().catch((error) => {
|
|
console.error(error);
|
|
process.exit(1);
|
|
});
|
|
NODE
|
|
}
|
|
|
|
assert_transfer_evidence() {
|
|
local task_id="$1"
|
|
local reimbursement_id="$2"
|
|
local from_approver_id="$3"
|
|
local to_approver_id="$4"
|
|
local reason="$5"
|
|
local expected_transfer_count="$6"
|
|
local scenario="$7"
|
|
|
|
DATABASE_URL="$DATABASE_URL" node - "$task_id" "$reimbursement_id" "$from_approver_id" "$to_approver_id" "$reason" "$expected_transfer_count" "$scenario" <<'NODE'
|
|
const [
|
|
taskId,
|
|
reimbursementId,
|
|
fromApproverId,
|
|
toApproverId,
|
|
reason,
|
|
expectedTransferCountRaw,
|
|
scenario,
|
|
] = process.argv.slice(2);
|
|
const expectedTransferCount = Number(expectedTransferCountRaw);
|
|
const { createPrismaClient } = require('./src/prismaClient');
|
|
|
|
(async () => {
|
|
const { prisma, pool } = createPrismaClient();
|
|
await prisma.$connect();
|
|
try {
|
|
const task = await prisma.workflowTask.findUnique({
|
|
where: { id: taskId },
|
|
select: {
|
|
assignee_id: true,
|
|
assignee_role: true,
|
|
},
|
|
});
|
|
if (!task) {
|
|
console.error(`[ERROR] ${scenario} task not found: ${taskId}`);
|
|
process.exit(1);
|
|
}
|
|
if (task.assignee_id !== toApproverId || task.assignee_role !== null) {
|
|
console.error(
|
|
`[ERROR] ${scenario} expected task assignee_id=${toApproverId}, assignee_role=null but got assignee_id=${task.assignee_id}, assignee_role=${task.assignee_role}`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
const transfer = await prisma.approvalTransfer.findFirst({
|
|
where: {
|
|
reimbursement_id: reimbursementId,
|
|
from_approver_id: fromApproverId,
|
|
to_approver_id: toApproverId,
|
|
reason,
|
|
status: 'active',
|
|
},
|
|
orderBy: { created_at: 'desc' },
|
|
select: { id: true },
|
|
});
|
|
if (!transfer) {
|
|
console.error(`[ERROR] ${scenario} approval_transfers evidence not found`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const history = await prisma.workflowHistory.findFirst({
|
|
where: {
|
|
task_id: taskId,
|
|
action: 'transfer',
|
|
actor_id: fromApproverId,
|
|
},
|
|
orderBy: { created_at: 'desc' },
|
|
select: {
|
|
comment: true,
|
|
payload: true,
|
|
},
|
|
});
|
|
|
|
if (!history) {
|
|
console.error(`[ERROR] ${scenario} workflow_history transfer evidence not found`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const payload = history.payload ?? {};
|
|
const payloadFrom = payload.from_approver_id ?? null;
|
|
const payloadTo = payload.to_approver_id ?? null;
|
|
const payloadReason = payload.reason ?? null;
|
|
const payloadTransferCount = Number(payload.transfer_count);
|
|
|
|
if (
|
|
history.comment !== reason ||
|
|
payloadFrom !== fromApproverId ||
|
|
payloadTo !== toApproverId ||
|
|
payloadReason !== reason ||
|
|
payloadTransferCount !== expectedTransferCount
|
|
) {
|
|
console.error(
|
|
`[ERROR] ${scenario} workflow_history payload mismatch: comment=${history.comment}, from=${payloadFrom}, to=${payloadTo}, reason=${payloadReason}, transfer_count=${payloadTransferCount}`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
} finally {
|
|
await prisma.$disconnect();
|
|
await pool.end();
|
|
}
|
|
})().catch((error) => {
|
|
console.error(error);
|
|
process.exit(1);
|
|
});
|
|
NODE
|
|
}
|
|
|
|
assert_delegation_history_evidence() {
|
|
local task_id="$1"
|
|
local delegation_id="$2"
|
|
local principal_id="$3"
|
|
local delegate_id="$4"
|
|
local scenario="$5"
|
|
|
|
DATABASE_URL="$DATABASE_URL" node - "$task_id" "$delegation_id" "$principal_id" "$delegate_id" "$scenario" <<'NODE'
|
|
const [taskId, delegationId, principalId, delegateId, scenario] = process.argv.slice(2);
|
|
const { createPrismaClient } = require('./src/prismaClient');
|
|
|
|
(async () => {
|
|
const { prisma, pool } = createPrismaClient();
|
|
await prisma.$connect();
|
|
try {
|
|
const history = await prisma.workflowHistory.findFirst({
|
|
where: { task_id: taskId },
|
|
orderBy: { created_at: 'desc' },
|
|
select: { payload: true },
|
|
});
|
|
|
|
if (!history) {
|
|
console.error(`[ERROR] ${scenario} history not found`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const payload = history.payload ?? {};
|
|
if (
|
|
payload.delegation_id !== delegationId ||
|
|
payload.principal_id !== principalId ||
|
|
payload.delegate_id !== delegateId
|
|
) {
|
|
console.error(
|
|
`[ERROR] ${scenario} delegation payload mismatch: delegation_id=${payload.delegation_id}, principal_id=${payload.principal_id}, delegate_id=${payload.delegate_id}`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
} finally {
|
|
await prisma.$disconnect();
|
|
await pool.end();
|
|
}
|
|
})().catch((error) => {
|
|
console.error(error);
|
|
process.exit(1);
|
|
});
|
|
NODE
|
|
}
|
|
|
|
assert_delegation_revoked_db() {
|
|
local delegation_id="$1"
|
|
local scenario="$2"
|
|
|
|
DATABASE_URL="$DATABASE_URL" node - "$delegation_id" "$scenario" <<'NODE'
|
|
const [delegationId, scenario] = process.argv.slice(2);
|
|
const { createPrismaClient } = require('./src/prismaClient');
|
|
|
|
(async () => {
|
|
const { prisma, pool } = createPrismaClient();
|
|
await prisma.$connect();
|
|
try {
|
|
const delegation = await prisma.delegationGrant.findUnique({
|
|
where: { id: delegationId },
|
|
select: { revoked_at: true },
|
|
});
|
|
|
|
if (!delegation || delegation.revoked_at == null) {
|
|
console.error(`[ERROR] ${scenario} expected revoked delegation but got none`);
|
|
process.exit(1);
|
|
}
|
|
} finally {
|
|
await prisma.$disconnect();
|
|
await pool.end();
|
|
}
|
|
})().catch((error) => {
|
|
console.error(error);
|
|
process.exit(1);
|
|
});
|
|
NODE
|
|
}
|
|
|
|
assert_delegation_auto_revoke_audit_count() {
|
|
local delegation_id="$1"
|
|
local expected_count="$2"
|
|
local scenario="$3"
|
|
|
|
DATABASE_URL="$DATABASE_URL" node - "$delegation_id" "$expected_count" "$scenario" <<'NODE'
|
|
const [delegationId, expectedCountRaw, scenario] = process.argv.slice(2);
|
|
const expectedCount = Number(expectedCountRaw);
|
|
const { createPrismaClient } = require('./src/prismaClient');
|
|
|
|
(async () => {
|
|
const { prisma, pool } = createPrismaClient();
|
|
await prisma.$connect();
|
|
try {
|
|
const count = await prisma.auditLog.count({
|
|
where: {
|
|
action: 'DELEGATION_AUTO_REVOKED',
|
|
target_id: delegationId,
|
|
},
|
|
});
|
|
|
|
if (count !== expectedCount) {
|
|
console.error(`[ERROR] ${scenario} expected audit count=${expectedCount} but got ${count}`);
|
|
process.exit(1);
|
|
}
|
|
} finally {
|
|
await prisma.$disconnect();
|
|
await pool.end();
|
|
}
|
|
})().catch((error) => {
|
|
console.error(error);
|
|
process.exit(1);
|
|
});
|
|
NODE
|
|
}
|
|
echo "[1/23] Health check + login baseline users"
|
|
health_status="$(curl -sS -o "$TMP_DIR/health.json" -w "%{http_code}" "$BASE_URL/health")"
|
|
assert_status "$health_status" "200" "health check" "$TMP_DIR/health.json"
|
|
|
|
EMPLOYEE_TOKEN="$(login_user "$EMPLOYEE_IDENTIFIER" "$EMPLOYEE_PASSWORD" "$DEVICE_FINGERPRINT-employee" "$TMP_DIR/login-employee.json")"
|
|
MANAGER_TOKEN="$(login_user "$MANAGER_IDENTIFIER" "$MANAGER_PASSWORD" "$DEVICE_FINGERPRINT-manager" "$TMP_DIR/login-manager.json")"
|
|
FINANCE_TOKEN="$(login_user "$FINANCE_IDENTIFIER" "$FINANCE_PASSWORD" "$DEVICE_FINGERPRINT-finance" "$TMP_DIR/login-finance.json")"
|
|
CEO_TOKEN="$(login_user "$CEO_IDENTIFIER" "$CEO_PASSWORD" "$DEVICE_FINGERPRINT-ceo" "$TMP_DIR/login-ceo.json")"
|
|
|
|
me_status="$(api_json_auth GET "$BASE_URL/api/me" "$EMPLOYEE_TOKEN" "$TMP_DIR/me.json")"
|
|
assert_status "$me_status" "200" "get /api/me" "$TMP_DIR/me.json"
|
|
EMPLOYEE_ID="$(jq -er '.data.id' "$TMP_DIR/me.json")"
|
|
ORIGINAL_EMPLOYEE_MANAGER_ID="$(jq -r '.data.directManagerId // "__NULL__"' "$TMP_DIR/me.json")"
|
|
|
|
manager_me_status="$(api_json_auth GET "$BASE_URL/api/me" "$MANAGER_TOKEN" "$TMP_DIR/me-manager.json")"
|
|
assert_status "$manager_me_status" "200" "get /api/me manager" "$TMP_DIR/me-manager.json"
|
|
MANAGER_ID="$(jq -er '.data.id' "$TMP_DIR/me-manager.json")"
|
|
|
|
ceo_me_status="$(api_json_auth GET "$BASE_URL/api/me" "$CEO_TOKEN" "$TMP_DIR/me-ceo.json")"
|
|
assert_status "$ceo_me_status" "200" "get /api/me ceo" "$TMP_DIR/me-ceo.json"
|
|
CEO_ID="$(jq -er '.data.id' "$TMP_DIR/me-ceo.json")"
|
|
|
|
echo "[2/23] reject: instance=rejected, reimbursement=rejected"
|
|
RID_REJECT="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/reject-create.json")"
|
|
create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_REJECT" "reject" >/dev/null
|
|
submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_REJECT" "$TMP_DIR/reject-submit.json"
|
|
TASK_REJECT="$(jq -er '.data.currentTaskId' "$TMP_DIR/reject-submit.json")"
|
|
INSTANCE_REJECT="$(jq -er '.data.workflowInstanceId' "$TMP_DIR/reject-submit.json")"
|
|
|
|
reject_status="$(api_json_auth POST "$BASE_URL/api/workflow/tasks/$TASK_REJECT/reject" "$MANAGER_TOKEN" "$TMP_DIR/reject-action.json" '{"comment":"reject by regression script"}')"
|
|
assert_status "$reject_status" "200" "workflow reject" "$TMP_DIR/reject-action.json"
|
|
assert_jq "$TMP_DIR/reject-action.json" '.data.outcome == "rejected" and .data.reimbursementStatus == "rejected"' "reject action response"
|
|
assert_instance_status "$INSTANCE_REJECT" "rejected" "reject scenario"
|
|
assert_reimbursement_status_db "$RID_REJECT" "rejected" "reject scenario"
|
|
|
|
echo "[3/23] return + resubmit: terminated/draft and new instance"
|
|
RID_RETURN="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/return-create.json")"
|
|
create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_RETURN" "return" >/dev/null
|
|
submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_RETURN" "$TMP_DIR/return-submit-1.json"
|
|
TASK_RETURN_1="$(jq -er '.data.currentTaskId' "$TMP_DIR/return-submit-1.json")"
|
|
INSTANCE_RETURN_1="$(jq -er '.data.workflowInstanceId' "$TMP_DIR/return-submit-1.json")"
|
|
|
|
return_status="$(api_json_auth POST "$BASE_URL/api/workflow/tasks/$TASK_RETURN_1/return" "$MANAGER_TOKEN" "$TMP_DIR/return-action.json" '{"comment":"return by regression script"}')"
|
|
assert_status "$return_status" "200" "workflow return" "$TMP_DIR/return-action.json"
|
|
assert_jq "$TMP_DIR/return-action.json" '.data.outcome == "returned" and .data.reimbursementStatus == "draft"' "return action response"
|
|
assert_instance_status "$INSTANCE_RETURN_1" "terminated" "return scenario"
|
|
assert_reimbursement_status_db "$RID_RETURN" "draft" "return scenario"
|
|
|
|
submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_RETURN" "$TMP_DIR/return-submit-2.json"
|
|
INSTANCE_RETURN_2="$(jq -er '.data.workflowInstanceId' "$TMP_DIR/return-submit-2.json")"
|
|
if [[ "$INSTANCE_RETURN_2" == "$INSTANCE_RETURN_1" ]]; then
|
|
echo "[ERROR] return resubmit expected new workflow instance but got same id" >&2
|
|
cat "$TMP_DIR/return-submit-2.json" >&2
|
|
exit 1
|
|
fi
|
|
|
|
withdraw_reimbursement "$EMPLOYEE_TOKEN" "$RID_RETURN" "cleanup after return-resubmit" "$TMP_DIR/return-withdraw-cleanup.json"
|
|
assert_instance_status "$INSTANCE_RETURN_2" "cancelled" "return resubmit cleanup"
|
|
assert_reimbursement_status_db "$RID_RETURN" "draft" "return resubmit cleanup"
|
|
|
|
echo "[4/23] withdraw: before approver processing"
|
|
RID_WITHDRAW="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/withdraw-create.json")"
|
|
create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_WITHDRAW" "withdraw" >/dev/null
|
|
submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_WITHDRAW" "$TMP_DIR/withdraw-submit.json"
|
|
INSTANCE_WITHDRAW="$(jq -er '.data.workflowInstanceId' "$TMP_DIR/withdraw-submit.json")"
|
|
|
|
withdraw_reimbursement "$EMPLOYEE_TOKEN" "$RID_WITHDRAW" "withdraw before approver handle" "$TMP_DIR/withdraw-action.json"
|
|
assert_jq "$TMP_DIR/withdraw-action.json" '.data.outcome == "withdrawn" and .data.reimbursementStatus == "draft"' "withdraw response"
|
|
assert_instance_status "$INSTANCE_WITHDRAW" "cancelled" "withdraw scenario"
|
|
assert_reimbursement_status_db "$RID_WITHDRAW" "draft" "withdraw scenario"
|
|
|
|
echo "[5/23] PERM_002: non-assignee cannot operate"
|
|
RID_PERM="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/perm-create.json")"
|
|
create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_PERM" "perm" >/dev/null
|
|
submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_PERM" "$TMP_DIR/perm-submit.json"
|
|
TASK_PERM="$(jq -er '.data.currentTaskId' "$TMP_DIR/perm-submit.json")"
|
|
INSTANCE_PERM="$(jq -er '.data.workflowInstanceId' "$TMP_DIR/perm-submit.json")"
|
|
|
|
perm_status="$(api_json_auth POST "$BASE_URL/api/workflow/tasks/$TASK_PERM/approve" "$FINANCE_TOKEN" "$TMP_DIR/perm-finance-approve.json" '{}')"
|
|
assert_status "$perm_status" "403" "PERM_002 non-assignee approve" "$TMP_DIR/perm-finance-approve.json"
|
|
assert_error_code "$TMP_DIR/perm-finance-approve.json" "PERM_002" "PERM_002 non-assignee approve"
|
|
|
|
withdraw_reimbursement "$EMPLOYEE_TOKEN" "$RID_PERM" "cleanup after PERM_002" "$TMP_DIR/perm-withdraw-cleanup.json"
|
|
assert_instance_status "$INSTANCE_PERM" "cancelled" "PERM_002 cleanup"
|
|
assert_reimbursement_status_db "$RID_PERM" "draft" "PERM_002 cleanup"
|
|
|
|
echo "[6/23] negative_not_allowed: submit blocked when total_amount < 0"
|
|
RID_NEGATIVE="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/negative-create.json")"
|
|
create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_NEGATIVE" "negative" >/dev/null
|
|
set_reimbursement_total_amount "$RID_NEGATIVE" "-1.00"
|
|
|
|
negative_submit_status="$(api_json_auth POST "$BASE_URL/api/reimbursements/$RID_NEGATIVE/submit" "$EMPLOYEE_TOKEN" "$TMP_DIR/negative-submit.json" '{}')"
|
|
assert_status "$negative_submit_status" "400" "negative_not_allowed submit" "$TMP_DIR/negative-submit.json"
|
|
assert_error_code "$TMP_DIR/negative-submit.json" "VALID_001" "negative_not_allowed submit"
|
|
assert_error_detail "$TMP_DIR/negative-submit.json" '.error.details.total_amount' "negative_not_allowed" "negative_not_allowed submit"
|
|
assert_no_workflow_instance "$RID_NEGATIVE" "negative_not_allowed"
|
|
|
|
echo "[7/23] Prepare manager-missing context"
|
|
set_employee_direct_manager "$EMPLOYEE_ID" "__NULL__"
|
|
EMPLOYEE_MANAGER_PATCHED=1
|
|
|
|
echo "[8/23] manager skip: missing manager -> skip with history + finance task"
|
|
RID_SKIP="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/skip-create.json")"
|
|
create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_SKIP" "skip" >/dev/null
|
|
submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_SKIP" "$TMP_DIR/skip-submit.json"
|
|
TASK_SKIP="$(jq -er '.data.currentTaskId' "$TMP_DIR/skip-submit.json")"
|
|
INSTANCE_SKIP="$(jq -er '.data.workflowInstanceId' "$TMP_DIR/skip-submit.json")"
|
|
|
|
assert_task_step_and_role "$TASK_SKIP" "approval_2_finance" "finance" "manager skip scenario"
|
|
assert_skip_history_record "$INSTANCE_SKIP" "approval_1_manager" "no_manager_found" "manager skip scenario"
|
|
|
|
skip_finance_approve_status="$(api_json_auth POST "$BASE_URL/api/workflow/tasks/$TASK_SKIP/approve" "$FINANCE_TOKEN" "$TMP_DIR/skip-finance-approve.json" '{}')"
|
|
assert_status "$skip_finance_approve_status" "200" "manager skip finance approve" "$TMP_DIR/skip-finance-approve.json"
|
|
assert_reimbursement_status_db "$RID_SKIP" "approved" "manager skip scenario"
|
|
|
|
echo "[9/23] no_actionable_approver: full chain non-actionable"
|
|
TMP_APPROVAL_RULE_ID="$(create_temp_manager_only_rule "tmp-manager-only-$RUN_SUFFIX" "9000.00" "9001.00" "1")"
|
|
|
|
RID_NO_ACTIONABLE="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/no-actionable-create.json")"
|
|
create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_NO_ACTIONABLE" "no-actionable" >/dev/null
|
|
set_reimbursement_total_amount "$RID_NO_ACTIONABLE" "9000.50"
|
|
|
|
no_actionable_submit_status="$(api_json_auth POST "$BASE_URL/api/reimbursements/$RID_NO_ACTIONABLE/submit" "$EMPLOYEE_TOKEN" "$TMP_DIR/no-actionable-submit.json" '{}')"
|
|
assert_status "$no_actionable_submit_status" "409" "no_actionable_approver submit" "$TMP_DIR/no-actionable-submit.json"
|
|
assert_error_code "$TMP_DIR/no-actionable-submit.json" "VALID_001" "no_actionable_approver submit"
|
|
assert_error_detail "$TMP_DIR/no-actionable-submit.json" '.error.details.workflow' "no_actionable_approver" "no_actionable_approver submit"
|
|
assert_no_workflow_instance "$RID_NO_ACTIONABLE" "no_actionable_approver"
|
|
|
|
delete_approval_rule "$TMP_APPROVAL_RULE_ID"
|
|
TMP_APPROVAL_RULE_ID=""
|
|
|
|
echo "[10/23] restore manager relation before transfer/pay scenarios"
|
|
set_employee_direct_manager "$EMPLOYEE_ID" "$ORIGINAL_EMPLOYEE_MANAGER_ID"
|
|
EMPLOYEE_MANAGER_PATCHED=0
|
|
|
|
echo "[11/23] transfer success: manager -> ceo with 3 evidences"
|
|
RID_TRANSFER_OK="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/transfer-ok-create.json")"
|
|
create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_TRANSFER_OK" "transfer-ok" >/dev/null
|
|
submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_TRANSFER_OK" "$TMP_DIR/transfer-ok-submit.json"
|
|
TASK_TRANSFER_OK="$(jq -er '.data.currentTaskId' "$TMP_DIR/transfer-ok-submit.json")"
|
|
INSTANCE_TRANSFER_OK="$(jq -er '.data.workflowInstanceId' "$TMP_DIR/transfer-ok-submit.json")"
|
|
|
|
transfer_ok_status="$(transfer_task "$MANAGER_TOKEN" "$TASK_TRANSFER_OK" "$CEO_ID" "transfer to ceo for regression" "$TMP_DIR/transfer-ok-action.json")"
|
|
assert_status "$transfer_ok_status" "200" "transfer success" "$TMP_DIR/transfer-ok-action.json"
|
|
assert_jq "$TMP_DIR/transfer-ok-action.json" '.data.outcome == "transferred" and .data.reimbursementStatus == "reviewing" and .data.transferCount == 1' "transfer success response"
|
|
assert_transfer_evidence "$TASK_TRANSFER_OK" "$RID_TRANSFER_OK" "$MANAGER_ID" "$CEO_ID" "transfer to ceo for regression" "1" "transfer success"
|
|
|
|
transfer_after_status="$(transfer_task "$MANAGER_TOKEN" "$TASK_TRANSFER_OK" "$CEO_ID" "second transfer should fail by perm" "$TMP_DIR/transfer-after-perm.json")"
|
|
assert_status "$transfer_after_status" "403" "transfer after reassignment should be forbidden" "$TMP_DIR/transfer-after-perm.json"
|
|
assert_error_code "$TMP_DIR/transfer-after-perm.json" "PERM_002" "transfer after reassignment should be forbidden"
|
|
|
|
ceo_approve_after_transfer_status="$(api_json_auth POST "$BASE_URL/api/workflow/tasks/$TASK_TRANSFER_OK/approve" "$CEO_TOKEN" "$TMP_DIR/transfer-ok-ceo-approve.json" '{}')"
|
|
assert_status "$ceo_approve_after_transfer_status" "200" "ceo approve transferred task" "$TMP_DIR/transfer-ok-ceo-approve.json"
|
|
|
|
NEXT_TASK_TRANSFER_OK="$(jq -r '.data.nextTaskId // empty' "$TMP_DIR/transfer-ok-ceo-approve.json")"
|
|
if [[ -n "$NEXT_TASK_TRANSFER_OK" ]]; then
|
|
finance_approve_after_transfer_status="$(api_json_auth POST "$BASE_URL/api/workflow/tasks/$NEXT_TASK_TRANSFER_OK/approve" "$FINANCE_TOKEN" "$TMP_DIR/transfer-ok-finance-approve-next.json" '{}')"
|
|
assert_status "$finance_approve_after_transfer_status" "200" "finance approve transferred next task" "$TMP_DIR/transfer-ok-finance-approve-next.json"
|
|
fi
|
|
|
|
assert_reimbursement_status_db "$RID_TRANSFER_OK" "approved" "transfer success approval"
|
|
|
|
echo "[12/23] transfer reason too short => VALID_002"
|
|
RID_TRANSFER_SHORT="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/transfer-short-create.json")"
|
|
create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_TRANSFER_SHORT" "transfer-short" >/dev/null
|
|
submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_TRANSFER_SHORT" "$TMP_DIR/transfer-short-submit.json"
|
|
TASK_TRANSFER_SHORT="$(jq -er '.data.currentTaskId' "$TMP_DIR/transfer-short-submit.json")"
|
|
|
|
transfer_short_status="$(transfer_task "$MANAGER_TOKEN" "$TASK_TRANSFER_SHORT" "$CEO_ID" "too short" "$TMP_DIR/transfer-short-action.json")"
|
|
assert_status "$transfer_short_status" "400" "transfer reason too short" "$TMP_DIR/transfer-short-action.json"
|
|
assert_error_code "$TMP_DIR/transfer-short-action.json" "VALID_002" "transfer reason too short"
|
|
|
|
withdraw_reimbursement "$EMPLOYEE_TOKEN" "$RID_TRANSFER_SHORT" "cleanup transfer short" "$TMP_DIR/transfer-short-withdraw-cleanup.json"
|
|
assert_reimbursement_status_db "$RID_TRANSFER_SHORT" "draft" "transfer short cleanup"
|
|
|
|
echo "[13/23] transfer target not approver => VALID_001"
|
|
RID_TRANSFER_TARGET="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/transfer-target-create.json")"
|
|
create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_TRANSFER_TARGET" "transfer-target" >/dev/null
|
|
submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_TRANSFER_TARGET" "$TMP_DIR/transfer-target-submit.json"
|
|
TASK_TRANSFER_TARGET="$(jq -er '.data.currentTaskId' "$TMP_DIR/transfer-target-submit.json")"
|
|
|
|
transfer_target_status="$(transfer_task "$MANAGER_TOKEN" "$TASK_TRANSFER_TARGET" "$EMPLOYEE_ID" "transfer target is not approver" "$TMP_DIR/transfer-target-action.json")"
|
|
assert_status "$transfer_target_status" "409" "transfer target not approver" "$TMP_DIR/transfer-target-action.json"
|
|
assert_error_code "$TMP_DIR/transfer-target-action.json" "VALID_001" "transfer target not approver"
|
|
assert_error_detail "$TMP_DIR/transfer-target-action.json" '.error.details.toApproverId' "target_not_approver" "transfer target not approver"
|
|
|
|
withdraw_reimbursement "$EMPLOYEE_TOKEN" "$RID_TRANSFER_TARGET" "cleanup transfer target" "$TMP_DIR/transfer-target-withdraw-cleanup.json"
|
|
assert_reimbursement_status_db "$RID_TRANSFER_TARGET" "draft" "transfer target cleanup"
|
|
|
|
echo "[14/23] transfer same assignee => VALID_001"
|
|
RID_TRANSFER_SAME="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/transfer-same-create.json")"
|
|
create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_TRANSFER_SAME" "transfer-same" >/dev/null
|
|
submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_TRANSFER_SAME" "$TMP_DIR/transfer-same-submit.json"
|
|
TASK_TRANSFER_SAME="$(jq -er '.data.currentTaskId' "$TMP_DIR/transfer-same-submit.json")"
|
|
|
|
transfer_same_status="$(transfer_task "$MANAGER_TOKEN" "$TASK_TRANSFER_SAME" "$MANAGER_ID" "transfer to same assignee should fail" "$TMP_DIR/transfer-same-action.json")"
|
|
assert_status "$transfer_same_status" "409" "transfer same assignee" "$TMP_DIR/transfer-same-action.json"
|
|
assert_error_code "$TMP_DIR/transfer-same-action.json" "VALID_001" "transfer same assignee"
|
|
assert_error_detail "$TMP_DIR/transfer-same-action.json" '.error.details.transfer' "same_assignee_not_allowed" "transfer same assignee"
|
|
|
|
withdraw_reimbursement "$EMPLOYEE_TOKEN" "$RID_TRANSFER_SAME" "cleanup transfer same assignee" "$TMP_DIR/transfer-same-withdraw-cleanup.json"
|
|
assert_reimbursement_status_db "$RID_TRANSFER_SAME" "draft" "transfer same cleanup"
|
|
|
|
echo "[15/23] transfer chain limit: 4th transfer blocked => WORKFLOW_003"
|
|
RID_TRANSFER_CHAIN="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/transfer-chain-create.json")"
|
|
create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_TRANSFER_CHAIN" "transfer-chain" >/dev/null
|
|
submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_TRANSFER_CHAIN" "$TMP_DIR/transfer-chain-submit.json"
|
|
TASK_TRANSFER_CHAIN="$(jq -er '.data.currentTaskId' "$TMP_DIR/transfer-chain-submit.json")"
|
|
|
|
transfer_chain_1_status="$(transfer_task "$MANAGER_TOKEN" "$TASK_TRANSFER_CHAIN" "$CEO_ID" "transfer chain step one" "$TMP_DIR/transfer-chain-1.json")"
|
|
assert_status "$transfer_chain_1_status" "200" "transfer chain step 1" "$TMP_DIR/transfer-chain-1.json"
|
|
assert_transfer_evidence "$TASK_TRANSFER_CHAIN" "$RID_TRANSFER_CHAIN" "$MANAGER_ID" "$CEO_ID" "transfer chain step one" "1" "transfer chain step 1"
|
|
|
|
transfer_chain_2_status="$(transfer_task "$CEO_TOKEN" "$TASK_TRANSFER_CHAIN" "$MANAGER_ID" "transfer chain step two" "$TMP_DIR/transfer-chain-2.json")"
|
|
assert_status "$transfer_chain_2_status" "200" "transfer chain step 2" "$TMP_DIR/transfer-chain-2.json"
|
|
assert_transfer_evidence "$TASK_TRANSFER_CHAIN" "$RID_TRANSFER_CHAIN" "$CEO_ID" "$MANAGER_ID" "transfer chain step two" "2" "transfer chain step 2"
|
|
|
|
transfer_chain_3_status="$(transfer_task "$MANAGER_TOKEN" "$TASK_TRANSFER_CHAIN" "$CEO_ID" "transfer chain step three" "$TMP_DIR/transfer-chain-3.json")"
|
|
assert_status "$transfer_chain_3_status" "200" "transfer chain step 3" "$TMP_DIR/transfer-chain-3.json"
|
|
assert_transfer_evidence "$TASK_TRANSFER_CHAIN" "$RID_TRANSFER_CHAIN" "$MANAGER_ID" "$CEO_ID" "transfer chain step three" "3" "transfer chain step 3"
|
|
|
|
transfer_chain_4_status="$(transfer_task "$CEO_TOKEN" "$TASK_TRANSFER_CHAIN" "$MANAGER_ID" "transfer chain step four should fail" "$TMP_DIR/transfer-chain-4.json")"
|
|
assert_status "$transfer_chain_4_status" "409" "transfer chain step 4" "$TMP_DIR/transfer-chain-4.json"
|
|
assert_error_code "$TMP_DIR/transfer-chain-4.json" "WORKFLOW_003" "transfer chain step 4"
|
|
assert_error_detail "$TMP_DIR/transfer-chain-4.json" '.error.details.transfer' "chain_limit_exceeded" "transfer chain step 4"
|
|
|
|
ceo_approve_chain_status="$(api_json_auth POST "$BASE_URL/api/workflow/tasks/$TASK_TRANSFER_CHAIN/approve" "$CEO_TOKEN" "$TMP_DIR/transfer-chain-approve-ceo.json" '{}')"
|
|
assert_status "$ceo_approve_chain_status" "200" "transfer chain final approval" "$TMP_DIR/transfer-chain-approve-ceo.json"
|
|
|
|
NEXT_TASK_TRANSFER_CHAIN="$(jq -r '.data.nextTaskId // empty' "$TMP_DIR/transfer-chain-approve-ceo.json")"
|
|
if [[ -n "$NEXT_TASK_TRANSFER_CHAIN" ]]; then
|
|
finance_approve_chain_next_status="$(api_json_auth POST "$BASE_URL/api/workflow/tasks/$NEXT_TASK_TRANSFER_CHAIN/approve" "$FINANCE_TOKEN" "$TMP_DIR/transfer-chain-approve-finance-next.json" '{}')"
|
|
assert_status "$finance_approve_chain_next_status" "200" "transfer chain finance next approval" "$TMP_DIR/transfer-chain-approve-finance-next.json"
|
|
fi
|
|
|
|
assert_reimbursement_status_db "$RID_TRANSFER_CHAIN" "approved" "transfer chain final approval"
|
|
|
|
echo "[17/23] delegation active window: delegate can approve"
|
|
RID_DELEGATION_OK="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/delegation-ok-create.json")"
|
|
create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_DELEGATION_OK" "delegation-ok" >/dev/null
|
|
submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_DELEGATION_OK" "$TMP_DIR/delegation-ok-submit.json"
|
|
TASK_DELEGATION_OK="$(jq -er '.data.currentTaskId' "$TMP_DIR/delegation-ok-submit.json")"
|
|
|
|
DELEGATION_VALID_FROM="$(date -u -d '-5 minutes' '+%Y-%m-%dT%H:%M:%SZ')"
|
|
DELEGATION_VALID_TO="$(date -u -d '+1 hour' '+%Y-%m-%dT%H:%M:%SZ')"
|
|
delegation_create_status="$(create_delegation "$MANAGER_TOKEN" "$CEO_ID" "all" "$DELEGATION_VALID_FROM" "$DELEGATION_VALID_TO" "$TMP_DIR/delegation-create-ok.json")"
|
|
assert_status "$delegation_create_status" "200" "create delegation active" "$TMP_DIR/delegation-create-ok.json"
|
|
DELEGATION_OK_ID="$(jq -er '.data.delegation.id' "$TMP_DIR/delegation-create-ok.json")"
|
|
|
|
ceo_approve_via_delegation_status="$(api_json_auth POST "$BASE_URL/api/workflow/tasks/$TASK_DELEGATION_OK/approve" "$CEO_TOKEN" "$TMP_DIR/delegation-ok-ceo-approve.json" '{}')"
|
|
assert_status "$ceo_approve_via_delegation_status" "200" "delegated approve by ceo" "$TMP_DIR/delegation-ok-ceo-approve.json"
|
|
assert_delegation_history_evidence "$TASK_DELEGATION_OK" "$DELEGATION_OK_ID" "$MANAGER_ID" "$CEO_ID" "delegation active approve"
|
|
|
|
revoke_delegation_status="$(revoke_delegation "$MANAGER_TOKEN" "$DELEGATION_OK_ID" "$TMP_DIR/delegation-revoke-ok.json")"
|
|
assert_status "$revoke_delegation_status" "200" "revoke delegation" "$TMP_DIR/delegation-revoke-ok.json"
|
|
assert_delegation_revoked_db "$DELEGATION_OK_ID" "delegation revoke"
|
|
|
|
echo "[18/23] delegation expired: delegate forbidden"
|
|
DELEGATION_EXPIRED_FROM="$(date -u -d '-2 hours' '+%Y-%m-%dT%H:%M:%SZ')"
|
|
DELEGATION_EXPIRED_TO="$(date -u -d '-1 hour' '+%Y-%m-%dT%H:%M:%SZ')"
|
|
delegation_expired_create_status="$(create_delegation "$MANAGER_TOKEN" "$CEO_ID" "all" "$DELEGATION_EXPIRED_FROM" "$DELEGATION_EXPIRED_TO" "$TMP_DIR/delegation-create-expired.json")"
|
|
assert_status "$delegation_expired_create_status" "200" "create expired delegation" "$TMP_DIR/delegation-create-expired.json"
|
|
DELEGATION_EXPIRED_ID="$(jq -er '.data.delegation.id' "$TMP_DIR/delegation-create-expired.json")"
|
|
|
|
RID_DELEGATION_EXPIRED="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/delegation-expired-create.json")"
|
|
create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_DELEGATION_EXPIRED" "delegation-expired" >/dev/null
|
|
submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_DELEGATION_EXPIRED" "$TMP_DIR/delegation-expired-submit.json"
|
|
TASK_DELEGATION_EXPIRED="$(jq -er '.data.currentTaskId' "$TMP_DIR/delegation-expired-submit.json")"
|
|
|
|
ceo_approve_expired_status="$(api_json_auth POST "$BASE_URL/api/workflow/tasks/$TASK_DELEGATION_EXPIRED/approve" "$CEO_TOKEN" "$TMP_DIR/delegation-expired-ceo-approve.json" '{}')"
|
|
assert_status "$ceo_approve_expired_status" "403" "expired delegation should be forbidden" "$TMP_DIR/delegation-expired-ceo-approve.json"
|
|
assert_error_code "$TMP_DIR/delegation-expired-ceo-approve.json" "PERM_002" "expired delegation should be forbidden"
|
|
|
|
if [[ "$RUN_DELEGATION_CONCURRENCY_CHECK" == "1" ]]; then
|
|
echo "[18.1/23] optional delegation revoke concurrency check"
|
|
(
|
|
npm run --silent job:revoke-expired-delegations > "$TMP_DIR/delegation-revoke-a.out"
|
|
) &
|
|
revoke_pid_a=$!
|
|
(
|
|
npm run --silent job:revoke-expired-delegations > "$TMP_DIR/delegation-revoke-b.out"
|
|
) &
|
|
revoke_pid_b=$!
|
|
wait "$revoke_pid_a"
|
|
wait "$revoke_pid_b"
|
|
|
|
revoke_job_output_a="$(cat "$TMP_DIR/delegation-revoke-a.out")"
|
|
revoke_job_output_b="$(cat "$TMP_DIR/delegation-revoke-b.out")"
|
|
revoke_job_status_a="$(printf '%s\n' "$revoke_job_output_a" | tail -n 1)"
|
|
revoke_job_status_b="$(printf '%s\n' "$revoke_job_output_b" | tail -n 1)"
|
|
|
|
if [[ "$revoke_job_status_a" != revoked=* ]]; then
|
|
echo "[ERROR] revoke concurrency output A invalid: $revoke_job_output_a" >&2
|
|
exit 1
|
|
fi
|
|
|
|
if [[ "$revoke_job_status_b" != revoked=* ]]; then
|
|
echo "[ERROR] revoke concurrency output B invalid: $revoke_job_output_b" >&2
|
|
exit 1
|
|
fi
|
|
|
|
assert_delegation_revoked_db "$DELEGATION_EXPIRED_ID" "delegation auto revoke concurrency"
|
|
assert_delegation_auto_revoke_audit_count "$DELEGATION_EXPIRED_ID" "1" "delegation auto revoke concurrency"
|
|
else
|
|
revoke_job_output="$(npm run --silent job:revoke-expired-delegations)"
|
|
revoke_job_status="$(printf '%s\n' "$revoke_job_output" | tail -n 1)"
|
|
if [[ "$revoke_job_status" != revoked=* ]]; then
|
|
echo "[ERROR] revoke expired delegations script output invalid: $revoke_job_output" >&2
|
|
exit 1
|
|
fi
|
|
assert_delegation_revoked_db "$DELEGATION_EXPIRED_ID" "delegation auto revoke"
|
|
fi
|
|
|
|
manager_approve_after_expired_status="$(api_json_auth POST "$BASE_URL/api/workflow/tasks/$TASK_DELEGATION_EXPIRED/approve" "$MANAGER_TOKEN" "$TMP_DIR/delegation-expired-manager-approve.json" '{}')"
|
|
assert_status "$manager_approve_after_expired_status" "200" "manager approve after expired delegation" "$TMP_DIR/delegation-expired-manager-approve.json"
|
|
|
|
echo "[19/23] pay success: approved -> paid and invoices archived"
|
|
RID_PAY_OK="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/pay-ok-create.json")"
|
|
IID_PAY_OK="$(create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_PAY_OK" "pay-ok")"
|
|
submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_PAY_OK" "$TMP_DIR/pay-ok-submit.json"
|
|
TASK_PAY_OK="$(jq -er '.data.currentTaskId' "$TMP_DIR/pay-ok-submit.json")"
|
|
|
|
approve_pay_ok_status="$(api_json_auth POST "$BASE_URL/api/workflow/tasks/$TASK_PAY_OK/approve" "$MANAGER_TOKEN" "$TMP_DIR/pay-ok-approve-manager.json" '{}')"
|
|
assert_status "$approve_pay_ok_status" "200" "approve for pay success scenario" "$TMP_DIR/pay-ok-approve-manager.json"
|
|
|
|
NEXT_TASK_PAY_OK="$(jq -r '.data.nextTaskId // empty' "$TMP_DIR/pay-ok-approve-manager.json")"
|
|
if [[ -n "$NEXT_TASK_PAY_OK" ]]; then
|
|
approve_pay_ok_finance_status="$(api_json_auth POST "$BASE_URL/api/workflow/tasks/$NEXT_TASK_PAY_OK/approve" "$FINANCE_TOKEN" "$TMP_DIR/pay-ok-approve-finance.json" '{}')"
|
|
assert_status "$approve_pay_ok_finance_status" "200" "approve finance step for pay success" "$TMP_DIR/pay-ok-approve-finance.json"
|
|
fi
|
|
|
|
assert_reimbursement_status_db "$RID_PAY_OK" "approved" "pay success precondition"
|
|
|
|
pay_ok_body="{\"paymentVoucherNo\":\"VOUCHER-$RUN_SUFFIX\",\"paymentDate\":\"2026-02-07\",\"paymentChannel\":\"bank\"}"
|
|
pay_ok_status="$(pay_reimbursement "$FINANCE_TOKEN" "$RID_PAY_OK" "$TMP_DIR/pay-ok-pay.json" "$pay_ok_body")"
|
|
assert_status "$pay_ok_status" "200" "pay approved reimbursement" "$TMP_DIR/pay-ok-pay.json"
|
|
assert_jq "$TMP_DIR/pay-ok-pay.json" '.data.outcome == "paid" and .data.reimbursementStatus == "paid" and (.data.archivedInvoiceCount | type == "number") and .data.archivedInvoiceCount >= 1 and .data.payment.idempotent == false and .data.payment.paymentChannel == "bank"' "pay success response"
|
|
assert_reimbursement_status_db "$RID_PAY_OK" "paid" "pay success"
|
|
assert_invoices_archived_used_complete "$RID_PAY_OK" "pay success"
|
|
|
|
echo "[20/23] pay invalid status: draft/reviewing/rejected/cancelled"
|
|
pay_invalid_draft_status="$(pay_reimbursement "$FINANCE_TOKEN" "$RID_NEGATIVE" "$TMP_DIR/pay-invalid-draft.json")"
|
|
assert_status "$pay_invalid_draft_status" "409" "pay invalid status draft" "$TMP_DIR/pay-invalid-draft.json"
|
|
assert_error_code "$TMP_DIR/pay-invalid-draft.json" "VALID_001" "pay invalid status draft"
|
|
assert_error_detail "$TMP_DIR/pay-invalid-draft.json" '.error.details.status' "invalid_pay_status" "pay invalid status draft"
|
|
|
|
RID_PAY_REVIEWING="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/pay-reviewing-create.json")"
|
|
create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_PAY_REVIEWING" "pay-reviewing" >/dev/null
|
|
submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_PAY_REVIEWING" "$TMP_DIR/pay-reviewing-submit.json"
|
|
pay_invalid_reviewing_status="$(pay_reimbursement "$FINANCE_TOKEN" "$RID_PAY_REVIEWING" "$TMP_DIR/pay-invalid-reviewing.json")"
|
|
assert_status "$pay_invalid_reviewing_status" "409" "pay invalid status reviewing" "$TMP_DIR/pay-invalid-reviewing.json"
|
|
assert_error_code "$TMP_DIR/pay-invalid-reviewing.json" "VALID_001" "pay invalid status reviewing"
|
|
assert_error_detail "$TMP_DIR/pay-invalid-reviewing.json" '.error.details.status' "invalid_pay_status" "pay invalid status reviewing"
|
|
|
|
withdraw_reimbursement "$EMPLOYEE_TOKEN" "$RID_PAY_REVIEWING" "cleanup pay invalid reviewing" "$TMP_DIR/pay-reviewing-withdraw-cleanup.json"
|
|
assert_reimbursement_status_db "$RID_PAY_REVIEWING" "draft" "pay invalid reviewing cleanup"
|
|
|
|
pay_invalid_rejected_status="$(pay_reimbursement "$FINANCE_TOKEN" "$RID_REJECT" "$TMP_DIR/pay-invalid-rejected.json")"
|
|
assert_status "$pay_invalid_rejected_status" "409" "pay invalid status rejected" "$TMP_DIR/pay-invalid-rejected.json"
|
|
assert_error_code "$TMP_DIR/pay-invalid-rejected.json" "VALID_001" "pay invalid status rejected"
|
|
assert_error_detail "$TMP_DIR/pay-invalid-rejected.json" '.error.details.status' "invalid_pay_status" "pay invalid status rejected"
|
|
|
|
RID_PAY_CANCELLED="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/pay-cancelled-create.json")"
|
|
set_reimbursement_status "$RID_PAY_CANCELLED" "cancelled"
|
|
pay_invalid_cancelled_status="$(pay_reimbursement "$FINANCE_TOKEN" "$RID_PAY_CANCELLED" "$TMP_DIR/pay-invalid-cancelled.json")"
|
|
assert_status "$pay_invalid_cancelled_status" "409" "pay invalid status cancelled" "$TMP_DIR/pay-invalid-cancelled.json"
|
|
assert_error_code "$TMP_DIR/pay-invalid-cancelled.json" "VALID_001" "pay invalid status cancelled"
|
|
assert_error_detail "$TMP_DIR/pay-invalid-cancelled.json" '.error.details.status' "invalid_pay_status" "pay invalid status cancelled"
|
|
|
|
echo "[21/23] pay permission: non-finance forbidden"
|
|
pay_perm_status="$(pay_reimbursement "$EMPLOYEE_TOKEN" "$RID_PAY_OK" "$TMP_DIR/pay-perm-employee.json")"
|
|
assert_status "$pay_perm_status" "403" "pay forbidden for non-finance" "$TMP_DIR/pay-perm-employee.json"
|
|
assert_error_code "$TMP_DIR/pay-perm-employee.json" "PERM_001" "pay forbidden for non-finance"
|
|
|
|
echo "[22/23] pay idempotent: paid reimbursement can be paid again"
|
|
pay_idempotent_body="{\"paymentVoucherNo\":\"VOUCHER-$RUN_SUFFIX\",\"paymentDate\":\"2026-02-07\",\"paymentChannel\":\"bank\"}"
|
|
pay_idempotent_status="$(pay_reimbursement "$FINANCE_TOKEN" "$RID_PAY_OK" "$TMP_DIR/pay-idempotent.json" "$pay_idempotent_body")"
|
|
assert_status "$pay_idempotent_status" "200" "pay idempotent" "$TMP_DIR/pay-idempotent.json"
|
|
assert_jq "$TMP_DIR/pay-idempotent.json" '.data.outcome == "paid" and .data.reimbursementStatus == "paid" and (.data.archivedInvoiceCount | type == "number") and .data.archivedInvoiceCount >= 0 and .data.payment.idempotent == true and .data.payment.paymentVoucherNo != null' "pay idempotent response"
|
|
assert_reimbursement_status_db "$RID_PAY_OK" "paid" "pay idempotent"
|
|
assert_invoices_archived_used_complete "$RID_PAY_OK" "pay idempotent"
|
|
|
|
pay_conflict_body="{\"paymentVoucherNo\":\"VOUCHER-CONFLICT-$RUN_SUFFIX\",\"paymentDate\":\"2026-02-06\",\"paymentChannel\":\"alipay\"}"
|
|
pay_conflict_status="$(pay_reimbursement "$FINANCE_TOKEN" "$RID_PAY_OK" "$TMP_DIR/pay-conflict.json" "$pay_conflict_body")"
|
|
assert_status "$pay_conflict_status" "409" "pay idempotent conflict" "$TMP_DIR/pay-conflict.json"
|
|
assert_error_code "$TMP_DIR/pay-conflict.json" "VALID_001" "pay idempotent conflict"
|
|
assert_error_detail "$TMP_DIR/pay-conflict.json" '.error.details.payment' "already_paid_conflict" "pay idempotent conflict"
|
|
|
|
if [[ "$RUN_PAY_CONCURRENCY_CHECK" == "1" ]]; then
|
|
echo "[22.1/23] optional pay concurrency check"
|
|
RID_PAY_CONCURRENCY="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/pay-concurrency-create.json")"
|
|
create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_PAY_CONCURRENCY" "pay-concurrency" >/dev/null
|
|
submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_PAY_CONCURRENCY" "$TMP_DIR/pay-concurrency-submit.json"
|
|
TASK_PAY_CONCURRENCY="$(jq -er '.data.currentTaskId' "$TMP_DIR/pay-concurrency-submit.json")"
|
|
approve_pay_concurrency_status="$(api_json_auth POST "$BASE_URL/api/workflow/tasks/$TASK_PAY_CONCURRENCY/approve" "$MANAGER_TOKEN" "$TMP_DIR/pay-concurrency-approve.json" '{}')"
|
|
assert_status "$approve_pay_concurrency_status" "200" "approve pay concurrency scenario" "$TMP_DIR/pay-concurrency-approve.json"
|
|
|
|
(
|
|
pay_reimbursement "$FINANCE_TOKEN" "$RID_PAY_CONCURRENCY" "$TMP_DIR/pay-concurrency-a.json" > "$TMP_DIR/pay-concurrency-a.status"
|
|
) &
|
|
pid_a=$!
|
|
(
|
|
pay_reimbursement "$FINANCE_TOKEN" "$RID_PAY_CONCURRENCY" "$TMP_DIR/pay-concurrency-b.json" > "$TMP_DIR/pay-concurrency-b.status"
|
|
) &
|
|
pid_b=$!
|
|
wait "$pid_a"
|
|
wait "$pid_b"
|
|
|
|
status_a="$(cat "$TMP_DIR/pay-concurrency-a.status")"
|
|
status_b="$(cat "$TMP_DIR/pay-concurrency-b.status")"
|
|
assert_status "$status_a" "200" "pay concurrency request a" "$TMP_DIR/pay-concurrency-a.json"
|
|
assert_status "$status_b" "200" "pay concurrency request b" "$TMP_DIR/pay-concurrency-b.json"
|
|
assert_reimbursement_status_db "$RID_PAY_CONCURRENCY" "paid" "pay concurrency"
|
|
assert_invoices_archived_used_complete "$RID_PAY_CONCURRENCY" "pay concurrency"
|
|
fi
|
|
|
|
echo "[22.2/23] invoice unbind/rebind"
|
|
RID_UNBIND_A="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/unbind-a-create.json")"
|
|
INVOICE_UNBIND="$(create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_UNBIND_A" "unbind")"
|
|
unbind_status="$(api_json_auth POST "$BASE_URL/api/invoice-pool/$INVOICE_UNBIND/unbind" "$EMPLOYEE_TOKEN" "$TMP_DIR/unbind.json" '{}')"
|
|
assert_status "$unbind_status" "200" "invoice unbind" "$TMP_DIR/unbind.json"
|
|
assert_jq "$TMP_DIR/unbind.json" '.data.status == "available" and .data.linkedReimbursementId == null and .data.linkedItemId == null' "invoice unbind response"
|
|
|
|
RID_UNBIND_B="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/unbind-b-create.json")"
|
|
rebind_status="$(api_json_auth POST "$BASE_URL/api/invoice-pool/$INVOICE_UNBIND/bind" "$EMPLOYEE_TOKEN" "$TMP_DIR/unbind-rebind.json" "{\"reimbursementId\":\"$RID_UNBIND_B\"}")"
|
|
assert_status "$rebind_status" "200" "invoice rebind after unbind" "$TMP_DIR/unbind-rebind.json"
|
|
|
|
echo "[23/23] Final manager relation restore"
|
|
set_employee_direct_manager "$EMPLOYEE_ID" "$ORIGINAL_EMPLOYEE_MANAGER_ID"
|
|
EMPLOYEE_MANAGER_PATCHED=0
|
|
|
|
echo "[OK] Workflow action regression passed"
|
|
echo "- reject reimbursementId: $RID_REJECT"
|
|
echo "- return reimbursementId: $RID_RETURN"
|
|
echo "- withdraw reimbursementId: $RID_WITHDRAW"
|
|
echo "- perm reimbursementId: $RID_PERM"
|
|
echo "- negative reimbursementId: $RID_NEGATIVE"
|
|
echo "- manager-skip reimbursementId: $RID_SKIP"
|
|
echo "- no-actionable reimbursementId: $RID_NO_ACTIONABLE"
|
|
echo "- transfer-ok reimbursementId: $RID_TRANSFER_OK"
|
|
echo "- transfer-chain reimbursementId: $RID_TRANSFER_CHAIN"
|
|
echo "- pay reimbursementId: $RID_PAY_OK"
|