#!/usr/bin/env bash set -euo pipefail BASE_URL="${BASE_URL:-http://127.0.0.1:3000}" DEVICE_FINGERPRINT="${DEVICE_FINGERPRINT:-regression-assets}" EMPLOYEE_IDENTIFIER="${EMPLOYEE_IDENTIFIER:-employee@hyinda.com}" EMPLOYEE_PASSWORD="${EMPLOYEE_PASSWORD:-EmployeePassword1234}" FINANCE_MGR_IDENTIFIER="${FINANCE_MGR_IDENTIFIER:-finance_mgr@hyinda.com}" FINANCE_MGR_PASSWORD="${FINANCE_MGR_PASSWORD:-FinanceMgrPassword1234}" ADMINISTRATION_IDENTIFIER="${ADMINISTRATION_IDENTIFIER:-administration@hyinda.com}" ADMINISTRATION_PASSWORD="${ADMINISTRATION_PASSWORD:-AdministrationPassword1234}" if ! command -v curl >/dev/null 2>&1; then echo "[ERROR] curl not found" >&2 exit 1 fi if ! command -v jq >/dev/null 2>&1; then echo "[ERROR] jq not found" >&2 exit 1 fi ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT_DIR" 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 TMP_DIR="$(mktemp -d)" cleanup() { rm -rf "$TMP_DIR" } trap cleanup EXIT assert_status() { local actual="$1" local expected="$2" local name="$3" local body_file="${4:-}" if [[ "$actual" != "$expected" ]]; then echo "[ERROR] $name expected HTTP $expected but got $actual" >&2 [[ -n "$body_file" && -f "$body_file" ]] && cat "$body_file" >&2 exit 1 fi } assert_jq() { local file_path="$1" local jq_expr="$2" local name="$3" if ! jq -e "$jq_expr" "$file_path" >/dev/null; then echo "[ERROR] $name assertion failed: $jq_expr" >&2 cat "$file_path" >&2 exit 1 fi } login_user() { local identifier="$1" local password="$2" local out_file="$3" local status status="$(curl -sS -o "$out_file" -w "%{http_code}" \ -X POST "$BASE_URL/api/auth/login" \ -H "content-type: application/json" \ -H "x-device-fingerprint: $DEVICE_FINGERPRINT" \ -d "{\"identifier\":\"$identifier\",\"password\":\"$password\"}")" assert_status "$status" "200" "login $identifier" "$out_file" jq -er '.data.accessToken' "$out_file" } api_json_auth() { local method="$1" local url="$2" local token="$3" local out_file="$4" local body="${5:-}" if [[ -n "$body" ]]; then curl -sS -o "$out_file" -w "%{http_code}" \ -X "$method" "$url" \ -H "authorization: Bearer $token" \ -H "content-type: application/json" \ -d "$body" else curl -sS -o "$out_file" -w "%{http_code}" \ -X "$method" "$url" \ -H "authorization: Bearer $token" fi } resolve_fixture_ids() { DATABASE_URL="$DATABASE_URL" node - <<'NODE' const { createPrismaClient } = require('./src/prismaClient'); (async () => { const { prisma, pool } = createPrismaClient(); await prisma.$connect(); try { const employee = await prisma.employee.findUnique({ where: { email: process.env.SEED_EMPLOYEE_EMAIL ?? 'employee@hyinda.com' }, select: { id: true, legal_entity_id: true }, }); const financeMgr = await prisma.employee.findUnique({ where: { email: process.env.SEED_FINANCE_MGR_EMAIL ?? 'finance_mgr@hyinda.com' }, select: { id: true }, }); const administration = await prisma.employee.findUnique({ where: { email: process.env.SEED_ADMINISTRATION_EMAIL ?? 'administration@hyinda.com' }, select: { id: true }, }); const costCenter = await prisma.costCenter.findFirst({ where: { legal_entity_id: employee.legal_entity_id, enabled: true, archived_at: null, }, orderBy: { created_at: 'asc' }, select: { id: true }, }); const approvedPurchase = await prisma.purchaseRequest.findFirst({ where: { status: 'approved' }, orderBy: { updated_at: 'desc' }, select: { id: true }, }); process.stdout.write([ employee.id, financeMgr.id, administration.id, employee.legal_entity_id ?? '', costCenter?.id ?? '', approvedPurchase?.id ?? '', ].join('\n')); } finally { await prisma.$disconnect(); await pool.end(); } })().catch((error) => { console.error(error); process.exit(1); }); NODE } assert_asset_state() { local asset_id="$1" local category_id="$2" local finance_mgr_id="$3" local inventory_check_id="$4" DATABASE_URL="$DATABASE_URL" node - "$asset_id" "$category_id" "$finance_mgr_id" "$inventory_check_id" <<'NODE' const [assetId, categoryId, financeMgrId, inventoryCheckId] = process.argv.slice(2); const { createPrismaClient } = require('./src/prismaClient'); (async () => { const { prisma, pool } = createPrismaClient(); await prisma.$connect(); try { const category = await prisma.assetCategory.findUnique({ where: { id: categoryId }, select: { name: true }, }); const asset = await prisma.assetCard.findUnique({ where: { id: assetId }, select: { asset_no: true, status: true, assignee_employee_id: true, location: true, }, }); const assignments = await prisma.assetAssignment.findMany({ where: { asset_id: assetId }, orderBy: { assigned_at: 'asc' }, select: { employee_id: true, returned_at: true }, }); const transfers = await prisma.assetTransfer.findMany({ where: { asset_id: assetId }, select: { to_employee_id: true, to_location: true }, }); const inventoryCheck = await prisma.inventoryCheck.findUnique({ where: { id: inventoryCheckId }, select: { status: true, items: { select: { asset_id: true, result: true }, }, }, }); const auditActions = await prisma.auditLog.findMany({ where: { target_id: { in: [assetId, inventoryCheckId] }, action: { in: [ 'ASSET_ASSIGNED', 'ASSET_RETURNED', 'ASSET_TRANSFERRED', 'INVENTORY_CHECK_CREATED', 'INVENTORY_CHECK_STARTED', 'INVENTORY_ITEM_RECORDED', 'INVENTORY_CHECK_COMPLETED', ], }, }, select: { action: true }, }); const openAssignments = assignments.filter((item) => item.returned_at == null); const actionSet = new Set(auditActions.map((item) => item.action)); if (!category || category.name !== '回归资产分类-已更新') { console.error('[ERROR] asset category state mismatch', category); process.exit(1); } if (!asset || !asset.asset_no.startsWith('ZC-') || asset.status !== 'in_use' || asset.assignee_employee_id !== financeMgrId || asset.location !== '深圳办公室 B-02') { console.error('[ERROR] asset card state mismatch', asset); process.exit(1); } if (assignments.length < 3 || openAssignments.length !== 1 || openAssignments[0].employee_id !== financeMgrId) { console.error('[ERROR] asset assignments state mismatch', assignments); process.exit(1); } if (!transfers.length || transfers[0].to_employee_id !== financeMgrId || transfers[0].to_location !== '深圳办公室 B-02') { console.error('[ERROR] asset transfer state mismatch', transfers); process.exit(1); } if (!inventoryCheck || inventoryCheck.status !== 'completed' || inventoryCheck.items.length !== 1 || inventoryCheck.items[0].asset_id !== assetId || inventoryCheck.items[0].result !== 'matched') { console.error('[ERROR] inventory check state mismatch', inventoryCheck); process.exit(1); } const requiredActions = [ 'ASSET_ASSIGNED', 'ASSET_RETURNED', 'ASSET_TRANSFERRED', 'INVENTORY_CHECK_CREATED', 'INVENTORY_CHECK_STARTED', 'INVENTORY_ITEM_RECORDED', 'INVENTORY_CHECK_COMPLETED', ]; for (const action of requiredActions) { if (!actionSet.has(action)) { console.error('[ERROR] missing audit action', action, auditActions); process.exit(1); } } } finally { await prisma.$disconnect(); await pool.end(); } })().catch((error) => { console.error(error); process.exit(1); }); NODE } echo "[1/10] login employee finance manager and administration" EMPLOYEE_TOKEN="$(login_user "$EMPLOYEE_IDENTIFIER" "$EMPLOYEE_PASSWORD" "$TMP_DIR/employee-login.json")" FINANCE_MGR_TOKEN="$(login_user "$FINANCE_MGR_IDENTIFIER" "$FINANCE_MGR_PASSWORD" "$TMP_DIR/finance-mgr-login.json")" ADMINISTRATION_TOKEN="$(login_user "$ADMINISTRATION_IDENTIFIER" "$ADMINISTRATION_PASSWORD" "$TMP_DIR/administration-login.json")" mapfile -t FIXTURE_IDS < <(resolve_fixture_ids) EMPLOYEE_ID="${FIXTURE_IDS[0]}" FINANCE_MGR_ID="${FIXTURE_IDS[1]}" ADMINISTRATION_ID="${FIXTURE_IDS[2]}" LEGAL_ENTITY_ID="${FIXTURE_IDS[3]}" COST_CENTER_ID="${FIXTURE_IDS[4]}" APPROVED_PURCHASE_ID="${FIXTURE_IDS[5]}" echo "[2/10] finance manager create and update asset category" CATEGORY_CREATE_BODY="$TMP_DIR/category-create.json" CATEGORY_CREATE_STATUS="$(api_json_auth POST "$BASE_URL/api/asset-categories" "$FINANCE_MGR_TOKEN" "$CATEGORY_CREATE_BODY" '{"code":"REG_ASSET_CAT_'"$(date +%s)'"'","name":"回归资产分类","enabled":true}')" assert_status "$CATEGORY_CREATE_STATUS" "200" "create asset category" "$CATEGORY_CREATE_BODY" CATEGORY_ID="$(jq -er '.data.assetCategory.id' "$CATEGORY_CREATE_BODY")" CATEGORY_PATCH_BODY="$TMP_DIR/category-patch.json" CATEGORY_PATCH_STATUS="$(api_json_auth PATCH "$BASE_URL/api/asset-categories/$CATEGORY_ID" "$FINANCE_MGR_TOKEN" "$CATEGORY_PATCH_BODY" '{"name":"回归资产分类-已更新"}')" assert_status "$CATEGORY_PATCH_STATUS" "200" "patch asset category" "$CATEGORY_PATCH_BODY" echo "[3/10] finance manager creates asset card" ASSET_CREATE_PAYLOAD="$(jq -n \ --arg name "回归资产卡片 $(date +%s)" \ --arg categoryId "$CATEGORY_ID" \ --arg legalEntityId "$LEGAL_ENTITY_ID" \ --arg costCenterId "$COST_CENTER_ID" \ --arg purchaseRequestId "$APPROVED_PURCHASE_ID" \ '{name:$name, categoryId:$categoryId, legalEntityId:$legalEntityId, costCenterId:$costCenterId, purchaseRequestId:($purchaseRequestId|select(length>0)), location:"深圳办公室 A-01", status:"in_stock"}')" ASSET_CREATE_BODY="$TMP_DIR/asset-create.json" ASSET_CREATE_STATUS="$(api_json_auth POST "$BASE_URL/api/assets" "$FINANCE_MGR_TOKEN" "$ASSET_CREATE_BODY" "$ASSET_CREATE_PAYLOAD")" assert_status "$ASSET_CREATE_STATUS" "200" "create asset card" "$ASSET_CREATE_BODY" ASSET_ID="$(jq -er '.data.asset.id' "$ASSET_CREATE_BODY")" echo "[4/10] direct assignee patch blocked to enforce controlled transfer" ASSET_PATCH_BODY="$TMP_DIR/asset-patch-invalid.json" ASSET_PATCH_STATUS="$(api_json_auth PATCH "$BASE_URL/api/assets/$ASSET_ID" "$FINANCE_MGR_TOKEN" "$ASSET_PATCH_BODY" '{"assigneeEmployeeId":"'"$EMPLOYEE_ID"'"}')" assert_status "$ASSET_PATCH_STATUS" "400" "patch asset assignee invalid" "$ASSET_PATCH_BODY" assert_jq "$ASSET_PATCH_BODY" '.error.code == "VALID_001"' "patch asset assignee invalid code" echo "[5/10] administration assign asset to employee" ASSET_ASSIGN_BODY="$TMP_DIR/asset-assign.json" ASSET_ASSIGN_STATUS="$(api_json_auth POST "$BASE_URL/api/assets/$ASSET_ID/assign" "$ADMINISTRATION_TOKEN" "$ASSET_ASSIGN_BODY" '{"employeeId":"'"$EMPLOYEE_ID"'","note":"首次领用"}')" assert_status "$ASSET_ASSIGN_STATUS" "200" "assign asset" "$ASSET_ASSIGN_BODY" assert_jq "$ASSET_ASSIGN_BODY" '.data.assigneeEmployeeId == "'"$EMPLOYEE_ID"'"' "assign asset assignee" echo "[6/10] employee only sees own asset and cannot transfer" EMPLOYEE_ASSETS_BODY="$TMP_DIR/employee-assets.json" EMPLOYEE_ASSETS_STATUS="$(api_json_auth GET "$BASE_URL/api/assets?mine=0" "$EMPLOYEE_TOKEN" "$EMPLOYEE_ASSETS_BODY")" assert_status "$EMPLOYEE_ASSETS_STATUS" "200" "employee assets mine forced" "$EMPLOYEE_ASSETS_BODY" assert_jq "$EMPLOYEE_ASSETS_BODY" '.data.mine == true' "employee mine forced" assert_jq "$EMPLOYEE_ASSETS_BODY" '.data.items[] | select(.id == "'"$ASSET_ID"'") | .assigneeEmployeeId == "'"$EMPLOYEE_ID"'"' "employee sees owned asset" EMPLOYEE_TRANSFER_BODY="$TMP_DIR/employee-transfer.json" EMPLOYEE_TRANSFER_STATUS="$(api_json_auth POST "$BASE_URL/api/assets/$ASSET_ID/transfer" "$EMPLOYEE_TOKEN" "$EMPLOYEE_TRANSFER_BODY" '{"toLocation":"深圳办公室 B-01"}')" assert_status "$EMPLOYEE_TRANSFER_STATUS" "403" "employee transfer forbidden" "$EMPLOYEE_TRANSFER_BODY" assert_jq "$EMPLOYEE_TRANSFER_BODY" '.error.code == "PERM_001"' "employee transfer forbidden code" echo "[7/10] administration return then assign asset again" ASSET_RETURN_BODY="$TMP_DIR/asset-return.json" ASSET_RETURN_STATUS="$(api_json_auth POST "$BASE_URL/api/assets/$ASSET_ID/return" "$ADMINISTRATION_TOKEN" "$ASSET_RETURN_BODY" '{"note":"归还入库"}')" assert_status "$ASSET_RETURN_STATUS" "200" "return asset" "$ASSET_RETURN_BODY" assert_jq "$ASSET_RETURN_BODY" '.data.assigneeEmployeeId == null' "return clears assignee" ASSET_REASSIGN_BODY="$TMP_DIR/asset-reassign.json" ASSET_REASSIGN_STATUS="$(api_json_auth POST "$BASE_URL/api/assets/$ASSET_ID/assign" "$ADMINISTRATION_TOKEN" "$ASSET_REASSIGN_BODY" '{"employeeId":"'"$EMPLOYEE_ID"'","note":"再次领用"}')" assert_status "$ASSET_REASSIGN_STATUS" "200" "reassign asset" "$ASSET_REASSIGN_BODY" echo "[8/10] administration transfers asset to finance manager and new location" ASSET_TRANSFER_BODY="$TMP_DIR/asset-transfer.json" ASSET_TRANSFER_STATUS="$(api_json_auth POST "$BASE_URL/api/assets/$ASSET_ID/transfer" "$ADMINISTRATION_TOKEN" "$ASSET_TRANSFER_BODY" '{"toEmployeeId":"'"$FINANCE_MGR_ID"'","toLocation":"深圳办公室 B-02","note":"部门间调拨"}')" assert_status "$ASSET_TRANSFER_STATUS" "200" "transfer asset" "$ASSET_TRANSFER_BODY" assert_jq "$ASSET_TRANSFER_BODY" '.data.assigneeEmployeeId == "'"$FINANCE_MGR_ID"'"' "transfer asset assignee" assert_jq "$ASSET_TRANSFER_BODY" '.data.location == "深圳办公室 B-02"' "transfer asset location" echo "[9/10] finance manager creates completes inventory check; employee forbidden" INVENTORY_CREATE_BODY="$TMP_DIR/inventory-create.json" INVENTORY_CREATE_STATUS="$(api_json_auth POST "$BASE_URL/api/inventory-checks" "$FINANCE_MGR_TOKEN" "$INVENTORY_CREATE_BODY" '{"scopeType":"cost_center","scopeRefId":"'"$COST_CENTER_ID"'"}')" assert_status "$INVENTORY_CREATE_STATUS" "200" "create inventory check" "$INVENTORY_CREATE_BODY" INVENTORY_CHECK_ID="$(jq -er '.data.inventoryCheck.id' "$INVENTORY_CREATE_BODY")" INVENTORY_START_BODY="$TMP_DIR/inventory-start.json" INVENTORY_START_STATUS="$(api_json_auth POST "$BASE_URL/api/inventory-checks/$INVENTORY_CHECK_ID/start" "$FINANCE_MGR_TOKEN" "$INVENTORY_START_BODY")" assert_status "$INVENTORY_START_STATUS" "200" "start inventory check" "$INVENTORY_START_BODY" INVENTORY_ITEM_BODY="$TMP_DIR/inventory-item.json" INVENTORY_ITEM_STATUS="$(api_json_auth POST "$BASE_URL/api/inventory-checks/$INVENTORY_CHECK_ID/items" "$FINANCE_MGR_TOKEN" "$INVENTORY_ITEM_BODY" '{"items":[{"assetId":"'"$ASSET_ID"'","result":"matched","note":"盘点一致"}]}')" assert_status "$INVENTORY_ITEM_STATUS" "200" "record inventory item" "$INVENTORY_ITEM_BODY" INVENTORY_COMPLETE_BODY="$TMP_DIR/inventory-complete.json" INVENTORY_COMPLETE_STATUS="$(api_json_auth POST "$BASE_URL/api/inventory-checks/$INVENTORY_CHECK_ID/complete" "$FINANCE_MGR_TOKEN" "$INVENTORY_COMPLETE_BODY")" assert_status "$INVENTORY_COMPLETE_STATUS" "200" "complete inventory check" "$INVENTORY_COMPLETE_BODY" EMPLOYEE_INVENTORY_BODY="$TMP_DIR/employee-inventory.json" EMPLOYEE_INVENTORY_STATUS="$(api_json_auth GET "$BASE_URL/api/inventory-checks" "$EMPLOYEE_TOKEN" "$EMPLOYEE_INVENTORY_BODY")" assert_status "$EMPLOYEE_INVENTORY_STATUS" "403" "employee inventory forbidden" "$EMPLOYEE_INVENTORY_BODY" assert_jq "$EMPLOYEE_INVENTORY_BODY" '.error.code == "PERM_001"' "employee inventory forbidden code" echo "[10/10] verify asset movement inventory db state and audit logs" assert_asset_state "$ASSET_ID" "$CATEGORY_ID" "$FINANCE_MGR_ID" "$INVENTORY_CHECK_ID" echo "[OK] Asset regression passed" echo "- assetCategoryId: $CATEGORY_ID" echo "- assetId: $ASSET_ID" echo "- inventoryCheckId: $INVENTORY_CHECK_ID"