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() {

View File

@@ -14,6 +14,7 @@ const DURATION_SECONDS = envNumber('PERF_DURATION_SECONDS', 15);
const CONCURRENCY = envNumber('PERF_CONCURRENCY', 6);
const RAMP_SECONDS = envNumber('PERF_RAMP_SECONDS', 3, { allowZero: true });
const INCLUDE_WRITES = envBool('PERF_INCLUDE_WRITES', false);
const INCLUDE_LEADERBOARD = envBool('PERF_INCLUDE_LEADERBOARD', false);
const START_PORT = Number(process.env.PERF_API_PORT || 0) || 0;
const TENANT_CODE = process.env.PERF_TENANT_CODE || 'master';
const QUESTION_LIMIT = envNumber('PERF_QUESTION_LIMIT', 20);
@@ -381,8 +382,16 @@ function createEndpointCatalog(context) {
{ name: 'catalog.content_entries', weight: 10, method: 'GET', path: '/api/catalog/content-entries', query: { entryType: 'question_practice' } },
{ name: 'learning.stats', weight: 5, method: 'GET', path: '/api/learning/stats' },
{ name: 'learning.trend', weight: 4, method: 'GET', path: '/api/learning/trend', query: { days: 14 } },
{ name: 'leaderboard.questions_7d', weight: 3, method: 'GET', path: '/api/learning/leaderboard', query: { metric: 'questions', period: '7d', limit: 20 } },
];
if (INCLUDE_LEADERBOARD) {
endpoints.push({
name: 'leaderboard.questions_7d',
weight: 3,
method: 'GET',
path: '/api/learning/leaderboard',
query: { metric: 'questions', period: '7d', limit: 20 },
});
}
if (context.entry) {
endpoints.push(