import assert from 'node:assert/strict'; import crypto from 'node:crypto'; import { spawn } from 'node:child_process'; import http from 'node:http'; import net from 'node:net'; import { SignJWT } from 'jose'; const DEFAULT_DATABASE_URL = 'postgresql://postgres:postgres@127.0.0.1:54322/postgres'; const MAIN_TENANT_ID = process.env.TENANT_ID || '00000000-0000-0000-0000-000000000001'; const PARTNER_TENANT_ID = process.env.PARTNER_TENANT_ID || '00000000-0000-0000-0000-000000000901'; const USER_ID = process.env.USER_ID || '00000000-0000-0000-0000-000000000101'; const TENANT_ADMIN_USER_ID = process.env.TENANT_ADMIN_USER_ID || '00000000-0000-0000-0000-000000000102'; const TENANT_OPERATOR_USER_ID = '00000000-0000-0000-0000-000000000103'; 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 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'; const AUTH_JWT_SECRET = 'development-jwt-secret-change-me'; const START_SERVER = process.argv.includes('--start-server'); const ENABLE_REAL_STORAGE_SIGN_TESTS = process.env.ENABLE_REAL_STORAGE_SIGN_TESTS === 'true'; const ids = { region: '00000000-0000-0000-0000-000000000301', subject: '00000000-0000-0000-0000-000000000501', category: '00000000-0000-0000-0000-000000000601', contentEntry: '00000000-0000-0000-0000-000000000611', contentNodeCulture: '00000000-0000-0000-0000-000000000612', contentNodeProfessional: '00000000-0000-0000-0000-000000000613', contentNodeSchoolTarget: '00000000-0000-0000-0000-000000000614', questionCollection: '00000000-0000-0000-0000-000000000615', practiceBlueprintSequential: '00000000-0000-0000-0000-000000000616', practiceBlueprintRandom: '00000000-0000-0000-0000-000000000617', practiceBlueprintMock: '00000000-0000-0000-0000-000000000618', question: '00000000-0000-0000-0000-000000000401', questionTwo: '00000000-0000-0000-0000-000000000403', questionThree: '00000000-0000-0000-0000-000000000405', plan: '00000000-0000-0000-0000-000000000201', couponOnlyPlan: '00000000-0000-0000-0000-000000000202', vocabularyUnit: '00000000-0000-0000-0000-000000000811', vocabularyWord: '00000000-0000-0000-0000-000000000812', video: '00000000-0000-0000-0000-000000000821', quotaVideo: '00000000-0000-0000-0000-000000000824', scorelineSchool: '00000000-0000-0000-0000-000000000831', tenantClass: '00000000-0000-0000-0000-000000000851', tenantClassOther: '00000000-0000-0000-0000-000000000852', examDate: '00000000-0000-0000-0000-000000000861', tenantExamDate: '00000000-0000-0000-0000-000000000862', }; const paymentFixture = (() => { const wechatMerchant = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }); const wechatPlatform = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }); const alipayApp = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }); const alipayPlatform = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }); return { wechatMerchantPrivateKey: wechatMerchant.privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(), wechatMerchantPublicKey: wechatMerchant.publicKey.export({ type: 'spki', format: 'pem' }).toString(), wechatPlatformPrivateKey: wechatPlatform.privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(), wechatPlatformPublicKey: wechatPlatform.publicKey.export({ type: 'spki', format: 'pem' }).toString(), alipayAppPrivateKey: alipayApp.privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(), alipayAppPublicKey: alipayApp.publicKey.export({ type: 'spki', format: 'pem' }).toString(), alipayPlatformPrivateKey: alipayPlatform.privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(), alipayPlatformPublicKey: alipayPlatform.publicKey.export({ type: 'spki', format: 'pem' }).toString(), wechatApiV3Key: '12345678901234567890123456789012', }; })(); let apiBase = process.env.API_BASE || 'http://127.0.0.1:8787'; let serverProcess = null; let serverLogs = ''; let legacyDisabledServer = null; let legacyDisabledServerLogs = ''; let fakeWechatServer = null; let fakeWechatPayServer = null; function buildUrl(path, query = {}) { return buildUrlAt(apiBase, path, query); } function buildUrlAt(baseUrl, path, query = {}) { const target = new URL(path, baseUrl); for (const [key, value] of Object.entries(query)) { if (value !== undefined && value !== null && value !== '') { target.searchParams.set(key, String(value)); } } return target; } async function request(path, options = {}) { const response = await fetch(buildUrl(path, options.query), { method: options.method || 'GET', headers: { 'content-type': 'application/json', ...(options.tenantId === false ? {} : { 'x-tenant-id': options.tenantId || MAIN_TENANT_ID }), ...(options.userId === false ? {} : { 'x-user-id': options.userId || USER_ID }), ...(options.headers || {}), }, body: options.body ? JSON.stringify(options.body) : undefined, }); const payload = await response.json().catch(() => ({})); if (options.expectStatus) { assert.equal(response.status, options.expectStatus, `${options.method || 'GET'} ${path} should return ${options.expectStatus}`); return payload; } if (!response.ok) { throw new Error(`${options.method || 'GET'} ${path} failed: ${response.status} ${JSON.stringify(payload)}`); } return payload; } async function requestAt(baseUrl, path, options = {}) { const response = await fetch(buildUrlAt(baseUrl, path, options.query), { method: options.method || 'GET', headers: { 'content-type': 'application/json', ...(options.tenantId === false ? {} : { 'x-tenant-id': options.tenantId || MAIN_TENANT_ID }), ...(options.userId === false ? {} : { 'x-user-id': options.userId || USER_ID }), ...(options.headers || {}), }, body: options.body ? JSON.stringify(options.body) : undefined, }); const payload = await response.json().catch(() => ({})); if (options.expectStatus) { assert.equal(response.status, options.expectStatus, `${options.method || 'GET'} ${path} should return ${options.expectStatus}`); return payload; } if (!response.ok) { throw new Error(`${options.method || 'GET'} ${path} failed: ${response.status} ${JSON.stringify(payload)}`); } return payload; } async function check(name, fn) { await fn(); console.log(`[PASS] ${name}`); } function getFreePort() { return new Promise((resolve, reject) => { const server = net.createServer(); server.on('error', reject); server.listen(0, '127.0.0.1', () => { const address = server.address(); server.close(() => resolve(address.port)); }); }); } async function waitForHealth(timeoutMs = 12_000) { const started = Date.now(); let lastError = null; while (Date.now() - started < timeoutMs) { try { const payload = await request('/health', { userId: false }); if (payload.ok) return; } catch (error) { lastError = error; } await new Promise(resolve => setTimeout(resolve, 250)); } throw new Error(`API server did not become healthy. ${lastError?.message || ''}\n${serverLogs}`); } async function waitForHealthAt(baseUrl, logsRef, timeoutMs = 12_000) { const started = Date.now(); let lastError = null; while (Date.now() - started < timeoutMs) { try { const payload = await requestAt(baseUrl, '/health', { userId: false }); if (payload.ok) return; } catch (error) { lastError = error; } await new Promise(resolve => setTimeout(resolve, 250)); } throw new Error(`API server did not become healthy. ${lastError?.message || ''}\n${logsRef()}`); } async function startServerIfNeeded() { if (!START_SERVER) return; const port = Number(process.env.TEST_API_PORT || 0) || await getFreePort(); apiBase = `http://127.0.0.1:${port}`; serverProcess = spawn(process.execPath, ['apps/api/dist/apps/api/src/server.js'], { cwd: process.cwd(), env: { ...process.env, PORT: String(port), DATABASE_URL: process.env.DATABASE_URL || DEFAULT_DATABASE_URL, MAX_JSON_BODY_BYTES: process.env.MAX_JSON_BODY_BYTES || '8192', MAX_IMPORT_JSON_BODY_BYTES: process.env.MAX_IMPORT_JSON_BODY_BYTES || '65536', }, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, }); serverProcess.stdout.on('data', chunk => { serverLogs += chunk.toString(); }); serverProcess.stderr.on('data', chunk => { serverLogs += chunk.toString(); }); await waitForHealth(); } async function startLegacyDisabledServer() { const port = await getFreePort(); const baseUrl = `http://127.0.0.1:${port}`; legacyDisabledServerLogs = ''; legacyDisabledServer = spawn(process.execPath, ['apps/api/dist/apps/api/src/server.js'], { cwd: process.cwd(), env: { ...process.env, PORT: String(port), DATABASE_URL: process.env.DATABASE_URL || DEFAULT_DATABASE_URL, MAX_JSON_BODY_BYTES: process.env.MAX_JSON_BODY_BYTES || '8192', MAX_IMPORT_JSON_BODY_BYTES: process.env.MAX_IMPORT_JSON_BODY_BYTES || '65536', ALLOW_LEGACY_AUTH_HEADERS: 'false', ALLOW_PLATFORM_ADMIN_KEY: 'false', }, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, }); legacyDisabledServer.stdout.on('data', chunk => { legacyDisabledServerLogs += chunk.toString(); }); legacyDisabledServer.stderr.on('data', chunk => { legacyDisabledServerLogs += chunk.toString(); }); await waitForHealthAt(baseUrl, () => legacyDisabledServerLogs); return baseUrl; } async function startFakeWechatServer() { const port = await getFreePort(); const baseUrl = `http://127.0.0.1:${port}`; const requests = []; fakeWechatServer = http.createServer((req, res) => { const url = new URL(req.url || '/', baseUrl); requests.push({ method: req.method, pathname: url.pathname, query: Object.fromEntries(url.searchParams.entries()), }); if (url.pathname !== '/sns/jscode2session') { res.writeHead(404, { 'content-type': 'application/json' }); res.end(JSON.stringify({ errcode: 404, errmsg: 'not found' })); return; } if ( url.searchParams.get('appid') !== 'wx-smoke-appid' || url.searchParams.get('secret') !== 'wechat-app-secret-smoke' || url.searchParams.get('grant_type') !== 'authorization_code' ) { res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify({ errcode: 40013, errmsg: 'invalid appid or secret' })); return; } const jsCode = url.searchParams.get('js_code') || 'unknown'; res.writeHead(200, { 'content-type': 'application/json' }); res.end( JSON.stringify({ openid: `openid-${jsCode}`, session_key: `session-key-${jsCode}`, unionid: 'unionid-smoke-user', }), ); }); await new Promise((resolve, reject) => { fakeWechatServer.once('error', reject); fakeWechatServer.listen(port, '127.0.0.1', resolve); }); return { endpoint: `${baseUrl}/sns/jscode2session`, requests, }; } async function startFakeWechatPayServer() { const port = await getFreePort(); const baseUrl = `http://127.0.0.1:${port}`; const requests = []; fakeWechatPayServer = http.createServer((req, res) => { const url = new URL(req.url || '/', baseUrl); let raw = ''; req.on('data', chunk => { raw += chunk.toString(); }); req.on('end', () => { const body = raw ? JSON.parse(raw) : {}; requests.push({ method: req.method, pathname: url.pathname, headers: req.headers, body, }); if (url.pathname !== '/v3/pay/transactions/jsapi') { res.writeHead(404, { 'content-type': 'application/json' }); res.end(JSON.stringify({ code: 'NOT_FOUND' })); return; } res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify({ prepay_id: `prepay-${body.out_trade_no || 'unknown'}` })); }); }); await new Promise((resolve, reject) => { fakeWechatPayServer.once('error', reject); fakeWechatPayServer.listen(port, '127.0.0.1', resolve); }); return { endpoint: `${baseUrl}/v3/pay/transactions/jsapi`, requests, }; } function encryptWechatResource(plain) { const nonce = crypto.randomBytes(12).toString('base64url'); const aad = 'transaction'; const cipher = crypto.createCipheriv('aes-256-gcm', Buffer.from(paymentFixture.wechatApiV3Key, 'utf8'), Buffer.from(nonce, 'utf8')); cipher.setAAD(Buffer.from(aad, 'utf8')); const encrypted = Buffer.concat([cipher.update(JSON.stringify(plain), 'utf8'), cipher.final()]); const authTag = cipher.getAuthTag(); return { algorithm: 'AEAD_AES_256_GCM', nonce, associated_data: aad, ciphertext: Buffer.concat([encrypted, authTag]).toString('base64'), }; } function signWechatNotification(rawBody, timestamp, nonce) { const message = `${timestamp}\n${nonce}\n${rawBody}\n`; return crypto.createSign('RSA-SHA256').update(message).sign(paymentFixture.wechatPlatformPrivateKey, 'base64'); } function signAlipayParams(params) { const canonical = Object.keys(params) .filter(key => !['sign', 'sign_type'].includes(key) && params[key] !== undefined && params[key] !== null && params[key] !== '') .sort() .map(key => `${key}=${params[key]}`) .join('&'); return crypto.createSign('RSA-SHA256').update(canonical).sign(paymentFixture.alipayPlatformPrivateKey, 'base64'); } async function createSupabaseJwt(authUserId, options = {}) { const secret = new TextEncoder().encode(options.secret || AUTH_JWT_SECRET); const now = Math.floor(Date.now() / 1000); const claims = { sub: authUserId, aud: 'authenticated', role: 'authenticated', phone: options.phone || undefined, app_metadata: { provider: 'phone', providers: ['phone'], ...(options.appRole ? { app_role: options.appRole } : {}), ...(options.tenantId === false ? {} : { tenant_id: options.tenantId || MAIN_TENANT_ID }), }, user_metadata: options.userMetadata || {}, }; return new SignJWT(claims) .setProtectedHeader({ alg: 'HS256', typ: 'JWT' }) .setIssuedAt(now) .setExpirationTime(now + 60 * 60) .sign(secret); } async function waitForProcessExit(child, timeoutMs = 5000) { return new Promise((resolve, reject) => { const timer = setTimeout(() => { child.kill(); reject(new Error('process did not exit before timeout')); }, timeoutMs); child.on('exit', (code, signal) => { clearTimeout(timer); resolve({ code, signal }); }); child.on('error', error => { clearTimeout(timer); reject(error); }); }); } async function testProductionConfigFailFast() { const port = await getFreePort(); const child = spawn(process.execPath, ['apps/api/dist/apps/api/src/server.js'], { cwd: process.cwd(), env: { ...process.env, NODE_ENV: 'production', PORT: String(port), DATABASE_URL: process.env.DATABASE_URL || DEFAULT_DATABASE_URL, CORS_ORIGIN: '*', AUTH_SMS_PROVIDER: 'mock', AUTH_CODE_PEPPER: 'development-code-pepper-change-me', AUTH_SESSION_SECRET: 'development-session-secret-change-me', PLATFORM_ADMIN_API_KEY: 'local-platform-admin-key', }, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, }); let logs = ''; child.stdout.on('data', chunk => { logs += chunk.toString(); }); child.stderr.on('data', chunk => { logs += chunk.toString(); }); const result = await waitForProcessExit(child); assert.notEqual(result.code, 0, 'production server with unsafe defaults should fail to start'); assert.match(logs, /Invalid production API configuration/, 'production fail-fast should explain unsafe config'); } async function loginBySms(phone = '13800000000') { const sent = await request('/api/auth/sms/send', { userId: false, method: 'POST', body: { phone, purpose: 'login' }, }); assert.ok(sent.debugCode, 'mock SMS login should expose debugCode in local tests'); const verified = await request('/api/auth/sms/verify', { userId: false, method: 'POST', body: { phone, code: sent.debugCode, purpose: 'login' }, }); assert.ok(verified.session?.token, 'SMS verify should issue a session token'); return verified; } async function testTrustedSessionIdentity() { const login = await loginBySms(); assert.equal(login.user?.id, USER_ID, 'smoke phone should log in as smoke user'); const authHeaders = { authorization: `Bearer ${login.session.token}` }; const me = await request('/api/auth/me', { userId: false, headers: authHeaders, }); assert.equal(me.user?.id, USER_ID, 'auth/me should resolve user from bearer session'); const profile = await request('/api/profile/me', { userId: false, headers: authHeaders, }); assert.equal(profile.item?.userId, USER_ID, 'profile should resolve user from bearer session without x-user-id'); const vocabularyProgress = await request('/api/learning/vocabulary/progress', { userId: false, headers: authHeaders, method: 'POST', body: { wordId: ids.vocabularyWord, status: 'learning', correctDelta: 0 }, }); assert.equal(vocabularyProgress.item?.wordId, ids.vocabularyWord, 'learning APIs should accept bearer session identity'); const vocabularyProgressList = await request('/api/learning/vocabulary/progress', { userId: false, headers: authHeaders, query: { unitId: ids.vocabularyUnit }, }); assert.ok( vocabularyProgressList.items?.some(item => item.wordId === ids.vocabularyWord), 'learning APIs should read progress through bearer session identity', ); const spoofedUser = await request('/api/profile/me', { userId: TENANT_ADMIN_USER_ID, headers: authHeaders, expectStatus: 403, }); assert.equal(spoofedUser.code, 'AUTH_USER_MISMATCH', 'session requests must reject spoofed x-user-id'); const spoofedBodyUser = await request('/api/learning/vocabulary/progress', { userId: false, headers: authHeaders, method: 'POST', body: { userId: TENANT_ADMIN_USER_ID, wordId: ids.vocabularyWord, status: 'learning', }, expectStatus: 403, }); assert.equal(spoofedBodyUser.code, 'AUTH_USER_MISMATCH', 'session requests must reject spoofed body userId'); const spoofedTenant = await request('/api/profile/me', { tenantId: PARTNER_TENANT_ID, userId: false, headers: authHeaders, expectStatus: 403, }); assert.equal(spoofedTenant.code, 'AUTH_TENANT_MISMATCH', 'session requests must reject spoofed tenant'); const invalidSession = await request('/api/profile/me', { headers: { authorization: 'Bearer tk_invalid_session_token' }, expectStatus: 401, }); assert.equal(invalidSession.code, 'AUTH_SESSION_INVALID', 'invalid bearer token must not fall back to legacy user headers'); } async function testSupabaseJwtIdentity() { const studentJwt = await createSupabaseJwt(AUTH_USER_ID, { phone: '13800000000' }); const studentHeaders = { authorization: `Bearer ${studentJwt}` }; const me = await request('/api/auth/me', { userId: false, headers: studentHeaders, }); assert.equal(me.user?.id, USER_ID, 'auth/me should map Supabase auth user to platform user'); assert.equal(me.session?.source, 'supabase_jwt', 'auth/me should expose Supabase JWT session source'); const profile = await request('/api/profile/me', { userId: false, headers: studentHeaders, }); assert.equal(profile.item?.userId, USER_ID, 'profile should accept Supabase JWT identity'); const tenantHeaderJwt = await createSupabaseJwt(AUTH_USER_ID, { tenantId: false }); const tenantHeaderProfile = await request('/api/profile/me', { userId: false, headers: { authorization: `Bearer ${tenantHeaderJwt}` }, }); assert.equal(tenantHeaderProfile.item?.userId, USER_ID, 'tenant header should scope Supabase JWT when token has no tenant claim'); const missingTenant = await request('/api/profile/me', { tenantId: false, userId: false, headers: { authorization: `Bearer ${tenantHeaderJwt}` }, expectStatus: 400, }); assert.equal(missingTenant.code, 'TENANT_ID_REQUIRED', 'Supabase JWT without tenant context must not guess a tenant'); const mismatchedTenant = await request('/api/profile/me', { tenantId: PARTNER_TENANT_ID, userId: false, headers: studentHeaders, expectStatus: 401, }); assert.equal(mismatchedTenant.code, 'AUTH_SESSION_INVALID', 'Supabase JWT tenant claim must not be overwritten by request tenant'); const badSignatureJwt = await createSupabaseJwt(AUTH_USER_ID, { secret: 'wrong-development-jwt-secret-change-me' }); const badSignature = await request('/api/profile/me', { userId: false, headers: { authorization: `Bearer ${badSignatureJwt}` }, expectStatus: 401, }); assert.equal(badSignature.code, 'AUTH_SESSION_INVALID', 'bad Supabase JWT signatures must be rejected'); const tenantAdminJwt = await createSupabaseJwt(AUTH_TENANT_ADMIN_USER_ID, { phone: '13800000001' }); const tenantOverview = await request('/api/tenant-admin/overview', { userId: false, headers: { authorization: `Bearer ${tenantAdminJwt}` }, }); assert.equal(tenantOverview.item?.id, MAIN_TENANT_ID, 'tenant admin should access own tenant through Supabase JWT'); const studentTenantAdminDenied = await request('/api/tenant-admin/overview', { userId: false, headers: studentHeaders, expectStatus: 403, }); assert.equal(studentTenantAdminDenied.code, 'TENANT_ADMIN_REQUIRED', 'student Supabase JWT must not access tenant admin APIs'); const platformAdminJwt = await createSupabaseJwt(AUTH_PLATFORM_ADMIN_USER_ID, { phone: '13999999999', appRole: 'platform_admin', }); const platformOverview = await request('/api/platform-admin/overview', { tenantId: false, userId: false, headers: { authorization: `Bearer ${platformAdminJwt}` }, }); assert.ok(platformOverview.item?.tenants?.total >= 1, 'platform admin Supabase JWT should access platform overview'); const studentPlatformDenied = await request('/api/platform-admin/overview', { tenantId: false, userId: false, headers: studentHeaders, expectStatus: 403, }); assert.equal(studentPlatformDenied.code, 'PLATFORM_ADMIN_REQUIRED', 'student Supabase JWT must not access platform APIs'); } async function testLegacyAuthHeadersDisabled() { const login = await loginBySms('13800000006'); const baseUrl = await startLegacyDisabledServer(); const authHeaders = { authorization: `Bearer ${login.session.token}` }; const legacyProfile = await requestAt(baseUrl, '/api/profile/me', { expectStatus: 401, }); assert.equal(legacyProfile.code, 'TRUSTED_USER_REQUIRED', 'legacy x-user-id should be disabled when configured off'); const trustedProfile = await requestAt(baseUrl, '/api/profile/me', { userId: false, headers: authHeaders, }); assert.equal(trustedProfile.item?.userId, login.user.id, 'trusted session should still work when legacy headers are disabled'); const legacyAdminKey = await requestAt(baseUrl, '/api/platform-admin/overview', { headers: { 'x-platform-admin-key': 'local-platform-admin-key' }, expectStatus: 401, }); assert.equal(legacyAdminKey.code, 'PLATFORM_ADMIN_KEY_DISABLED', 'platform admin key should be disabled when configured off'); } function stopServer() { if (serverProcess && !serverProcess.killed) { serverProcess.kill(); } if (legacyDisabledServer && !legacyDisabledServer.killed) { legacyDisabledServer.kill(); } if (fakeWechatServer) { fakeWechatServer.close(); fakeWechatServer = null; } if (fakeWechatPayServer) { fakeWechatPayServer.close(); fakeWechatPayServer = null; } } async function testCatalogAndLearning() { const questions = await request('/api/catalog/questions', { query: { limit: 500 } }); const question = questions.items?.find(item => item.id === ids.question); assert.ok(question, 'main tenant should return smoke question'); assert.equal(question.hasVideoExplanation, true, 'smoke question should expose video marker'); assert.equal(question.contentNodeId, ids.contentNodeSchoolTarget, 'question should expose new content node binding'); const entries = await request('/api/catalog/content-entries', { query: { regionId: ids.region, entryType: 'question_practice' }, }); assert.ok(entries.items?.some(item => item.id === ids.contentEntry), 'catalog should expose question practice entry'); const rootNodes = await request('/api/catalog/content-nodes', { query: { entryId: ids.contentEntry, parentId: 'root' }, }); assert.ok(rootNodes.items?.some(item => item.id === ids.contentNodeProfessional), 'catalog should expose root professional node'); const schoolNodes = await request('/api/catalog/content-nodes', { query: { entryId: ids.contentEntry, markerType: 'school', mode: 'flat' }, }); assert.ok( schoolNodes.items?.some(item => item.id === ids.contentNodeSchoolTarget && item.markerConfig?.salesIntent === true), 'catalog should expose school target marker for sales intent', ); const collections = await request('/api/catalog/question-collections', { query: { nodeId: ids.contentNodeSchoolTarget }, }); assert.ok(collections.items?.some(item => item.id === ids.questionCollection), 'catalog should expose node question collection'); const collectionQuestions = await request('/api/catalog/question-collections/questions', { query: { collectionId: ids.questionCollection }, }); assert.ok(collectionQuestions.items?.some(item => item.id === ids.question), 'collection questions should include smoke question'); const blueprintList = await request('/api/catalog/practice-blueprints', { query: { collectionId: ids.questionCollection }, }); assert.ok(blueprintList.items?.some(item => item.id === ids.practiceBlueprintMock), 'catalog should expose mock exam blueprint'); const examDates = await request('/api/catalog/exam-dates', { query: { regionId: ids.region }, }); const smokeExamDate = examDates.items?.find(item => item.id === ids.examDate); assert.equal(smokeExamDate?.examName, '烟测统考', 'catalog should expose tenant exam date'); assert.equal(typeof smokeExamDate?.daysLeft, 'number', 'catalog exam date should include countdown days'); const questionsByNode = await request('/api/catalog/questions', { query: { contentNodeId: ids.contentNodeSchoolTarget, limit: 20 }, }); assert.ok(questionsByNode.items?.some(item => item.id === ids.question), 'catalog questions should filter by contentNodeId'); const freeLogin = await loginBySms('13800000008'); const freeSession = await request('/api/learning/practice-sessions', { userId: false, headers: { authorization: `Bearer ${freeLogin.session.token}` }, method: 'POST', body: { mode: 'sequential', collectionId: ids.questionCollection, questionLimit: 5, }, }); assert.equal(freeSession.item?.collectionId, ids.questionCollection, 'collection session should bind collection'); assert.equal(freeSession.item?.accessMode, 'free', 'non-SVIP user should use free quota'); assert.equal(freeSession.item?.consumedFreeQuota, 2, 'free practice should consume configured daily quota'); assert.equal(freeSession.item?.questionCount, 2, 'free practice should be truncated to configured free limit'); assert.equal(freeSession.item?.accessSnapshot?.truncated, true, 'free practice access snapshot should mark truncation'); assert.ok(freeSession.item?.questionIds?.includes(ids.question), 'free session should snapshot first question'); const freeLimitReached = await request('/api/learning/practice-sessions', { userId: false, headers: { authorization: `Bearer ${freeLogin.session.token}` }, method: 'POST', body: { mode: 'sequential', collectionId: ids.questionCollection, questionLimit: 5, }, expectStatus: 403, }); assert.equal(freeLimitReached.code, 'PRACTICE_FREE_LIMIT_REACHED', 'second free session should be blocked after quota is exhausted'); const forbiddenAnswer = await request('/api/learning/answers', { userId: false, headers: { authorization: `Bearer ${freeLogin.session.token}` }, method: 'POST', body: { questionId: ids.questionThree, selectedOptions: ['1'], practiceSessionId: freeSession.item.id, }, expectStatus: 403, }); assert.equal(forbiddenAnswer.code, 'PRACTICE_SESSION_QUESTION_FORBIDDEN', 'answers outside the session snapshot should be rejected'); const answer = await request('/api/learning/answers', { userId: false, headers: { authorization: `Bearer ${freeLogin.session.token}` }, method: 'POST', body: { questionId: ids.question, selectedOptions: ['0'], practiceSessionId: freeSession.item.id, }, }); assert.equal(answer.item?.isCorrect, false, 'wrong answer should be judged false'); const staffSequentialSession = await request('/api/learning/practice-sessions', { userId: TENANT_ADMIN_USER_ID, method: 'POST', body: { userId: TENANT_ADMIN_USER_ID, mode: 'sequential', collectionId: ids.questionCollection, questionLimit: 5, }, }); assert.equal(staffSequentialSession.item?.accessMode, 'staff', 'tenant staff should bypass free practice quota'); assert.ok(staffSequentialSession.item?.questionIds?.includes(ids.questionThree), 'staff session should receive the full collection'); const nodeSession = await request('/api/learning/practice-sessions', { userId: TENANT_ADMIN_USER_ID, method: 'POST', body: { userId: TENANT_ADMIN_USER_ID, mode: 'random', contentNodeId: ids.contentNodeProfessional, questionLimit: 5, }, }); assert.equal(nodeSession.item?.contentNodeId, ids.contentNodeProfessional, 'node session should bind parent content node'); assert.ok(nodeSession.item?.questionIds?.includes(ids.question), 'node session should include descendant questions'); const mockSession = await request('/api/learning/practice-sessions', { userId: TENANT_ADMIN_USER_ID, method: 'POST', body: { userId: TENANT_ADMIN_USER_ID, blueprintId: ids.practiceBlueprintMock, }, }); assert.equal(mockSession.item?.mode, 'mock_exam', 'mock blueprint should create mock_exam session'); assert.equal(mockSession.item?.blueprintId, ids.practiceBlueprintMock, 'mock session should bind blueprint'); assert.equal(mockSession.item?.durationMinutes, 120, 'mock session should inherit duration'); assert.equal(Number(mockSession.item?.totalScore), 100, 'mock session should inherit total score'); assert.ok(mockSession.item?.questionIds?.includes(ids.question), 'mock session should snapshot assembled questions'); await request('/api/learning/answers', { userId: TENANT_ADMIN_USER_ID, method: 'POST', body: { userId: TENANT_ADMIN_USER_ID, questionId: ids.question, selectedOptions: ['1'], practiceSessionId: mockSession.item.id, }, }); await request('/api/learning/answers', { userId: TENANT_ADMIN_USER_ID, method: 'POST', body: { userId: TENANT_ADMIN_USER_ID, questionId: ids.questionTwo, selectedOptions: ['0'], practiceSessionId: mockSession.item.id, }, }); const mockReport = await request('/api/learning/practice-sessions/submit', { userId: TENANT_ADMIN_USER_ID, method: 'POST', body: { userId: TENANT_ADMIN_USER_ID, practiceSessionId: mockSession.item.id, }, }); assert.equal(mockReport.item?.practiceSessionId, mockSession.item.id, 'mock submit should create a report for the session'); assert.equal(mockReport.item?.totalQuestions, 3, 'mock report should count session snapshot questions'); assert.equal(mockReport.item?.answeredCount, 2, 'mock report should count latest submitted answers'); assert.equal(mockReport.item?.correctCount, 1, 'mock report should count correct answers'); assert.equal(mockReport.item?.wrongCount, 1, 'mock report should count wrong answers'); assert.equal(mockReport.item?.unansweredCount, 1, 'mock report should count unanswered questions'); assert.equal(mockReport.item?.score, 2, 'mock report should use backend scoring only'); assert.equal(mockReport.item?.totalScore, 100, 'mock report should keep configured paper total score'); assert.ok(mockReport.item?.wrongQuestionIds?.includes(ids.questionTwo), 'mock report should include wrong question ids'); assert.ok(mockReport.item?.questionResults?.some(item => item.questionId === ids.question && item.isCorrect === true), 'mock report should include per-question result'); const idempotentReport = await request('/api/learning/practice-sessions/submit', { userId: TENANT_ADMIN_USER_ID, method: 'POST', body: { userId: TENANT_ADMIN_USER_ID, practiceSessionId: mockSession.item.id, }, }); assert.equal(idempotentReport.item?.id, mockReport.item.id, 'mock submit should be idempotent'); const fetchedReport = await request('/api/learning/practice-sessions/report', { userId: TENANT_ADMIN_USER_ID, query: { practiceSessionId: mockSession.item.id }, }); assert.equal(fetchedReport.item?.id, mockReport.item.id, 'mock report should be queryable'); const reportList = await request('/api/learning/practice-reports', { userId: TENANT_ADMIN_USER_ID, query: { blueprintId: ids.practiceBlueprintMock }, }); assert.ok(reportList.items?.some(item => item.id === mockReport.item.id), 'mock report list should include submitted report'); const crossUserReport = await request('/api/learning/practice-sessions/report', { query: { practiceSessionId: mockSession.item.id }, expectStatus: 404, }); assert.equal(crossUserReport.code, 'PRACTICE_REPORT_NOT_FOUND', 'student should not query another user report'); const crossUserSubmit = await request('/api/learning/practice-sessions/submit', { method: 'POST', body: { practiceSessionId: mockSession.item.id, }, expectStatus: 404, }); assert.equal(crossUserSubmit.code, 'PRACTICE_SESSION_NOT_FOUND', 'student should not submit another user session'); const wrong = await request('/api/learning/wrong-questions', { userId: false, headers: { authorization: `Bearer ${freeLogin.session.token}` }, query: { status: 'all' }, }); assert.ok(wrong.items?.some(item => item.questionId === ids.question), 'wrong book should include smoke question'); const wrongReviewPlan = await request('/api/learning/wrong-questions/review-plan', { userId: false, headers: { authorization: `Bearer ${freeLogin.session.token}` }, query: { limit: 10 }, }); assert.ok(wrongReviewPlan.items?.some(item => item.questionId === ids.question), 'wrong review plan should include unresolved wrong question'); assert.equal(wrongReviewPlan.nextAction?.body?.mode, 'wrong_review', 'wrong review plan should guide backend session creation'); const wrongReviewSession = await request('/api/learning/practice-sessions', { userId: false, headers: { authorization: `Bearer ${freeLogin.session.token}` }, method: 'POST', body: { mode: 'wrong_review', questionLimit: 5, }, }); assert.equal(wrongReviewSession.item?.mode, 'wrong_review', 'wrong review should create a dedicated practice session'); assert.ok(wrongReviewSession.item?.questionIds?.includes(ids.question), 'wrong review session should be assembled by backend wrong book'); const history = await request('/api/learning/practice-sessions/history', { userId: TENANT_ADMIN_USER_ID, query: { mode: 'mock_exam', limit: 20 }, }); assert.ok(history.items?.some(item => item.id === mockSession.item.id && item.reportId === mockReport.item.id), 'practice history should include submitted mock session'); const learningStats = await request('/api/learning/stats', { userId: TENANT_ADMIN_USER_ID, query: { days: 30 }, }); assert.ok(learningStats.item?.answers?.totalAnswered >= 2, 'learning stats should count answer records'); assert.ok(learningStats.item?.reports?.reportCount >= 1, 'learning stats should count reports'); assert.ok(learningStats.item?.questionTypes?.some(item => item.questionType === 'choice'), 'learning stats should include question type distribution'); const learningTrend = await request('/api/learning/trend', { userId: TENANT_ADMIN_USER_ID, query: { days: 7 }, }); assert.ok(Array.isArray(learningTrend.items), 'learning trend should return daily buckets'); assert.ok(learningTrend.items.length >= 1, 'learning trend should include at least one bucket'); assert.ok(learningTrend.items.some(item => item.answeredCount >= 2), 'learning trend should include answer activity'); await request('/api/learning/favorites/questions', { userId: TENANT_ADMIN_USER_ID, method: 'POST', body: { userId: TENANT_ADMIN_USER_ID, questionId: ids.questionThree, favorite: true, }, }); const favoriteReviewSession = await request('/api/learning/practice-sessions', { userId: TENANT_ADMIN_USER_ID, method: 'POST', body: { userId: TENANT_ADMIN_USER_ID, mode: 'favorite_review', questionLimit: 5, }, }); assert.equal(favoriteReviewSession.item?.mode, 'favorite_review', 'favorite review should create a dedicated practice session'); assert.ok(favoriteReviewSession.item?.questionIds?.includes(ids.questionThree), 'favorite review session should be assembled by backend favorites'); } async function testProfile() { const payload = await request('/api/profile/me'); 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'); const countdowns = await request('/api/profile/exam-countdowns'); const profileExamDate = countdowns.items?.find(item => item.id === ids.examDate); assert.equal(profileExamDate?.examName, '烟测统考', 'profile countdown should include tenant exam date'); assert.equal(typeof profileExamDate?.daysLeft, 'number', 'profile countdown should include daysLeft'); assert.equal(countdowns.target?.regionId, ids.region, 'profile countdown should use student target region'); const checkIn = await request('/api/profile/check-in', { method: 'POST' }); assert.equal(checkIn.item?.checkedIn, true, 'student should be able to check in'); assert.ok(checkIn.item?.pointsAdded >= 10, 'check-in should add points'); assert.ok(checkIn.item?.score >= checkIn.item?.pointsAdded, 'check-in should return updated score'); assert.equal(checkIn.item?.ledger?.eventType, 'check_in', 'check-in should write score ledger'); const duplicateCheckIn = await request('/api/profile/check-in', { method: 'POST' }); assert.equal(duplicateCheckIn.item?.checkedIn, false, 'duplicate daily check-in should be idempotent'); assert.equal(duplicateCheckIn.item?.alreadyCheckedIn, true, 'duplicate daily check-in should report already checked in'); assert.equal(duplicateCheckIn.item?.pointsAdded, 0, 'duplicate daily check-in should not add points'); const scoreEvents = await request('/api/profile/score-events', { query: { limit: 20 } }); assert.ok(scoreEvents.items?.some(item => item.eventType === 'check_in'), 'score ledger should include check-in event'); const feedback = await request('/api/profile/feedbacks', { method: 'POST', body: { questionId: ids.question, type: 'question_error', category: 'answer', title: '集成测试题目纠错', description: '这道烟测题目的解析需要租户后台核对。', contact: 'student@example.test', metadata: { source: 'api-integration-test' }, }, }); assert.equal(feedback.item?.status, 'pending', 'student feedback should start as pending'); assert.equal(feedback.item?.questionId, ids.question, 'student feedback should bind tenant question'); const feedbackList = await request('/api/profile/feedbacks', { query: { status: 'pending', limit: 20 } }); assert.ok(feedbackList.items?.some(item => item.id === feedback.item.id), 'profile feedback list should include submitted feedback'); const trustedLogin = await loginBySms('13800000000'); const crossTenantFeedback = await request('/api/profile/feedbacks', { tenantId: PARTNER_TENANT_ID, userId: false, headers: { authorization: `Bearer ${trustedLogin.session.token}` }, method: 'POST', body: { questionId: ids.question, description: '不应跨租户提交旧租户题目纠错。', }, expectStatus: 403, }); assert.equal(crossTenantFeedback.code, 'AUTH_TENANT_MISMATCH', 'feedback submission must use authenticated tenant context'); } async function testLearningLeaderboard() { const questions = await request('/api/learning/leaderboard', { query: { metric: 'questions', period: 'all', limit: 10 }, }); assert.equal(questions.metric, 'questions', 'question leaderboard should echo metric'); assert.ok(questions.items?.some(item => item.userId === SECOND_STUDENT_USER_ID), 'question leaderboard should include second student'); assert.ok(questions.items?.some(item => item.userId === USER_ID), 'question leaderboard should include current student'); const secondQuestions = questions.items?.find(item => item.userId === SECOND_STUDENT_USER_ID); const currentQuestions = questions.currentUser; assert.ok(secondQuestions?.value >= currentQuestions?.value, 'second student should rank at least as high as smoke user by questions'); assert.equal(currentQuestions?.userId, USER_ID, 'question leaderboard should include current user rank'); assert.equal(currentQuestions?.isCurrentUser, true, 'current user rank should be flagged'); const score = await request('/api/learning/leaderboard', { query: { metric: 'score', period: 'all', limit: 10 }, }); const scoreLeader = score.items?.find(item => item.userId === SECOND_STUDENT_USER_ID); assert.ok(scoreLeader?.value >= 30, 'score leaderboard should use platform user score'); assert.ok(score.currentUser?.value >= 10, 'score leaderboard should include current user score'); const vocabulary = await request('/api/learning/leaderboard', { query: { metric: 'vocabulary', period: 'all', limit: 10 }, }); const vocabularyLeader = vocabulary.items?.find(item => item.userId === SECOND_STUDENT_USER_ID); assert.equal(vocabularyLeader?.value, 2, 'vocabulary leaderboard should count mastered words'); const mockExam = await request('/api/learning/leaderboard', { query: { metric: 'mock_exam', period: '30d', limit: 10 }, }); const mockLeader = mockExam.items?.find(item => item.userId === SECOND_STUDENT_USER_ID); assert.equal(mockLeader?.value, 95, 'mock exam leaderboard should use best report score'); assert.ok(mockExam.currentUser?.value >= 70, 'mock exam leaderboard should include current user best score'); const classScoped = await request('/api/learning/leaderboard', { query: { metric: 'questions', classId: ids.tenantClass, limit: 10 }, }); assert.ok(classScoped.items?.some(item => item.userId === USER_ID), 'class scoped leaderboard should include class student'); assert.ok(!classScoped.items?.some(item => item.userId === SECOND_STUDENT_USER_ID), 'class scoped leaderboard should exclude other class student'); const trustedLogin = await loginBySms('13800000000'); const crossTenantDenied = await request('/api/learning/leaderboard', { tenantId: PARTNER_TENANT_ID, userId: false, headers: { authorization: `Bearer ${trustedLogin.session.token}` }, expectStatus: 403, }); assert.equal(crossTenantDenied.code, 'AUTH_TENANT_MISMATCH', 'leaderboard must reject trusted session cross-tenant access'); } async function testScoreline() { const fields = await request('/api/scoreline/fields', { query: { regionId: ids.region } }); assert.ok(fields.items?.some(item => item.fieldKey === 'minScore'), 'scoreline fields should include minScore'); const schools = await request('/api/scoreline/schools', { query: { regionId: ids.region } }); assert.ok(schools.items?.some(item => item.id === ids.scorelineSchool), 'scoreline school should exist'); const records = await request('/api/scoreline/records', { query: { regionId: ids.region, pageSize: 5 } }); assert.ok(records.total >= 1, 'scoreline records should have data'); assert.ok(records.items?.some(item => item.fieldValues?.minScore === 188), 'scoreline record should include dynamic field values'); const years = await request('/api/scoreline/years', { query: { regionId: ids.region } }); assert.ok(years.items?.includes(2026), 'scoreline years should include 2026'); } async function testVideos() { const single = await request(`/api/questions/${ids.question}/videos`); assert.ok(single.total >= 1, 'question should have videos'); const svipVideo = single.videos?.find(item => item.id === ids.video); const quotaVideo = single.videos?.find(item => item.id === ids.quotaVideo); assert.equal(svipVideo?.accessMode, 'svip', 'SVIP video should expose access mode'); assert.equal(svipVideo?.videoUrl, null, 'SVIP video list must not expose playable URL'); assert.equal(quotaVideo?.accessMode, 'video_quota', 'quota video should expose access mode'); const batch = await request('/api/questions/videos/batch', { method: 'POST', body: { questionIds: [ids.question] }, }); assert.equal(batch.data?.[ids.question]?.hasVideo, true, 'batch video lookup should mark question as having video'); const search = await request('/api/videos/search', { query: { tags: '烟测' } }); assert.ok(search.videos?.some(item => item.title === '烟测题目视频讲解'), 'general video search should find smoke video'); assert.ok(!JSON.stringify(search).includes('https://example.test/videos/smoke.mp4'), 'video search must not expose paid playback URL'); const noSvipLogin = await loginBySms('13800000007'); const svipDenied = await request('/api/videos/play', { userId: false, headers: { authorization: `Bearer ${noSvipLogin.session.token}` }, method: 'POST', body: { videoId: ids.video, questionId: ids.question }, expectStatus: 403, }); assert.equal(svipDenied.code, 'VIDEO_SVIP_REQUIRED', 'SVIP video playback should require entitlement before commerce grants it'); const quotaPlayback = await request('/api/videos/play', { method: 'POST', body: { videoId: ids.quotaVideo, questionId: ids.question }, }); assert.ok(quotaPlayback.playback?.url, 'quota video playback should return signed URL'); assert.equal(quotaPlayback.access?.mode, 'video_quota', 'quota video playback should use quota mode'); assert.equal(quotaPlayback.access?.consumedQuota, 1, 'quota video playback should consume one quota'); assert.ok(quotaPlayback.playToken?.startsWith('vp_'), 'video playback should return a play token'); } async function testVocabulary() { const stats = await request('/api/learning/vocabulary/stats', { query: { unitId: ids.vocabularyUnit } }); assert.ok(stats.item?.totalWords >= 1, 'word stats should count smoke word'); const reviewUnknown = await request('/api/learning/vocabulary/review', { method: 'POST', body: { userId: USER_ID, wordId: ids.vocabularyWord, result: 'unknown' }, }); assert.equal(reviewUnknown.item?.lastResult, 'unknown', 'word review should persist unknown result'); assert.equal(reviewUnknown.item?.dueLevel, 'again', 'unknown word should be scheduled again'); const reviewPlan = await request('/api/learning/vocabulary/review-plan', { query: { unitId: ids.vocabularyUnit, reviewLimit: 10, newLimit: 10 }, }); assert.ok(Array.isArray(reviewPlan.item?.words), 'word review plan should return planned words'); assert.ok(reviewPlan.item?.words?.some(item => item.wordId === ids.vocabularyWord), 'word review plan should include due smoke word'); const progress = await request('/api/learning/vocabulary/progress', { method: 'POST', body: { userId: USER_ID, wordId: ids.vocabularyWord, status: 'mastered', correctDelta: 1 }, }); assert.equal(progress.item?.status, 'mastered', 'word progress should update to mastered'); const reviewKnown = await request('/api/learning/vocabulary/review', { method: 'POST', body: { userId: USER_ID, wordId: ids.vocabularyWord, result: 'known' }, }); assert.equal(reviewKnown.item?.lastResult, 'known', 'word review should persist known result'); assert.ok(reviewKnown.item?.reviewCount >= 1, 'word review should increment review count'); assert.ok(reviewKnown.item?.nextReviewDate, 'word review should compute next review date'); const favorite = await request('/api/learning/vocabulary/favorites', { method: 'POST', body: { userId: USER_ID, wordId: ids.vocabularyWord, favorite: true }, }); assert.equal(favorite.favorite, true, 'word favorite should be true'); const favorites = await request('/api/learning/vocabulary/favorites', { query: { unitId: ids.vocabularyUnit } }); assert.ok(favorites.items?.some(item => item.wordId === ids.vocabularyWord), 'favorite list should include smoke word'); } async function testCommerce() { const orders = await request('/api/commerce/orders'); assert.ok(orders.items?.some(item => item.orderNo === 'SMOKE-ORDER-20260621'), 'orders should include smoke order'); const activationPrecheck = await request('/api/commerce/activation-codes/check', { method: 'POST', body: { code: ' smoke20260621 ', regionId: ids.region }, }); assert.equal(activationPrecheck.valid, true, 'activation code precheck should validate unused code'); assert.equal(activationPrecheck.days, 30, 'activation code precheck should expose granted days'); assert.equal(activationPrecheck.regionId, ids.region, 'activation code precheck should expose bound region'); const selfCodePrecheck = await request('/api/commerce/activation-codes/check', { method: 'POST', body: { code: 'SMOKESELF20260621', regionId: ids.region }, }); assert.equal(selfCodePrecheck.valid, false, 'self-issued activation code should be invalid for the issuing user'); assert.equal( selfCodePrecheck.reasonCode, 'ACTIVATION_CODE_SELF_REDEEM_FORBIDDEN', 'self-issued activation precheck should expose a stable reason code', ); const selfCodeRedeem = await request('/api/commerce/activation-codes/redeem', { method: 'POST', body: { code: 'SMOKESELF20260621', regionId: ids.region }, expectStatus: 409, }); assert.equal( selfCodeRedeem.code, 'ACTIVATION_CODE_SELF_REDEEM_FORBIDDEN', 'redeeming a self-issued activation code must be blocked', ); const redeemed = await request('/api/commerce/activation-codes/redeem', { method: 'POST', body: { code: 'SMOKE20260621', regionId: ids.region }, }); assert.ok(redeemed.item?.entitlement?.id, 'activation code should grant an entitlement'); const usedCodePrecheck = await request('/api/commerce/activation-codes/check', { method: 'POST', body: { code: 'SMOKE20260621', regionId: ids.region }, }); assert.equal(usedCodePrecheck.valid, false, 'activation code precheck should reject used code'); assert.equal(usedCodePrecheck.reasonCode, 'ACTIVATION_CODE_USED', 'used activation code should return stable reason code'); const entitlements = await request('/api/commerce/entitlements'); assert.ok(Array.isArray(entitlements.items), 'entitlements should return a list'); assert.ok(entitlements.summary && typeof entitlements.summary.isSvip === 'boolean', 'entitlements should include summary'); assert.equal(entitlements.summary.isSvip, true, 'redeemed activation code should make smoke user SVIP'); const svipPlayback = await request('/api/videos/play', { method: 'POST', body: { videoId: ids.video, questionId: ids.question }, }); assert.ok(svipPlayback.playback?.url, 'SVIP video playback should return signed URL after entitlement is active'); assert.equal(svipPlayback.access?.mode, 'svip', 'SVIP video playback should use svip mode'); assert.equal(svipPlayback.access?.consumedQuota, 0, 'SVIP video playback should not consume quota'); const couponOnlyDenied = await request('/api/commerce/orders', { method: 'POST', body: { planId: ids.couponOnlyPlan, payProvider: 'manual', payMethod: 'manual', regionId: ids.region, }, expectStatus: 403, }); assert.equal(couponOnlyDenied.code, 'PLAN_REQUIRES_COUPON', 'coupon-only plan should require a coupon'); const claimedCoupon = await request('/api/commerce/coupons/claim', { method: 'POST', body: { code: 'SMOKE50', planId: ids.couponOnlyPlan, regionId: ids.region }, }); assert.equal(claimedCoupon.valid, true, 'coupon claim should be valid'); assert.equal(claimedCoupon.idempotent, false, 'first coupon claim should create a redemption'); assert.equal(claimedCoupon.coupon?.discountCents, 500, 'coupon claim should calculate 50% discount'); const claimedCouponAgain = await request('/api/commerce/coupons/claim', { method: 'POST', body: { code: 'SMOKE50', planId: ids.couponOnlyPlan, regionId: ids.region }, }); assert.equal(claimedCouponAgain.idempotent, true, 'second coupon claim should be idempotent'); assert.equal( claimedCouponAgain.redemption?.id, claimedCoupon.redemption?.id, 'idempotent coupon claim should return the original redemption', ); const discountedOrder = await request('/api/commerce/orders', { method: 'POST', body: { planId: ids.couponOnlyPlan, payProvider: 'manual', payMethod: 'manual', regionId: ids.region, couponRedemptionId: claimedCoupon.redemption.id, }, }); assert.equal(discountedOrder.item?.originalAmountCents, 1000, 'discounted order should use server-side plan price'); assert.equal(discountedOrder.item?.discountCents, 500, 'discounted order should apply claimed coupon'); assert.equal(discountedOrder.item?.amountCents, 500, 'discounted order payable amount should be 500 cents'); assert.equal(discountedOrder.item?.status, 'pending', 'partially discounted order should remain pending'); const discountedDetail = await request('/api/commerce/orders/detail', { query: { orderNo: discountedOrder.item.orderNo }, }); assert.equal(discountedDetail.item?.pricing?.discountCents, 500, 'order detail should expose pricing snapshot'); assert.ok( discountedDetail.item?.items?.some(item => item.itemType === 'coupon_discount' && item.totalAmountCents === -500), 'order detail should include a negative coupon order item', ); assert.ok( discountedDetail.item?.couponRedemptions?.some(item => item.status === 'used' && item.discountAppliedCents === 500), 'order detail should include used coupon redemption', ); const discountedStatus = await request('/api/commerce/orders/status', { query: { orderNo: discountedOrder.item.orderNo }, }); assert.equal(discountedStatus.item?.status, 'pending', 'order status endpoint should return pending status'); assert.equal(discountedStatus.item?.payment?.status, 'pending', 'order status endpoint should include latest payment'); const studentManualConfirmDenied = await request('/api/commerce/payments/manual-confirm', { method: 'POST', body: { orderNo: discountedOrder.item.orderNo, amountCents: discountedOrder.item.amountCents }, expectStatus: 403, }); assert.equal( studentManualConfirmDenied.code, 'TENANT_ADMIN_REQUIRED', 'student must not manually confirm commerce payments', ); const manualConfirmed = await request('/api/commerce/payments/manual-confirm', { userId: TENANT_ADMIN_USER_ID, method: 'POST', body: { orderNo: discountedOrder.item.orderNo, amountCents: discountedOrder.item.amountCents, providerTradeNo: `manual-${discountedOrder.item.orderNo}`, }, }); assert.equal(manualConfirmed.item?.status, 'paid', 'tenant admin should manually confirm discounted order'); assert.equal(manualConfirmed.item?.confirmedBy, TENANT_ADMIN_USER_ID, 'manual confirmation should record operator'); assert.ok(manualConfirmed.item?.entitlement?.id, 'manual confirmation should grant entitlement'); const manualConfirmedAgain = await request('/api/commerce/payments/manual-confirm', { userId: TENANT_ADMIN_USER_ID, method: 'POST', body: { orderNo: discountedOrder.item.orderNo, amountCents: discountedOrder.item.amountCents, providerTradeNo: `manual-${discountedOrder.item.orderNo}`, }, }); assert.equal(manualConfirmedAgain.item?.idempotent, true, 'duplicate manual confirmation should be idempotent'); const usedCouponClaim = await request('/api/commerce/coupons/claim', { method: 'POST', body: { code: 'SMOKE50', planId: ids.couponOnlyPlan, regionId: ids.region }, expectStatus: 409, }); assert.equal(usedCouponClaim.code, 'COUPON_ALREADY_USED', 'used coupon should not be claimable again by same user'); const freeOrder = await request('/api/commerce/orders', { method: 'POST', body: { planId: ids.couponOnlyPlan, payProvider: 'manual', payMethod: 'manual', regionId: ids.region, couponCode: 'SMOKEFREE', }, }); assert.equal(freeOrder.item?.originalAmountCents, 1000, 'free order should still keep original server price'); assert.equal(freeOrder.item?.discountCents, 1000, 'free coupon should discount the full amount'); assert.equal(freeOrder.item?.amountCents, 0, 'free order payable amount should be zero'); assert.equal(freeOrder.item?.status, 'paid', 'zero-amount order should be marked paid immediately'); assert.ok(freeOrder.item?.tradeNo?.startsWith('zero-'), 'zero-amount order should get an internal trade number'); assert.ok(freeOrder.item?.entitlement?.id, 'zero-amount order should grant entitlement immediately'); const freeStatus = await request('/api/commerce/orders/status', { query: { orderNo: freeOrder.item.orderNo }, }); assert.equal(freeStatus.item?.status, 'paid', 'zero-amount order status should be paid'); assert.equal(freeStatus.item?.payment?.status, 'paid', 'zero-amount order payment row should be paid'); assert.equal(freeStatus.item?.payment?.amountCents, 0, 'zero-amount order payment row should have zero amount'); const freePaymentDenied = await request('/api/commerce/payments/create', { method: 'POST', body: { orderNo: freeOrder.item.orderNo, provider: 'manual' }, expectStatus: 409, }); assert.equal(freePaymentDenied.code, 'ORDER_ALREADY_PAID', 'paid zero-amount order should not create another payment'); const crossTenantOrderDetail = await request('/api/commerce/orders/detail', { tenantId: PARTNER_TENANT_ID, query: { orderNo: freeOrder.item.orderNo }, expectStatus: 404, }); assert.equal(crossTenantOrderDetail.code, 'ORDER_NOT_FOUND', 'order detail must be tenant isolated'); const fakeWechatPay = await startFakeWechatPayServer(); const wechatAccount = await request('/api/tenant-admin/payment-accounts', { userId: TENANT_ADMIN_USER_ID, method: 'PUT', body: { provider: 'wechat_pay', mode: 'tenant_collect', displayName: '集成测试微信支付', status: 'active', configPublic: { appId: 'wx-pay-smoke-appid', merchantId: '1900000001', merchantSerialNo: 'serial-smoke', notifyUrl: 'https://pay.example.test/wechat/notify', endpoint: fakeWechatPay.endpoint, wechatpayPublicKey: paymentFixture.wechatPlatformPublicKey, }, secret: { secretJson: { privateKey: paymentFixture.wechatMerchantPrivateKey, apiV3Key: paymentFixture.wechatApiV3Key, }, }, }, }); assert.equal(wechatAccount.item?.provider, 'wechat_pay', 'tenant admin should configure active WeChat Pay account'); assert.ok(!JSON.stringify(wechatAccount).includes(paymentFixture.wechatApiV3Key), 'payment account response must not leak apiV3Key'); const wechatOrder = await request('/api/commerce/orders', { method: 'POST', body: { planId: ids.plan, payProvider: 'wechat_pay', payMethod: 'jsapi', regionId: ids.region, }, }); assert.ok(wechatOrder.item?.orderNo, 'student should create a WeChat Pay order'); const wechatPayment = await request('/api/commerce/payments/create', { method: 'POST', body: { orderNo: wechatOrder.item.orderNo, provider: 'wechat_pay', openId: 'openid-pay-smoke', }, }); assert.equal(wechatPayment.item?.provider, 'wechat_pay', 'payment create should use WeChat Pay provider'); assert.ok(wechatPayment.item?.paymentParams?.paySign, 'WeChat Pay create should return signed JSAPI params'); assert.equal(fakeWechatPay.requests.at(-1)?.body?.out_trade_no, wechatOrder.item.orderNo, 'WeChat Pay provider should call transaction endpoint with order number'); const wechatNotificationBody = { id: `evt-${wechatOrder.item.orderNo}`, create_time: '2026-06-28T00:00:00+08:00', event_type: 'TRANSACTION.SUCCESS', resource_type: 'encrypt-resource', resource: encryptWechatResource({ appid: 'wx-pay-smoke-appid', mchid: '1900000001', out_trade_no: wechatOrder.item.orderNo, transaction_id: `wx-trade-${wechatOrder.item.orderNo}`, trade_state: 'SUCCESS', success_time: '2026-06-28T00:00:00+08:00', amount: { total: wechatOrder.item.amountCents, currency: 'CNY' }, }), }; const wechatRaw = JSON.stringify(wechatNotificationBody); const wechatTimestamp = String(Math.floor(Date.now() / 1000)); const wechatNonce = 'nonce-pay-smoke'; const wechatNotify = await request('/api/commerce/payments/notify/wechat_pay', { userId: false, method: 'POST', query: { tenantId: MAIN_TENANT_ID }, headers: { 'wechatpay-timestamp': wechatTimestamp, 'wechatpay-nonce': wechatNonce, 'wechatpay-signature': signWechatNotification(wechatRaw, wechatTimestamp, wechatNonce), 'wechatpay-serial': 'platform-serial-smoke', }, body: wechatNotificationBody, }); assert.equal(wechatNotify.item?.status, 'paid', 'WeChat Pay notify should mark order paid'); assert.ok(wechatNotify.item?.entitlement?.id, 'WeChat Pay notify should grant entitlement'); const wechatNotifyAgain = await request('/api/commerce/payments/notify/wechat_pay', { userId: false, method: 'POST', query: { tenantId: MAIN_TENANT_ID }, headers: { 'wechatpay-timestamp': wechatTimestamp, 'wechatpay-nonce': wechatNonce, 'wechatpay-signature': signWechatNotification(wechatRaw, wechatTimestamp, wechatNonce), 'wechatpay-serial': 'platform-serial-smoke', }, body: wechatNotificationBody, }); assert.equal(wechatNotifyAgain.item?.idempotent, true, 'duplicate WeChat Pay notify should be idempotent'); const alipayAccount = await request('/api/tenant-admin/payment-accounts', { userId: TENANT_ADMIN_USER_ID, method: 'PUT', body: { provider: 'alipay', mode: 'tenant_collect', displayName: '集成测试支付宝', status: 'active', configPublic: { appId: 'alipay-smoke-appid', notifyUrl: 'https://pay.example.test/alipay/notify', returnUrl: 'https://app.example.test/pay/success', }, secret: { secretJson: { privateKey: paymentFixture.alipayAppPrivateKey, alipayPublicKey: paymentFixture.alipayPlatformPublicKey, }, }, }, }); assert.equal(alipayAccount.item?.provider, 'alipay', 'tenant admin should configure active Alipay account'); assert.ok(!JSON.stringify(alipayAccount).includes('PRIVATE KEY'), 'Alipay account response must not leak private key'); const alipayOrder = await request('/api/commerce/orders', { method: 'POST', body: { planId: ids.plan, payProvider: 'alipay', payMethod: 'wap', regionId: ids.region, }, }); const alipayPayment = await request('/api/commerce/payments/create', { method: 'POST', body: { orderNo: alipayOrder.item.orderNo, provider: 'alipay', }, }); assert.equal(alipayPayment.item?.provider, 'alipay', 'payment create should use Alipay provider'); assert.ok(alipayPayment.item?.paymentParams?.url?.includes('alipay.trade.wap.pay'), 'Alipay create should return WAP URL'); const alipayNotifyBody = { notify_id: `alipay-notify-${alipayOrder.item.orderNo}`, notify_time: '2026-06-28 00:00:00', app_id: 'alipay-smoke-appid', trade_no: `ali-trade-${alipayOrder.item.orderNo}`, out_trade_no: alipayOrder.item.orderNo, trade_status: 'TRADE_SUCCESS', total_amount: (alipayOrder.item.amountCents / 100).toFixed(2), receipt_amount: (alipayOrder.item.amountCents / 100).toFixed(2), charset: 'utf-8', version: '1.0', }; alipayNotifyBody.sign_type = 'RSA2'; alipayNotifyBody.sign = signAlipayParams(alipayNotifyBody); const alipayNotify = await request('/api/commerce/payments/notify/alipay', { userId: false, method: 'POST', query: { tenantId: MAIN_TENANT_ID }, body: alipayNotifyBody, }); assert.equal(alipayNotify.item?.status, 'paid', 'Alipay notify should mark order paid'); assert.ok(alipayNotify.item?.entitlement?.id, 'Alipay notify should grant entitlement'); const tamperedAlipayNotify = await request('/api/commerce/payments/notify/alipay', { userId: false, method: 'POST', query: { tenantId: MAIN_TENANT_ID }, body: { ...alipayNotifyBody, total_amount: '0.01', }, expectStatus: 400, }); assert.equal(tamperedAlipayNotify.code, 'PAYMENT_SIGNATURE_INVALID', 'tampered Alipay notify must fail signature verification'); } async function testTenantIsolation() { const partnerQuestions = await request('/api/catalog/questions', { tenantId: PARTNER_TENANT_ID, query: { limit: 20 }, }); assert.ok(!partnerQuestions.items?.some(item => item.id === ids.question), 'partner tenant must not see main tenant question'); const partnerProfile = await request('/api/profile/me', { tenantId: PARTNER_TENANT_ID, expectStatus: 404, }); assert.equal(partnerProfile.code, 'PROFILE_NOT_FOUND', 'partner tenant must not see main tenant student profile'); const partnerScoreline = await request('/api/scoreline/records', { tenantId: PARTNER_TENANT_ID, query: { regionId: ids.region }, }); assert.equal(partnerScoreline.total, 0, 'partner tenant must not see main tenant scoreline records'); const partnerVideos = await request('/api/questions/videos/batch', { tenantId: PARTNER_TENANT_ID, method: 'POST', body: { questionIds: [ids.question] }, }); assert.equal(partnerVideos.data?.[ids.question], undefined, 'partner tenant must not see main tenant question videos'); const partnerWordStats = await request('/api/learning/vocabulary/stats', { tenantId: PARTNER_TENANT_ID, query: { unitId: ids.vocabularyUnit }, }); assert.equal(partnerWordStats.item?.totalWords, 0, 'partner tenant must not see main tenant vocabulary words'); const partnerEntries = await request('/api/catalog/content-entries', { tenantId: PARTNER_TENANT_ID, query: { regionId: ids.region }, }); assert.ok(!partnerEntries.items?.some(item => item.id === ids.contentEntry), 'partner tenant must not see main tenant content entries'); const partnerCollections = await request('/api/catalog/question-collections', { tenantId: PARTNER_TENANT_ID, query: { nodeId: ids.contentNodeSchoolTarget }, }); assert.ok(!partnerCollections.items?.some(item => item.id === ids.questionCollection), 'partner tenant must not see main tenant collections'); const partnerBlueprintSession = await request('/api/learning/practice-sessions', { tenantId: PARTNER_TENANT_ID, method: 'POST', body: { userId: USER_ID, blueprintId: ids.practiceBlueprintMock }, expectStatus: 404, }); assert.equal(partnerBlueprintSession.code, 'PRACTICE_BLUEPRINT_NOT_FOUND', 'partner tenant must not assemble main tenant blueprint'); } async function testTenantContentAdmin() { const denied = await request('/api/tenant-content/vocabulary-units', { method: 'PUT', body: { name: '学生不能写入的单元' }, expectStatus: 403, }); assert.equal(denied.code, 'TENANT_CONTENT_EDITOR_REQUIRED', 'student should not write tenant content'); const deniedEntry = await request('/api/tenant-content/content-entries', { method: 'PUT', body: { entryKey: 'student-denied', name: '学生不能配置入口' }, expectStatus: 403, }); assert.equal(deniedEntry.code, 'TENANT_CONTENT_EDITOR_REQUIRED', 'student should not manage content navigation'); const entry = await request('/api/tenant-content/content-entries', { userId: TENANT_ADMIN_USER_ID, method: 'PUT', body: { entryKey: 'integration-practice', regionId: ids.region, name: '集成测试刷题入口', entryType: 'question_practice', route: '/practice/integration', layoutConfig: { tabs: ['all', 'paper', 'chapter', 'type'] }, order: 11, }, }); assert.equal(entry.item?.entryKey, 'integration-practice', 'tenant admin should create content entry'); const rootNode = await request('/api/tenant-content/content-nodes', { userId: TENANT_ADMIN_USER_ID, method: 'PUT', body: { entryId: entry.item.id, nodeKey: 'integration-professional', regionId: ids.region, name: '专业课', nodeType: 'category', markerType: 'exam_track', markerConfig: { intentKey: 'professional' }, isLeaf: false, order: 1, }, }); assert.equal(rootNode.item?.markerType, 'exam_track', 'tenant admin should create marked root node'); const childNode = await request('/api/tenant-content/content-nodes', { userId: TENANT_ADMIN_USER_ID, method: 'PUT', body: { entryId: entry.item.id, parentId: rootNode.item.id, nodeKey: 'integration-school-target', regionId: ids.region, name: '集成测试学院', nodeType: 'school', markerType: 'school', markerConfig: { salesIntent: true, schoolName: '集成测试学院' }, isLeaf: true, order: 1, }, }); assert.equal(childNode.item?.parentId, rootNode.item.id, 'tenant admin should create child node'); assert.ok(String(childNode.item?.path || '').includes(String(rootNode.item?.path || '')), 'child node should have hierarchical path'); const unit = await request('/api/tenant-content/vocabulary-units', { userId: TENANT_ADMIN_USER_ID, method: 'PUT', body: { regionId: ids.region, name: '集成测试单词单元', description: '租户后台内容维护集成测试', order: 99, }, }); assert.ok(unit.item?.id, 'tenant admin should create vocabulary unit'); const word = await request('/api/tenant-content/vocabulary-words', { userId: TENANT_ADMIN_USER_ID, method: 'PUT', body: { unitId: unit.item.id, word: 'commercial', phonetic: '/kəˈmɜːrʃl/', meaning: '商业的', tags: ['integration'], }, }); assert.equal(word.item?.word, 'commercial', 'tenant admin should create vocabulary word'); const video = await request('/api/tenant-content/videos', { userId: TENANT_ADMIN_USER_ID, method: 'PUT', body: { title: '集成测试视频', videoUrl: 'https://example.test/videos/integration.mp4', knowledgeTags: ['integration'], isGeneral: true, }, }); assert.ok(video.item?.id, 'tenant admin should create video'); const question = await request('/api/tenant-content/questions', { userId: TENANT_ADMIN_USER_ID, method: 'POST', body: { subjectId: '00000000-0000-0000-0000-000000000501', categoryId: '00000000-0000-0000-0000-000000000601', entryId: entry.item.id, contentNodeId: childNode.item.id, type: 'choice', typeLabel: '单选题', difficulty: 2, content: '集成测试题:2 + 2 = ?', options: [ { label: 'A', text: '3' }, { label: 'B', text: '4' }, ], correctOptionIndex: 1, correctOptionIndices: [1], answerText: '4', explanation: '基础加法。', status: 'published', examMarkers: { schoolName: '集成测试学院', salesIntent: true }, }, }); assert.ok(question.item?.id, 'tenant admin should create question'); assert.equal(question.item?.currentVersion?.correctOptionIndex, 1, 'created question should have a version'); const collection = await request('/api/tenant-content/question-collections', { userId: TENANT_ADMIN_USER_ID, method: 'PUT', body: { entryId: entry.item.id, nodeId: childNode.item.id, regionId: ids.region, subjectId: ids.subject, categoryId: ids.category, questionBankId: '00000000-0000-0000-0000-000000000400', name: '集成测试题目列表', collectionType: 'manual', sourceType: 'manual_questions', durationMinutes: 90, totalScore: 100, questions: [{ questionId: question.item.id, sectionKey: 'choice', order: 1, score: 2 }], }, }); assert.equal(collection.item?.questionCount, 1, 'tenant admin should create question collection with items'); const blueprint = await request('/api/tenant-content/practice-blueprints', { userId: TENANT_ADMIN_USER_ID, method: 'PUT', body: { entryId: entry.item.id, nodeId: childNode.item.id, collectionId: collection.item.id, regionId: ids.region, name: '集成测试全真模拟', mode: 'mock_exam', assemblyType: 'collection', questionLimit: 10, durationMinutes: 90, totalScore: 100, passScore: 60, sections: [{ key: 'choice', questionType: 'choice', questionCount: 10, scoreEach: 2 }], rules: { randomize: true }, }, }); assert.equal(blueprint.item?.mode, 'mock_exam', 'tenant admin should create mock exam blueprint'); const adminNodes = await request('/api/tenant-content/content-nodes', { userId: TENANT_ADMIN_USER_ID, query: { entryId: entry.item.id, mode: 'flat' }, }); assert.ok(adminNodes.items?.some(item => item.id === childNode.item.id), 'tenant admin should list created navigation nodes'); const publicCreatedCollections = await request('/api/catalog/question-collections', { query: { nodeId: childNode.item.id }, }); assert.ok(publicCreatedCollections.items?.some(item => item.id === collection.item.id), 'catalog should expose created collection'); const createdBlueprintSession = await request('/api/learning/practice-sessions', { method: 'POST', body: { userId: USER_ID, blueprintId: blueprint.item.id }, }); assert.equal(createdBlueprintSession.item?.blueprintId, blueprint.item.id, 'created blueprint should assemble a practice session'); assert.ok(createdBlueprintSession.item?.questionIds?.includes(question.item.id), 'created blueprint session should include created question'); const binding = await request('/api/tenant-content/question-videos', { userId: TENANT_ADMIN_USER_ID, method: 'POST', body: { questionId: question.item.id, videoId: video.item.id, videoType: 'specific' }, }); assert.equal(binding.item?.questionId, question.item.id, 'tenant admin should bind question video'); const scoreSchool = await request('/api/tenant-content/scoreline/schools', { userId: TENANT_ADMIN_USER_ID, method: 'PUT', body: { regionId: ids.region, name: '集成测试学院', shortName: '集测学院', isHot: true }, }); assert.ok(scoreSchool.item?.id, 'tenant admin should create scoreline school'); const scoreMajor = await request('/api/tenant-content/scoreline/majors', { userId: TENANT_ADMIN_USER_ID, method: 'PUT', body: { regionId: ids.region, schoolId: scoreSchool.item.id, name: '软件工程' }, }); assert.ok(scoreMajor.item?.id, 'tenant admin should create scoreline major'); const scoreRecord = await request('/api/tenant-content/scoreline/records', { userId: TENANT_ADMIN_USER_ID, method: 'PUT', body: { regionId: ids.region, schoolId: scoreSchool.item.id, majorId: scoreMajor.item.id, year: 2026, schoolName: '集成测试学院', majorName: '软件工程', fieldValues: { minScore: 199 }, }, }); assert.equal(scoreRecord.item?.fieldValues?.minScore, 199, 'tenant admin should create scoreline record'); const handbookSubject = await request('/api/tenant-content/handbook-subjects', { userId: TENANT_ADMIN_USER_ID, method: 'PUT', body: { regionId: ids.region, name: '集成测试手册', type: 'guide' }, }); assert.ok(handbookSubject.item?.id, 'tenant admin should create handbook subject'); const handbookChapter = await request('/api/tenant-content/handbook-chapters', { userId: TENANT_ADMIN_USER_ID, method: 'PUT', body: { subjectId: handbookSubject.item.id, name: '第一章' }, }); assert.ok(handbookChapter.item?.id, 'tenant admin should create handbook chapter'); const handbookEntry = await request('/api/tenant-content/handbook-entries', { userId: TENANT_ADMIN_USER_ID, method: 'PUT', body: { chapterId: handbookChapter.item.id, title: '商用交付标准', content: '内容维护必须可测试。' }, }); assert.equal(handbookEntry.item?.title, '商用交付标准', 'tenant admin should create handbook entry'); const partnerWrite = await request('/api/tenant-content/question-videos', { tenantId: PARTNER_TENANT_ID, userId: TENANT_ADMIN_USER_ID, method: 'POST', body: { questionId: question.item.id, videoId: video.item.id }, expectStatus: 403, }); assert.equal(partnerWrite.code, 'TENANT_CONTENT_EDITOR_REQUIRED', 'admin user must not administer another tenant without membership'); } async function testTenantContentAssetsAndImports() { const deniedAsset = await request('/api/tenant-content/assets', { method: 'PUT', body: { title: '学生不能上传资料', cdnUrl: 'https://example.test/denied.pdf' }, expectStatus: 403, }); assert.equal(deniedAsset.code, 'TENANT_CONTENT_EDITOR_REQUIRED', 'student should not manage content assets'); const upload = await request('/api/tenant-content/assets/sign-upload', { userId: TENANT_ADMIN_USER_ID, method: 'POST', body: { fileName: 'integration-resource.pdf', assetType: 'pdf', storageProvider: 'local_dev', mimeType: 'application/pdf', }, }); assert.equal(upload.assetDraft?.assetType, 'pdf', 'upload signer should return asset draft'); assert.ok(upload.upload?.objectKey?.includes(MAIN_TENANT_ID), 'upload object key should be tenant scoped'); assert.equal(upload.upload?.signatureMode, 'local-placeholder', 'local signer should be explicit in test env'); assert.equal(upload.upload?.headers?.['content-type'], 'application/pdf', 'upload signer should bind content-type'); assert.ok(!JSON.stringify(upload).includes('ACCESS_KEY'), 'upload signer must not leak cloud credentials'); if (ENABLE_REAL_STORAGE_SIGN_TESTS) { const tencentUpload = await request('/api/tenant-content/assets/sign-upload', { userId: TENANT_ADMIN_USER_ID, method: 'POST', body: { fileName: 'cos-resource.pdf', assetType: 'pdf', storageProvider: 'tencent_cos', bucket: process.env.TEST_TENCENT_COS_BUCKET || 'tenant-assets', objectKey: `${MAIN_TENANT_ID}/pdf/cos-resource.pdf`, mimeType: 'application/pdf', }, }); assert.ok(tencentUpload.upload?.url?.includes('q-sign-algorithm=sha1'), 'Tencent COS signed URL should expose XML V5 query params'); assert.ok(!tencentUpload.upload?.url?.includes('SecretKey'), 'Tencent COS signed URL must not leak SecretKey'); } const unsafeUpload = await request('/api/tenant-content/assets/sign-upload', { userId: TENANT_ADMIN_USER_ID, method: 'POST', body: { fileName: 'unsafe.pdf', assetType: 'pdf', storageProvider: 'local_dev', objectKey: '../escape.pdf', mimeType: 'application/pdf', }, expectStatus: 400, }); assert.equal(unsafeUpload.code, 'INVALID_OBJECT_KEY', 'upload signer should reject path traversal object keys'); const crossTenantUpload = await request('/api/tenant-content/assets/sign-upload', { userId: TENANT_ADMIN_USER_ID, method: 'POST', body: { fileName: 'cross-tenant.pdf', assetType: 'pdf', storageProvider: 'local_dev', objectKey: `${PARTNER_TENANT_ID}/pdf/cross-tenant.pdf`, mimeType: 'application/pdf', }, expectStatus: 400, }); assert.equal(crossTenantUpload.code, 'OBJECT_KEY_TENANT_PREFIX_REQUIRED', 'upload object key should stay in current tenant prefix'); const deniedMimeUpload = await request('/api/tenant-content/assets/sign-upload', { userId: TENANT_ADMIN_USER_ID, method: 'POST', body: { fileName: 'script.html', assetType: 'document', storageProvider: 'local_dev', mimeType: 'text/html', }, expectStatus: 400, }); assert.equal(deniedMimeUpload.code, 'MIME_TYPE_NOT_ALLOWED', 'upload signer should reject unsupported mime types'); const deniedLargeUpload = await request('/api/tenant-content/assets/sign-upload', { userId: TENANT_ADMIN_USER_ID, method: 'POST', body: { fileName: 'huge-video.mp4', assetType: 'video', storageProvider: 'local_dev', mimeType: 'video/mp4', fileSizeBytes: 1024 * 1024 * 1024, }, expectStatus: 400, }); assert.equal(deniedLargeUpload.code, 'FILE_TOO_LARGE', 'upload signer should enforce max upload size'); const asset = await request('/api/tenant-content/assets', { userId: TENANT_ADMIN_USER_ID, method: 'PUT', body: { title: '集成测试 SVIP PDF 资料', assetType: 'pdf', storageProvider: 'external_url', cdnUrl: 'https://example.test/resources/integration.pdf', previewUrl: 'https://example.test/resources/integration-preview.pdf', fileName: 'integration.pdf', mimeType: 'application/pdf', fileSizeBytes: 2048, visibility: 'svip', regionId: ids.region, subjectId: ids.subject, categoryId: ids.category, metadata: { source: 'api-integration' }, }, }); assert.equal(asset.item?.visibility, 'svip', 'tenant admin should create svip asset'); const adminAssets = await request('/api/tenant-content/assets', { userId: TENANT_ADMIN_USER_ID, query: { assetType: 'pdf', regionId: ids.region }, }); assert.ok(adminAssets.items?.some(item => item.id === asset.item.id), 'tenant admin should list own asset'); assert.ok(!JSON.stringify(adminAssets).includes('apiV3Key'), 'asset list should not expose unrelated secrets'); const publicAssets = await request('/api/catalog/assets', { userId: false, query: { assetType: 'pdf', regionId: ids.region, includeLocked: true }, }); assert.ok(!publicAssets.items?.some(item => item.id === asset.item.id), 'anonymous catalog should not list locked svip asset'); const lockedAssets = await request('/api/catalog/assets', { query: { assetType: 'pdf', regionId: ids.region, includeLocked: true }, }); assert.ok(lockedAssets.items?.some(item => item.id === asset.item.id), 'student catalog should list locked svip asset'); const download = await request('/api/catalog/assets/download', { query: { assetId: asset.item.id }, }); assert.equal(download.item?.id, asset.item.id, 'svip student should receive asset download'); assert.equal(download.access?.svip, true, 'asset download should report svip access'); assert.equal(download.download?.url, 'https://example.test/resources/integration.pdf', 'external asset download should use cdn url'); assert.equal(download.download?.signatureMode, 'public-or-provider-managed', 'external asset should be marked provider managed'); const localAsset = await request('/api/tenant-content/assets', { userId: TENANT_ADMIN_USER_ID, method: 'PUT', body: { title: '集成测试本地对象资料', assetType: 'pdf', storageProvider: 'local_dev', bucket: upload.assetDraft.bucket, objectKey: upload.assetDraft.objectKey, fileName: upload.assetDraft.fileName, mimeType: upload.assetDraft.mimeType, fileSizeBytes: upload.assetDraft.fileSizeBytes, visibility: 'tenant', status: 'active', }, }); assert.equal(localAsset.item?.objectKey, upload.assetDraft.objectKey, 'asset upsert should keep normalized object key'); const localDownload = await request('/api/catalog/assets/download', { query: { assetId: localAsset.item.id }, }); assert.equal(localDownload.download?.method, 'GET', 'object asset download should sign GET'); assert.equal(localDownload.download?.signatureMode, 'local-placeholder', 'local object asset should use local placeholder signer'); assert.ok(localDownload.download?.url?.includes(encodeURIComponent(localAsset.item.bucket)), 'object asset download should include bucket'); const invalidObjectAsset = await request('/api/tenant-content/assets', { userId: TENANT_ADMIN_USER_ID, method: 'PUT', body: { title: '非法对象键', assetType: 'pdf', storageProvider: 'local_dev', bucket: 'tenant-assets', objectKey: `${PARTNER_TENANT_ID}/pdf/unsafe.pdf`, fileName: 'unsafe.pdf', mimeType: 'application/pdf', visibility: 'tenant', status: 'active', }, expectStatus: 400, }); assert.equal(invalidObjectAsset.code, 'OBJECT_KEY_TENANT_PREFIX_REQUIRED', 'asset upsert should reject cross-tenant object keys'); const partnerAssetList = await request('/api/tenant-content/assets', { tenantId: PARTNER_TENANT_ID, userId: TENANT_ADMIN_USER_ID, expectStatus: 403, }); assert.equal(partnerAssetList.code, 'TENANT_CONTENT_EDITOR_REQUIRED', 'asset admin must be tenant isolated'); const deniedImport = await request('/api/tenant-content/imports/preview/questions', { method: 'POST', body: { subjectId: ids.subject, categoryId: ids.category, items: [{ type: 'choice', content: '学生不能预览导入', options: ['A', 'B'], correctOptionIndices: [0] }], }, expectStatus: 403, }); assert.equal(deniedImport.code, 'TENANT_CONTENT_EDITOR_REQUIRED', 'student should not preview content import'); const oversizedNormalJson = await request('/api/auth/sms/send', { method: 'POST', body: { phone: '13800000009', purpose: 'login', padding: 'x'.repeat(9000), }, expectStatus: 413, }); assert.equal(oversizedNormalJson.code, 'JSON_BODY_TOO_LARGE', 'ordinary JSON endpoints should enforce body size limit'); const largeImportPreview = await request('/api/tenant-content/imports/preview/questions', { userId: TENANT_ADMIN_USER_ID, method: 'POST', body: { sourceName: 'large-but-allowed-question-import.json', subjectId: ids.subject, categoryId: ids.category, regionId: ids.region, items: [ { legacyId: 'integration-large-preview-001', type: 'choice', content: `大体积导入预览:${'企业级导入需要受控上限。'.repeat(500)}`, options: ['正确', '错误'], correctOptionIndices: [0], }, ], }, }); assert.equal(largeImportPreview.job?.errorCount, 0, 'import preview should use larger bounded body limit'); const invalidPreview = await request('/api/tenant-content/imports/preview/questions', { userId: TENANT_ADMIN_USER_ID, method: 'POST', body: { sourceName: 'invalid-question-import.json', subjectId: ids.subject, categoryId: ids.category, regionId: ids.region, items: [ { type: 'choice', content: '错误导入题:缺少选项和答案', options: [], correctOptionIndices: [], }, ], }, }); assert.equal(invalidPreview.job?.errorCount > 0, true, 'invalid preview should record errors'); assert.ok(invalidPreview.issues?.some(issue => issue.code === 'OPTIONS_REQUIRED'), 'invalid preview should include option issue'); const invalidIssues = await request('/api/tenant-content/imports/issues', { userId: TENANT_ADMIN_USER_ID, query: { jobId: invalidPreview.job.id }, }); assert.ok(invalidIssues.items?.some(item => item.code === 'OPTIONS_REQUIRED'), 'import issues API should return validation issues'); const rejectedImport = await request('/api/tenant-content/imports/questions', { userId: TENANT_ADMIN_USER_ID, method: 'POST', body: { previewJobId: invalidPreview.job.id }, expectStatus: 409, }); assert.equal(rejectedImport.code, 'IMPORT_HAS_ERRORS', 'invalid import should be rejected without allowPartial'); const validPreview = await request('/api/tenant-content/imports/preview/questions', { userId: TENANT_ADMIN_USER_ID, method: 'POST', body: { sourceName: 'valid-question-import.json', subjectId: ids.subject, categoryId: ids.category, regionId: ids.region, entryId: ids.contentEntry, contentNodeId: ids.contentNodeSchoolTarget, collectionId: ids.questionCollection, items: [ { legacyId: 'integration-import-choice-001', type: 'choice', typeLabel: '单选题', content: '批量导入题:企业级 SaaS 应优先使用哪种数据库?', options: ['SQLite', 'PostgreSQL', '纯 JSON 文件', '浏览器缓存'], correctOptionIndices: [1], explanation: 'PostgreSQL 更适合多租户商用场景。', difficulty: 2, tags: ['integration', 'import'], }, { legacyId: 'integration-import-reading-001', type: 'reading', content: '阅读材料:多租户系统需要隔离租户数据。', sub_questions: [ { type: 'choice', content: '多租户系统最重要的边界是什么?', options: ['颜色主题', '数据隔离', '页面动画', '字体大小'], correctOptionIndices: [1], }, ], tags: ['integration', 'reading'], }, ], }, }); assert.equal(validPreview.job?.errorCount, 0, 'valid preview should have no errors'); assert.equal(validPreview.job?.validCount, 2, 'valid preview should count valid rows'); const imported = await request('/api/tenant-content/imports/questions', { userId: TENANT_ADMIN_USER_ID, method: 'POST', body: { previewJobId: validPreview.job.id }, }); assert.equal(imported.item?.status, 'completed', 'valid import should complete'); assert.equal( (imported.item?.insertedCount || 0) + (imported.item?.updatedCount || 0) + (imported.item?.skippedCount || 0), 2, 'valid import should process all valid questions idempotently', ); const jobs = await request('/api/tenant-content/imports', { userId: TENANT_ADMIN_USER_ID, query: { importType: 'questions', limit: 10 }, }); assert.ok(jobs.items?.some(item => item.id === validPreview.job.id && item.status === 'completed'), 'import job list should include completed job'); const importedQuestions = await request('/api/catalog/questions', { query: { collectionId: ids.questionCollection, limit: 100 }, }); assert.ok( importedQuestions.items?.some(item => item.content === '批量导入题:企业级 SaaS 应优先使用哪种数据库?'), 'catalog should expose imported question through the new collection binding', ); const vocabEntry = await request('/api/tenant-content/content-entries', { userId: TENANT_ADMIN_USER_ID, method: 'PUT', body: { entryKey: 'integration-vocabulary', regionId: ids.region, name: '集成测试背单词入口', entryType: 'vocabulary', route: '/vocabulary', order: 21, }, }); const vocabRoot = await request('/api/tenant-content/content-nodes', { userId: TENANT_ADMIN_USER_ID, method: 'PUT', body: { entryId: vocabEntry.item.id, regionId: ids.region, nodeKey: 'integration-vocabulary-root', name: '英语核心词', nodeType: 'category', isLeaf: false, }, }); const deniedVocabularyPreview = await request('/api/tenant-content/imports/preview/vocabulary', { method: 'POST', body: { units: [{ name: '学生不能导入单词', words: [{ word: 'deny', meaning: '拒绝' }] }], }, expectStatus: 403, }); assert.equal(deniedVocabularyPreview.code, 'TENANT_CONTENT_EDITOR_REQUIRED', 'student should not preview vocabulary import'); const invalidVocabularyPreview = await request('/api/tenant-content/imports/preview/vocabulary', { userId: TENANT_ADMIN_USER_ID, method: 'POST', body: { sourceName: 'invalid-vocabulary-import.json', entryId: vocabEntry.item.id, contentNodeId: vocabRoot.item.id, vocabulary_units_示例数据: [ { legacyId: 'integration-vocab-invalid-unit', name: '旧格式错误单词单元', order: 1 }, ], vocabulary_示例数据: [ { unitId: 'integration-vocab-invalid-unit', word: '', meaning: '', order: 1 }, ], }, }); assert.ok(invalidVocabularyPreview.issues?.some(issue => issue.code === 'VOCABULARY_WORD_REQUIRED'), 'invalid vocabulary preview should validate word'); assert.ok(invalidVocabularyPreview.issues?.some(issue => issue.code === 'VOCABULARY_MEANING_REQUIRED'), 'invalid vocabulary preview should validate meaning'); const rejectedVocabularyImport = await request('/api/tenant-content/imports/vocabulary', { userId: TENANT_ADMIN_USER_ID, method: 'POST', body: { previewJobId: invalidVocabularyPreview.job.id }, expectStatus: 409, }); assert.equal(rejectedVocabularyImport.code, 'IMPORT_HAS_ERRORS', 'invalid vocabulary import should be rejected'); const vocabularyPreview = await request('/api/tenant-content/imports/preview/vocabulary', { userId: TENANT_ADMIN_USER_ID, method: 'POST', body: { sourceName: 'legacy-vocabulary-import.json', regionId: ids.region, entryId: vocabEntry.item.id, contentNodeId: vocabRoot.item.id, vocabulary_units_示例数据: [ { legacyId: 'integration-vocab-unit-001', name: 'Unit 1 - 高频核心词', description: '旧模板字段导入验证', order: 1, isActive: true, }, ], vocabulary_示例数据: [ { legacyId: 'integration-vocab-word-abandon', unitId: 'integration-vocab-unit-001', word: 'abandon', phonetic: '/əˈbændən/', meaning: 'v. 放弃,抛弃', example: 'He had to abandon his car in the snow.', exampleTranslation: '他不得不把车丢弃在雪地里。', difficulty: 3, tags: ['高频词', '考纲核心'], order: 1, isActive: true, }, ], }, }); assert.equal(vocabularyPreview.job?.errorCount, 0, 'valid vocabulary preview should have no errors'); const vocabularyImport = await request('/api/tenant-content/imports/vocabulary', { userId: TENANT_ADMIN_USER_ID, method: 'POST', body: { previewJobId: vocabularyPreview.job.id }, }); assert.equal(vocabularyImport.item?.status, 'completed', 'valid vocabulary import should complete'); assert.ok( (vocabularyImport.item?.insertedCount || 0) + (vocabularyImport.item?.updatedCount || 0) + (vocabularyImport.item?.skippedCount || 0) >= 2, 'vocabulary import should process unit and word idempotently', ); const vocabularyUnits = await request('/api/catalog/vocabulary-units', { query: { regionId: ids.region }, }); const importedVocabularyUnit = vocabularyUnits.items?.find(item => item.legacyId === 'integration-vocab-unit-001'); assert.ok(importedVocabularyUnit, 'catalog should expose imported vocabulary unit'); assert.equal(importedVocabularyUnit.entryId, vocabEntry.item.id, 'vocabulary unit should bind content entry'); const vocabularyWords = await request('/api/catalog/vocabulary-words', { query: { unitId: importedVocabularyUnit.id }, }); assert.ok(vocabularyWords.items?.some(item => item.word === 'abandon' && item.contentNodeId), 'catalog should expose imported vocabulary word with node binding'); const handbookEntry = await request('/api/tenant-content/content-entries', { userId: TENANT_ADMIN_USER_ID, method: 'PUT', body: { entryKey: 'integration-handbook', regionId: ids.region, name: '集成测试知识手册入口', entryType: 'handbook', route: '/handbook', order: 22, }, }); const handbookRoot = await request('/api/tenant-content/content-nodes', { userId: TENANT_ADMIN_USER_ID, method: 'PUT', body: { entryId: handbookEntry.item.id, regionId: ids.region, nodeKey: 'integration-handbook-root', name: '文化课', nodeType: 'category', isLeaf: false, }, }); const handbookPreview = await request('/api/tenant-content/imports/preview/handbook', { userId: TENANT_ADMIN_USER_ID, method: 'POST', body: { sourceName: 'handbook-nested-import.json', regionId: ids.region, entryId: handbookEntry.item.id, contentNodeId: handbookRoot.item.id, subjects: [ { legacyId: 'integration-handbook-chinese', name: '大学语文', type: 'guide', icon: 'book-open', color: '#10B981', chapters: [ { legacyId: 'integration-handbook-chapter-outline', name: '一、语文考纲', sections: [ { legacyId: 'integration-handbook-section-outline', name: '考纲解读', entries: [ { legacyId: 'integration-handbook-entry-outline', title: '2024年天津专升本语文考试大纲', summary: '全面解读语文考试要求和考点分布', content: '一、考试性质\n\n天津市高职升本科招生统一考试是选拔性考试。', tags: ['考纲', '必读'], order: 1, }, ], }, ], }, ], }, ], }, }); assert.equal(handbookPreview.job?.errorCount, 0, 'valid handbook preview should have no errors'); const handbookImport = await request('/api/tenant-content/imports/handbook', { userId: TENANT_ADMIN_USER_ID, method: 'POST', body: { previewJobId: handbookPreview.job.id }, }); assert.equal(handbookImport.item?.status, 'completed', 'valid handbook import should complete'); assert.ok( (handbookImport.item?.insertedCount || 0) + (handbookImport.item?.updatedCount || 0) + (handbookImport.item?.skippedCount || 0) >= 3, 'handbook import should process subject, chapter, and entry idempotently', ); const handbookSubjects = await request('/api/catalog/handbook-subjects', { query: { regionId: ids.region }, }); const importedHandbookSubject = handbookSubjects.items?.find(item => item.legacyId === 'integration-handbook-chinese'); assert.ok(importedHandbookSubject, 'catalog should expose imported handbook subject'); assert.equal(importedHandbookSubject.entryId, handbookEntry.item.id, 'handbook subject should bind content entry'); const handbookChapters = await request('/api/catalog/handbook-chapters', { query: { subjectId: importedHandbookSubject.id }, }); const importedHandbookChapter = handbookChapters.items?.find(item => item.legacyId === 'integration-handbook-chapter-outline'); assert.ok(importedHandbookChapter, 'catalog should expose imported handbook chapter'); const handbookEntries = await request('/api/catalog/handbook-entries', { query: { chapterId: importedHandbookChapter.id, includeContent: true }, }); assert.ok( handbookEntries.items?.some(item => item.legacyId === 'integration-handbook-entry-outline' && item.content?.includes('选拔性考试')), 'catalog should expose imported handbook entry content', ); const importJobs = await request('/api/tenant-content/imports', { userId: TENANT_ADMIN_USER_ID, query: { importType: 'vocabulary', limit: 10 }, }); assert.ok(importJobs.items?.some(item => item.id === vocabularyPreview.job.id && item.status === 'completed'), 'import job list should include completed vocabulary job'); const partnerImports = await request('/api/tenant-content/imports', { tenantId: PARTNER_TENANT_ID, userId: TENANT_ADMIN_USER_ID, expectStatus: 403, }); assert.equal(partnerImports.code, 'TENANT_CONTENT_EDITOR_REQUIRED', 'import jobs must be tenant isolated'); } async function testTenantAdminOps() { const fakeWechat = await startFakeWechatServer(); const denied = await request('/api/tenant-admin/branding', { method: 'PUT', body: { brandName: '学生不能改品牌' }, expectStatus: 403, }); assert.equal(denied.code, 'TENANT_ADMIN_REQUIRED', 'student should not access tenant admin config'); const overview = await request('/api/tenant-admin/overview', { userId: TENANT_ADMIN_USER_ID, }); assert.equal(overview.item?.id, MAIN_TENANT_ID, 'tenant admin overview should belong to main tenant'); const branding = await request('/api/tenant-admin/branding', { userId: TENANT_ADMIN_USER_ID, method: 'PUT', body: { brandName: '集成测试品牌', shortName: '集测题库', slogan: '一套前端服务多个合作商', logoUrl: 'https://example.test/logo.png', theme: { primaryColor: '#0f766e' }, publicAssets: { h5Logo: 'https://example.test/h5-logo.png' }, }, }); assert.equal(branding.item?.brandName, '集成测试品牌', 'tenant admin should update branding'); const publicSecretRejected = await request('/api/tenant-admin/auth-providers', { userId: TENANT_ADMIN_USER_ID, method: 'PUT', body: { provider: 'wechat-miniapp', status: 'testing', configPublic: { appId: 'wx-smoke-appid', appSecret: 'must-not-be-public', }, }, expectStatus: 400, }); assert.equal(publicSecretRejected.code, 'PUBLIC_CONFIG_SECRET_REJECTED', 'public config should reject secret-like keys'); const authProvider = await request('/api/tenant-admin/auth-providers', { userId: TENANT_ADMIN_USER_ID, method: 'PUT', body: { provider: 'wechat-miniapp', displayName: '微信小程序登录', status: 'testing', configPublic: { appId: 'wx-smoke-appid', envVersion: 'trial', endpoint: fakeWechat.endpoint, }, secret: { secretValue: 'wechat-app-secret-smoke', }, }, }); assert.equal(authProvider.item?.provider, 'wechat-miniapp', 'tenant admin should upsert auth provider'); assert.equal(authProvider.item?.configPublic?.secretRef, 'app_private.tenant_secrets:oauth:wechat-miniapp', 'auth provider should expose only secretRef'); assert.equal(authProvider.item?.secret?.hasSecretValue, true, 'auth provider should report masked secret status'); assert.ok(!JSON.stringify(authProvider).includes('wechat-app-secret-smoke'), 'auth provider response must not include secret plaintext'); const miniappLogin = await request('/api/auth/oauth/wechat-miniapp', { userId: false, method: 'POST', body: { code: 'integration-code-001', profile: { nickName: '微信烟测学生', avatarUrl: 'https://example.test/avatar.png', }, }, }); assert.equal(miniappLogin.provider, 'wechat-miniapp', 'wechat miniapp login should return provider'); assert.ok(miniappLogin.user?.id, 'wechat miniapp login should create or resolve user'); assert.ok(miniappLogin.session?.token?.startsWith('tk_'), 'wechat miniapp login should issue API session token'); assert.equal(miniappLogin.identity?.openId, 'openid-integration-code-001', 'wechat miniapp login should expose openId'); assert.equal(miniappLogin.identity?.unionId, 'unionid-smoke-user', 'wechat miniapp login should expose unionId'); assert.equal(fakeWechat.requests.at(-1)?.query?.js_code, 'integration-code-001', 'wechat code should be exchanged server-side'); assert.ok(!JSON.stringify(miniappLogin).includes('session-key-integration-code-001'), 'login response must not leak WeChat session_key'); assert.ok(!JSON.stringify(miniappLogin).includes('wechat-app-secret-smoke'), 'login response must not leak app secret'); const miniappMe = await request('/api/auth/me', { userId: false, headers: { authorization: `Bearer ${miniappLogin.session.token}` }, }); assert.equal(miniappMe.user?.id, miniappLogin.user.id, 'wechat session should work with auth/me'); const paymentAccount = await request('/api/tenant-admin/payment-accounts', { userId: TENANT_ADMIN_USER_ID, method: 'PUT', body: { provider: 'wechat_pay', mode: 'tenant_collect', displayName: '合作商微信商户', status: 'pending', configPublic: { merchantId: '1900000001', appId: 'wx-smoke-appid', notifyUrl: 'https://pay.example.test/wechat/notify', }, secret: { secretJson: { apiV3Key: 'wechat-pay-api-v3-key', merchantSerialNo: 'serial-smoke', }, }, }, }); assert.equal(paymentAccount.item?.provider, 'wechat_pay', 'tenant admin should upsert payment account'); assert.equal(paymentAccount.item?.configPublic?.secretRef, 'app_private.tenant_secrets:payment:wechat_pay', 'payment account should expose only secretRef'); assert.ok(!JSON.stringify(paymentAccount).includes('wechat-pay-api-v3-key'), 'payment response must not include secret json values'); const secrets = await request('/api/tenant-admin/secrets', { userId: TENANT_ADMIN_USER_ID, query: { scope: 'payment' }, }); assert.ok(secrets.items?.some(item => item.secretRef === 'app_private.tenant_secrets:payment:wechat_pay'), 'masked secret list should include payment secretRef'); assert.ok(!JSON.stringify(secrets).includes('wechat-pay-api-v3-key'), 'secret list must not leak secret json values'); const banner = await request('/api/tenant-admin/banners', { userId: TENANT_ADMIN_USER_ID, method: 'PUT', body: { regionId: ids.region, title: '集成测试活动', subtitle: '租户自有活动位', content: '合作商可配置自己的首页 Banner', buttonText: '查看会员', buttonLink: '/store', order: 7, isActive: true, }, }); assert.equal(banner.item?.title, '集成测试活动', 'tenant admin should upsert banner'); const faq = await request('/api/tenant-admin/faqs', { userId: TENANT_ADMIN_USER_ID, method: 'PUT', body: { regionId: ids.region, question: '合作商能否配置自己的支付商户?', answer: '可以,密钥写入私密表,前端只拿公开配置。', order: 8, }, }); assert.equal(faq.item?.question, '合作商能否配置自己的支付商户?', 'tenant admin should upsert faq'); const announcement = await request('/api/tenant-admin/announcements', { userId: TENANT_ADMIN_USER_ID, method: 'PUT', body: { content: '集成测试公告', link: '/announcements/integration', bgColor: '#f0fdfa', order: 9, }, }); assert.equal(announcement.item?.content, '集成测试公告', 'tenant admin should upsert announcement'); const examDate = await request('/api/tenant-admin/exam-dates', { userId: TENANT_ADMIN_USER_ID, method: 'PUT', body: { id: ids.tenantExamDate, regionId: ids.region, legacyId: 'integration-exam-date', examName: '集成测试考试', examDate: '2030-07-01', examType: 'school', description: '租户后台维护的考试日期', metadata: { source: 'integration-test' }, sortOrder: 2, isActive: true, }, }); assert.equal(examDate.item?.id, ids.tenantExamDate, 'tenant admin should upsert exam date'); const examDates = await request('/api/tenant-admin/exam-dates', { userId: TENANT_ADMIN_USER_ID, query: { regionId: ids.region, limit: 20 }, }); assert.ok(examDates.items?.some(item => item.id === ids.tenantExamDate), 'tenant admin should list tenant exam date'); const studentExamDateDenied = await request('/api/tenant-admin/exam-dates', { expectStatus: 403, }); assert.equal(studentExamDateDenied.code, 'TENANT_ADMIN_REQUIRED', 'student should not access tenant exam date admin'); const publicBanners = await request('/api/catalog/banners', { query: { regionId: ids.region }, }); assert.ok(publicBanners.items?.some(item => item.title === '集成测试活动'), 'public catalog should expose active tenant banner'); const publicExamDates = await request('/api/catalog/exam-dates', { query: { regionId: ids.region, limit: 20 }, }); assert.ok(publicExamDates.items?.some(item => item.id === ids.tenantExamDate), 'public catalog should expose tenant admin exam date'); const feedbacksBefore = await request('/api/tenant-admin/feedbacks', { userId: TENANT_ADMIN_USER_ID, query: { status: 'pending', limit: 20 }, }); const pendingFeedback = feedbacksBefore.items?.find(item => item.title === '集成测试题目纠错'); assert.ok(pendingFeedback?.id, 'tenant admin should list student feedback'); const studentFeedbackDenied = await request('/api/tenant-admin/feedbacks', { expectStatus: 403, }); assert.equal(studentFeedbackDenied.code, 'TENANT_ADMIN_REQUIRED', 'student should not access tenant feedback admin'); const resolvedFeedback = await request('/api/tenant-admin/feedbacks/status', { userId: TENANT_ADMIN_USER_ID, method: 'POST', body: { reportId: pendingFeedback.id, status: 'resolved', priority: 'high', resolution: '已核对并修正解析。', note: '集成测试处理完成', rewardPoints: 5, metadata: { source: 'integration-test' }, }, }); assert.equal(resolvedFeedback.item?.status, 'resolved', 'tenant admin should resolve feedback'); assert.equal(resolvedFeedback.item?.reward?.eventType, 'feedback_reward', 'feedback reward should write score ledger'); const rewardEventsAfterResolve = await request('/api/profile/score-events', { query: { limit: 50 } }); const rewardCountAfterResolve = rewardEventsAfterResolve.items?.filter( item => item.eventType === 'feedback_reward' && item.sourceId === pendingFeedback.id, ).length || 0; assert.equal(rewardCountAfterResolve, 1, 'feedback reward should be recorded once after first resolution'); const repeatedReward = await request('/api/tenant-admin/feedbacks/status', { userId: TENANT_ADMIN_USER_ID, method: 'POST', body: { reportId: pendingFeedback.id, status: 'resolved', priority: 'high', resolution: '重复处理不应重复发积分。', rewardPoints: 5, }, }); assert.equal(repeatedReward.item?.reward, null, 'repeated feedback reward should be idempotent'); const rewardEventsAfterRepeat = await request('/api/profile/score-events', { query: { limit: 50 } }); const rewardCountAfterRepeat = rewardEventsAfterRepeat.items?.filter( item => item.eventType === 'feedback_reward' && item.sourceId === pendingFeedback.id, ).length || 0; assert.equal(rewardCountAfterRepeat, 1, 'feedback reward should not duplicate on repeated status updates'); const feedbackEvents = await request('/api/tenant-admin/feedbacks/events', { userId: TENANT_ADMIN_USER_ID, query: { reportId: pendingFeedback.id }, }); assert.ok(feedbackEvents.items?.some(item => item.toStatus === 'pending'), 'feedback events should include initial pending event'); assert.ok(feedbackEvents.items?.some(item => item.toStatus === 'resolved'), 'feedback events should include resolved event'); const partnerFeedbackDenied = await request('/api/tenant-admin/feedbacks', { tenantId: PARTNER_TENANT_ID, userId: TENANT_ADMIN_USER_ID, expectStatus: 403, }); assert.equal(partnerFeedbackDenied.code, 'TENANT_ADMIN_REQUIRED', 'feedback admin must be tenant isolated'); const batch = await request('/api/tenant-admin/code-batches', { userId: TENANT_ADMIN_USER_ID, method: 'PUT', body: { name: '集成测试激活码批次', saleType: 'saas_partner', channel: 'offline', campaignName: 'partner-onboarding', defaultUnitPriceCents: 9900, days: 365, regionId: ids.region, remark: '用于租户后台接口测试', }, }); assert.ok(batch.item?.id, 'tenant admin should create code batch'); const generated = await request('/api/tenant-admin/activation-codes/generate', { userId: TENANT_ADMIN_USER_ID, method: 'POST', body: { batchId: batch.item.id, count: 2, prefix: 'IT', soldTo: 'integration-partner', }, }); assert.equal(generated.count, 2, 'tenant admin should batch generate activation codes'); assert.ok(generated.items?.every(item => String(item.code).startsWith('IT')), 'generated codes should use prefix'); const activationCode = await request('/api/tenant-admin/activation-codes', { userId: TENANT_ADMIN_USER_ID, method: 'PUT', body: { code: 'IT-MANUAL-001', days: 30, batchId: batch.item.id, soldTo: 'manual-customer', remark: 'manual integration code', }, }); assert.equal(activationCode.item?.code, 'IT-MANUAL-001', 'tenant admin should upsert activation code'); const coupon = await request('/api/tenant-admin/coupons', { userId: TENANT_ADMIN_USER_ID, method: 'PUT', body: { code: 'IT-COUPON-001', planId: '00000000-0000-0000-0000-000000000201', discountType: 'fixed', discountValue: 10, maxUses: 100, source: 'integration-test', }, }); assert.equal(coupon.item?.code, 'IT-COUPON-001', 'tenant admin should upsert coupon'); const partnerDenied = await request('/api/tenant-admin/auth-providers', { tenantId: PARTNER_TENANT_ID, userId: TENANT_ADMIN_USER_ID, method: 'PUT', body: { provider: 'qq-oauth', configPublic: { appId: 'qq-smoke' }, }, expectStatus: 403, }); assert.equal(partnerDenied.code, 'TENANT_ADMIN_REQUIRED', 'tenant admin must not administer another tenant without membership'); } 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 === 'roles:write'), 'permission matrix should expose role template permission'); assert.ok(permissionMatrix.menuGroups?.some(item => item.key === 'sales'), 'permission matrix should expose menu groups'); assert.ok(permissionMatrix.roleDefaults?.tenant_operator?.includes('marketing:*'), 'permission matrix should include role defaults'); const roleTemplate = await request('/api/tenant-admin/role-templates', { userId: TENANT_ADMIN_USER_ID, method: 'PUT', body: { code: 'ops-marketing', name: '运营活动模板', description: '允许运营维护活动内容,同时可配置前端菜单和字段可见性', baseRole: 'tenant_operator', permissions: { 'marketing:*': true, 'tenant:payment:write': true, }, menuPermissions: { dashboard: true, marketing: true, settings: false, }, modulePermissions: { banners: true, coupons: true, }, fieldPermissions: { 'student.phone': false, 'order.amount': true, }, dataScope: { mode: 'tenant', ownLeadsOnly: false, }, sortOrder: 10, }, }); assert.equal(roleTemplate.item?.code, 'ops-marketing', 'tenant admin should upsert custom role template'); assert.equal(roleTemplate.item?.menuPermissions?.marketing, true, 'role template should persist menu permissions'); const roleTemplates = await request('/api/tenant-admin/role-templates', { userId: TENANT_ADMIN_USER_ID, }); assert.ok(roleTemplates.items?.some(item => item.id === roleTemplate.item.id), 'role template list should include custom template'); const wildcardTemplateDenied = await request('/api/tenant-admin/role-templates', { userId: TENANT_ADMIN_USER_ID, method: 'PUT', body: { code: 'super-admin-template', name: '危险全权限模板', baseRole: 'tenant_admin', permissions: { '*': true }, }, expectStatus: 403, }); assert.equal(wildcardTemplateDenied.code, 'TENANT_OWNER_REQUIRED', 'tenant admin should not create owner/admin level role template'); const operator = await request('/api/tenant-admin/members', { userId: TENANT_ADMIN_USER_ID, method: 'PUT', body: { userId: TENANT_OPERATOR_USER_ID, username: 'integration_operator', phone: '13800000003', name: 'Integration Operator', role: 'tenant_operator', roleTemplateId: roleTemplate.item.id, status: 'active', permissions: { 'marketing:*': true, 'tenant:payment:*': false, }, }, }); assert.equal(operator.item?.role, 'tenant_operator', 'tenant admin should create operator membership'); assert.equal(operator.item?.roleTemplateId, roleTemplate.item.id, 'member should bind custom role template'); const members = await request('/api/tenant-admin/members', { userId: TENANT_ADMIN_USER_ID, query: { keyword: 'Integration Operator' }, }); assert.ok(members.items?.some(item => item.userId === TENANT_OPERATOR_USER_ID), 'member list should find operator'); assert.ok(members.items?.some(item => item.userId === TENANT_OPERATOR_USER_ID && item.roleTemplateCode === 'ops-marketing'), 'member list should expose role template binding'); const operatorPermissionMatrix = await request('/api/tenant-admin/permissions', { userId: TENANT_OPERATOR_USER_ID, }); assert.equal(operatorPermissionMatrix.current?.roleTemplateCode, 'ops-marketing', 'current permission matrix should include role template'); assert.equal(operatorPermissionMatrix.current?.menuPermissions?.marketing, true, 'current permission matrix should expose menu permissions'); const operatorBanner = await request('/api/tenant-admin/banners', { userId: TENANT_OPERATOR_USER_ID, method: 'PUT', body: { regionId: ids.region, title: '运营角色活动位', content: '运营成员可以维护活动内容', order: 17, }, }); assert.equal(operatorBanner.item?.title, '运营角色活动位', 'operator should write marketing content'); const operatorPaymentDenied = await request('/api/tenant-admin/payment-accounts', { userId: TENANT_OPERATOR_USER_ID, method: 'PUT', body: { provider: 'alipay', mode: 'tenant_collect', configPublic: { appId: 'alipay-appid' }, }, expectStatus: 403, }); assert.equal(operatorPaymentDenied.code, 'TENANT_PERMISSION_REQUIRED', 'operator should not write payment config'); const grantAdminDenied = await request('/api/tenant-admin/members', { userId: TENANT_OPERATOR_USER_ID, method: 'PUT', body: { userId: TENANT_SALES_USER_ID, role: 'tenant_admin', permissions: { '*': true }, }, expectStatus: 403, }); assert.equal(grantAdminDenied.code, 'TENANT_PERMISSION_REQUIRED', 'operator should not manage members without permission'); const sales = await request('/api/tenant-admin/members', { userId: TENANT_ADMIN_USER_ID, method: 'PUT', body: { userId: TENANT_SALES_USER_ID, username: 'integration_sales', phone: '13800000004', name: 'Integration Sales', role: 'sales', status: 'active', permissions: { 'codes:*': true, 'coupons:*': true, }, }, }); assert.equal(sales.item?.role, 'sales', 'tenant admin should create sales membership'); const salesBatch = await request('/api/tenant-admin/code-batches', { userId: TENANT_SALES_USER_ID, method: 'PUT', body: { name: '销售角色批次', saleType: 'sales', defaultUnitPriceCents: 19900, days: 180, }, }); assert.ok(salesBatch.item?.id, 'sales role should create code batch'); const salesBrandingDenied = await request('/api/tenant-admin/branding', { userId: TENANT_SALES_USER_ID, method: 'PUT', body: { brandName: '销售不能改品牌', }, expectStatus: 403, }); assert.equal(salesBrandingDenied.code, 'TENANT_PERMISSION_REQUIRED', 'sales role should not update branding'); const disableSales = await request('/api/tenant-admin/members/disable', { userId: TENANT_ADMIN_USER_ID, method: 'POST', body: { membershipId: sales.item.id }, }); assert.equal(disableSales.item?.status, 'disabled', 'tenant admin should disable sales membership'); const disabledSalesDenied = await request('/api/tenant-admin/code-batches', { userId: TENANT_SALES_USER_ID, method: 'PUT', body: { name: '禁用后不能新建批次', days: 10, }, expectStatus: 403, }); assert.equal(disabledSalesDenied.code, 'TENANT_ADMIN_REQUIRED', 'disabled sales membership should lose tenant admin access'); const disabledTemplate = await request('/api/tenant-admin/role-templates/disable', { userId: TENANT_ADMIN_USER_ID, method: 'POST', body: { roleTemplateId: roleTemplate.item.id }, }); assert.equal(disabledTemplate.item?.status, 'disabled', 'tenant admin should disable custom role template'); const auditLogs = await request('/api/tenant-admin/audit-logs', { userId: TENANT_ADMIN_USER_ID, query: { action: 'tenant.', limit: 50 }, }); assert.ok(auditLogs.items?.some(item => item.action === 'tenant.member.upserted'), 'audit logs should include member upsert'); assert.ok(auditLogs.items?.some(item => item.action === 'tenant.member.disabled'), 'audit logs should include member disable'); assert.ok(auditLogs.items?.some(item => item.action === 'tenant.role_template.upserted'), 'audit logs should include role template upsert'); assert.ok(auditLogs.items?.some(item => item.action === 'tenant.role_template.disabled'), 'audit logs should include role template disable'); const partnerAuditDenied = await request('/api/tenant-admin/audit-logs', { tenantId: PARTNER_TENANT_ID, userId: TENANT_ADMIN_USER_ID, expectStatus: 403, }); assert.equal(partnerAuditDenied.code, 'TENANT_ADMIN_REQUIRED', 'tenant audit logs must be tenant isolated'); } async function testTenantClassStudentScopes() { const permissionMatrix = await request('/api/tenant-admin/permissions', { userId: TENANT_ADMIN_USER_ID, }); assert.ok(permissionMatrix.permissions?.some(item => item.key === 'classes:write'), 'permission matrix should expose class write permission'); assert.ok(permissionMatrix.permissions?.some(item => item.key === 'students:read'), 'permission matrix should expose student read permission'); const createdClass = await request('/api/tenant-admin/classes', { userId: TENANT_ADMIN_USER_ID, method: 'PUT', body: { id: ids.tenantClass, regionId: ids.region, code: 'integration-main', name: '集成测试主班级', description: '教师和学生范围权限测试', sortOrder: 1, metadata: { stage: 'integration' }, }, }); assert.equal(createdClass.item?.id, ids.tenantClass, 'tenant admin should upsert class'); const otherClass = await request('/api/tenant-admin/classes', { userId: TENANT_ADMIN_USER_ID, method: 'PUT', body: { id: ids.tenantClassOther, regionId: ids.region, code: 'integration-other', name: '集成测试其他班级', sortOrder: 2, }, }); assert.equal(otherClass.item?.id, ids.tenantClassOther, 'tenant admin should upsert another class'); const teacherTemplate = await request('/api/tenant-admin/role-templates', { userId: TENANT_ADMIN_USER_ID, method: 'PUT', body: { code: 'teacher-class-scope', name: '教师班级范围模板', baseRole: 'teacher', permissions: { 'classes:read': true, 'students:read': true, }, menuPermissions: { teachers: true, students: true, }, fieldPermissions: { 'student.phone': false, }, dataScope: { mode: 'classes', }, }, }); assert.equal(teacherTemplate.item?.code, 'teacher-class-scope', 'tenant admin should create teacher scope template'); const teacher = await request('/api/tenant-admin/members', { userId: TENANT_ADMIN_USER_ID, method: 'PUT', body: { userId: TENANT_TEACHER_USER_ID, username: 'integration_teacher', phone: '13800000015', name: 'Integration Teacher', role: 'teacher', roleTemplateId: teacherTemplate.item.id, status: 'active', permissions: {}, }, }); assert.equal(teacher.item?.role, 'teacher', 'tenant admin should create teacher member'); const student = await request('/api/tenant-admin/students', { userId: TENANT_ADMIN_USER_ID, method: 'PUT', body: { userId: USER_ID, username: 'smoke_student', phone: '13800000000', name: 'Smoke Student', regionId: ids.region, status: 'active', }, }); assert.equal(student.item?.userId, USER_ID, 'tenant admin should upsert student profile'); const secondStudent = await request('/api/tenant-admin/students', { userId: TENANT_ADMIN_USER_ID, method: 'PUT', body: { userId: SECOND_STUDENT_USER_ID, username: 'integration_second_student', phone: '13800000016', name: 'Integration Second Student', regionId: ids.region, status: 'active', }, }); assert.equal(secondStudent.item?.userId, SECOND_STUDENT_USER_ID, 'tenant admin should upsert another student'); const teacherAssignment = await request('/api/tenant-admin/classes/members', { userId: TENANT_ADMIN_USER_ID, method: 'PUT', body: { classId: ids.tenantClass, userId: TENANT_TEACHER_USER_ID, memberType: 'teacher', status: 'active', }, }); assert.equal(teacherAssignment.item?.memberType, 'teacher', 'tenant admin should assign teacher to class'); const studentAssignment = await request('/api/tenant-admin/classes/members', { userId: TENANT_ADMIN_USER_ID, method: 'PUT', body: { classId: ids.tenantClass, userId: USER_ID, memberType: 'student', status: 'active', }, }); assert.equal(studentAssignment.item?.memberType, 'student', 'tenant admin should assign student to class'); const otherStudentAssignment = await request('/api/tenant-admin/classes/members', { userId: TENANT_ADMIN_USER_ID, method: 'PUT', body: { classId: ids.tenantClassOther, userId: SECOND_STUDENT_USER_ID, memberType: 'student', status: 'active', }, }); assert.equal(otherStudentAssignment.item?.classId, ids.tenantClassOther, 'tenant admin should assign second student to another class'); const adminClasses = await request('/api/tenant-admin/classes', { userId: TENANT_ADMIN_USER_ID, }); assert.ok(adminClasses.items?.some(item => item.id === ids.tenantClass && item.studentCount >= 1), 'admin should see main class student count'); assert.ok(adminClasses.items?.some(item => item.id === ids.tenantClassOther), 'admin should see other class'); const teacherClasses = await request('/api/tenant-admin/classes', { userId: TENANT_TEACHER_USER_ID, }); assert.equal(teacherClasses.scoped, true, 'teacher class list should be scoped'); assert.ok(teacherClasses.items?.some(item => item.id === ids.tenantClass), 'teacher should see assigned class'); assert.ok(!teacherClasses.items?.some(item => item.id === ids.tenantClassOther), 'teacher should not see unassigned class'); const teacherStudents = await request('/api/tenant-admin/students', { userId: TENANT_TEACHER_USER_ID, }); assert.equal(teacherStudents.scoped, true, 'teacher student list should be scoped'); assert.ok(teacherStudents.items?.some(item => item.userId === USER_ID), 'teacher should see student in assigned class'); assert.ok(!teacherStudents.items?.some(item => item.userId === SECOND_STUDENT_USER_ID), 'teacher should not see student in unassigned class'); const visibleStudent = teacherStudents.items?.find(item => item.userId === USER_ID); assert.equal(visibleStudent?.phone, null, 'teacher role template should mask student phone'); const teacherOtherClassDenied = await request('/api/tenant-admin/classes/members', { userId: TENANT_TEACHER_USER_ID, query: { classId: ids.tenantClassOther }, expectStatus: 403, }); assert.equal(teacherOtherClassDenied.code, 'CLASS_SCOPE_REQUIRED', 'teacher should not read another class members'); const adminStudents = await request('/api/tenant-admin/students', { userId: TENANT_ADMIN_USER_ID, query: { classId: ids.tenantClassOther }, }); assert.ok(adminStudents.items?.some(item => item.userId === SECOND_STUDENT_USER_ID), 'tenant admin should filter students by class'); const studentDenied = await request('/api/tenant-admin/students', { userId: USER_ID, expectStatus: 403, }); assert.equal(studentDenied.code, 'TENANT_ADMIN_REQUIRED', 'student should not access tenant admin student list'); const partnerClassDenied = await request('/api/tenant-admin/classes/members', { tenantId: PARTNER_TENANT_ID, userId: TENANT_ADMIN_USER_ID, method: 'PUT', body: { classId: ids.tenantClass, userId: USER_ID, memberType: 'student', }, expectStatus: 403, }); assert.equal(partnerClassDenied.code, 'TENANT_ADMIN_REQUIRED', 'tenant admin must not assign another tenant class'); const auditLogs = await request('/api/tenant-admin/audit-logs', { userId: TENANT_ADMIN_USER_ID, query: { action: 'tenant.class', limit: 50 }, }); assert.ok(auditLogs.items?.some(item => item.action === 'tenant.class.upserted'), 'audit logs should include class upsert'); assert.ok(auditLogs.items?.some(item => item.action === 'tenant.class_member.upserted'), 'audit logs should include class member upsert'); } async function testTenantStudentOperations() { let bulkStudentUserId = ''; const bulk = await request('/api/tenant-admin/students/bulk-upsert', { userId: TENANT_ADMIN_USER_ID, method: 'POST', body: { students: [ { username: 'integration_bulk_student', phone: '13800000018', name: 'Integration Bulk Student', regionId: ids.region, legacyUserId: 'legacy-bulk-student', status: 'active', stats: { source: 'integration' }, }, { username: 'integration_bulk_missing_region', phone: '13800000019', name: 'Integration Bulk Missing Region', regionId: '00000000-0000-0000-0000-ffffffffffff', }, ], }, }); assert.equal(bulk.total, 2, 'bulk student upsert should report total'); assert.equal(bulk.successCount, 1, 'bulk student upsert should import valid student'); assert.equal(bulk.errorCount, 1, 'bulk student upsert should report invalid rows'); bulkStudentUserId = bulk.items?.find(item => item.status === 'active')?.userId || ''; assert.ok(bulkStudentUserId, 'bulk result should include created student id'); assert.ok(bulk.errors?.some(item => item.code === 'REGION_NOT_FOUND'), 'bulk result should include row-level validation error'); const bulkLimitDenied = await request('/api/tenant-admin/students/bulk-upsert', { userId: TENANT_ADMIN_USER_ID, method: 'POST', body: { students: Array.from({ length: 201 }, (_, index) => ({ username: `too_many_${index}`, phone: `13988${String(index).padStart(6, '0')}`, })), }, expectStatus: 413, }); assert.ok( ['BULK_LIMIT_EXCEEDED', 'JSON_BODY_TOO_LARGE'].includes(bulkLimitDenied.code), 'bulk student upsert should enforce item or body limit', ); const bulkAssign = await request('/api/tenant-admin/classes/members/bulk-assign', { userId: TENANT_ADMIN_USER_ID, method: 'POST', body: { classId: ids.tenantClass, assignments: [ { userId: bulkStudentUserId, memberType: 'student', status: 'active' }, { userId: '00000000-0000-0000-0000-ffffffffffff', memberType: 'student' }, ], }, }); assert.equal(bulkAssign.total, 2, 'bulk class assign should report total'); assert.equal(bulkAssign.successCount, 1, 'bulk class assign should assign valid student'); assert.equal(bulkAssign.errorCount, 1, 'bulk class assign should report invalid rows'); const adminClassStudents = await request('/api/tenant-admin/students', { userId: TENANT_ADMIN_USER_ID, query: { classId: ids.tenantClass, keyword: 'Bulk Student' }, }); assert.ok(adminClassStudents.items?.some(item => item.userId === bulkStudentUserId), 'bulk assigned student should appear in class student list'); const disabled = await request('/api/tenant-admin/students/status', { userId: TENANT_ADMIN_USER_ID, method: 'POST', body: { userId: bulkStudentUserId, status: 'disabled', reason: 'integration-test', }, }); assert.equal(disabled.item?.status, 'disabled', 'tenant admin should disable student membership'); const disabledStudents = await request('/api/tenant-admin/students', { userId: TENANT_ADMIN_USER_ID, query: { status: 'disabled', keyword: 'Bulk Student' }, }); assert.ok(disabledStudents.items?.some(item => item.userId === bulkStudentUserId), 'disabled student should be queryable by status'); const restored = await request('/api/tenant-admin/students/status', { userId: TENANT_ADMIN_USER_ID, method: 'POST', body: { userId: bulkStudentUserId, status: 'active', reason: 'restore-integration-test', }, }); assert.equal(restored.item?.status, 'active', 'tenant admin should restore student membership'); const teacherNote = await request('/api/tenant-admin/students/notes', { userId: TENANT_TEACHER_USER_ID, method: 'PUT', body: { studentUserId: USER_ID, noteType: 'learning', content: '该学生本周错题复习需要跟进。', visibility: 'class_staff', isPinned: true, metadata: { source: 'integration' }, }, }); assert.equal(teacherNote.item?.noteType, 'learning', 'teacher should create note for scoped student'); const teacherNotes = await request('/api/tenant-admin/students/notes', { userId: TENANT_TEACHER_USER_ID, query: { studentUserId: USER_ID }, }); assert.ok(teacherNotes.items?.some(item => item.id === teacherNote.item.id), 'teacher should list scoped student notes'); const teacherOtherNoteDenied = await request('/api/tenant-admin/students/notes', { userId: TENANT_TEACHER_USER_ID, method: 'PUT', body: { studentUserId: SECOND_STUDENT_USER_ID, noteType: 'learning', content: '不应允许教师给非负责学生写备注。', }, expectStatus: 403, }); assert.equal(teacherOtherNoteDenied.code, 'STUDENT_SCOPE_REQUIRED', 'teacher should not write note for unscoped student'); const followup = await request('/api/tenant-admin/students/followups', { userId: TENANT_TEACHER_USER_ID, method: 'PUT', body: { studentUserId: USER_ID, assignedToUserId: TENANT_TEACHER_USER_ID, classId: ids.tenantClass, title: '错题复盘督导', description: '提醒学生完成本周错题复盘。', followupType: 'learning', priority: 'high', status: 'open', dueAt: '2026-07-01T10:00:00.000Z', }, }); assert.equal(followup.item?.status, 'open', 'teacher should create follow-up for scoped student'); const teacherFollowups = await request('/api/tenant-admin/students/followups', { userId: TENANT_TEACHER_USER_ID, query: { studentUserId: USER_ID, status: 'open' }, }); assert.ok(teacherFollowups.items?.some(item => item.id === followup.item.id), 'teacher should list scoped follow-ups'); const visibleFollowup = teacherFollowups.items?.find(item => item.id === followup.item.id); assert.equal(visibleFollowup?.studentPhone, null, 'teacher follow-up list should mask student phone'); const completedFollowup = await request('/api/tenant-admin/students/followups', { userId: TENANT_TEACHER_USER_ID, method: 'PUT', body: { id: followup.item.id, studentUserId: USER_ID, assignedToUserId: TENANT_TEACHER_USER_ID, classId: ids.tenantClass, title: '错题复盘督导', followupType: 'learning', priority: 'high', status: 'done', }, }); assert.equal(completedFollowup.item?.status, 'done', 'teacher should complete scoped follow-up'); assert.equal(completedFollowup.item?.completedBy, TENANT_TEACHER_USER_ID, 'completed follow-up should record completer'); const teacherOtherFollowupDenied = await request('/api/tenant-admin/students/followups', { userId: TENANT_TEACHER_USER_ID, method: 'PUT', body: { studentUserId: SECOND_STUDENT_USER_ID, classId: ids.tenantClassOther, title: '不应创建', followupType: 'learning', }, expectStatus: 403, }); assert.equal(teacherOtherFollowupDenied.code, 'STUDENT_SCOPE_REQUIRED', 'teacher should not create follow-up for unscoped student'); const studentStatusDenied = await request('/api/tenant-admin/students/status', { userId: TENANT_TEACHER_USER_ID, method: 'POST', body: { userId: USER_ID, status: 'disabled', }, expectStatus: 403, }); assert.equal(studentStatusDenied.code, 'TENANT_PERMISSION_REQUIRED', 'teacher should not disable students without status permission'); const partnerBulkDenied = await request('/api/tenant-admin/students/bulk-upsert', { tenantId: PARTNER_TENANT_ID, userId: TENANT_ADMIN_USER_ID, method: 'POST', body: { students: [{ userId: USER_ID, name: 'cross tenant denied' }], }, expectStatus: 403, }); assert.equal(partnerBulkDenied.code, 'TENANT_ADMIN_REQUIRED', 'student bulk import must be tenant isolated'); const auditLogs = await request('/api/tenant-admin/audit-logs', { userId: TENANT_ADMIN_USER_ID, query: { action: 'tenant.student', limit: 100 }, }); assert.ok(auditLogs.items?.some(item => item.action === 'tenant.students.bulk_upserted'), 'audit logs should include bulk student upsert'); assert.ok(auditLogs.items?.some(item => item.action === 'tenant.student.status_updated'), 'audit logs should include student status update'); assert.ok(auditLogs.items?.some(item => item.action === 'tenant.student_note.upserted'), 'audit logs should include student note upsert'); assert.ok(auditLogs.items?.some(item => item.action === 'tenant.student_followup.upserted'), 'audit logs should include student follow-up upsert'); } async function testReferralAndCrmGrowth() { const salesMember = await request('/api/tenant-admin/members', { userId: TENANT_ADMIN_USER_ID, method: 'PUT', body: { userId: TENANT_SALES_USER_ID, username: 'integration_sales', phone: '13800000004', name: 'Integration Sales', role: 'sales', status: 'active', permissions: { 'codes:*': true, 'coupons:*': true, 'referral:*': true, }, }, }); assert.equal(salesMember.item?.status, 'active', 'tenant admin should reactivate sales membership for referral tests'); const agent = await request('/api/tenant-admin/members', { userId: TENANT_ADMIN_USER_ID, method: 'PUT', body: { userId: TENANT_AGENT_USER_ID, username: 'integration_agent', phone: '13800000005', name: 'Integration Agent', role: 'agent', status: 'active', permissions: { 'referral:self': true, }, }, }); assert.equal(agent.item?.role, 'agent', 'tenant admin should create agent membership'); const salesCode = await request('/api/referral/invite-code', { userId: TENANT_SALES_USER_ID, method: 'POST', }); assert.ok(/^[A-Z0-9]{6}$/.test(salesCode.inviteCode), 'sales should get an invite code'); const agentCode = await request('/api/referral/invite-code', { userId: TENANT_AGENT_USER_ID, method: 'POST', }); assert.ok(/^[A-Z0-9]{6}$/.test(agentCode.inviteCode), 'agent should get an invite code'); const resolved = await request('/api/referral/resolve', { userId: false, method: 'POST', body: { code: salesCode.inviteCode }, }); assert.equal(resolved.valid, true, 'invite code should resolve'); assert.equal(resolved.inviterId, TENANT_SALES_USER_ID, 'invite code should resolve to sales user'); const crmConfig = await request('/api/crm/config', { userId: TENANT_ADMIN_USER_ID, method: 'PUT', body: { enabled: true, url: 'https://crm.example.test/webhook', secret: 'crm-secret-smoke', formName: '集成测试客资', examType: '专升本', delaySec: 1, }, }); assert.equal(crmConfig.item?.enabled, true, 'tenant admin should enable CRM config'); assert.ok(!JSON.stringify(crmConfig).includes('crm-secret-smoke'), 'CRM config response should not leak secret'); const tracked = await request('/api/referral/track-event', { userId: false, method: 'POST', body: { eventType: 'register', refCode: salesCode.inviteCode, targetUserId: USER_ID, source: 'qrcode', metadata: { page: 'pages/index/index' }, }, }); assert.equal(tracked.item?.refUserId, TENANT_SALES_USER_ID, 'tracking event should resolve referrer'); assert.equal(tracked.lead?.bound, true, 'first referral event should bind lead'); assert.equal(tracked.lead?.item?.referrerUserId, TENANT_SALES_USER_ID, 'lead should be bound to sales user'); assert.ok(tracked.crmQueue?.id, 'first lead binding should enqueue CRM task'); const secondBind = await request('/api/referral/bind', { method: 'POST', body: { userId: USER_ID, refCode: agentCode.inviteCode, source: 'qrcode', }, }); assert.equal(secondBind.lead?.bound, false, 'second referral bind should be blocked by first-binding protection'); assert.equal(secondBind.lead?.item?.referrerUserId, TENANT_SALES_USER_ID, 'protected lead should remain with first sales user'); const salesStats = await request('/api/referral/stats', { userId: TENANT_SALES_USER_ID, }); assert.equal(salesStats.item?.leadCount, 1, 'sales should see own lead count'); const salesClients = await request('/api/referral/sales-clients', { userId: TENANT_SALES_USER_ID, }); assert.ok(salesClients.items?.some(item => item.studentUserId === USER_ID), 'sales should see own protected client'); const agentClients = await request('/api/referral/sales-clients', { userId: TENANT_AGENT_USER_ID, }); assert.ok(!agentClients.items?.some(item => item.studentUserId === USER_ID), 'agent should not see sales protected client'); const allStats = await request('/api/referral/sales-stats', { userId: TENANT_ADMIN_USER_ID, }); assert.ok(allStats.items?.some(item => item.referrerUserId === TENANT_SALES_USER_ID && item.leadCount >= 1), 'tenant admin should see all referral stats'); const manual = await request('/api/referral/manual-bind', { userId: TENANT_ADMIN_USER_ID, method: 'POST', body: { studentUserId: USER_ID, referrerUserId: TENANT_AGENT_USER_ID, force: true, source: 'manual', }, }); assert.equal(manual.lead?.bound, true, 'tenant admin should be able to force manual bind'); assert.equal(manual.lead?.item?.referrerUserId, TENANT_AGENT_USER_ID, 'manual bind should move lead to agent'); const qrcode = await request('/api/referral/qrcode', { userId: TENANT_AGENT_USER_ID, method: 'POST', body: { page: 'pages/index/index', }, }); assert.equal(qrcode.item?.refCode, agentCode.inviteCode, 'qrcode should use agent invite code'); assert.equal(qrcode.item?.status, 'ready', 'qrcode placeholder should be ready locally'); const team = await request('/api/referral/team', { userId: TENANT_ADMIN_USER_ID, method: 'PUT', body: { leaderUserId: TENANT_SALES_USER_ID, memberUserId: TENANT_AGENT_USER_ID, relationType: 'agent_network', }, }); assert.equal(team.item?.leaderUserId, TENANT_SALES_USER_ID, 'tenant admin should assign agent leader'); const teamList = await request('/api/referral/team', { userId: TENANT_SALES_USER_ID, query: { leaderUserId: TENANT_SALES_USER_ID }, }); assert.ok(teamList.items?.some(item => item.memberUserId === TENANT_AGENT_USER_ID), 'sales should see own agent team'); const crmQueue = await request('/api/crm/queue', { userId: TENANT_ADMIN_USER_ID, query: { status: 'pending' }, }); assert.ok(crmQueue.items?.some(item => item.leadId === manual.lead.item.id || item.leadId === tracked.lead.item.id), 'CRM queue should include referral lead task'); assert.ok(!JSON.stringify(crmQueue).includes('crm-secret-smoke'), 'CRM queue should not leak secret'); const partnerReferralDenied = await request('/api/referral/sales-stats', { tenantId: PARTNER_TENANT_ID, userId: TENANT_SALES_USER_ID, expectStatus: 403, }); assert.equal(partnerReferralDenied.code, 'TENANT_ADMIN_REQUIRED', 'sales user must not see another tenant referral stats'); } async function main() { try { await check('production config fail-fast', testProductionConfigFailFast); await startServerIfNeeded(); console.log(`[INFO] API integration target: ${apiBase}`); await check('health', () => request('/health', { userId: false }).then(payload => assert.equal(payload.ok, true))); await check('trusted session identity', testTrustedSessionIdentity); await check('Supabase JWT identity', testSupabaseJwtIdentity); await check('legacy auth headers disabled', testLegacyAuthHeadersDisabled); await check('catalog and learning', testCatalogAndLearning); await check('profile', testProfile); await check('learning leaderboard', testLearningLeaderboard); await check('scoreline', testScoreline); await check('question videos', testVideos); await check('vocabulary', testVocabulary); await check('commerce', testCommerce); await check('tenant isolation', testTenantIsolation); await check('tenant content admin', testTenantContentAdmin); await check('tenant content assets and imports', testTenantContentAssetsAndImports); await check('tenant admin operations', testTenantAdminOps); await check('tenant member permissions and audit', testTenantMemberPermissionsAndAudit); await check('tenant class and student scopes', testTenantClassStudentScopes); await check('tenant student operations', testTenantStudentOperations); await check('referral and CRM growth', testReferralAndCrmGrowth); console.log('API integration tests complete.'); } finally { stopServer(); } } main().catch(error => { console.error(error); if (serverLogs) console.error(serverLogs); process.exitCode = 1; });