forked from wangziqi/gongxue-base
feat: add point activities and exchange
This commit is contained in:
@@ -55,6 +55,11 @@ const ids = {
|
||||
tenantCheckInBadge: '00000000-0000-0000-0000-000000000873',
|
||||
tenantScoreBadge: '00000000-0000-0000-0000-000000000874',
|
||||
tenantFeedbackBadge: '00000000-0000-0000-0000-000000000875',
|
||||
tenantActivityBadge: '00000000-0000-0000-0000-000000000876',
|
||||
pointActivityTask: '00000000-0000-0000-0000-000000000877',
|
||||
pointSystemTask: '00000000-0000-0000-0000-000000000878',
|
||||
pointExchangeItem: '00000000-0000-0000-0000-000000000879',
|
||||
pointExpensiveExchangeItem: '00000000-0000-0000-0000-000000000880',
|
||||
questionBank: '00000000-0000-0000-0000-000000000400',
|
||||
publicQuestionBankGrant: '00000000-0000-0000-0000-000000000906',
|
||||
};
|
||||
@@ -6811,11 +6816,245 @@ async function testTenantAdminOps() {
|
||||
assert.equal(partnerDenied.code, 'TENANT_ADMIN_REQUIRED', 'tenant admin must not administer another tenant without membership');
|
||||
}
|
||||
|
||||
async function testPointActivitiesAndExchange() {
|
||||
const activityBadge = await request('/api/tenant-admin/badges', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
method: 'PUT',
|
||||
body: {
|
||||
id: ids.tenantActivityBadge,
|
||||
legacyId: 'integration-badge-activity-auto',
|
||||
name: '集成测试活动任务勋章',
|
||||
description: '完成积分活动任务后自动发放',
|
||||
category: 'activity',
|
||||
iconUrl: 'https://example.test/badges/activity-auto.png',
|
||||
level: 1,
|
||||
unlockType: 'activity_reward',
|
||||
conditionField: 'activityRewardCount',
|
||||
conditionOperator: 'gte',
|
||||
conditionValue: 1,
|
||||
metadata: { source: 'integration-test', trigger: 'activity_reward' },
|
||||
order: 4,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
assert.equal(activityBadge.item?.id, ids.tenantActivityBadge, 'tenant admin should create activity reward badge');
|
||||
|
||||
const manualTask = await request('/api/tenant-admin/point-activity-tasks', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
method: 'PUT',
|
||||
body: {
|
||||
id: ids.pointActivityTask,
|
||||
legacyId: 'integration-point-task-manual',
|
||||
code: 'integration-manual-task',
|
||||
title: '集成测试积分任务',
|
||||
description: '用于验证学生端领取积分任务',
|
||||
taskType: 'manual',
|
||||
rewardPoints: 18,
|
||||
claimLimitPerUser: 1,
|
||||
periodType: 'once',
|
||||
metadata: { source: 'integration-test' },
|
||||
order: 1,
|
||||
status: 'active',
|
||||
},
|
||||
});
|
||||
assert.equal(manualTask.item?.id, ids.pointActivityTask, 'tenant admin should upsert point activity task');
|
||||
|
||||
const systemTask = await request('/api/tenant-admin/point-activity-tasks', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
method: 'PUT',
|
||||
body: {
|
||||
id: ids.pointSystemTask,
|
||||
legacyId: 'integration-point-task-feedback-resolved',
|
||||
code: 'integration-system-feedback-task',
|
||||
title: '集成测试系统发放任务',
|
||||
taskType: 'feedback_resolved',
|
||||
rewardPoints: 6,
|
||||
periodType: 'once',
|
||||
status: 'active',
|
||||
},
|
||||
});
|
||||
assert.equal(systemTask.item?.taskType, 'feedback_resolved', 'tenant admin should allow system-granted task config');
|
||||
|
||||
const tasks = await request('/api/profile/activity-tasks', { query: { limit: 20 } });
|
||||
assert.ok(tasks.items?.some(item => item.id === ids.pointActivityTask), 'student should list active point activity task');
|
||||
|
||||
const systemClaimDenied = await request('/api/profile/activity-tasks/claim', {
|
||||
method: 'POST',
|
||||
body: { taskId: ids.pointSystemTask },
|
||||
expectStatus: 403,
|
||||
});
|
||||
assert.equal(systemClaimDenied.code, 'POINT_TASK_SYSTEM_GRANTED_ONLY', 'student must not self-claim system workflow tasks');
|
||||
|
||||
const claimed = await request('/api/profile/activity-tasks/claim', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
taskId: ids.pointActivityTask,
|
||||
idempotencyKey: 'integration-manual-claim',
|
||||
metadata: { source: 'integration-test' },
|
||||
},
|
||||
});
|
||||
assert.equal(claimed.item?.pointsAdded, 18, 'claiming activity task should add configured points');
|
||||
assert.equal(claimed.item?.ledger?.eventType, 'activity_reward', 'activity task claim should write activity_reward ledger');
|
||||
assert.equal(claimed.item?.claim?.taskId, ids.pointActivityTask, 'activity claim should bind task id');
|
||||
assert.ok(
|
||||
claimed.item?.autoBadges?.some(item => item.badgeId === ids.tenantActivityBadge),
|
||||
'activity claim should auto grant activity badge',
|
||||
);
|
||||
|
||||
const duplicateClaim = await request('/api/profile/activity-tasks/claim', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
taskId: ids.pointActivityTask,
|
||||
idempotencyKey: 'integration-manual-claim',
|
||||
},
|
||||
expectStatus: 409,
|
||||
});
|
||||
assert.equal(duplicateClaim.code, 'POINT_TASK_CLAIM_LIMIT_REACHED', 'activity task should enforce claim limit');
|
||||
|
||||
const scoreEventsAfterClaim = await request('/api/profile/score-events', { query: { limit: 80 } });
|
||||
assert.ok(
|
||||
scoreEventsAfterClaim.items?.some(item => item.eventType === 'activity_reward' && item.sourceId === ids.pointActivityTask),
|
||||
'score ledger should include activity reward source',
|
||||
);
|
||||
|
||||
const activityClaims = await request('/api/tenant-admin/point-activity-claims', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
query: { taskId: ids.pointActivityTask, limit: 20 },
|
||||
});
|
||||
assert.ok(activityClaims.items?.some(item => item.userId === USER_ID), 'tenant admin should list activity claims');
|
||||
|
||||
const exchangeCoupon = await request('/api/tenant-admin/coupons', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
method: 'PUT',
|
||||
body: {
|
||||
code: 'IT-POINT-EXCHANGE-COUPON',
|
||||
planId: ids.couponOnlyPlan,
|
||||
discountType: 'fixed',
|
||||
discountValue: 100,
|
||||
maxUses: 10,
|
||||
perUserLimit: 10,
|
||||
campaignName: 'integration-point-exchange',
|
||||
metadata: { source: 'integration-test' },
|
||||
source: 'points-exchange',
|
||||
},
|
||||
});
|
||||
assert.equal(exchangeCoupon.item?.code, 'IT-POINT-EXCHANGE-COUPON', 'tenant admin should create coupon for point exchange');
|
||||
|
||||
const exchangeItem = await request('/api/tenant-admin/point-exchange-items', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
method: 'PUT',
|
||||
body: {
|
||||
id: ids.pointExchangeItem,
|
||||
legacyId: 'integration-point-exchange-item',
|
||||
code: 'integration-coupon-exchange',
|
||||
title: '集成测试积分兑换券',
|
||||
description: '用积分兑换优惠券',
|
||||
costPoints: 12,
|
||||
itemType: 'coupon',
|
||||
couponId: exchangeCoupon.item.id,
|
||||
stockTotal: 1,
|
||||
perUserLimit: 1,
|
||||
metadata: { source: 'integration-test' },
|
||||
status: 'active',
|
||||
},
|
||||
});
|
||||
assert.equal(exchangeItem.item?.id, ids.pointExchangeItem, 'tenant admin should upsert point exchange item');
|
||||
|
||||
const exchangeList = await request('/api/profile/exchange-items', { query: { limit: 20 } });
|
||||
assert.ok(exchangeList.items?.some(item => item.id === ids.pointExchangeItem), 'student should list active exchange item');
|
||||
|
||||
const redeemed = await request('/api/profile/exchange-items/redeem', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
itemId: ids.pointExchangeItem,
|
||||
idempotencyKey: 'integration-exchange-redeem',
|
||||
metadata: { source: 'integration-test' },
|
||||
},
|
||||
});
|
||||
assert.equal(redeemed.item?.pointsSpent, 12, 'exchange should spend configured points');
|
||||
assert.equal(redeemed.item?.ledger?.eventType, 'redeem_cost', 'exchange should write redeem_cost ledger');
|
||||
assert.equal(redeemed.item?.order?.itemId, ids.pointExchangeItem, 'exchange order should bind item');
|
||||
assert.ok(redeemed.item?.couponRedemption?.id, 'coupon exchange should create coupon redemption');
|
||||
|
||||
const repeatedRedeem = await request('/api/profile/exchange-items/redeem', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
itemId: ids.pointExchangeItem,
|
||||
idempotencyKey: 'integration-exchange-redeem',
|
||||
},
|
||||
});
|
||||
assert.equal(repeatedRedeem.item?.idempotent, true, 'same exchange idempotency key should return existing order');
|
||||
|
||||
const redeemLimitDenied = await request('/api/profile/exchange-items/redeem', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
itemId: ids.pointExchangeItem,
|
||||
idempotencyKey: 'integration-exchange-redeem-2',
|
||||
},
|
||||
expectStatus: 409,
|
||||
});
|
||||
assert.equal(redeemLimitDenied.code, 'POINT_EXCHANGE_USER_LIMIT_REACHED', 'exchange should enforce per-user limit');
|
||||
|
||||
const stockDenied = await request('/api/profile/exchange-items/redeem', {
|
||||
userId: SECOND_STUDENT_USER_ID,
|
||||
method: 'POST',
|
||||
body: {
|
||||
itemId: ids.pointExchangeItem,
|
||||
idempotencyKey: 'integration-exchange-second-student',
|
||||
},
|
||||
expectStatus: 409,
|
||||
});
|
||||
assert.equal(stockDenied.code, 'POINT_EXCHANGE_OUT_OF_STOCK', 'exchange should enforce stock before charging another user');
|
||||
|
||||
const expensiveItem = await request('/api/tenant-admin/point-exchange-items', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
method: 'PUT',
|
||||
body: {
|
||||
id: ids.pointExpensiveExchangeItem,
|
||||
legacyId: 'integration-point-exchange-expensive',
|
||||
code: 'integration-expensive-exchange',
|
||||
title: '集成测试高积分兑换',
|
||||
costPoints: 9999,
|
||||
itemType: 'manual',
|
||||
stockTotal: 20,
|
||||
perUserLimit: 1,
|
||||
status: 'active',
|
||||
},
|
||||
});
|
||||
assert.equal(expensiveItem.item?.id, ids.pointExpensiveExchangeItem, 'tenant admin should create high-cost exchange item');
|
||||
|
||||
const insufficientScoreDenied = await request('/api/profile/exchange-items/redeem', {
|
||||
userId: SECOND_STUDENT_USER_ID,
|
||||
method: 'POST',
|
||||
body: {
|
||||
itemId: ids.pointExpensiveExchangeItem,
|
||||
idempotencyKey: 'integration-expensive-second-student',
|
||||
},
|
||||
expectStatus: 409,
|
||||
});
|
||||
assert.equal(insufficientScoreDenied.code, 'POINT_EXCHANGE_INSUFFICIENT_SCORE', 'exchange should reject insufficient score balance');
|
||||
|
||||
const exchangeOrders = await request('/api/tenant-admin/point-exchange-orders', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
query: { itemId: ids.pointExchangeItem, limit: 20 },
|
||||
});
|
||||
assert.ok(exchangeOrders.items?.some(item => item.userId === USER_ID), 'tenant admin should list exchange orders');
|
||||
|
||||
const partnerExchangeDenied = await request('/api/tenant-admin/point-exchange-items', {
|
||||
tenantId: PARTNER_TENANT_ID,
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
expectStatus: 403,
|
||||
});
|
||||
assert.equal(partnerExchangeDenied.code, 'TENANT_ADMIN_REQUIRED', 'point exchange admin must be tenant isolated');
|
||||
}
|
||||
|
||||
async function testTenantMemberPermissionsAndAudit() {
|
||||
const permissionMatrix = await request('/api/tenant-admin/permissions', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
});
|
||||
assert.ok(permissionMatrix.permissions?.some(item => item.key === 'marketing:write'), 'permission matrix should expose marketing permission');
|
||||
assert.ok(permissionMatrix.permissions?.some(item => item.key === 'marketing:points:read'), 'permission matrix should expose point marketing read permission');
|
||||
assert.ok(permissionMatrix.permissions?.some(item => item.key === 'marketing:points:write'), 'permission matrix should expose point marketing write permission');
|
||||
assert.ok(permissionMatrix.permissions?.some(item => item.key === 'dashboard:read'), 'permission matrix should expose dashboard read permission');
|
||||
assert.ok(permissionMatrix.permissions?.some(item => item.key === 'roles:write'), 'permission matrix should expose role template permission');
|
||||
assert.ok(permissionMatrix.permissions?.some(item => item.key === 'badges:grant'), 'permission matrix should expose badge grant permission');
|
||||
@@ -7927,6 +8166,7 @@ async function main() {
|
||||
await check('tenant content assets and imports', testTenantContentAssetsAndImports);
|
||||
await check('public question bank adoption', testPublicQuestionBankAdoption);
|
||||
await check('tenant admin operations', testTenantAdminOps);
|
||||
await check('point activities and exchange', testPointActivitiesAndExchange);
|
||||
await check('tenant member permissions and audit', testTenantMemberPermissionsAndAudit);
|
||||
await check('tenant class and student scopes', testTenantClassStudentScopes);
|
||||
await check('tenant student operations', testTenantStudentOperations);
|
||||
|
||||
Reference in New Issue
Block a user