feat: manage tenant badges

This commit is contained in:
Codex
2026-06-29 07:04:53 +08:00
parent 4954ad3ca9
commit 78a26d1df2
19 changed files with 650 additions and 38 deletions

View File

@@ -49,6 +49,7 @@ const ids = {
tenantClassOther: '00000000-0000-0000-0000-000000000852',
examDate: '00000000-0000-0000-0000-000000000861',
tenantExamDate: '00000000-0000-0000-0000-000000000862',
tenantBadge: '00000000-0000-0000-0000-000000000871',
questionBank: '00000000-0000-0000-0000-000000000400',
publicQuestionBankGrant: '00000000-0000-0000-0000-000000000906',
};
@@ -3562,6 +3563,181 @@ async function testTenantAdminOps() {
});
assert.ok(publicExamDates.items?.some(item => item.id === ids.tenantExamDate), 'public catalog should expose tenant admin exam date');
const studentBadgeAdminDenied = await request('/api/tenant-admin/badges', {
expectStatus: 403,
});
assert.equal(studentBadgeAdminDenied.code, 'TENANT_ADMIN_REQUIRED', 'student should not access badge admin');
const badge = await request('/api/tenant-admin/badges', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
id: ids.tenantBadge,
legacyId: 'integration-badge-first-practice',
name: '集成测试首练勋章',
description: '完成首次练习后展示',
category: 'practice',
iconUrl: 'https://example.test/badges/first-practice.png',
level: 1,
unlockType: 'manual',
conditionField: 'practice.completedCount',
conditionOperator: 'gte',
conditionValue: 1,
conditionExtra: { mode: 'any' },
metadata: { source: 'integration-test' },
order: 3,
isActive: true,
},
});
assert.equal(badge.item?.id, ids.tenantBadge, 'tenant admin should create badge with stable id');
assert.equal(badge.item?.category, 'practice', 'badge should persist category');
const badgeByLegacy = await request('/api/tenant-admin/badges', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
legacyId: 'integration-badge-first-practice',
name: '集成测试首练勋章升级',
description: '同 legacyId 应幂等更新原勋章',
category: 'practice',
iconUrl: 'https://example.test/badges/first-practice-v2.png',
level: 2,
unlockType: 'manual',
conditionField: 'practice.completedCount',
conditionOperator: 'gte',
conditionValue: 1,
conditionExtra: { mode: 'any', version: 2 },
metadata: { source: 'integration-test', updated: true },
order: 2,
isActive: true,
},
});
assert.equal(badgeByLegacy.item?.id, ids.tenantBadge, 'badge legacyId should update existing badge idempotently');
assert.equal(badgeByLegacy.item?.level, 2, 'badge update should persist new level');
const badgeConflict = await request('/api/tenant-admin/badges', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
id: '00000000-0000-0000-0000-000000000872',
legacyId: 'integration-badge-first-practice',
name: '冲突勋章',
},
expectStatus: 409,
});
assert.equal(badgeConflict.code, 'BADGE_ID_CONFLICT', 'badge id and legacyId conflict should be explicit');
const invalidBadgeCategory = await request('/api/tenant-admin/badges', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
name: '非法分类勋章',
category: 'unsafe-category',
},
expectStatus: 400,
});
assert.equal(invalidBadgeCategory.code, 'INVALID_FIELD_VALUE', 'badge admin should reject invalid category');
const invalidBadgeNumber = await request('/api/tenant-admin/badges', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
name: '非法数值勋章',
category: 'practice',
conditionValue: 'not-a-number',
},
expectStatus: 400,
});
assert.equal(invalidBadgeNumber.code, 'INVALID_NUMBER', 'badge admin should reject invalid numeric condition');
const invalidBadgeId = await request('/api/tenant-admin/badges', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
id: 'not-a-uuid',
name: '非法 ID 勋章',
},
expectStatus: 400,
});
assert.equal(invalidBadgeId.code, 'INVALID_UUID', 'badge admin should reject invalid badge id before database cast');
const badges = await request('/api/tenant-admin/badges', {
userId: TENANT_ADMIN_USER_ID,
query: { category: 'practice', includeInactive: 'true', limit: 20 },
});
assert.ok(badges.items?.some(item => item.id === ids.tenantBadge), 'tenant admin should list badge by category');
const grant = await request('/api/tenant-admin/badge-grants', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
badgeId: ids.tenantBadge,
userId: USER_ID,
note: '集成测试手动授予',
metadata: { source: 'integration-test', firstGrant: true },
},
});
assert.equal(grant.item?.badgeId, ids.tenantBadge, 'tenant admin should grant badge');
assert.equal(grant.item?.userId, USER_ID, 'badge grant should target student');
const repeatedGrant = await request('/api/tenant-admin/badge-grants', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
badgeId: ids.tenantBadge,
userId: USER_ID,
note: '重复授予不应产生重复记录',
metadata: { source: 'integration-test-repeat', repeated: true },
},
});
assert.equal(repeatedGrant.item?.id, grant.item?.id, 'repeated badge grant should be idempotent');
const badgeGrants = await request('/api/tenant-admin/badge-grants', {
userId: TENANT_ADMIN_USER_ID,
query: { userId: USER_ID, badgeId: ids.tenantBadge, limit: 20 },
});
assert.equal(
badgeGrants.items?.filter(item => item.userId === USER_ID && item.badgeId === ids.tenantBadge).length,
1,
'badge grants should not duplicate per user and badge',
);
const profileBadges = await request('/api/profile/badges', {
query: { includeLocked: 'true', category: 'practice', limit: 20 },
});
const profileBadge = profileBadges.items?.find(item => item.badgeId === ids.tenantBadge);
assert.equal(profileBadge?.unlocked, true, 'profile badges should expose granted badge as unlocked');
assert.equal(profileBadges.summary?.unlocked >= 1, true, 'profile badges summary should count unlocked badges');
const profileBadgesInvalid = await request('/api/profile/badges', {
query: { category: 'unsafe-category' },
expectStatus: 400,
});
assert.equal(profileBadgesInvalid.code, 'INVALID_BADGE_CATEGORY', 'profile badges should reject invalid category');
const badgeGrantInvalidId = await request('/api/tenant-admin/badge-grants', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
badgeId: 'not-a-uuid',
userId: USER_ID,
},
expectStatus: 400,
});
assert.equal(badgeGrantInvalidId.code, 'INVALID_UUID', 'badge grant should reject invalid UUID input');
const badgeCrossTenantDenied = await request('/api/tenant-admin/badge-grants', {
tenantId: PARTNER_TENANT_ID,
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
badgeId: ids.tenantBadge,
userId: USER_ID,
},
expectStatus: 403,
});
assert.equal(badgeCrossTenantDenied.code, 'TENANT_ADMIN_REQUIRED', 'badge grant must be tenant isolated');
const feedbacksBefore = await request('/api/tenant-admin/feedbacks', {
userId: TENANT_ADMIN_USER_ID,
query: { status: 'pending', limit: 20 },
@@ -3705,9 +3881,11 @@ async function testTenantMemberPermissionsAndAudit() {
assert.ok(permissionMatrix.permissions?.some(item => item.key === 'marketing:write'), 'permission matrix should expose marketing 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');
assert.ok(permissionMatrix.menuGroups?.some(item => item.key === 'sales'), 'permission matrix should expose menu groups');
assert.ok(permissionMatrix.roleDefaults?.tenant_operator?.includes('dashboard:read'), 'tenant operator defaults should include dashboard read');
assert.ok(permissionMatrix.roleDefaults?.tenant_operator?.includes('marketing:*'), 'permission matrix should include role defaults');
assert.ok(permissionMatrix.roleDefaults?.tenant_operator?.includes('badges:*'), 'tenant operator defaults should include badge management');
const roleTemplate = await request('/api/tenant-admin/role-templates', {
userId: TENANT_ADMIN_USER_ID,