forked from wangziqi/gongxue-base
1139 lines
43 KiB
JavaScript
1139 lines
43 KiB
JavaScript
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əˈ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',
|
||
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;
|
||
});
|