docs/test(P19IM1-5,P19IM1-6): 收口IM协同文档矩阵与回归

这个提交包含在:
2026-03-08 13:14:30 +08:00
父节点 faff86c947
当前提交 cf6754819c
共修改 7 个文件,包含 675 行新增和 81 行删除
+4 -2
查看文件
@@ -15,6 +15,7 @@ RUN_VERIFICATION_REGRESSION="${RUN_VERIFICATION_REGRESSION:-0}"
RUN_DASHBOARD_REGRESSION="${RUN_DASHBOARD_REGRESSION:-0}"
RUN_BUDGET_REGRESSION="${RUN_BUDGET_REGRESSION:-0}"
RUN_INTEGRATIONS_REGRESSION="${RUN_INTEGRATIONS_REGRESSION:-0}"
RUN_IM_NOTIFIER_REGRESSION="${RUN_IM_NOTIFIER_REGRESSION:-1}"
RUN_FEATURE_FLAGS_REGRESSION="${RUN_FEATURE_FLAGS_REGRESSION:-0}"
JSON_REPORT_PATH=""
@@ -57,7 +58,7 @@ if [[ -n "$JSON_REPORT_PATH" ]]; then
trap rr_cleanup EXIT
fi
TOTAL_STEPS=14
TOTAL_STEPS=15
PASSED_STEPS=0
FAILED_STEPS=0
SKIPPED_STEPS=0
@@ -134,7 +135,8 @@ run_step 10 "verification" "Verification Regression" "$RUN_VERIFICATION_REGRESSI
run_step 11 "dashboard" "Dashboard Regression" "$RUN_DASHBOARD_REGRESSION" '"$SCRIPT_DIR"/regression-dashboard.sh' "RUN_DASHBOARD_REGRESSION=$RUN_DASHBOARD_REGRESSION"
run_step 12 "budget" "Budget Regression" "$RUN_BUDGET_REGRESSION" '"$SCRIPT_DIR"/regression-budget.sh' "RUN_BUDGET_REGRESSION=$RUN_BUDGET_REGRESSION"
run_step 13 "integrations" "Integrations Regression" "$RUN_INTEGRATIONS_REGRESSION" '"$SCRIPT_DIR"/regression-integrations.sh' "RUN_INTEGRATIONS_REGRESSION=$RUN_INTEGRATIONS_REGRESSION"
run_step 14 "feature_flags" "Feature Flags Regression" "$RUN_FEATURE_FLAGS_REGRESSION" '"$SCRIPT_DIR"/regression-feature-flags.sh' "RUN_FEATURE_FLAGS_REGRESSION=$RUN_FEATURE_FLAGS_REGRESSION"
run_step 14 "im_notifier" "IM Notifier Regression" "$RUN_IM_NOTIFIER_REGRESSION" '"$SCRIPT_DIR"/regression-im-notifier.sh' "RUN_IM_NOTIFIER_REGRESSION=$RUN_IM_NOTIFIER_REGRESSION"
run_step 15 "feature_flags" "Feature Flags Regression" "$RUN_FEATURE_FLAGS_REGRESSION" '"$SCRIPT_DIR"/regression-feature-flags.sh' "RUN_FEATURE_FLAGS_REGRESSION=$RUN_FEATURE_FLAGS_REGRESSION"
RUN_ENDED_MS="$(rr_now_ms)"
OVERALL_DURATION_MS="$((RUN_ENDED_MS - RUN_STARTED_MS))"
+529
查看文件
@@ -0,0 +1,529 @@
#!/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 ?? '[email protected]' },
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"