feat: use preset avatars and disable leaderboard by default

This commit is contained in:
Codex
2026-06-30 14:07:48 +08:00
parent 8e3252081f
commit 9299552451
18 changed files with 228 additions and 52 deletions

View File

@@ -221,6 +221,29 @@ async function latestVideoPlayEventByToken(playToken) {
}
}
async function setTenantFeatureFlag(tenantId, flag, enabled) {
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL || DEFAULT_DATABASE_URL });
try {
await pool.query(
`
insert into public.tenant_settings (tenant_id, feature_flags, admin_feature_flags, public_config)
values ($1, jsonb_build_object($2::text, $3::boolean), '{}'::jsonb, '{}'::jsonb)
on conflict (tenant_id)
do update set feature_flags = jsonb_set(
coalesce(public.tenant_settings.feature_flags, '{}'::jsonb),
array[$2::text],
to_jsonb($3::boolean),
true
),
updated_at = now()
`,
[tenantId, flag, enabled],
);
} finally {
await pool.end();
}
}
function getFreePort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
@@ -2711,6 +2734,39 @@ async function testProfile() {
assert.equal(payload.item?.userId, USER_ID, 'profile should belong to smoke user');
assert.ok(payload.item?.stats?.vocabulary?.totalWords >= 1, 'profile should include vocabulary stats');
assert.ok(Array.isArray(payload.item?.recentPractices), 'profile should include recent practices');
assert.equal(payload.item?.avatarPreset, 'male', 'profile should default to the male preset avatar');
assert.equal(payload.item?.avatar?.preset, 'male', 'profile avatar object should expose preset');
assert.ok(payload.item?.avatar?.displayUrl?.includes('/assets/avatars/default-male.svg'), 'profile should expose the default male avatar asset path');
const directAvatarRejected = await request('/api/profile/me', {
method: 'PATCH',
body: { avatarUrl: 'https://example.test/not-allowed-avatar.png' },
expectStatus: 400,
});
assert.equal(
directAvatarRejected.code,
'PROFILE_AVATAR_URL_DIRECT_UPDATE_REJECTED',
'students must not set arbitrary avatar URLs directly',
);
const invalidAvatarPatch = await request('/api/profile/me', {
method: 'PATCH',
body: { avatarPreset: 'custom' },
expectStatus: 400,
});
assert.equal(invalidAvatarPatch.code, 'INVALID_AVATAR_PRESET', 'profile must reject unknown avatar presets');
const avatarPresetUpdated = await request('/api/profile/me', {
method: 'PATCH',
body: { avatarPreset: 'female' },
});
assert.equal(avatarPresetUpdated.item?.avatarPreset, 'female', 'student should update preset avatar choice');
const profileAfterAvatarPreset = await request('/api/profile/me');
assert.equal(profileAfterAvatarPreset.item?.avatar?.preset, 'female', 'profile should expose updated preset avatar');
assert.ok(
profileAfterAvatarPreset.item?.avatar?.displayUrl?.includes('/assets/avatars/default-female.svg'),
'profile should expose the default female avatar asset path',
);
const countdowns = await request('/api/profile/exam-countdowns');
const profileExamDate = countdowns.items?.find(item => item.id === ids.examDate);
@@ -2865,6 +2921,14 @@ async function testProfile() {
}
async function testLearningLeaderboard() {
const disabled = await request('/api/learning/leaderboard', {
query: { metric: 'questions', period: 'all', limit: 10 },
expectStatus: 403,
});
assert.equal(disabled.code, 'LEADERBOARD_DISABLED', 'leaderboard should be disabled by default for tenants');
await setTenantFeatureFlag(MAIN_TENANT_ID, 'enableLeaderboard', true);
const questions = await request('/api/learning/leaderboard', {
query: { metric: 'questions', period: 'all', limit: 10 },
});
@@ -2911,6 +2975,13 @@ async function testLearningLeaderboard() {
expectStatus: 403,
});
assert.equal(crossTenantDenied.code, 'AUTH_TENANT_MISMATCH', 'leaderboard must reject trusted session cross-tenant access');
await setTenantFeatureFlag(MAIN_TENANT_ID, 'enableLeaderboard', false);
const disabledAfterRestore = await request('/api/learning/leaderboard', {
query: { metric: 'questions', period: 'all', limit: 10 },
expectStatus: 403,
});
assert.equal(disabledAfterRestore.code, 'LEADERBOARD_DISABLED', 'leaderboard feature flag should be restorable after tests');
}
async function testScoreline() {