feat: add crm dead-letter operations and benchmark summary

This commit is contained in:
Codex
2026-06-30 23:07:43 +08:00
parent 90ad9e90a3
commit 9891fe9ef3
22 changed files with 1284 additions and 110 deletions

View File

@@ -76,6 +76,7 @@ const ids = {
publicQuestionBankGrant: '00000000-0000-0000-0000-000000000906',
partnerSubscription: '00000000-0000-0000-0000-000000000902',
platformOverdueInvoice: crypto.randomUUID(),
crmDeadLetterQueue: crypto.randomUUID(),
};
const paymentFixture = (() => {
@@ -3235,6 +3236,7 @@ async function testProfile() {
}
async function testLearningLeaderboard() {
await setTenantFeatureFlag(MAIN_TENANT_ID, 'enableLeaderboard', false);
const disabled = await request('/api/learning/leaderboard', {
query: { metric: 'questions', period: 'all', limit: 10 },
expectStatus: 403,
@@ -3242,55 +3244,69 @@ async function testLearningLeaderboard() {
assert.equal(disabled.code, 'LEADERBOARD_DISABLED', 'leaderboard should be disabled by default for tenants');
await setTenantFeatureFlag(MAIN_TENANT_ID, 'enableLeaderboard', true);
try {
const questions = await request('/api/learning/leaderboard', {
query: { metric: 'questions', period: 'all', limit: 10 },
});
assert.equal(questions.metric, 'questions', 'question leaderboard should echo metric');
assert.ok(Array.isArray(questions.items), 'question leaderboard should return list items');
const currentQuestions = questions.currentUser;
assert.equal(currentQuestions?.userId, USER_ID, 'question leaderboard should include current user rank');
assert.equal(currentQuestions?.isCurrentUser, true, 'current user rank should be flagged');
const questions = await request('/api/learning/leaderboard', {
query: { metric: 'questions', period: 'all', limit: 10 },
});
assert.equal(questions.metric, 'questions', 'question leaderboard should echo metric');
assert.ok(questions.items?.some(item => item.userId === SECOND_STUDENT_USER_ID), 'question leaderboard should include second student');
assert.ok(questions.items?.some(item => item.userId === USER_ID), 'question leaderboard should include current student');
const secondQuestions = questions.items?.find(item => item.userId === SECOND_STUDENT_USER_ID);
const currentQuestions = questions.currentUser;
assert.ok(secondQuestions?.value >= currentQuestions?.value, 'second student should rank at least as high as smoke user by questions');
assert.equal(currentQuestions?.userId, USER_ID, 'question leaderboard should include current user rank');
assert.equal(currentQuestions?.isCurrentUser, true, 'current user rank should be flagged');
const classScoped = await request('/api/learning/leaderboard', {
query: { metric: 'questions', classId: ids.tenantClass, limit: 10 },
});
assert.ok(classScoped.items?.some(item => item.userId === USER_ID), 'class scoped leaderboard should include class student');
assert.ok(!classScoped.items?.some(item => item.userId === SECOND_STUDENT_USER_ID), 'class scoped leaderboard should exclude other class student');
const score = await request('/api/learning/leaderboard', {
query: { metric: 'score', period: 'all', limit: 10 },
});
const scoreLeader = score.items?.find(item => item.userId === SECOND_STUDENT_USER_ID);
assert.ok(scoreLeader?.value >= 30, 'score leaderboard should use platform user score');
assert.ok(score.currentUser?.value >= 10, 'score leaderboard should include current user score');
const secondClassScoped = await request('/api/learning/leaderboard', {
query: { metric: 'questions', classId: ids.tenantClassOther, limit: 10 },
});
const secondQuestions = secondClassScoped.items?.find(item => item.userId === SECOND_STUDENT_USER_ID);
assert.ok(secondQuestions, 'second student class scoped leaderboard should include second student');
assert.ok(secondQuestions?.value >= currentQuestions?.value, 'second student should rank at least as high as smoke user by questions');
const vocabulary = await request('/api/learning/leaderboard', {
query: { metric: 'vocabulary', period: 'all', limit: 10 },
});
const vocabularyLeader = vocabulary.items?.find(item => item.userId === SECOND_STUDENT_USER_ID);
assert.equal(vocabularyLeader?.value, 2, 'vocabulary leaderboard should count mastered words');
const score = await request('/api/learning/leaderboard', {
query: { metric: 'score', period: 'all', classId: ids.tenantClassOther, limit: 10 },
});
const scoreLeader = score.items?.find(item => item.userId === SECOND_STUDENT_USER_ID);
assert.ok(scoreLeader?.value >= 30, 'score leaderboard should use platform user score');
const mockExam = await request('/api/learning/leaderboard', {
query: { metric: 'mock_exam', period: '30d', limit: 10 },
});
const mockLeader = mockExam.items?.find(item => item.userId === SECOND_STUDENT_USER_ID);
assert.equal(mockLeader?.value, 95, 'mock exam leaderboard should use best report score');
assert.ok(mockExam.currentUser?.value >= 70, 'mock exam leaderboard should include current user best score');
const currentScore = await request('/api/learning/leaderboard', {
query: { metric: 'score', period: 'all', classId: ids.tenantClass, limit: 10 },
});
assert.ok(currentScore.currentUser?.value >= 10, 'score leaderboard should include current user score');
const classScoped = await request('/api/learning/leaderboard', {
query: { metric: 'questions', classId: ids.tenantClass, limit: 10 },
});
assert.ok(classScoped.items?.some(item => item.userId === USER_ID), 'class scoped leaderboard should include class student');
assert.ok(!classScoped.items?.some(item => item.userId === SECOND_STUDENT_USER_ID), 'class scoped leaderboard should exclude other class student');
const vocabulary = await request('/api/learning/leaderboard', {
query: { metric: 'vocabulary', period: 'all', classId: ids.tenantClassOther, limit: 10 },
});
const vocabularyLeader = vocabulary.items?.find(item => item.userId === SECOND_STUDENT_USER_ID);
assert.equal(vocabularyLeader?.value, 2, 'vocabulary leaderboard should count mastered words');
const trustedLogin = await loginBySms('13800000000');
const crossTenantDenied = await request('/api/learning/leaderboard', {
tenantId: PARTNER_TENANT_ID,
userId: false,
headers: { authorization: `Bearer ${trustedLogin.session.token}` },
expectStatus: 403,
});
assert.equal(crossTenantDenied.code, 'AUTH_TENANT_MISMATCH', 'leaderboard must reject trusted session cross-tenant access');
const mockExam = await request('/api/learning/leaderboard', {
query: { metric: 'mock_exam', period: '30d', classId: ids.tenantClassOther, limit: 10 },
});
const mockLeader = mockExam.items?.find(item => item.userId === SECOND_STUDENT_USER_ID);
assert.equal(mockLeader?.value, 95, 'mock exam leaderboard should use best report score');
await setTenantFeatureFlag(MAIN_TENANT_ID, 'enableLeaderboard', false);
const currentMockExam = await request('/api/learning/leaderboard', {
query: { metric: 'mock_exam', period: '30d', classId: ids.tenantClass, limit: 10 },
});
assert.ok(currentMockExam.currentUser?.value >= 70, 'mock exam leaderboard should include current user best score');
const trustedLogin = await loginBySms('13800000000');
const crossTenantDenied = await request('/api/learning/leaderboard', {
tenantId: PARTNER_TENANT_ID,
userId: false,
headers: { authorization: `Bearer ${trustedLogin.session.token}` },
expectStatus: 403,
});
assert.equal(crossTenantDenied.code, 'AUTH_TENANT_MISMATCH', 'leaderboard must reject trusted session cross-tenant access');
} finally {
await setTenantFeatureFlag(MAIN_TENANT_ID, 'enableLeaderboard', false);
}
const disabledAfterRestore = await request('/api/learning/leaderboard', {
query: { metric: 'questions', period: 'all', limit: 10 },
expectStatus: 403,
@@ -10363,6 +10379,170 @@ async function testReferralAndCrmGrowth() {
);
assert.ok(!JSON.stringify(crmQueue).includes('crm-secret-smoke'), 'CRM queue should not leak secret');
const crmDeadLetterPool = new pg.Pool({ connectionString: process.env.DATABASE_URL || DEFAULT_DATABASE_URL });
try {
await crmDeadLetterPool.query(
`
insert into public.crm_webhook_queue (
id, tenant_id, record_id, status, scheduled_at, attempts, next_attempt_at,
last_error, last_http_code, lead_id, source, payload, idempotency_key,
target_url, provider, last_attempt_at, last_response_summary, dead_lettered_at
)
values (
$1::uuid, $2, $3, 'failed', now(), 3, null,
$4, 500, $5, 'tenant.student.crm_push',
$6::jsonb, $7, $8, 'dingtalk', now(), $9, now()
)
on conflict (id) do update set status = excluded.status,
last_error = excluded.last_error,
target_url = excluded.target_url,
payload = excluded.payload,
dead_lettered_at = now(),
updated_at = now()
`,
[
ids.crmDeadLetterQueue,
MAIN_TENANT_ID,
USER_ID,
'HTTP 500 access_token=dead-letter-secret should be redacted',
manual.lead.item.id,
JSON.stringify({
eventType: 'student.crm_push',
token: 'payload-secret-token',
student: { userId: USER_ID, avatarPreset: 'male' },
}),
'integration-crm-dead-letter',
'https://oapi.dingtalk.com/robot/send?access_token=dead-letter-secret',
'response contains access_token=dead-letter-secret',
],
);
await crmDeadLetterPool.query(
`
insert into public.crm_webhook_log (
tenant_id, queue_id, record_id, http_code, outcome, error_message, lead_id,
request_body, request_payload, response_summary, signed_at, attempt
)
values (
$1, $2::uuid, $3, 500, 'failed', $4, $5,
$6, $7::jsonb, $8, now(), 3
)
`,
[
MAIN_TENANT_ID,
ids.crmDeadLetterQueue,
USER_ID,
'access_token=dead-letter-secret failed',
manual.lead.item.id,
'{"access_token":"dead-letter-secret","student":{"avatarPreset":"male"}}',
JSON.stringify({ url: 'https://oapi.dingtalk.com/robot/send?access_token=dead-letter-secret', token: 'dead-letter-secret' }),
'remote response access_token=dead-letter-secret',
],
);
} finally {
await crmDeadLetterPool.end();
}
const crmDeadLetters = await request('/api/crm/dead-letters', {
userId: TENANT_ADMIN_USER_ID,
query: { limit: 10 },
});
const deadLetterItem = crmDeadLetters.items?.find(item => item.id === ids.crmDeadLetterQueue);
assert.ok(deadLetterItem, 'CRM dead-letter list should include failed task');
assert.equal(crmDeadLetters.summary?.failed >= 1, true, 'CRM dead-letter summary should count failed tasks');
assert.ok(!JSON.stringify(crmDeadLetters).includes('dead-letter-secret'), 'CRM dead-letter list must redact webhook secrets');
assert.equal(deadLetterItem?.payload?.token, '[redacted]', 'CRM dead-letter payload should redact token fields');
const crmDeadLetterLogs = await request('/api/crm/queue/logs', {
userId: TENANT_ADMIN_USER_ID,
query: { queueId: ids.crmDeadLetterQueue, limit: 10 },
});
assert.ok(crmDeadLetterLogs.items?.some(item => item.outcome === 'failed'), 'CRM queue logs should include failed attempts');
assert.ok(!JSON.stringify(crmDeadLetterLogs).includes('dead-letter-secret'), 'CRM queue logs must redact webhook secrets');
const crmRetryDenied = await request('/api/crm/queue/action', {
userId: TENANT_OPERATOR_USER_ID,
method: 'POST',
body: {
queueId: ids.crmDeadLetterQueue,
action: 'retry',
},
expectStatus: 403,
});
assert.equal(crmRetryDenied.code, 'TENANT_PERMISSION_REQUIRED', 'CRM retry should require crm:write');
const crmCrossTenantDenied = await request('/api/crm/queue/logs', {
tenantId: PARTNER_TENANT_ID,
userId: PARTNER_TENANT_ADMIN_USER_ID,
query: { queueId: ids.crmDeadLetterQueue },
expectStatus: 404,
});
assert.equal(crmCrossTenantDenied.code, 'CRM_QUEUE_TASK_NOT_FOUND', 'CRM queue logs must be tenant isolated');
const crmRetried = await request('/api/crm/queue/action', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
queueId: ids.crmDeadLetterQueue,
action: 'retry',
note: 'integration retry',
metadata: { source: 'integration-test', accessToken: 'dead-letter-secret' },
},
});
assert.equal(crmRetried.item?.status, 'pending', 'CRM retry should reset failed task to pending');
assert.equal(crmRetried.item?.lastError, null, 'CRM retry should clear last error');
assert.equal(crmRetried.item?.lastOperatorAction, 'retry', 'CRM retry should record operator action');
assert.ok(!JSON.stringify(crmRetried).includes('dead-letter-secret'), 'CRM retry response must redact metadata secrets');
const crmRetriedLogs = await request('/api/crm/queue/logs', {
userId: TENANT_ADMIN_USER_ID,
query: { queueId: ids.crmDeadLetterQueue, limit: 10 },
});
assert.ok(crmRetriedLogs.items?.some(item => item.operation === 'retry'), 'CRM retry should append operator log');
assert.ok(!JSON.stringify(crmRetriedLogs).includes('dead-letter-secret'), 'CRM retry operator log must redact metadata secrets');
const crmIgnorePending = await request('/api/crm/queue/action', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
queueId: ids.crmDeadLetterQueue,
action: 'ignore',
},
expectStatus: 409,
});
assert.equal(crmIgnorePending.code, 'CRM_QUEUE_STATUS_NOT_IGNORABLE', 'CRM ignore should only apply to dead-letter tasks');
const crmDeadLetterResetPool = new pg.Pool({ connectionString: process.env.DATABASE_URL || DEFAULT_DATABASE_URL });
try {
await crmDeadLetterResetPool.query(
`
update public.crm_webhook_queue
set status = 'failed',
attempts = 4,
last_error = 'retry failed with access_token=dead-letter-secret',
last_http_code = 500,
dead_lettered_at = now(),
updated_at = now()
where tenant_id = $1 and id = $2::uuid
`,
[MAIN_TENANT_ID, ids.crmDeadLetterQueue],
);
} finally {
await crmDeadLetterResetPool.end();
}
const crmIgnored = await request('/api/crm/queue/action', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
queueId: ids.crmDeadLetterQueue,
action: 'ignore',
note: 'integration ignored after manual review',
},
});
assert.equal(crmIgnored.item?.status, 'discarded', 'CRM ignore should move failed task to discarded');
assert.equal(crmIgnored.item?.ignoredBy, TENANT_ADMIN_USER_ID, 'CRM ignore should record operator');
assert.equal(crmIgnored.item?.lastOperatorAction, 'ignore', 'CRM ignore should record operator action');
const partnerReferralDenied = await request('/api/referral/sales-stats', {
tenantId: PARTNER_TENANT_ID,
userId: TENANT_SALES_USER_ID,