Files
gongxue-base/scripts/api-integration-test.js
2026-06-21 21:54:43 +08:00

1139 lines
43 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import net from 'node:net';
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 START_SERVER = process.argv.includes('--start-server');
const ids = {
region: '00000000-0000-0000-0000-000000000301',
subject: '00000000-0000-0000-0000-000000000501',
category: '00000000-0000-0000-0000-000000000601',
question: '00000000-0000-0000-0000-000000000401',
vocabularyUnit: '00000000-0000-0000-0000-000000000811',
vocabularyWord: '00000000-0000-0000-0000-000000000812',
scorelineSchool: '00000000-0000-0000-0000-000000000831',
};
let apiBase = process.env.API_BASE || 'http://127.0.0.1:8787';
let serverProcess = null;
let serverLogs = '';
function buildUrl(path, query = {}) {
const target = new URL(path, apiBase);
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 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 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,
},
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
serverProcess.stdout.on('data', chunk => {
serverLogs += chunk.toString();
});
serverProcess.stderr.on('data', chunk => {
serverLogs += chunk.toString();
});
await waitForHealth();
}
function stopServer() {
if (serverProcess && !serverProcess.killed) {
serverProcess.kill();
}
}
async function testCatalogAndLearning() {
const questions = await request('/api/catalog/questions', { query: { limit: 20 } });
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');
const session = await request('/api/learning/practice-sessions', {
method: 'POST',
body: { userId: USER_ID, mode: 'chapter', targetType: 'category', targetId: ids.question },
});
assert.ok(session.item?.id, 'practice session should be created');
const answer = await request('/api/learning/answers', {
method: 'POST',
body: {
userId: USER_ID,
questionId: ids.question,
selectedOptions: ['0'],
practiceSessionId: session.item.id,
},
});
assert.equal(answer.item?.isCorrect, false, 'wrong answer should be judged false');
const wrong = await request('/api/learning/wrong-questions', { query: { status: 'all' } });
assert.ok(wrong.items?.some(item => item.questionId === ids.question), 'wrong book should include smoke question');
}
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');
}
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 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');
}
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 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 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 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 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');
}
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');
}
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 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əˈː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',
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',
},
});
assert.ok(question.item?.id, 'tenant admin should create question');
assert.equal(question.item?.currentVersion?.correctOptionIndex, 1, 'created question should have a version');
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');
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');
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 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,
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: { categoryId: ids.category, limit: 100 },
});
assert.ok(
importedQuestions.items?.some(item => item.content === '批量导入题:企业级 SaaS 应优先使用哪种数据库?'),
'catalog should expose imported question',
);
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 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',
},
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 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 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 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.roleDefaults?.tenant_operator?.includes('marketing:*'), 'permission matrix should include role defaults');
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',
status: 'active',
permissions: {
'marketing:*': true,
'tenant:payment:*': false,
},
},
});
assert.equal(operator.item?.role, 'tenant_operator', 'tenant admin should create operator membership');
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');
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 auditLogs = await request('/api/tenant-admin/audit-logs', {
userId: TENANT_ADMIN_USER_ID,
query: { action: 'tenant.member', limit: 20 },
});
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');
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 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 startServerIfNeeded();
console.log(`[INFO] API integration target: ${apiBase}`);
await check('health', () => request('/health', { userId: false }).then(payload => assert.equal(payload.ok, true)));
await check('catalog and learning', testCatalogAndLearning);
await check('profile', testProfile);
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('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;
});