forked from wangziqi/gongxue-base
feat: add crm dead-letter operations and benchmark summary
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user