#!/usr/bin/env bash set -euo pipefail BASE_URL="${BASE_URL:-http://127.0.0.1:3000}" DEVICE_FINGERPRINT="${DEVICE_FINGERPRINT:-regression-im-notifier}" 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}" RUN_SUFFIX="$(date +%s)-$RANDOM" 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" 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'); const { resolveApplicableBudgetPoolByContext } = require('./src/services/budgetPool'); (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: { department_id: true, legal_entity_id: true }, }); const costCenter = await prisma.costCenter.findFirst({ where: { enabled: true, archived_at: null, legal_entity_id: employee.legal_entity_id, }, orderBy: { created_at: 'asc' }, select: { id: true }, }); const supplier = await prisma.supplier.findFirst({ where: { enabled: true, archived_at: null, OR: [{ legal_entity_id: employee.legal_entity_id }, { legal_entity_id: null }], }, orderBy: { created_at: 'asc' }, select: { id: true }, }); const documentCategory = await prisma.documentCategory.findFirst({ where: { enabled: true, archived_at: null, entity_type: 'purchase_request', }, orderBy: { created_at: 'asc' }, select: { id: true }, }); const budgetPool = await resolveApplicableBudgetPoolByContext(prisma, { departmentId: employee.department_id, legalEntityId: employee.legal_entity_id, costCenterId: costCenter?.id ?? null, effectiveDate: new Date(), }); process.stdout.write([ employee.legal_entity_id ?? '', costCenter?.id ?? '', supplier?.id ?? '', documentCategory?.id ?? '', budgetPool?.id ?? '', ].join('\n')); } finally { await prisma.$disconnect(); await pool.end(); } })().catch((error) => { console.error(error); process.exit(1); }); NODE } create_channel() { local token="$1" local name="$2" local mode="$3" local webhook_url="$4" local out_file="$5" local body body="$(jq -nc \ --arg name "$name" \ --arg mode "$mode" \ --arg webhookUrl "$webhook_url" \ '{ channelType:"wecom", name:$name, enabled:true, configJson:{ mode:$mode, webhookUrl:(if $webhookUrl == "" then null else $webhookUrl end), secret:null, timeoutMs:800, routing:{ roleCodes:["manager","ceo","admin"], domainScopes:["workflow"], allowedTypes:["workflow.task_assigned"] } }, templateJson:{ title:"{{title}}", body:"{{body}}\n\n查看详情:{{detailUrl}}", footer:"来自海音达工作台", deepLinkLabel:"查看详情" } }')" local status status="$(api_json_auth POST "$BASE_URL/api/notifications/channels" "$token" "$out_file" "$body")" assert_status "$status" "200" "create channel $name" "$out_file" jq -er '.data.channel.id' "$out_file" } patch_channel_enabled() { local token="$1" local channel_id="$2" local enabled="$3" local out_file="$4" local status status="$(api_json_auth PATCH "$BASE_URL/api/notifications/channels/$channel_id" "$token" "$out_file" "{\"enabled\":$enabled}")" assert_status "$status" "200" "patch channel $channel_id enabled=$enabled" "$out_file" } create_and_submit_purchase() { local token="$1" local label="$2" local out_prefix="$3" local legal_entity_id="$4" local cost_center_id="$5" local supplier_id="$6" local document_category_id="$7" local budget_pool_id="$8" local create_status create_status="$(api_json_auth POST "$BASE_URL/api/purchase-requests" "$token" "$TMP_DIR/${out_prefix}-create.json" '{}')" assert_status "$create_status" "200" "create purchase request $label" "$TMP_DIR/${out_prefix}-create.json" local purchase_id purchase_id="$(jq -er '.data.purchaseRequest.id' "$TMP_DIR/${out_prefix}-create.json")" local patch_body if [[ -n "$budget_pool_id" ]]; then patch_body="$(jq -nc \ --arg title "$label" \ --arg le "$legal_entity_id" \ --arg cc "$cost_center_id" \ --arg sp "$supplier_id" \ --arg dc "$document_category_id" \ --arg bp "$budget_pool_id" \ '{ title:$title, legalEntityId:$le, costCenterId:$cc, supplierId:$sp, documentCategoryId:$dc, budgetPoolId:$bp, items:[{description:"IM notifier regression", specification:"mock event", quantity:"1.00", unitPrice:"88.00", isAssetCandidate:false}] }')" else patch_body="$(jq -nc \ --arg title "$label" \ --arg le "$legal_entity_id" \ --arg cc "$cost_center_id" \ --arg sp "$supplier_id" \ --arg dc "$document_category_id" \ '{ title:$title, legalEntityId:$le, costCenterId:$cc, supplierId:$sp, documentCategoryId:$dc, items:[{description:"IM notifier regression", specification:"mock event", quantity:"1.00", unitPrice:"88.00", isAssetCandidate:false}] }')" fi local patch_status patch_status="$(api_json_auth PATCH "$BASE_URL/api/purchase-requests/$purchase_id" "$token" "$TMP_DIR/${out_prefix}-patch.json" "$patch_body")" assert_status "$patch_status" "200" "patch purchase request $label" "$TMP_DIR/${out_prefix}-patch.json" local submit_status submit_status="$(api_json_auth POST "$BASE_URL/api/purchase-requests/$purchase_id/submit" "$token" "$TMP_DIR/${out_prefix}-submit.json" '{}')" assert_status "$submit_status" "200" "submit purchase request $label" "$TMP_DIR/${out_prefix}-submit.json" assert_jq "$TMP_DIR/${out_prefix}-submit.json" '.data.status == "reviewing" and (.data.currentTaskId | length > 0)' "purchase submit reviewing $label" printf '%s\n' "$purchase_id" } query_im_outboxes_for_entity() { local entity_id="$1" local channel_ids_csv="$2" DATABASE_URL="$DATABASE_URL" ENTITY_ID="$entity_id" CHANNEL_IDS="$channel_ids_csv" node - <<'NODE' const { createPrismaClient } = require('./src/prismaClient'); (async () => { const entityId = process.env.ENTITY_ID; const channelIds = (process.env.CHANNEL_IDS || '').split(',').filter(Boolean); const { prisma, pool } = createPrismaClient(); await prisma.$connect(); try { const items = await prisma.integrationOutbox.findMany({ where: { event_type: 'notification.im' }, orderBy: { created_at: 'desc' }, take: 200, select: { id: true, status: true, last_error: true, payload: true, }, }); const filtered = items.filter((item) => { const payload = item.payload && typeof item.payload === 'object' ? item.payload : null; return payload && payload.entityId === entityId && payload.notificationType === 'workflow.task_assigned' && channelIds.includes(payload.channelId); }).map((item) => ({ id: item.id, status: item.status, lastError: item.last_error, channelId: item.payload.channelId, code: item.payload.notificationType, })); process.stdout.write(JSON.stringify(filtered)); } finally { await prisma.$disconnect(); await pool.end(); } })().catch((error) => { console.error(error); process.exit(1); }); NODE } enqueue_manual_im_outbox() { local channel_id="$1" DATABASE_URL="$DATABASE_URL" CHANNEL_ID="$channel_id" RUN_SUFFIX="$RUN_SUFFIX" node - <<'NODE' const { createPrismaClient } = require('./src/prismaClient'); const { enqueueIntegrationEvent } = require('./src/services/integrations/outbox'); (async () => { const { prisma, pool } = createPrismaClient(); await prisma.$connect(); try { const outbox = await enqueueIntegrationEvent(prisma, { eventType: 'notification.im', dedupeKey: `manual-im-disabled-${process.env.RUN_SUFFIX}`, maxAttempts: 5, actorId: null, payload: { channelId: process.env.CHANNEL_ID, channelType: 'wecom', notificationType: 'workflow.task_assigned', domainScope: 'workflow', title: '【待办】IM disabled regression', body: 'IM disabled regression body', targetType: 'workflow_task', targetId: `manual-task-${process.env.RUN_SUFFIX}`, targetPath: '/workflow/tasks/manual-task', detailUrl: 'https://app.example.com/workflow/tasks/manual-task', recipientEmployeeIds: [], recipientRoleCodes: ['manager'], entityType: 'purchase_request', entityId: `manual-pr-${process.env.RUN_SUFFIX}`, }, }); process.stdout.write(outbox.id); } finally { await prisma.$disconnect(); await pool.end(); } })().catch((error) => { console.error(error); process.exit(1); }); NODE } query_outbox_by_id() { local outbox_id="$1" DATABASE_URL="$DATABASE_URL" OUTBOX_ID="$outbox_id" node - <<'NODE' const { createPrismaClient } = require('./src/prismaClient'); (async () => { const { prisma, pool } = createPrismaClient(); await prisma.$connect(); try { const row = await prisma.integrationOutbox.findUnique({ where: { id: process.env.OUTBOX_ID }, select: { id: true, status: true, last_error: true, attempts: true, }, }); process.stdout.write(JSON.stringify(row)); } finally { await prisma.$disconnect(); await pool.end(); } })().catch((error) => { console.error(error); process.exit(1); }); NODE } dispatch_outboxes_by_ids() { local outbox_ids_csv="$1" local im_enabled="$2" DATABASE_URL="$DATABASE_URL" OUTBOX_IDS="$outbox_ids_csv" IM_NOTIFIER_ENABLED="$im_enabled" node - <<'NODE' require('dotenv/config'); const { createPrismaClient } = require('./src/prismaClient'); const { getConfig } = require('./src/config'); const { dispatchOutboxEvent } = require('./src/services/integrations/dispatcher'); const { markOutboxFailed, markOutboxProcessing, markOutboxSucceeded, } = require('./src/services/integrations/outbox'); (async () => { const ids = (process.env.OUTBOX_IDS || '').split(',').filter(Boolean); const config = getConfig(); const { prisma, pool } = createPrismaClient(); await prisma.$connect(); try { for (const id of ids) { const item = await prisma.integrationOutbox.findUnique({ where: { id }, select: { id: true, event_type: true, payload: true, attempts: true, max_attempts: true, }, }); if (!item) { throw new Error(`outbox_not_found:${id}`); } await markOutboxProcessing(prisma, item.id, null); try { await dispatchOutboxEvent({ prisma, config, eventType: item.event_type, payload: item.payload, }); await markOutboxSucceeded(prisma, item.id, null); } catch (error) { const retryable = typeof error?.retryable === 'boolean' ? error.retryable : true; await markOutboxFailed(prisma, config, item, error, null, { retryable }); console.error({ err: error, retryable, outboxId: item.id, eventType: item.event_type, channelId: item.payload?.channelId ?? null, channelType: item.payload?.channelType ?? null, notificationType: item.payload?.notificationType ?? null, }, 'Integration outbox dispatch failed'); } } } finally { await prisma.$disconnect(); await pool.end(); } })().catch((error) => { console.error(error); process.exit(1); }); NODE } echo "[1/7] login employee + finance_mgr" 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")" mapfile -t FIXTURE_IDS < <(resolve_fixture_ids) LEGAL_ENTITY_ID="${FIXTURE_IDS[0]}" COST_CENTER_ID="${FIXTURE_IDS[1]}" SUPPLIER_ID="${FIXTURE_IDS[2]}" DOCUMENT_CATEGORY_ID="${FIXTURE_IDS[3]}" BUDGET_POOL_ID="${FIXTURE_IDS[4]}" echo "[2/7] create mock + failing wecom channels" MOCK_CHANNEL_ID="$(create_channel "$FINANCE_MGR_TOKEN" "IM Mock ${RUN_SUFFIX}" "mock" "" "$TMP_DIR/create-mock-channel.json")" FAIL_CHANNEL_ID="$(create_channel "$FINANCE_MGR_TOKEN" "IM Failing ${RUN_SUFFIX}" "wecom_webhook" "http://127.0.0.1:9/wecom" "$TMP_DIR/create-failing-channel.json")" echo "[3/7] submit purchase request and verify IM outbox written" PURCHASE_ID_A="$(create_and_submit_purchase "$EMPLOYEE_TOKEN" "im notifier regression A ${RUN_SUFFIX}" "purchase-a" "$LEGAL_ENTITY_ID" "$COST_CENTER_ID" "$SUPPLIER_ID" "$DOCUMENT_CATEGORY_ID" "$BUDGET_POOL_ID")" query_im_outboxes_for_entity "$PURCHASE_ID_A" "$MOCK_CHANNEL_ID,$FAIL_CHANNEL_ID" > "$TMP_DIR/outboxes-a.json" assert_jq "$TMP_DIR/outboxes-a.json" 'length == 2 and all(.[]; .status == "pending")' "submit purchase enqueues two IM outboxes" echo "[4/7] run outbox job => mock succeeds, failing webhook does not block business transaction" OUTBOX_IDS_A="$(jq -r "map(.id) | join(\",\")" "$TMP_DIR/outboxes-a.json")" dispatch_outboxes_by_ids "$OUTBOX_IDS_A" "true" > "$TMP_DIR/outbox-job-1.log" query_im_outboxes_for_entity "$PURCHASE_ID_A" "$MOCK_CHANNEL_ID,$FAIL_CHANNEL_ID" > "$TMP_DIR/outboxes-a-after-job.json" assert_jq "$TMP_DIR/outboxes-a-after-job.json" 'map(.status) | sort == ["failed","succeeded"]' "outbox success and failure statuses" assert_jq "$TMP_DIR/outboxes-a-after-job.json" 'map(select(.status == "failed"))[0].lastError | type == "string" and length > 0' "failing webhook captured error" echo "[5/7] disable channels and verify business submission safely skips IM enqueue" patch_channel_enabled "$FINANCE_MGR_TOKEN" "$MOCK_CHANNEL_ID" "false" "$TMP_DIR/disable-mock-channel.json" patch_channel_enabled "$FINANCE_MGR_TOKEN" "$FAIL_CHANNEL_ID" "false" "$TMP_DIR/disable-fail-channel.json" PURCHASE_ID_B="$(create_and_submit_purchase "$EMPLOYEE_TOKEN" "im notifier regression B ${RUN_SUFFIX}" "purchase-b" "$LEGAL_ENTITY_ID" "$COST_CENTER_ID" "$SUPPLIER_ID" "$DOCUMENT_CATEGORY_ID" "$BUDGET_POOL_ID")" query_im_outboxes_for_entity "$PURCHASE_ID_B" "$MOCK_CHANNEL_ID,$FAIL_CHANNEL_ID" > "$TMP_DIR/outboxes-b.json" assert_jq "$TMP_DIR/outboxes-b.json" 'length == 0' "disabled channels should not enqueue IM outbox" echo "[6/7] verify IM_NOTIFIER_ENABLED=false marks outbox as skipped success" patch_channel_enabled "$FINANCE_MGR_TOKEN" "$MOCK_CHANNEL_ID" "true" "$TMP_DIR/re-enable-mock-channel.json" MANUAL_OUTBOX_ID="$(enqueue_manual_im_outbox "$MOCK_CHANNEL_ID")" dispatch_outboxes_by_ids "$MANUAL_OUTBOX_ID" "false" > "$TMP_DIR/outbox-job-im-disabled.log" query_outbox_by_id "$MANUAL_OUTBOX_ID" > "$TMP_DIR/manual-outbox.json" assert_jq "$TMP_DIR/manual-outbox.json" '.status == "succeeded" and .attempts == 0' "im disabled outbox skipped as succeeded" echo "[7/7] verify channel test API still works for saved channel" test_status="$(api_json_auth POST "$BASE_URL/api/notifications/channels/$MOCK_CHANNEL_ID/test" "$FINANCE_MGR_TOKEN" "$TMP_DIR/channel-test.json" '{}')" assert_status "$test_status" "200" "test channel by id" "$TMP_DIR/channel-test.json" assert_jq "$TMP_DIR/channel-test.json" '.data.testResult.ok == true and .data.testResult.code == "mock_sent"' "test channel endpoint mock sent" echo "[OK] IM notifier regression passed"