feat: add public question bank adoption

This commit is contained in:
Codex
2026-06-29 03:07:57 +08:00
parent c767b87c6f
commit 7c41bf525f
15 changed files with 1324 additions and 16 deletions

View File

@@ -15,6 +15,7 @@ const TENANT_SALES_USER_ID = '00000000-0000-0000-0000-000000000104';
const TENANT_AGENT_USER_ID = '00000000-0000-0000-0000-000000000105';
const TENANT_TEACHER_USER_ID = '00000000-0000-0000-0000-000000000106';
const SECOND_STUDENT_USER_ID = '00000000-0000-0000-0000-000000000107';
const PARTNER_TENANT_ADMIN_USER_ID = '00000000-0000-0000-0000-000000000907';
const AUTH_USER_ID = '00000000-0000-0000-0000-00000000a101';
const AUTH_TENANT_ADMIN_USER_ID = '00000000-0000-0000-0000-00000000a102';
const AUTH_PLATFORM_ADMIN_USER_ID = '00000000-0000-0000-0000-00000000a999';
@@ -48,6 +49,8 @@ const ids = {
tenantClassOther: '00000000-0000-0000-0000-000000000852',
examDate: '00000000-0000-0000-0000-000000000861',
tenantExamDate: '00000000-0000-0000-0000-000000000862',
questionBank: '00000000-0000-0000-0000-000000000400',
publicQuestionBankGrant: '00000000-0000-0000-0000-000000000906',
};
const paymentFixture = (() => {
@@ -2404,6 +2407,115 @@ async function testTenantContentAssetsAndImports() {
assert.equal(partnerImports.code, 'TENANT_CONTENT_EDITOR_REQUIRED', 'import jobs must be tenant isolated');
}
async function testPublicQuestionBankAdoption() {
const studentDenied = await request('/api/tenant-content/public-question-banks', {
expectStatus: 403,
});
assert.equal(studentDenied.code, 'TENANT_CONTENT_EDITOR_REQUIRED', 'student should not browse adoptable public banks');
const platformBanks = await request('/api/platform-admin/question-banks', {
userId: false,
headers: { 'x-platform-admin-key': 'local-platform-admin-key' },
query: { q: '烟测公共题库' },
});
assert.ok(platformBanks.items?.some(item => item.id === ids.questionBank && item.sourceScope === 'platform'), 'platform admin should list platform public banks');
const grant = await request('/api/platform-admin/question-bank-grants', {
userId: false,
headers: { 'x-platform-admin-key': 'local-platform-admin-key' },
method: 'PUT',
body: {
id: ids.publicQuestionBankGrant,
sourceQuestionBankId: ids.questionBank,
grantScope: 'plans',
allowedPlanCodes: ['starter_yearly', 'pro_yearly'],
status: 'active',
metadata: { source: 'integration-test' },
},
});
assert.equal(grant.item?.sourceQuestionBankId, ids.questionBank, 'platform admin should upsert public bank grant');
const grants = await request('/api/platform-admin/question-bank-grants', {
userId: false,
headers: { 'x-platform-admin-key': 'local-platform-admin-key' },
query: { questionBankId: ids.questionBank },
});
assert.ok(grants.items?.some(item => item.id === ids.publicQuestionBankGrant), 'platform admin should list public bank grants');
const partnerBanks = await request('/api/tenant-content/public-question-banks', {
tenantId: PARTNER_TENANT_ID,
userId: PARTNER_TENANT_ADMIN_USER_ID,
});
const adoptable = partnerBanks.items?.find(item => item.grantId === ids.publicQuestionBankGrant);
assert.ok(adoptable, 'partner tenant should see public bank granted by SaaS plan');
assert.equal(adoptable.adoptedId, null, 'public bank should start as not adopted after smoke seed');
const adopted = await request('/api/tenant-content/public-question-banks/adopt', {
tenantId: PARTNER_TENANT_ID,
userId: PARTNER_TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
grantId: ids.publicQuestionBankGrant,
entryName: '合作商采纳烟测公共题库',
collectionName: '合作商公共题库题目',
copyLimit: 3,
metadata: { source: 'integration-test' },
},
});
assert.equal(adopted.item?.sourceQuestionBankId, ids.questionBank, 'adoption should bind source public question bank');
assert.equal(adopted.item?.copiedQuestionCount, 3, 'adoption should copy a snapshot of published questions');
assert.ok(adopted.item?.targetEntryId, 'adoption should create tenant content entry');
assert.ok(adopted.item?.targetCollectionId, 'adoption should create tenant question collection');
const repeated = await request('/api/tenant-content/public-question-banks/adopt', {
tenantId: PARTNER_TENANT_ID,
userId: PARTNER_TENANT_ADMIN_USER_ID,
method: 'POST',
body: { grantId: ids.publicQuestionBankGrant },
expectStatus: 409,
});
assert.equal(repeated.code, 'QUESTION_BANK_ALREADY_ADOPTED', 'tenant should not adopt the same public bank twice');
const adoptedList = await request('/api/tenant-content/public-question-banks', {
tenantId: PARTNER_TENANT_ID,
userId: PARTNER_TENANT_ADMIN_USER_ID,
});
const adoptedItem = adoptedList.items?.find(item => item.grantId === ids.publicQuestionBankGrant);
assert.equal(adoptedItem?.adoptionStatus, 'active', 'adoptable list should expose adoption status');
const entries = await request('/api/catalog/content-entries', {
tenantId: PARTNER_TENANT_ID,
userId: false,
query: { entryType: 'question_practice' },
});
assert.ok(entries.items?.some(item => item.id === adopted.item.targetEntryId), 'adopted public bank should appear in tenant catalog entries');
const collections = await request('/api/catalog/question-collections', {
tenantId: PARTNER_TENANT_ID,
userId: false,
query: { entryId: adopted.item.targetEntryId },
});
assert.ok(collections.items?.some(item => item.id === adopted.item.targetCollectionId && item.questionCount === 3), 'adopted public bank should expose a tenant collection');
const session = await request('/api/learning/practice-sessions', {
tenantId: PARTNER_TENANT_ID,
userId: PARTNER_TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
collectionId: adopted.item.targetCollectionId,
mode: 'sequential',
questionLimit: 2,
},
});
assert.equal(session.item?.questionCount, 2, 'adopted public bank collection should be usable for practice sessions');
const mainTenantNotAdopted = await request('/api/tenant-content/public-question-banks', {
userId: TENANT_ADMIN_USER_ID,
query: { onlyNotAdopted: 'true' },
});
assert.ok(!mainTenantNotAdopted.items?.some(item => item.grantId === ids.publicQuestionBankGrant), 'platform-owned main tenant should not see plan grant without matching SaaS subscription');
}
async function testTenantAdminOps() {
const fakeWechat = await startFakeWechatServer();
const denied = await request('/api/tenant-admin/branding', {
@@ -3567,6 +3679,7 @@ async function main() {
await check('tenant isolation', testTenantIsolation);
await check('tenant content admin', testTenantContentAdmin);
await check('tenant content assets and imports', testTenantContentAssetsAndImports);
await check('public question bank adoption', testPublicQuestionBankAdoption);
await check('tenant admin operations', testTenantAdminOps);
await check('tenant member permissions and audit', testTenantMemberPermissionsAndAudit);
await check('tenant class and student scopes', testTenantClassStudentScopes);

View File

@@ -75,6 +75,8 @@ const ids = {
partnerInvoice: '00000000-0000-0000-0000-000000000903',
partnerInvoiceItem: '00000000-0000-0000-0000-000000000904',
partnerInvoicePayment: '00000000-0000-0000-0000-000000000905',
publicQuestionBankGrant: '00000000-0000-0000-0000-000000000906',
partnerTenantAdminUser: '00000000-0000-0000-0000-000000000907',
platformAdminUser: '00000000-0000-0000-0000-000000000999',
};
@@ -504,6 +506,20 @@ async function main() {
[ids.platformAdminUser, ids.authPlatformAdminUser],
);
await client.query(
`
insert into public.platform_users (id, auth_user_id, username, phone, name, primary_role, raw_profile)
values ($1, null, 'smoke_partner_admin', '13800000907', 'Smoke Partner Admin', 'tenant_admin', '{"source":"smoke-seed"}'::jsonb)
on conflict (id)
do update set username = excluded.username,
phone = excluded.phone,
name = excluded.name,
primary_role = excluded.primary_role,
updated_at = now()
`,
[ids.partnerTenantAdminUser],
);
await client.query(
`
insert into public.user_identities (user_id, provider, provider_subject, phone)
@@ -526,6 +542,16 @@ async function main() {
[tenantId, ids.user],
);
await client.query(
`
delete from public.tenant_memberships
where tenant_id = $1
and user_id = $2
and role = 'tenant_admin'
`,
[ids.partnerTenant, ids.tenantAdminUser],
);
await client.query(
`
insert into public.tenant_memberships (tenant_id, user_id, role, status, permissions)
@@ -650,10 +676,13 @@ async function main() {
await client.query(
`
insert into public.question_banks (id, tenant_id, region_id, name, source_scope, status, metadata)
values ($1, $2, $3, '烟测题库', 'tenant', 'active', '{"source":"smoke-seed"}'::jsonb)
values ($1, $2, $3, '烟测公共题库', 'platform', 'active', '{"source":"smoke-seed","commercialScope":"public_region_bank"}'::jsonb)
on conflict (id)
do update set name = excluded.name,
region_id = excluded.region_id,
source_scope = excluded.source_scope,
status = excluded.status,
metadata = excluded.metadata,
updated_at = now()
`,
[ids.questionBank, tenantId, ids.region],
@@ -1784,6 +1813,109 @@ async function main() {
[ids.partnerInvoicePayment, ids.partnerTenant, ids.partnerInvoice],
);
await client.query(
`
insert into public.tenant_memberships (tenant_id, user_id, role, status, permissions)
values ($1, $2, 'tenant_admin', 'active', '{"content:*":true}'::jsonb)
on conflict (tenant_id, user_id, role)
do update set status = 'active',
permissions = excluded.permissions,
updated_at = now()
`,
[ids.partnerTenant, ids.partnerTenantAdminUser],
);
await client.query(
`
insert into public.question_bank_grants (
id, source_question_bank_id, grant_scope, allowed_plan_codes,
allowed_tenant_ids, status, starts_at, expires_at, metadata
)
values (
$1, $2, 'plans', array['starter_yearly','pro_yearly']::text[],
'{}'::uuid[], 'active', '2026-06-21T00:00:00Z', null,
'{"source":"smoke-seed","business":"public_question_bank_authorization"}'::jsonb
)
on conflict (id)
do update set grant_scope = excluded.grant_scope,
allowed_plan_codes = excluded.allowed_plan_codes,
status = excluded.status,
starts_at = excluded.starts_at,
expires_at = excluded.expires_at,
metadata = excluded.metadata,
updated_at = now()
`,
[ids.publicQuestionBankGrant, ids.questionBank],
);
await client.query(
`
delete from public.tenant_question_bank_adoptions
where tenant_id = $1
and source_question_bank_id = $2
`,
[ids.partnerTenant, ids.questionBank],
);
await client.query(
`
delete from public.question_collection_items
where tenant_id = $1
and metadata->>'source' = 'public_question_bank_adoption'
`,
[ids.partnerTenant],
);
await client.query(
`
delete from public.question_versions
where tenant_id = $1
and question_id in (
select id
from public.questions
where tenant_id = $1
and legacy_id like 'public:%'
)
`,
[ids.partnerTenant],
);
await client.query(
`
delete from public.questions
where tenant_id = $1
and legacy_id like 'public:%'
`,
[ids.partnerTenant],
);
await client.query(
`
delete from public.question_collections
where tenant_id = $1
and metadata->>'source' = 'public_question_bank_adoption'
`,
[ids.partnerTenant],
);
await client.query(
`
delete from public.content_entries
where tenant_id = $1
and entry_key like 'public-%'
`,
[ids.partnerTenant],
);
await client.query(
`
delete from public.question_banks
where tenant_id = $1
and metadata->>'source' = 'public_question_bank_adoption'
`,
[ids.partnerTenant],
);
await client.query(
`
delete from public.tenant_usage_records