Files
gongxue-base/scripts/api-integration-test.js

8476 lines
355 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 crypto from 'node:crypto';
import { spawn } from 'node:child_process';
import http from 'node:http';
import net from 'node:net';
import pg from 'pg';
import { SignJWT, exportJWK } from 'jose';
import ExcelJS from 'exceljs';
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 PARTNER_TENANT_ADMIN_USER_ID = '00000000-0000-0000-0000-000000000907';
const AUTH_USER_ID = '00000000-0000-0000-0000-00000000a101';
const AUTH_TENANT_ADMIN_USER_ID = '00000000-0000-0000-0000-00000000a102';
const AUTH_PLATFORM_ADMIN_USER_ID = '00000000-0000-0000-0000-00000000a999';
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',
tenantBadge: '00000000-0000-0000-0000-000000000871',
tenantCheckInBadge: '00000000-0000-0000-0000-000000000873',
tenantScoreBadge: '00000000-0000-0000-0000-000000000874',
tenantFeedbackBadge: '00000000-0000-0000-0000-000000000875',
tenantActivityBadge: '00000000-0000-0000-0000-000000000876',
pointActivityTask: '00000000-0000-0000-0000-000000000877',
pointSystemTask: '00000000-0000-0000-0000-000000000878',
pointExchangeItem: '00000000-0000-0000-0000-000000000879',
pointExpensiveExchangeItem: '00000000-0000-0000-0000-000000000880',
questionBank: '00000000-0000-0000-0000-000000000400',
publicQuestionBankGrant: '00000000-0000-0000-0000-000000000906',
};
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 jwksAuthServer = null;
let jwksAuthServerLogs = '';
let jwksServer = null;
let fakeWechatServer = null;
let fakeQqServer = 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}`);
}
async function markAssetSecurityScanPassed(assetId, provider = 'api_integration_test') {
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL || DEFAULT_DATABASE_URL });
try {
await pool.query(
`
update public.content_assets
set security_scan_status = 'passed',
security_scanned_at = now(),
security_scan_provider = $3,
security_scan_summary = '{"riskLevel":"none","issueCodes":[],"provider":"api_integration_test"}'::jsonb,
security_flags = coalesce(security_flags, '{}'::jsonb) - 'assetSecurityScanFailed',
updated_at = now()
where tenant_id = $1 and id = $2::uuid
`,
[MAIN_TENANT_ID, assetId, provider],
);
await pool.query(
`
insert into public.content_asset_security_scan_events (
tenant_id, asset_id, provider, scan_status, risk_level, issue_codes, details
)
values (
$1, $2::uuid, $3, 'passed', 'none', '{}'::text[],
'{"source":"api_integration_test"}'::jsonb
)
`,
[MAIN_TENANT_ID, assetId, provider],
);
} finally {
await pool.end();
}
}
async function latestVideoPlayEventByToken(playToken) {
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL || DEFAULT_DATABASE_URL });
try {
const tokenHash = crypto.createHash('sha256').update(playToken).digest('hex');
const result = await pool.query(
`
select id, metadata
from public.video_play_events
where tenant_id = $1 and play_token_hash = $2
order by created_at desc
limit 1
`,
[MAIN_TENANT_ID, tokenHash],
);
return result.rows[0] || null;
} finally {
await pool.end();
}
}
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));
});
});
}
function shanghaiDateKey(date = new Date()) {
const parts = Object.fromEntries(
new Intl.DateTimeFormat('en-US', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).formatToParts(date).map(part => [part.type, part.value]),
);
return `${parts.year}-${parts.month}-${parts.day}`;
}
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 startJwksAuthServer(jwksUrl) {
const port = await getFreePort();
const baseUrl = `http://127.0.0.1:${port}`;
jwksAuthServerLogs = '';
jwksAuthServer = 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',
AUTH_JWT_JWKS_URL: jwksUrl,
AUTH_JWT_ISSUER: 'https://auth.gongxue100.test/auth/v1',
AUTH_JWT_AUDIENCE: 'authenticated',
ALLOW_LEGACY_AUTH_HEADERS: 'false',
ALLOW_PLATFORM_ADMIN_KEY: 'false',
},
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
jwksAuthServer.stdout.on('data', chunk => {
jwksAuthServerLogs += chunk.toString();
});
jwksAuthServer.stderr.on('data', chunk => {
jwksAuthServerLogs += chunk.toString();
});
await waitForHealthAt(baseUrl, () => jwksAuthServerLogs);
return baseUrl;
}
async function startLocalJwksServer(jwksPayload) {
const port = await getFreePort();
const baseUrl = `http://127.0.0.1:${port}`;
jwksServer = http.createServer((req, res) => {
const url = new URL(req.url || '/', baseUrl);
if (url.pathname !== '/auth/v1/.well-known/jwks.json') {
res.writeHead(404, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: 'not_found' }));
return;
}
res.writeHead(200, {
'cache-control': 'public, max-age=600',
'content-type': 'application/json',
});
res.end(JSON.stringify(jwksPayload));
});
await new Promise((resolve, reject) => {
jwksServer.once('error', reject);
jwksServer.listen(port, '127.0.0.1', resolve);
});
return `${baseUrl}/auth/v1/.well-known/jwks.json`;
}
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 (!['/sns/jscode2session', '/sns/oauth2/access_token', '/sns/userinfo'].includes(url.pathname)) {
res.writeHead(404, { 'content-type': 'application/json' });
res.end(JSON.stringify({ errcode: 404, errmsg: 'not found' }));
return;
}
if (url.pathname === '/sns/userinfo') {
const accessToken = url.searchParams.get('access_token') || '';
const openid = url.searchParams.get('openid') || '';
if (!accessToken.startsWith('wechat-web-token-') || !openid.startsWith('wechat-web-openid-')) {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ errcode: 40003, errmsg: 'invalid openid' }));
return;
}
res.writeHead(200, { 'content-type': 'application/json' });
res.end(
JSON.stringify({
openid,
nickname: '微信网页烟测学生',
headimgurl: 'https://example.test/wechat-web-avatar.png',
unionid: 'unionid-smoke-user',
privilege: [],
}),
);
return;
}
const appId = url.searchParams.get('appid');
const secret = url.searchParams.get('secret');
const grantType = url.searchParams.get('grant_type');
if (appId !== 'wx-smoke-appid' || secret !== 'wechat-app-secret-smoke' || grantType !== 'authorization_code') {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ errcode: 40013, errmsg: 'invalid appid or secret' }));
return;
}
if (url.pathname === '/sns/oauth2/access_token') {
const webCode = url.searchParams.get('code') || 'unknown';
res.writeHead(200, { 'content-type': 'application/json' });
res.end(
JSON.stringify({
access_token: `wechat-web-token-${webCode}`,
expires_in: 7200,
refresh_token: `wechat-web-refresh-${webCode}`,
openid: `wechat-web-openid-${webCode}`,
scope: 'snsapi_login',
unionid: 'unionid-smoke-user',
}),
);
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`,
webTokenEndpoint: `${baseUrl}/sns/oauth2/access_token`,
webUserInfoEndpoint: `${baseUrl}/sns/userinfo`,
requests,
};
}
async function startFakeQqServer() {
const port = await getFreePort();
const baseUrl = `http://127.0.0.1:${port}`;
const requests = [];
const tokenToOpenId = new Map();
fakeQqServer = 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 === '/oauth2.0/token') {
if (
url.searchParams.get('client_id') !== 'qq-smoke-appid' ||
url.searchParams.get('client_secret') !== 'qq-client-secret-smoke' ||
url.searchParams.get('grant_type') !== 'authorization_code' ||
url.searchParams.get('redirect_uri') !== 'https://h5.example.test/auth/qq/callback'
) {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: 100010, error_description: 'invalid client' }));
return;
}
const code = url.searchParams.get('code') || 'unknown';
const accessToken = `qq-token-${code}`;
tokenToOpenId.set(accessToken, `qq-openid-${code}`);
res.writeHead(200, { 'content-type': 'text/plain' });
res.end(`access_token=${encodeURIComponent(accessToken)}&expires_in=7776000&refresh_token=qq-refresh-${encodeURIComponent(code)}`);
return;
}
if (url.pathname === '/oauth2.0/me') {
const accessToken = url.searchParams.get('access_token') || '';
const openid = tokenToOpenId.get(accessToken);
if (!openid) {
res.writeHead(200, { 'content-type': 'application/javascript' });
res.end('callback( {"error":100016,"error_description":"access token check failed"} );');
return;
}
res.writeHead(200, { 'content-type': 'application/javascript' });
res.end(`callback( {"client_id":"qq-smoke-appid","openid":"${openid}"} );`);
return;
}
if (url.pathname === '/user/get_user_info') {
const accessToken = url.searchParams.get('access_token') || '';
const openid = url.searchParams.get('openid') || '';
if (!tokenToOpenId.has(accessToken) || tokenToOpenId.get(accessToken) !== openid) {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ ret: 100001, msg: 'invalid token' }));
return;
}
res.writeHead(200, { 'content-type': 'application/json' });
res.end(
JSON.stringify({
ret: 0,
msg: '',
nickname: 'QQ烟测学生',
figureurl_qq_2: 'https://example.test/qq-avatar.png',
}),
);
return;
}
res.writeHead(404, { 'content-type': 'application/json' });
res.end(JSON.stringify({ ret: 404, msg: 'not found' }));
});
await new Promise((resolve, reject) => {
fakeQqServer.once('error', reject);
fakeQqServer.listen(port, '127.0.0.1', resolve);
});
return {
tokenEndpoint: `${baseUrl}/oauth2.0/token`,
openIdEndpoint: `${baseUrl}/oauth2.0/me`,
userInfoEndpoint: `${baseUrl}/user/get_user_info`,
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/refund/domestic/refunds') {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(
JSON.stringify({
refund_id: `refund-${body.out_refund_no || 'unknown'}`,
out_refund_no: body.out_refund_no,
out_trade_no: body.out_trade_no,
transaction_id: body.transaction_id,
status: 'PROCESSING',
amount: body.amount,
}),
);
return;
}
if (req.method === 'GET' && url.pathname.startsWith('/v3/refund/domestic/refunds/')) {
const outRefundNo = decodeURIComponent(url.pathname.split('/').pop() || '');
res.writeHead(200, { 'content-type': 'application/json' });
res.end(
JSON.stringify({
refund_id: `refund-${outRefundNo || 'unknown'}`,
out_refund_no: outRefundNo,
status: 'SUCCESS',
amount: { refund: 100, total: 500, currency: 'CNY' },
success_time: '2026-06-28T00:00:00+08:00',
}),
);
return;
}
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`,
refundEndpoint: `${baseUrl}/v3/refund/domestic/refunds`,
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 encryptWechatRefundResource(plain) {
const nonce = crypto.randomBytes(12).toString('base64url');
const aad = 'refund';
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 startFakeAlipayServer() {
const port = await getFreePort();
const baseUrl = `http://127.0.0.1:${port}`;
const requests = [];
const server = http.createServer((req, res) => {
let raw = '';
req.on('data', chunk => {
raw += chunk.toString();
});
req.on('end', () => {
const params = Object.fromEntries(new URLSearchParams(raw).entries());
requests.push({
method: req.method,
pathname: req.url || '/',
params,
});
if (!['alipay.trade.refund', 'alipay.trade.fastpay.refund.query'].includes(params.method)) {
res.writeHead(404, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error_response: { code: '404', msg: 'not found' } }));
return;
}
const biz = JSON.parse(params.biz_content || '{}');
if (params.method === 'alipay.trade.fastpay.refund.query') {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(
JSON.stringify({
alipay_trade_fastpay_refund_query_response: {
code: '10000',
msg: 'Success',
trade_no: biz.trade_no || `ali-trade-${biz.out_trade_no || 'unknown'}`,
out_trade_no: biz.out_trade_no,
out_request_no: biz.out_request_no,
refund_amount: '5.00',
refund_status: 'REFUND_SUCCESS',
},
}),
);
return;
}
res.writeHead(200, { 'content-type': 'application/json' });
res.end(
JSON.stringify({
alipay_trade_refund_response: {
code: '10000',
msg: 'Success',
trade_no: biz.trade_no || `ali-trade-${biz.out_trade_no || 'unknown'}`,
out_trade_no: biz.out_trade_no,
out_request_no: biz.out_request_no,
refund_fee: biz.refund_amount,
fund_change: 'Y',
},
}),
);
});
});
await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(port, '127.0.0.1', resolve);
});
return {
endpoint: `${baseUrl}/gateway.do`,
requests,
close: () => server.close(),
};
}
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 createSupabaseJwksJwt(authUserId, privateKey, options = {}) {
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: 'RS256', typ: 'JWT', kid: options.kid || 'local-jwks-key-1' })
.setIssuer('https://auth.gongxue100.test/auth/v1')
.setIssuedAt(now)
.setExpirationTime(now + 60 * 60)
.sign(privateKey);
}
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',
STORAGE_DEFAULT_PROVIDER: 'local_dev',
},
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');
assert.match(logs, /STORAGE_DEFAULT_PROVIDER=local_dev/, 'production fail-fast should reject local_dev storage');
}
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 sendMockSmsCode(phone, purpose) {
const sent = await request('/api/auth/sms/send', {
userId: false,
method: 'POST',
body: { phone, purpose },
});
assert.ok(sent.debugCode, `mock SMS ${purpose} should expose debugCode in local tests`);
return sent.debugCode;
}
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 testPhoneBinding() {
const phoneSuffix = String(Date.now()).slice(-6);
const oldPhone = `13920${phoneSuffix}`;
const newPhone = `13921${phoneSuffix}`;
const wrongPurposePhone = `13922${phoneSuffix}`;
const login = await loginBySms(oldPhone);
const authHeaders = { authorization: `Bearer ${login.session.token}` };
const bindCode = await sendMockSmsCode(newPhone, 'bind_phone');
const bound = await request('/api/auth/phone/bind', {
userId: false,
headers: authHeaders,
method: 'POST',
body: { phone: newPhone, code: bindCode },
});
assert.equal(bound.user?.id, login.user.id, 'phone bind should update the authenticated user');
assert.equal(bound.user?.phone, newPhone, 'phone bind should replace platform user phone');
assert.equal(bound.phoneChanged, true, 'phone bind should report phoneChanged for a real change');
const me = await request('/api/auth/me', {
userId: false,
headers: authHeaders,
});
assert.equal(me.user?.phone, newPhone, 'current session should see the newly bound phone');
const oldPhoneLogin = await loginBySms(oldPhone);
assert.notEqual(oldPhoneLogin.user?.id, login.user.id, 'old phone identity should no longer log into the changed account');
const conflictCode = await sendMockSmsCode('13800000000', 'bind_phone');
const conflict = await request('/api/auth/phone/bind', {
userId: false,
headers: authHeaders,
method: 'POST',
body: { phone: '13800000000', code: conflictCode },
expectStatus: 409,
});
assert.equal(conflict.code, 'PHONE_ALREADY_BOUND', 'binding a phone owned by another account should be rejected');
const wrongPurposeCode = await sendMockSmsCode(wrongPurposePhone, 'login');
const wrongPurpose = await request('/api/auth/phone/bind', {
userId: false,
headers: authHeaders,
method: 'POST',
body: { phone: wrongPurposePhone, code: wrongPurposeCode, purpose: 'login' },
expectStatus: 400,
});
assert.equal(wrongPurpose.code, 'PHONE_BIND_PURPOSE_REQUIRED', 'phone bind endpoint must reject login SMS codes');
}
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 testSupabaseJwksIdentity() {
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
const publicJwk = await exportJWK(publicKey);
publicJwk.kid = 'local-jwks-key-1';
publicJwk.alg = 'RS256';
publicJwk.use = 'sig';
const jwksUrl = await startLocalJwksServer({ keys: [publicJwk] });
const baseUrl = await startJwksAuthServer(jwksUrl);
const studentJwt = await createSupabaseJwksJwt(AUTH_USER_ID, privateKey, { phone: '13800000000' });
const studentHeaders = { authorization: `Bearer ${studentJwt}` };
const me = await requestAt(baseUrl, '/api/auth/me', {
userId: false,
headers: studentHeaders,
});
assert.equal(me.user?.id, USER_ID, 'JWKS RS256 JWT should map Supabase auth user to platform user');
assert.equal(me.session?.source, 'supabase_jwt', 'JWKS JWT auth/me should expose Supabase JWT session source');
const tenantHeaderJwt = await createSupabaseJwksJwt(AUTH_USER_ID, privateKey, { tenantId: false });
const tenantHeaderProfile = await requestAt(baseUrl, '/api/profile/me', {
userId: false,
headers: { authorization: `Bearer ${tenantHeaderJwt}` },
});
assert.equal(tenantHeaderProfile.item?.userId, USER_ID, 'JWKS JWT without tenant claim should be scoped by x-tenant-id');
const tenantAdminJwt = await createSupabaseJwksJwt(AUTH_TENANT_ADMIN_USER_ID, privateKey, { phone: '13800000001' });
const tenantOverview = await requestAt(baseUrl, '/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 JWKS JWT');
const platformAdminJwt = await createSupabaseJwksJwt(AUTH_PLATFORM_ADMIN_USER_ID, privateKey, {
phone: '13999999999',
appRole: 'platform_admin',
});
const platformOverview = await requestAt(baseUrl, '/api/platform-admin/overview', {
tenantId: false,
userId: false,
headers: { authorization: `Bearer ${platformAdminJwt}` },
});
assert.ok(platformOverview.item?.tenants?.total >= 1, 'platform admin should access platform overview through JWKS JWT');
const badKidJwt = await createSupabaseJwksJwt(AUTH_USER_ID, privateKey, { kid: 'unknown-key-id' });
const badKid = await requestAt(baseUrl, '/api/profile/me', {
userId: false,
headers: { authorization: `Bearer ${badKidJwt}` },
expectStatus: 401,
});
assert.equal(badKid.code, 'AUTH_SESSION_INVALID', 'JWKS JWT with an unknown kid must be rejected');
}
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');
}
async function testPlatformTenantOperationsAndAudit() {
const adminHeaders = { 'x-platform-admin-key': 'local-platform-admin-key' };
const slug = `integration-tenant-${Date.now().toString(36)}`;
const created = await request('/api/platform-admin/tenants', {
tenantId: false,
userId: false,
headers: adminHeaders,
method: 'POST',
body: {
slug,
name: '集成测试合作商',
legalName: '集成测试合作商有限公司',
brandName: '集成测试题库',
primaryHost: `${slug}.example.test`,
planCode: 'starter_yearly',
billingStatus: 'trial',
amountCents: 120000,
billing: {
billingName: '集成测试合作商有限公司',
taxId: 'TESTTAXNO',
contactName: '平台测试联系人',
contactPhone: '13800001111',
invoiceTitle: '集成测试合作商有限公司',
invoiceType: 'normal_vat',
},
},
});
assert.ok(created.item?.id, 'platform admin should create a tenant');
const tenantId = created.item.id;
const detail = await request('/api/platform-admin/tenants/detail', {
tenantId: false,
userId: false,
headers: adminHeaders,
query: { tenantId },
});
assert.equal(detail.item?.tenant?.slug, slug, 'tenant detail should return created tenant');
assert.ok(detail.item?.domains?.some(item => item.host === `${slug}.example.test`), 'tenant detail should include primary host');
assert.ok(detail.item?.subscriptions?.some(item => item.planCode === 'starter_yearly'), 'tenant detail should include subscription');
const billing = await request('/api/platform-admin/tenants/billing-profile', {
tenantId: false,
userId: false,
headers: adminHeaders,
method: 'PUT',
body: {
tenantId,
billingName: '集成测试更新主体',
taxId: 'UPDATEDTAXNO',
contactName: '更新联系人',
contactPhone: '13800002222',
contactEmail: 'finance@example.test',
invoiceTitle: '集成测试更新主体',
invoiceType: 'special_vat',
bankName: '测试银行',
bankAccountMasked: '****2222',
},
});
assert.equal(billing.item?.billingName, '集成测试更新主体', 'platform admin should update tenant billing profile');
const status = await request('/api/platform-admin/tenants/status', {
tenantId: false,
userId: false,
headers: adminHeaders,
method: 'PATCH',
body: {
tenantId,
status: 'active',
billingStatus: 'active',
reason: 'integration audit coverage',
},
});
assert.equal(status.item?.billingStatus, 'active', 'platform admin should update tenant billing status');
const updatedDetail = await request('/api/platform-admin/tenants/detail', {
tenantId: false,
userId: false,
headers: adminHeaders,
query: { tenantId },
});
assert.equal(updatedDetail.item?.tenant?.billingName, '集成测试更新主体', 'tenant detail should include updated billing profile');
assert.equal(updatedDetail.item?.tenant?.bankAccountMasked, '****2222', 'tenant detail should only expose masked bank account');
const audit = await request('/api/platform-admin/audit-logs', {
tenantId: false,
userId: false,
headers: adminHeaders,
query: { tenantId, limit: 20 },
});
const actions = new Set((audit.items || []).map(item => item.action));
assert.ok(actions.has('platform.tenant.created'), 'platform audit should include tenant creation');
assert.ok(actions.has('platform.tenant.billing_profile_upserted'), 'platform audit should include billing profile update');
assert.ok(actions.has('platform.tenant.status_updated'), 'platform audit should include status update');
const candidates = await request('/api/platform-admin/invoices/subscription-candidates', {
tenantId: false,
userId: false,
headers: adminHeaders,
query: { tenantIds: tenantId, daysAhead: 400, limit: 20 },
});
const candidate = candidates.items?.find(item => item.tenantId === tenantId);
assert.ok(candidate?.subscriptionId, 'platform admin should preview subscription invoice candidates');
assert.equal(candidate.hasExistingInvoice, false, 'new tenant subscription should not have an existing invoice');
const invalidCandidateQuery = await request('/api/platform-admin/invoices/subscription-candidates', {
tenantId: false,
userId: false,
headers: adminHeaders,
query: { tenantIds: 'not-a-uuid' },
expectStatus: 400,
});
assert.equal(invalidCandidateQuery.code, 'INVALID_UUID', 'subscription candidate query should reject malformed UUID filters');
const candidateBySubscription = await request('/api/platform-admin/invoices/subscription-candidates', {
tenantId: false,
userId: false,
headers: adminHeaders,
query: { subscriptionIds: candidate.subscriptionId, daysAhead: 400, limit: 20 },
});
assert.equal(candidateBySubscription.items?.length, 1, 'subscription candidate query should support precise subscription filters');
assert.equal(candidateBySubscription.items?.[0]?.subscriptionId, candidate.subscriptionId, 'subscription candidate filter should return the requested subscription');
const dryRunBatch = await request('/api/platform-admin/invoices/from-subscriptions-batch', {
tenantId: false,
userId: false,
headers: adminHeaders,
method: 'POST',
body: {
tenantIds: [tenantId],
daysAhead: 400,
dryRun: true,
dueDate: '2026-07-31',
note: 'integration dry-run',
},
});
assert.equal(dryRunBatch.item?.dryRun, true, 'batch subscription invoice dry-run should not create invoices');
assert.ok(dryRunBatch.item?.items?.some(item => item.subscriptionId === candidate.subscriptionId), 'dry-run should include the candidate subscription');
const invalidBatchStatus = await request('/api/platform-admin/invoices/from-subscriptions-batch', {
tenantId: false,
userId: false,
headers: adminHeaders,
method: 'POST',
body: {
tenantIds: [tenantId],
daysAhead: 400,
status: 'unexpected',
},
expectStatus: 400,
});
assert.equal(invalidBatchStatus.code, 'INVALID_INVOICE_STATUS', 'batch subscription invoice should reject invalid invoice statuses');
const batchCreated = await request('/api/platform-admin/invoices/from-subscriptions-batch', {
tenantId: false,
userId: false,
headers: adminHeaders,
method: 'POST',
body: {
tenantIds: [tenantId],
daysAhead: 400,
dueDate: '2026-07-31',
note: 'integration batch invoice',
},
});
assert.equal(batchCreated.item?.createdCount, 1, 'batch subscription invoice should create one invoice');
assert.equal(batchCreated.item?.skippedCount, 0, 'first batch subscription invoice should not skip');
const duplicateBatch = await request('/api/platform-admin/invoices/from-subscriptions-batch', {
tenantId: false,
userId: false,
headers: adminHeaders,
method: 'POST',
body: {
tenantIds: [tenantId],
daysAhead: 400,
dueDate: '2026-07-31',
},
});
assert.equal(duplicateBatch.item?.createdCount, 0, 'duplicate batch invoice run should not create another invoice');
const duplicateSingle = await request('/api/platform-admin/invoices/from-subscription', {
tenantId: false,
userId: false,
headers: adminHeaders,
method: 'POST',
body: { tenantId },
expectStatus: 409,
});
assert.equal(duplicateSingle.code, 'SUBSCRIPTION_INVOICE_EXISTS', 'single subscription invoice should reject duplicates');
const auditAfterBatch = await request('/api/platform-admin/audit-logs', {
tenantId: false,
userId: false,
headers: adminHeaders,
query: { q: 'platform.invoice.subscription_batch_created', limit: 20 },
});
assert.ok(auditAfterBatch.items?.some(item => item.action === 'platform.invoice.subscription_batch_created'), 'batch invoice creation should write platform audit');
const studentAuditDenied = await request('/api/platform-admin/audit-logs', {
tenantId: false,
userId: USER_ID,
expectStatus: 403,
});
assert.equal(studentAuditDenied.code, 'PLATFORM_ADMIN_REQUIRED', 'student must not read platform audit logs');
}
function stopServer() {
if (serverProcess && !serverProcess.killed) {
serverProcess.kill();
}
if (legacyDisabledServer && !legacyDisabledServer.killed) {
legacyDisabledServer.kill();
}
if (jwksAuthServer && !jwksAuthServer.killed) {
jwksAuthServer.kill();
}
if (jwksServer) {
jwksServer.close();
jwksServer = null;
}
if (fakeWechatServer) {
fakeWechatServer.close();
fakeWechatServer = null;
}
if (fakeQqServer) {
fakeQqServer.close();
fakeQqServer = 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 freeSessionDetail = await request('/api/learning/practice-sessions/detail', {
userId: false,
headers: { authorization: `Bearer ${freeLogin.session.token}` },
query: { practiceSessionId: freeSession.item.id },
});
assert.equal(freeSessionDetail.item?.id, freeSession.item.id, 'session detail should load own active practice session');
assert.equal(freeSessionDetail.item?.status, 'active', 'session detail should expose active status');
assert.deepEqual(freeSessionDetail.item?.questionIds, freeSession.item.questionIds, 'session detail should preserve backend question snapshot');
assert.ok(freeSessionDetail.item?.questions?.some(item => item.id === ids.question), 'session detail should include snapshot questions');
assert.equal(
freeSessionDetail.item?.answersByQuestion?.[ids.question]?.isCorrect,
false,
'session detail should include latest answer records for resume',
);
const crossUserSessionDetail = await request('/api/learning/practice-sessions/detail', {
query: { practiceSessionId: freeSession.item.id },
expectStatus: 404,
});
assert.equal(crossUserSessionDetail.code, 'PRACTICE_SESSION_NOT_FOUND', 'session detail must not expose another user session');
const selfJudgedObjective = await request('/api/learning/answers', {
userId: false,
headers: { authorization: `Bearer ${freeLogin.session.token}` },
method: 'POST',
body: {
questionId: ids.question,
selectedOptions: ['0'],
selfJudgedCorrect: true,
practiceSessionId: freeSession.item.id,
},
expectStatus: 400,
});
assert.equal(selfJudgedObjective.code, 'SELF_JUDGMENT_NOT_ALLOWED', 'objective questions must not accept self judgment');
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(
[ids.question, ids.questionTwo, ids.questionThree].some(questionId => nodeSession.item?.questionIds?.includes(questionId)),
'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 subjectiveAnswer = await request('/api/learning/answers', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
userId: TENANT_ADMIN_USER_ID,
questionId: ids.questionThree,
answerText: '已经对照参考答案完成自评',
selfJudgedCorrect: true,
practiceSessionId: mockSession.item.id,
},
});
assert.equal(subjectiveAnswer.item?.isCorrect, true, 'subjective self judgment should be persisted by backend');
assert.equal(subjectiveAnswer.item?.selfJudged, true, 'subjective answer response should mark self judgment');
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, 3, 'mock report should count latest submitted answers');
assert.equal(mockReport.item?.correctCount, 2, 'mock report should count correct answers');
assert.equal(mockReport.item?.wrongCount, 1, 'mock report should count wrong answers');
assert.equal(mockReport.item?.unansweredCount, 0, 'mock report should count unanswered questions');
assert.equal(mockReport.item?.score, 4, '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');
assert.ok(
mockReport.item?.questionResults?.some(item => item.questionId === ids.questionThree && item.isCorrect === true && item.answerText === '已经对照参考答案完成自评'),
'mock report should include backend-persisted subjective self judgment',
);
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 finishedSessionDetail = await request('/api/learning/practice-sessions/detail', {
userId: TENANT_ADMIN_USER_ID,
query: { practiceSessionId: mockSession.item.id },
});
assert.equal(finishedSessionDetail.item?.status, 'finished', 'session detail should expose finished status after submit');
assert.equal(finishedSessionDetail.item?.answersByQuestion?.[ids.questionThree]?.isCorrect, true, 'finished session detail should include subjective answer');
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 testCompositePracticeQuestions() {
const question = await request('/api/tenant-content/questions', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
questionBankId: ids.questionBank,
subjectId: ids.subject,
categoryId: ids.category,
entryId: ids.contentEntry,
contentNodeId: ids.contentNodeSchoolTarget,
primaryCollectionId: ids.questionCollection,
legacyId: `integration-composite-${Date.now()}`,
type: 'reading',
typeLabel: '阅读理解',
difficulty: 2,
content: '阅读材料Supabase SaaS 题库需要后端统一校验租户、权限和权益。',
options: [],
correctOptionIndices: [],
answerText: null,
explanation: '复合题解析应在报告中保留子题明细。',
subQuestions: [
{
id: 'main-idea',
type: 'choice',
typeLabel: '单选题',
content: '材料强调题库权限应由谁统一校验?',
options: ['前端页面', '后端服务', '浏览器缓存'],
correctOptionIndices: [1],
explanation: '租户隔离、权限和权益必须由后端统一校验。',
},
{
id: 'reason',
type: 'short_answer',
typeLabel: '简答题',
content: '简述为什么复合题需要保存每个子题的结构化答案。',
answerText: '为了支持断点续练、逐小题复盘和统计分析。',
explanation: '结构化子题结果可以服务报告、错题和后续数据分析。',
},
],
status: 'published',
},
});
assert.ok(question.item?.id, 'tenant admin should create a composite reading question');
const collection = await request('/api/tenant-content/question-collections', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
entryId: ids.contentEntry,
nodeId: ids.contentNodeSchoolTarget,
regionId: ids.region,
subjectId: ids.subject,
categoryId: ids.category,
questionBankId: ids.questionBank,
name: `集成测试复合题集合 ${Date.now()}`,
collectionType: 'manual',
sourceType: 'manual_questions',
totalScore: 10,
questions: [{ questionId: question.item.id, sectionKey: 'reading', order: 1, score: 10 }],
},
});
assert.equal(collection.item?.questionCount, 1, 'composite collection should contain the reading question');
const session = await request('/api/learning/practice-sessions', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
userId: TENANT_ADMIN_USER_ID,
collectionId: collection.item.id,
mode: 'sequential',
questionLimit: 1,
},
});
assert.deepEqual(session.item?.questionIds, [question.item.id], 'composite practice session should snapshot the reading question');
const topLevelRejected = await request('/api/learning/answers', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
userId: TENANT_ADMIN_USER_ID,
practiceSessionId: session.item.id,
questionId: question.item.id,
answerText: '不能用顶层答案提交复合题',
},
expectStatus: 400,
});
assert.equal(topLevelRejected.code, 'SUB_ANSWERS_REQUIRED', 'composite question should require subAnswers');
const objectiveSelfJudged = await request('/api/learning/answers', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
userId: TENANT_ADMIN_USER_ID,
practiceSessionId: session.item.id,
questionId: question.item.id,
subAnswers: [
{ subQuestionId: 'main-idea', selectedOptions: ['1'], selfJudgedCorrect: true },
],
},
expectStatus: 400,
});
assert.equal(objectiveSelfJudged.code, 'SELF_JUDGMENT_NOT_ALLOWED', 'objective sub questions must not accept self judgment');
const answer = await request('/api/learning/answers', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
userId: TENANT_ADMIN_USER_ID,
practiceSessionId: session.item.id,
questionId: question.item.id,
subAnswers: [
{ subQuestionId: 'main-idea', selectedOptions: ['1'] },
{ subQuestionId: 'reason', answerText: '已按参考答案完成自评', selfJudgedCorrect: true },
],
},
});
assert.equal(answer.item?.isCorrect, true, 'composite answer should be correct when all sub questions are correct');
assert.equal(answer.item?.compositeSummary?.correctCount, 2, 'composite answer should return sub question summary');
assert.ok(answer.item?.subResults?.some(item => item.subQuestionId === 'main-idea' && item.isCorrect === true), 'composite answer should include objective sub result');
assert.ok(answer.item?.subResults?.some(item => item.subQuestionId === 'reason' && item.selfJudged === true), 'composite answer should include subjective self judgment');
const detail = await request('/api/learning/practice-sessions/detail', {
userId: TENANT_ADMIN_USER_ID,
query: { practiceSessionId: session.item.id },
});
const storedAnswer = detail.item?.answersByQuestion?.[question.item.id];
assert.equal(storedAnswer?.answerPayload?.summary?.correctCount, 2, 'session detail should expose composite answer payload for resume');
const storedPayloadText = JSON.stringify(storedAnswer?.answerPayload || {});
assert.ok(!storedPayloadText.includes('correctAnswerText'), 'stored composite payload must not persist correct answers');
assert.ok(!storedPayloadText.includes('explanation'), 'stored composite payload must not persist explanations');
assert.ok(!storedPayloadText.includes('为了支持断点续练'), 'stored composite payload must not persist subjective reference answer');
assert.equal(detail.item?.questions?.[0]?.subQuestions?.length, 2, 'session detail should include composite sub questions');
const report = await request('/api/learning/practice-sessions/submit', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
userId: TENANT_ADMIN_USER_ID,
practiceSessionId: session.item.id,
},
});
assert.equal(report.item?.totalQuestions, 1, 'composite report should still count one top-level question');
assert.equal(report.item?.correctCount, 1, 'composite report should mark the top-level question correct when all sub questions are correct');
assert.equal(report.item?.score, 10, 'composite report should scale sub question score to collection item score');
const result = report.item?.questionResults?.find(item => item.questionId === question.item.id);
assert.equal(result?.subResults?.length, 2, 'composite report should include per-sub-question results');
assert.equal(result?.subResults?.[0]?.totalScore, 5, 'composite report should split top-level score across sub questions by default');
assert.ok(
result?.subResults?.some(item => item.subQuestionId === 'reason' && item.correctAnswerText === '为了支持断点续练、逐小题复盘和统计分析。'),
'composite report should enrich reference answers from the current question version',
);
assert.ok(
result?.subResults?.some(item => item.subQuestionId === 'main-idea' && item.explanation === '租户隔离、权限和权益必须由后端统一校验。'),
'composite report should enrich explanations from the current question version',
);
}
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 checkInBadge = await request('/api/tenant-admin/badges', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
id: ids.tenantCheckInBadge,
legacyId: 'integration-badge-check-in-auto',
name: '集成测试签到勋章',
description: '连续签到达到规则后自动发放',
category: 'activity',
iconUrl: 'https://example.test/badges/check-in-auto.png',
level: 1,
unlockType: 'check_in',
conditionField: 'checkInStreak',
conditionOperator: 'gte',
conditionValue: 1,
metadata: { source: 'integration-test', trigger: 'check_in' },
order: 1,
isActive: true,
},
});
assert.equal(checkInBadge.item?.id, ids.tenantCheckInBadge, 'tenant admin should create automatic check-in badge');
const scoreBadge = await request('/api/tenant-admin/badges', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
id: ids.tenantScoreBadge,
legacyId: 'integration-badge-score-auto',
name: '集成测试积分勋章',
description: '积分达到规则后自动发放',
category: 'activity',
iconUrl: 'https://example.test/badges/score-auto.png',
level: 1,
unlockType: 'score',
conditionField: 'score',
conditionOperator: 'gte',
conditionValue: 10,
metadata: { source: 'integration-test', trigger: 'score' },
order: 2,
isActive: true,
},
});
assert.equal(scoreBadge.item?.id, ids.tenantScoreBadge, 'tenant admin should create automatic score badge');
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');
assert.ok(
checkIn.item?.autoBadges?.some(item => item.badgeId === ids.tenantCheckInBadge),
'check-in should auto grant check-in badge',
);
assert.ok(
checkIn.item?.autoBadges?.some(item => item.badgeId === ids.tenantScoreBadge),
'check-in should auto grant score threshold badge',
);
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');
assert.equal(duplicateCheckIn.item?.autoBadges, undefined, 'duplicate check-in should not auto grant badges');
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 autoProfileBadges = await request('/api/profile/badges', {
query: { includeLocked: 'true', category: 'activity', limit: 20 },
});
assert.equal(
autoProfileBadges.items?.find(item => item.badgeId === ids.tenantCheckInBadge)?.unlocked,
true,
'profile badges should expose auto check-in badge as unlocked',
);
assert.equal(
autoProfileBadges.items?.find(item => item.badgeId === ids.tenantScoreBadge)?.unlocked,
true,
'profile badges should expose auto score badge as unlocked',
);
const badgeNotifications = await request('/api/profile/notifications', {
query: { notificationType: 'badge_granted', status: 'unread', limit: 20 },
});
assert.ok(
badgeNotifications.items?.some(item => item.metadata?.badgeId === ids.tenantCheckInBadge),
'auto badge grant should create unread student notification',
);
assert.equal(
badgeNotifications.summary?.unread >= 1,
true,
'profile notification summary should count unread notifications',
);
const firstBadgeNotification = badgeNotifications.items?.find(item => item.metadata?.badgeId === ids.tenantCheckInBadge);
const markedNotification = await request('/api/profile/notifications/status', {
method: 'POST',
body: {
notificationIds: [firstBadgeNotification.id],
status: 'read',
},
});
assert.equal(markedNotification.item?.updatedCount, 1, 'student should mark own notification as read');
const otherUserNotifications = await request('/api/profile/notifications', {
userId: SECOND_STUDENT_USER_ID,
query: { notificationType: 'badge_granted', limit: 20 },
});
assert.ok(
!otherUserNotifications.items?.some(item => item.metadata?.badgeId === ids.tenantCheckInBadge),
'another student must not see current student notifications',
);
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 testAiSchoolRecommendationRequiresSvip() {
const denied = await request('/api/ai/school-recommendations/generate', {
userId: SECOND_STUDENT_USER_ID,
method: 'POST',
body: {
regionId: ids.region,
estimatedScore: 210,
riskPreference: 'balanced',
},
expectStatus: 403,
});
assert.equal(denied.code, 'AI_SVIP_REQUIRED', 'AI school recommendation should require SVIP entitlement');
}
async function testAiSchoolRecommendationAfterSvip() {
const generated = await request('/api/ai/school-recommendations/generate', {
method: 'POST',
body: {
regionId: ids.region,
targetSchoolId: ids.scorelineSchool,
estimatedScore: 210,
riskPreference: 'balanced',
constraints: '优先考虑计算机相关专业',
recommendationLimit: 5,
},
});
assert.equal(generated.item?.status, 'generated', 'AI school recommendation should generate report');
assert.equal(generated.item?.provider, 'local_rules', 'AI recommendation should use deterministic local provider in tests');
assert.equal(generated.item?.resultPayload?.schemaVersion, 'school-recommendation-report-v1', 'AI report should use stable schema');
assert.ok(
generated.item?.resultPayload?.recommendedSchools?.some(item => item.schoolName === '烟测学院'),
'AI report should recommend from tenant scoreline context',
);
assert.ok(!JSON.stringify(generated).includes('secret'), 'AI recommendation response should not leak provider secrets');
const reports = await request('/api/ai/school-recommendations', {
query: { limit: 5 },
});
assert.ok(reports.items?.some(item => item.id === generated.item.id), 'AI report list should include generated report');
const detail = await request('/api/ai/school-recommendations/detail', {
query: { reportId: generated.item.id },
});
assert.equal(detail.item?.id, generated.item.id, 'AI report detail should load own report');
const otherStudentDenied = await request('/api/ai/school-recommendations/detail', {
userId: SECOND_STUDENT_USER_ID,
query: { reportId: generated.item.id },
expectStatus: 404,
});
assert.equal(otherStudentDenied.code, 'AI_REPORT_NOT_FOUND', 'students must not read other students AI reports');
const partnerTenantDenied = await request('/api/ai/school-recommendations/generate', {
tenantId: PARTNER_TENANT_ID,
method: 'POST',
body: {
regionId: ids.region,
estimatedScore: 210,
riskPreference: 'balanced',
},
expectStatus: 404,
});
assert.equal(partnerTenantDenied.code, 'PROFILE_NOT_FOUND', 'AI recommendation must not use a student profile from another tenant');
}
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');
assert.equal(quotaPlayback.watermark?.mode, 'visible_overlay', 'quota video playback should include watermark context');
assert.equal(quotaPlayback.watermark?.required, true, 'quota video playback watermark should be required');
const quotaPlayEvent = await latestVideoPlayEventByToken(quotaPlayback.playToken);
assert.equal(
quotaPlayEvent?.metadata?.watermark?.traceId,
quotaPlayback.watermark?.traceId,
'quota video play event should record watermark trace id',
);
const progressStarted = await request('/api/videos/progress', {
method: 'POST',
body: { playToken: quotaPlayback.playToken, eventType: 'start', progressSeconds: 3, durationSeconds: 90 },
});
assert.equal(progressStarted.item?.status, 'started', 'video progress start should mark event started');
assert.equal(progressStarted.item?.playback?.watchedSeconds, 3, 'video progress start should record watched seconds');
const progressHeartbeat = await request('/api/videos/progress', {
method: 'POST',
body: { playToken: quotaPlayback.playToken, eventType: 'heartbeat', progressSeconds: 45, watchedSeconds: 48, durationSeconds: 90 },
});
assert.equal(progressHeartbeat.item?.status, 'started', 'video heartbeat should keep event started before completion threshold');
assert.equal(progressHeartbeat.item?.playback?.watchedSeconds, 48, 'video heartbeat should keep max watched seconds');
const progressCompleted = await request('/api/videos/progress', {
method: 'POST',
body: { playToken: quotaPlayback.playToken, eventType: 'complete', progressSeconds: 90, durationSeconds: 90 },
});
assert.equal(progressCompleted.item?.status, 'completed', 'video completion should mark event completed');
assert.equal(progressCompleted.item?.playback?.completionRate, 1, 'video completion should record completion rate');
const secondUserProgressDenied = await request('/api/videos/progress', {
userId: SECOND_STUDENT_USER_ID,
method: 'POST',
body: { playToken: quotaPlayback.playToken, eventType: 'heartbeat', progressSeconds: 10 },
expectStatus: 404,
});
assert.equal(secondUserProgressDenied.code, 'VIDEO_PLAY_EVENT_NOT_FOUND', 'another user must not update 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');
assert.equal(svipPlayback.watermark?.mode, 'visible_overlay', 'SVIP video playback should include watermark context');
assert.ok(svipPlayback.watermark?.text?.includes('仅限本人学习'), 'SVIP video watermark should use student wording');
const svipPlayEvent = await latestVideoPlayEventByToken(svipPlayback.playToken);
assert.equal(
svipPlayEvent?.metadata?.watermark?.traceId,
svipPlayback.watermark?.traceId,
'SVIP video play event should record watermark trace id',
);
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 studentRefundDenied = await request('/api/commerce/refunds', {
method: 'POST',
body: {
orderNo: discountedOrder.item.orderNo,
amountCents: 500,
reason: 'student should not be able to request admin refund',
},
expectStatus: 403,
});
assert.equal(studentRefundDenied.code, 'TENANT_ADMIN_REQUIRED', 'student must not create refund request');
const refund = await request('/api/commerce/refunds', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
orderNo: discountedOrder.item.orderNo,
refundNo: 'RF-INTEGRATION-001',
amountCents: 500,
reason: 'integration refund',
},
});
assert.equal(refund.item?.refundNo, 'RF-INTEGRATION-001', 'tenant admin should create refund request');
assert.equal(refund.item?.status, 'requested', 'refund should start requested');
assert.equal(refund.item?.amountCents, 500, 'refund should use cents amount');
const refundAgain = await request('/api/commerce/refunds', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
orderNo: discountedOrder.item.orderNo,
refundNo: 'RF-INTEGRATION-001',
amountCents: 500,
reason: 'integration refund duplicate',
},
});
assert.equal(refundAgain.item?.idempotent, true, 'refundNo should be idempotent for same order and amount');
assert.equal(refundAgain.item?.id, refund.item.id, 'idempotent refund should return original request');
const excessiveRefund = await request('/api/commerce/refunds', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
orderNo: discountedOrder.item.orderNo,
refundNo: 'RF-INTEGRATION-EXCESS',
amountCents: 1,
reason: 'should exceed because full amount is already reserved',
},
expectStatus: 409,
});
assert.equal(excessiveRefund.code, 'REFUND_AMOUNT_EXCEEDS_PAID', 'reserved refunds must prevent over-refund');
const listedRefunds = await request('/api/commerce/refunds', {
userId: TENANT_ADMIN_USER_ID,
query: { orderNo: discountedOrder.item.orderNo },
});
assert.ok(listedRefunds.items?.some(item => item.id === refund.item.id), 'refund list should include created refund');
const approvedRefund = await request('/api/commerce/refunds/status', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
refundId: refund.item.id,
action: 'approve',
note: 'approved by integration test',
},
});
assert.equal(approvedRefund.item?.status, 'approved', 'tenant admin should approve refund');
const succeededRefund = await request('/api/commerce/refunds/status', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
refundId: refund.item.id,
action: 'mark_succeeded',
providerRefundNo: 'provider-refund-integration-001',
},
});
assert.equal(succeededRefund.item?.status, 'succeeded', 'tenant admin should mark refund succeeded');
assert.equal(succeededRefund.item?.providerRefundNo, 'provider-refund-integration-001', 'refund should record provider refund no');
const refundedStatus = await request('/api/commerce/orders/status', {
query: { orderNo: discountedOrder.item.orderNo },
});
assert.equal(refundedStatus.item?.status, 'refunded', 'full refund should mark order refunded');
assert.equal(refundedStatus.item?.payment?.status, 'refunded', 'full refund should mark payment refunded');
assert.equal(refundedStatus.item?.refundedAmountCents, 500, 'order status should expose refunded amount');
const entitlementAfterRefund = await request('/api/commerce/entitlements');
assert.ok(
entitlementAfterRefund.items?.some(
item => item.sourceType === 'order' && item.sourceId === discountedOrder.item.id && item.status === 'revoked',
),
'full order refund should revoke entitlement from that order',
);
const refundStatusAgain = await request('/api/commerce/refunds/status', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
refundId: refund.item.id,
action: 'mark_succeeded',
},
expectStatus: 409,
});
assert.equal(refundStatusAgain.code, 'REFUND_STATUS_INVALID', 'succeeded refund must not be processed twice');
const crossTenantRefundDenied = await request('/api/commerce/refunds', {
tenantId: PARTNER_TENANT_ID,
userId: TENANT_ADMIN_USER_ID,
query: { orderNo: discountedOrder.item.orderNo },
expectStatus: 403,
});
assert.equal(crossTenantRefundDenied.code, 'TENANT_ADMIN_REQUIRED', 'refund admin list must be tenant isolated');
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 minOrderCoupon = await request('/api/tenant-admin/coupons', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
code: `ITMIN${Date.now()}`,
planId: ids.couponOnlyPlan,
discountType: 'fixed',
discountValue: 300,
minOrderAmountCents: 1500,
campaignName: 'integration-coupon-rules',
maxUses: 50,
},
});
assert.equal(minOrderCoupon.item?.minOrderAmountCents, 1500, 'tenant admin should persist coupon minimum amount rule');
const minOrderCouponDenied = await request('/api/commerce/coupons/claim', {
method: 'POST',
body: { code: minOrderCoupon.item.code, planId: ids.couponOnlyPlan, regionId: ids.region },
expectStatus: 409,
});
assert.equal(minOrderCouponDenied.code, 'COUPON_MIN_ORDER_AMOUNT_NOT_MET', 'coupon minimum order amount should be enforced by backend');
const disabledCoupon = await request('/api/tenant-admin/coupons', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
code: `ITDIS${Date.now()}`,
planId: ids.couponOnlyPlan,
discountType: 'fixed',
discountValue: 100,
status: 'disabled',
campaignName: 'integration-coupon-rules',
},
});
const disabledCouponDenied = await request('/api/commerce/coupons/claim', {
method: 'POST',
body: { code: disabledCoupon.item.code, planId: ids.couponOnlyPlan, regionId: ids.region },
expectStatus: 409,
});
assert.equal(disabledCouponDenied.code, 'COUPON_DISABLED', 'disabled coupon should not be claimable');
const regionRestrictedCoupon = await request('/api/tenant-admin/coupons', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
code: `ITREG${Date.now()}`,
planId: ids.couponOnlyPlan,
discountType: 'fixed',
discountValue: 100,
allowedRegionIds: ['00000000-0000-0000-0000-000000000999'],
campaignName: 'integration-coupon-rules',
},
});
const regionCouponDenied = await request('/api/commerce/coupons/claim', {
method: 'POST',
body: { code: regionRestrictedCoupon.item.code, planId: ids.couponOnlyPlan, regionId: ids.region },
expectStatus: 409,
});
assert.equal(regionCouponDenied.code, 'COUPON_REGION_MISMATCH', 'coupon allowed region rule should be enforced');
const multiUseCoupon = await request('/api/tenant-admin/coupons', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
code: `ITMULTI${Date.now()}`,
planId: ids.couponOnlyPlan,
discountType: 'fixed',
discountValue: 100,
maxDiscountCents: 80,
perUserLimit: 2,
allowedPlanIds: [ids.couponOnlyPlan],
allowedRegionIds: [ids.region],
campaignName: 'integration-coupon-rules',
metadata: { scenario: 'multi-use' },
},
});
assert.equal(multiUseCoupon.item?.maxDiscountCents, 80, 'tenant admin should persist max discount cap');
assert.equal(multiUseCoupon.item?.perUserLimit, 2, 'tenant admin should persist per-user limit');
const multiClaimOne = await request('/api/commerce/coupons/claim', {
method: 'POST',
body: { code: multiUseCoupon.item.code, planId: ids.couponOnlyPlan, regionId: ids.region },
});
assert.equal(multiClaimOne.coupon?.discountCents, 80, 'coupon max discount cap should apply during claim');
const multiOrderOne = await request('/api/commerce/orders', {
method: 'POST',
body: {
planId: ids.couponOnlyPlan,
payProvider: 'manual',
payMethod: 'manual',
regionId: ids.region,
couponRedemptionId: multiClaimOne.redemption.id,
},
});
assert.equal(multiOrderOne.item?.discountCents, 80, 'order should apply capped coupon discount');
const multiClaimTwo = await request('/api/commerce/coupons/claim', {
method: 'POST',
body: { code: multiUseCoupon.item.code, planId: ids.couponOnlyPlan, regionId: ids.region },
});
assert.notEqual(multiClaimTwo.redemption?.id, multiClaimOne.redemption?.id, 'perUserLimit > 1 should allow another redemption after first use');
const multiOrderTwo = await request('/api/commerce/orders', {
method: 'POST',
body: {
planId: ids.couponOnlyPlan,
payProvider: 'manual',
payMethod: 'manual',
regionId: ids.region,
couponRedemptionId: multiClaimTwo.redemption.id,
},
});
assert.equal(multiOrderTwo.item?.discountCents, 80, 'second allowed coupon use should still apply cap');
const multiClaimThreeDenied = await request('/api/commerce/coupons/claim', {
method: 'POST',
body: { code: multiUseCoupon.item.code, planId: ids.couponOnlyPlan, regionId: ids.region },
expectStatus: 409,
});
assert.equal(multiClaimThreeDenied.code, 'COUPON_USER_LIMIT_REACHED', 'per-user coupon limit should be enforced after allowed uses');
const firstOrderCoupon = await request('/api/tenant-admin/coupons', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
code: `ITFIRST${Date.now()}`,
planId: ids.couponOnlyPlan,
discountType: 'fixed',
discountValue: 100,
firstOrderOnly: true,
campaignName: 'integration-coupon-rules',
},
});
const firstOrderDenied = await request('/api/commerce/coupons/claim', {
method: 'POST',
body: { code: firstOrderCoupon.item.code, planId: ids.couponOnlyPlan, regionId: ids.region },
expectStatus: 409,
});
assert.equal(firstOrderDenied.code, 'COUPON_FIRST_ORDER_ONLY', 'first-order-only coupon should reject users with paid orders');
const couponReport = await request('/api/tenant-admin/coupons/report', {
userId: TENANT_ADMIN_USER_ID,
query: { campaignName: 'integration-coupon-rules' },
});
assert.ok(couponReport.item?.claimCount >= 2, 'coupon report should aggregate campaign claims');
assert.ok(couponReport.item?.usedCount >= 2, 'coupon report should aggregate used redemptions');
assert.ok(
couponReport.item?.byCoupon?.some(item => item.id === multiUseCoupon.item.id && item.redeemedCount >= 2),
'coupon report should include per-coupon redemption stats',
);
const couponRedemptions = await request('/api/tenant-admin/coupons/redemptions', {
userId: TENANT_ADMIN_USER_ID,
query: { couponId: multiUseCoupon.item.id, status: 'used' },
});
assert.ok(
couponRedemptions.items?.some(item => item.orderNo === multiOrderOne.item.orderNo),
'coupon redemption list should include used order identity',
);
const couponRedemptionsOperatorDenied = await request('/api/tenant-admin/coupons/redemptions', {
userId: TENANT_OPERATOR_USER_ID,
query: { couponId: multiUseCoupon.item.id, status: 'used' },
expectStatus: 403,
});
assert.equal(
couponRedemptionsOperatorDenied.code,
'TENANT_PERMISSION_REQUIRED',
'coupon redemption list should require dedicated redemption-report permission beyond coupon config read',
);
const partialRefundOrder = await request('/api/commerce/orders', {
method: 'POST',
body: {
planId: ids.plan,
payProvider: 'manual',
payMethod: 'manual',
regionId: ids.region,
},
});
await request('/api/commerce/payments/manual-confirm', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
orderNo: partialRefundOrder.item.orderNo,
amountCents: partialRefundOrder.item.amountCents,
providerTradeNo: `manual-${partialRefundOrder.item.orderNo}`,
},
});
const partialRefund = await request('/api/commerce/refunds', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
orderNo: partialRefundOrder.item.orderNo,
refundNo: 'RF-INTEGRATION-PARTIAL',
amountCents: 100,
entitlementAction: 'revoke_on_success',
reason: 'partial refund should keep entitlement active',
},
});
await request('/api/commerce/refunds/status', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
refundId: partialRefund.item.id,
action: 'mark_succeeded',
providerRefundNo: 'provider-refund-integration-partial',
},
});
const partialRefundStatus = await request('/api/commerce/orders/status', {
query: { orderNo: partialRefundOrder.item.orderNo },
});
assert.equal(partialRefundStatus.item?.status, 'partially_refunded', 'partial refund should mark order partially_refunded');
const entitlementAfterPartialRefund = await request('/api/commerce/entitlements');
assert.ok(
entitlementAfterPartialRefund.items?.some(
item => item.sourceType === 'order' && item.sourceId === partialRefundOrder.item.id && item.status === 'active',
),
'partial refund should not revoke the order entitlement',
);
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 studentReconciliationDenied = await request('/api/commerce/reconciliation/batches', {
expectStatus: 403,
});
assert.equal(studentReconciliationDenied.code, 'TENANT_ADMIN_REQUIRED', 'students must not access finance reconciliation');
const reconMatchedOrder = await request('/api/commerce/orders', {
method: 'POST',
body: {
planId: ids.plan,
payProvider: 'manual',
payMethod: 'manual',
regionId: ids.region,
},
});
await request('/api/commerce/payments/manual-confirm', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
orderNo: reconMatchedOrder.item.orderNo,
amountCents: reconMatchedOrder.item.amountCents,
providerTradeNo: `manual-recon-${reconMatchedOrder.item.orderNo}`,
},
});
const reconMissingProviderOrder = await request('/api/commerce/orders', {
method: 'POST',
body: {
planId: ids.plan,
payProvider: 'manual',
payMethod: 'manual',
regionId: ids.region,
},
});
await request('/api/commerce/payments/manual-confirm', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
orderNo: reconMissingProviderOrder.item.orderNo,
amountCents: reconMissingProviderOrder.item.amountCents,
providerTradeNo: `manual-recon-${reconMissingProviderOrder.item.orderNo}`,
},
});
const billDate = shanghaiDateKey();
const reconciliationRows = [
{
transactionType: 'payment',
orderNo: reconMatchedOrder.item.orderNo,
providerTradeNo: `manual-recon-${reconMatchedOrder.item.orderNo}`,
amountCents: reconMatchedOrder.item.amountCents,
providerStatus: 'SUCCESS',
},
{
transactionType: 'payment',
orderNo: reconMatchedOrder.item.orderNo,
providerTradeNo: `manual-recon-${reconMatchedOrder.item.orderNo}`,
amountCents: reconMatchedOrder.item.amountCents,
providerStatus: 'SUCCESS',
},
{
transactionType: 'payment',
orderNo: reconMatchedOrder.item.orderNo,
providerTradeNo: `manual-recon-mismatch-${reconMatchedOrder.item.orderNo}`,
amountCents: reconMatchedOrder.item.amountCents + 1,
providerStatus: 'SUCCESS',
},
{
transactionType: 'payment',
orderNo: 'PROVIDER-ONLY-ORDER',
providerTradeNo: 'provider-only-trade',
amountCents: 1888,
providerStatus: 'SUCCESS',
},
];
const reconciliationPreview = await request('/api/commerce/reconciliation/preview', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
provider: 'manual',
billDate,
billType: 'payment',
sourceName: 'integration-manual-bill.json',
rows: reconciliationRows,
previewLimit: 20,
},
});
assert.equal(reconciliationPreview.item?.status, 'preview', 'reconciliation preview should not persist a completed batch');
assert.ok(
reconciliationPreview.items?.some(item => item.orderNo === reconMatchedOrder.item.orderNo && item.matchStatus === 'matched'),
'reconciliation preview should match a provider payment row to local payment',
);
assert.ok(
reconciliationPreview.items?.some(item => item.matchStatus === 'duplicate'),
'reconciliation preview should detect duplicate provider rows',
);
assert.ok(
reconciliationPreview.items?.some(item => item.matchStatus === 'amount_mismatch'),
'reconciliation preview should detect amount mismatches',
);
assert.ok(
reconciliationPreview.items?.some(item => item.matchStatus === 'missing_local'),
'reconciliation preview should detect provider rows missing local orders',
);
assert.ok(
reconciliationPreview.items?.some(item => item.orderNo === reconMissingProviderOrder.item.orderNo && item.matchStatus === 'missing_provider'),
'reconciliation preview should detect local paid orders missing from provider bill',
);
const reconciliationImport = await request('/api/commerce/reconciliation/import', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
provider: 'manual',
billDate,
billType: 'payment',
sourceName: 'integration-manual-bill.json',
rows: reconciliationRows,
metadata: { test: 'commerce-reconciliation' },
previewLimit: 20,
},
});
assert.ok(reconciliationImport.item?.id, 'tenant admin should import reconciliation batch');
assert.equal(
reconciliationImport.item?.status,
'completed_with_issues',
'reconciliation import should mark batches with anomalies as completed_with_issues',
);
assert.ok(reconciliationImport.item?.matchedCount >= 1, 'reconciliation batch should count matched rows');
assert.ok(reconciliationImport.item?.mismatchCount >= 1, 'reconciliation batch should count mismatch rows');
assert.ok(reconciliationImport.item?.missingLocalCount >= 1, 'reconciliation batch should count missing local rows');
assert.ok(reconciliationImport.item?.missingProviderCount >= 1, 'reconciliation batch should count missing provider rows');
assert.ok(reconciliationImport.item?.duplicateCount >= 1, 'reconciliation batch should count duplicate rows');
const reconciliationBatches = await request('/api/commerce/reconciliation/batches', {
userId: TENANT_ADMIN_USER_ID,
query: { provider: 'manual', billDate },
});
assert.ok(
reconciliationBatches.items?.some(item => item.id === reconciliationImport.item.id),
'reconciliation batches endpoint should list imported batch',
);
const reconciliationItems = await request('/api/commerce/reconciliation/items', {
userId: TENANT_ADMIN_USER_ID,
query: { batchId: reconciliationImport.item.id, matchStatus: 'missing_provider' },
});
assert.ok(
reconciliationItems.items?.some(item => item.orderNo === reconMissingProviderOrder.item.orderNo),
'reconciliation items endpoint should filter missing provider rows',
);
const reconciliationAnomalies = await request('/api/commerce/reconciliation/anomalies', {
userId: TENANT_ADMIN_USER_ID,
query: { provider: 'manual' },
});
assert.ok(
reconciliationAnomalies.items?.some(item => item.batchId === reconciliationImport.item.id && item.matchStatus === 'missing_local'),
'reconciliation anomalies endpoint should expose imported issues',
);
const actionableReconItem = reconciliationItems.items?.find(item => item.orderNo === reconMissingProviderOrder.item.orderNo);
assert.ok(actionableReconItem?.id, 'reconciliation test should have an actionable missing_provider item');
const studentReconciliationIssueDenied = await request('/api/commerce/reconciliation/issues/create', {
method: 'POST',
body: { itemId: actionableReconItem.id },
expectStatus: 403,
});
assert.equal(studentReconciliationIssueDenied.code, 'TENANT_ADMIN_REQUIRED', 'students must not create reconciliation issues');
const reconciliationIssue = await request('/api/commerce/reconciliation/issues/create', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
itemId: actionableReconItem.id,
assignedTo: TENANT_OPERATOR_USER_ID,
dueAt: new Date(Date.now() + 86_400_000).toISOString(),
summary: '人工核对供应商缺失账单',
note: '集成测试创建差错工单',
metadata: { source: 'api-integration-test' },
},
});
assert.ok(reconciliationIssue.item?.id, 'tenant admin should create a reconciliation issue');
assert.equal(reconciliationIssue.item?.status, 'open', 'created reconciliation issue should start open');
assert.equal(reconciliationIssue.item?.assignedTo, TENANT_OPERATOR_USER_ID, 'created issue should keep assignee');
assert.equal(reconciliationIssue.item?.matchStatus, 'missing_provider', 'created issue should copy match status');
const duplicateReconciliationIssue = await request('/api/commerce/reconciliation/issues/create', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: { itemId: actionableReconItem.id },
});
assert.equal(duplicateReconciliationIssue.item?.id, reconciliationIssue.item.id, 'duplicate active issue creation should be idempotent');
assert.equal(duplicateReconciliationIssue.idempotent, true, 'duplicate active issue creation should return idempotent=true');
const reconciliationIssues = await request('/api/commerce/reconciliation/issues', {
userId: TENANT_ADMIN_USER_ID,
query: { status: 'open', assignedTo: TENANT_OPERATOR_USER_ID },
});
assert.ok(
reconciliationIssues.items?.some(item => item.id === reconciliationIssue.item.id),
'reconciliation issues endpoint should filter by status and assignee',
);
const startedReconciliationIssue = await request('/api/commerce/reconciliation/issues/status', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
issueId: reconciliationIssue.item.id,
action: 'start',
note: '开始核查',
},
});
assert.equal(startedReconciliationIssue.item?.status, 'investigating', 'start action should mark issue investigating');
const assignedReconciliationIssue = await request('/api/commerce/reconciliation/issues/status', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
issueId: reconciliationIssue.item.id,
action: 'assign',
assignedTo: TENANT_ADMIN_USER_ID,
note: '转交财务管理员复核',
},
});
assert.equal(assignedReconciliationIssue.item?.assignedTo, TENANT_ADMIN_USER_ID, 'assign action should update assignee');
assert.equal(assignedReconciliationIssue.item?.status, 'investigating', 'assign action should not close issue');
const resolvedReconciliationIssue = await request('/api/commerce/reconciliation/issues/status', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
issueId: reconciliationIssue.item.id,
action: 'resolve',
resolutionType: 'manual_adjustment',
note: '已生成线下调整凭证',
metadata: { voucherNo: 'VOUCHER-RECON-001' },
},
});
assert.equal(resolvedReconciliationIssue.item?.status, 'resolved', 'resolve action should close issue');
assert.equal(resolvedReconciliationIssue.item?.resolutionType, 'manual_adjustment', 'resolve action should keep resolution type');
assert.equal(resolvedReconciliationIssue.item?.resolvedBy, TENANT_ADMIN_USER_ID, 'resolve action should record resolver');
const reconciliationIssueEvents = await request('/api/commerce/reconciliation/issues/events', {
userId: TENANT_ADMIN_USER_ID,
query: { issueId: reconciliationIssue.item.id },
});
assert.ok(
reconciliationIssueEvents.items?.some(item => item.eventType === 'created' && item.toStatus === 'open'),
'reconciliation issue events should include creation',
);
assert.ok(
reconciliationIssueEvents.items?.some(item => item.eventType === 'start' && item.toStatus === 'investigating'),
'reconciliation issue events should include start transition',
);
assert.ok(
reconciliationIssueEvents.items?.some(item => item.eventType === 'resolve' && item.toStatus === 'resolved'),
'reconciliation issue events should include resolution transition',
);
const crossTenantReconciliationIssueDenied = await request('/api/commerce/reconciliation/issues/events', {
tenantId: PARTNER_TENANT_ID,
userId: TENANT_ADMIN_USER_ID,
query: { issueId: reconciliationIssue.item.id },
expectStatus: 403,
});
assert.equal(crossTenantReconciliationIssueDenied.code, 'TENANT_ADMIN_REQUIRED', 'reconciliation issue events must be tenant isolated');
const crossTenantReconciliationDenied = await request('/api/commerce/reconciliation/items', {
tenantId: PARTNER_TENANT_ID,
userId: TENANT_ADMIN_USER_ID,
query: { batchId: reconciliationImport.item.id },
expectStatus: 403,
});
assert.equal(crossTenantReconciliationDenied.code, 'TENANT_ADMIN_REQUIRED', 'reconciliation items must be tenant isolated');
const studentProviderBillRequestDenied = await request('/api/commerce/reconciliation/provider-bills/request', {
method: 'POST',
body: {
provider: 'wechat_pay',
billDate,
billType: 'payment',
},
expectStatus: 403,
});
assert.equal(studentProviderBillRequestDenied.code, 'TENANT_ADMIN_REQUIRED', 'students must not request official provider bill downloads');
const providerBillJob = await request('/api/commerce/reconciliation/provider-bills/request', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
provider: 'wechat_pay',
billDate,
billType: 'payment',
metadata: { source: 'api-integration-test' },
},
});
assert.ok(providerBillJob.item?.id, 'tenant admin should request official provider bill download job');
assert.equal(providerBillJob.item?.status, 'queued', 'new provider bill job should be queued');
assert.equal(providerBillJob.item?.provider, 'wechat_pay', 'provider bill job should keep provider');
assert.ok(!JSON.stringify(providerBillJob).includes(paymentFixture.wechatApiV3Key), 'provider bill job response must not leak payment secrets');
assert.ok(!JSON.stringify(providerBillJob).includes('PRIVATE KEY'), 'provider bill job response must not leak private keys');
const providerBillJobAgain = await request('/api/commerce/reconciliation/provider-bills/request', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
provider: 'wechat_pay',
billDate,
billType: 'payment',
},
});
assert.equal(providerBillJobAgain.item?.id, providerBillJob.item.id, 'provider bill job request should be idempotent by provider/date/type');
assert.equal(providerBillJobAgain.idempotent, true, 'provider bill duplicate request should return idempotent=true');
const providerBillJobs = await request('/api/commerce/reconciliation/provider-bills/jobs', {
userId: TENANT_ADMIN_USER_ID,
query: { provider: 'wechat_pay', billDate },
});
assert.ok(
providerBillJobs.items?.some(item => item.id === providerBillJob.item.id),
'provider bill jobs endpoint should list tenant jobs',
);
assert.ok(!JSON.stringify(providerBillJobs).includes(paymentFixture.wechatApiV3Key), 'provider bill jobs list must not leak payment secrets');
const crossTenantProviderBillJobsDenied = await request('/api/commerce/reconciliation/provider-bills/jobs', {
tenantId: PARTNER_TENANT_ID,
userId: TENANT_ADMIN_USER_ID,
query: { provider: 'wechat_pay', billDate },
expectStatus: 403,
});
assert.equal(crossTenantProviderBillJobsDenied.code, 'TENANT_ADMIN_REQUIRED', 'provider bill jobs must be tenant isolated');
const operationReconItems = await request('/api/commerce/reconciliation/items', {
userId: TENANT_ADMIN_USER_ID,
query: { batchId: reconciliationImport.item.id, matchStatus: 'amount_mismatch' },
});
const operationReconItem = operationReconItems.items?.find(item => item.matchStatus === 'amount_mismatch');
assert.ok(operationReconItem?.id, 'operation anomaly test should have an open actionable reconciliation item');
const operationIssue = await request('/api/commerce/reconciliation/issues/create', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
itemId: operationReconItem.id,
summary: '运营台展示未关闭对账差错',
note: '保持 open 供运营台测试',
metadata: { source: 'operation-anomaly-test' },
},
});
assert.equal(operationIssue.item?.status, 'open', 'operation anomaly issue should remain open');
const studentOperationsDenied = await request('/api/commerce/operations/anomalies', {
expectStatus: 403,
});
assert.equal(studentOperationsDenied.code, 'TENANT_ADMIN_REQUIRED', 'students must not access finance operation anomalies');
const operationAnomalies = await request('/api/commerce/operations/anomalies', {
userId: TENANT_ADMIN_USER_ID,
query: { provider: 'manual' },
});
assert.ok(
operationAnomalies.items?.some(item => item.type === 'reconciliation_issue' && item.id === operationIssue.item.id),
'operation anomaly console should surface open reconciliation issues',
);
const voucherSourceOrderBefore = await request('/api/commerce/orders/status', {
query: { orderNo: reconMissingProviderOrder.item.orderNo },
});
const voucherSourcePaymentBefore = voucherSourceOrderBefore.item?.payment;
const voucherEntitlementsBefore = await request('/api/commerce/entitlements', {});
const studentAdjustmentDenied = await request('/api/commerce/adjustment-vouchers', {
method: 'POST',
body: {
reconciliationIssueId: reconciliationIssue.item.id,
title: 'student should not create adjustment voucher',
},
expectStatus: 403,
});
assert.equal(studentAdjustmentDenied.code, 'TENANT_ADMIN_REQUIRED', 'students must not create adjustment vouchers');
const adjustmentVoucherNo = `ADJ-INTEGRATION-${Date.now()}`;
const adjustmentVoucher = await request('/api/commerce/adjustment-vouchers', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
voucherNo: adjustmentVoucherNo,
reconciliationIssueId: reconciliationIssue.item.id,
adjustmentType: 'write_off',
direction: 'decrease',
amountCents: reconMissingProviderOrder.item.amountCents,
title: '供应商缺失账单人工核销凭证',
description: '仅作为财务复核证据,不直接修改订单状态',
externalUrl: 'https://finance.example.test/proofs/adj-integration-001',
metadata: { source: 'api-integration-test' },
},
});
assert.ok(adjustmentVoucher.item?.id, 'tenant admin should create adjustment voucher');
assert.equal(adjustmentVoucher.item?.status, 'submitted', 'adjustment voucher should default to submitted');
assert.equal(adjustmentVoucher.item?.reconciliationIssueId, reconciliationIssue.item.id, 'voucher should link reconciliation issue');
assert.equal(adjustmentVoucher.item?.orderNo, reconMissingProviderOrder.item.orderNo, 'voucher should copy order identity from issue');
const duplicateAdjustmentVoucher = await request('/api/commerce/adjustment-vouchers', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
voucherNo: adjustmentVoucherNo,
reconciliationIssueId: reconciliationIssue.item.id,
title: 'duplicate voucher number should be rejected',
},
expectStatus: 409,
});
assert.equal(duplicateAdjustmentVoucher.code, 'ADJUSTMENT_VOUCHER_DUPLICATE', 'duplicate voucher number should return business conflict');
const invalidAdjustmentSource = await request('/api/commerce/adjustment-vouchers', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
reconciliationIssueId: 'not-a-uuid',
title: 'invalid source id should be rejected',
},
expectStatus: 400,
});
assert.equal(invalidAdjustmentSource.code, 'INVALID_UUID', 'adjustment voucher should reject malformed source UUIDs');
const listedAdjustmentVouchers = await request('/api/commerce/adjustment-vouchers', {
userId: TENANT_ADMIN_USER_ID,
query: { status: 'submitted', orderNo: reconMissingProviderOrder.item.orderNo },
});
assert.ok(
listedAdjustmentVouchers.items?.some(item => item.id === adjustmentVoucher.item.id),
'adjustment voucher list should filter submitted vouchers by order number',
);
const adjustmentReport = await request('/api/commerce/adjustment-vouchers/report', {
userId: TENANT_ADMIN_USER_ID,
query: { startDate: billDate, endDate: billDate },
});
assert.ok(adjustmentReport.item?.totalCount >= 1, 'adjustment voucher report should include created voucher');
assert.ok(
adjustmentReport.item?.byStatus?.some(item => item.status === 'submitted' && item.count >= 1),
'adjustment voucher report should aggregate by status',
);
const approvedAdjustmentVoucher = await request('/api/commerce/adjustment-vouchers/status', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
voucherId: adjustmentVoucher.item.id,
status: 'approved',
reviewNote: '财务复核通过,仅保留审计凭证',
metadata: { reviewedFrom: 'api-integration-test' },
},
});
assert.equal(approvedAdjustmentVoucher.item?.status, 'approved', 'reviewer should approve submitted adjustment voucher');
assert.equal(approvedAdjustmentVoucher.item?.reviewedBy, TENANT_ADMIN_USER_ID, 'voucher approval should record reviewer');
const adjustmentVoucherEvents = await request('/api/commerce/adjustment-vouchers/events', {
userId: TENANT_ADMIN_USER_ID,
query: { voucherId: adjustmentVoucher.item.id },
});
assert.ok(
adjustmentVoucherEvents.items?.some(item => item.eventType === 'submitted' && item.toStatus === 'submitted'),
'voucher events should include submit event',
);
assert.ok(
adjustmentVoucherEvents.items?.some(item => item.eventType === 'approved' && item.toStatus === 'approved'),
'voucher events should include approval event',
);
const closedVoucherChangeDenied = await request('/api/commerce/adjustment-vouchers/status', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
voucherId: adjustmentVoucher.item.id,
status: 'rejected',
reviewNote: 'closed voucher should not flip to rejected',
},
expectStatus: 409,
});
assert.equal(closedVoucherChangeDenied.code, 'ADJUSTMENT_VOUCHER_CLOSED', 'closed adjustment voucher must be immutable');
const crossTenantVoucherDenied = await request('/api/commerce/adjustment-vouchers', {
tenantId: PARTNER_TENANT_ID,
userId: TENANT_ADMIN_USER_ID,
query: { voucherNo: adjustmentVoucherNo },
expectStatus: 403,
});
assert.equal(crossTenantVoucherDenied.code, 'TENANT_ADMIN_REQUIRED', 'adjustment vouchers must be tenant isolated');
const voucherSourceOrderAfter = await request('/api/commerce/orders/status', {
query: { orderNo: reconMissingProviderOrder.item.orderNo },
});
const voucherEntitlementsAfter = await request('/api/commerce/entitlements', {});
assert.equal(voucherSourceOrderAfter.item?.status, voucherSourceOrderBefore.item?.status, 'adjustment voucher approval must not mutate order status');
assert.equal(
voucherSourceOrderAfter.item?.payment?.status,
voucherSourcePaymentBefore?.status,
'adjustment voucher approval must not mutate payment status',
);
assert.equal(
voucherEntitlementsAfter.items?.length,
voucherEntitlementsBefore.items?.length,
'adjustment voucher approval must not grant or revoke entitlements',
);
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,
refundEndpoint: fakeWechatPay.refundEndpoint,
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 wechatRefund = await request('/api/commerce/refunds', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
orderNo: wechatOrder.item.orderNo,
refundNo: 'RF-WECHAT-PROVIDER-001',
amountCents: 100,
reason: 'provider refund smoke',
},
});
await request('/api/commerce/refunds/status', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
refundId: wechatRefund.item.id,
action: 'approve',
},
});
const wechatSubmittedRefund = await request('/api/commerce/refunds/status', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
refundId: wechatRefund.item.id,
action: 'submit_provider_refund',
providerNotifyUrl: 'https://pay.example.test/wechat/refund-notify',
},
});
assert.equal(wechatSubmittedRefund.item?.status, 'processing', 'WeChat provider refund should enter processing until refund notify/query confirms');
assert.equal(wechatSubmittedRefund.item?.providerRefundNo, 'refund-RF-WECHAT-PROVIDER-001', 'WeChat refund should record provider refund id');
const wechatRefundRequest = fakeWechatPay.requests.find(item => item.pathname === '/v3/refund/domestic/refunds');
assert.equal(wechatRefundRequest?.body?.out_refund_no, 'RF-WECHAT-PROVIDER-001', 'WeChat refund should use out_refund_no');
assert.equal(wechatRefundRequest?.body?.amount?.refund, 100, 'WeChat refund should send refund cents');
assert.equal(wechatRefundRequest?.body?.amount?.total, wechatOrder.item.amountCents, 'WeChat refund should send total cents');
const wechatRefundNotifyBody = {
id: `refund-evt-${wechatOrder.item.orderNo}`,
create_time: '2026-06-28T00:00:00+08:00',
event_type: 'REFUND.SUCCESS',
resource_type: 'encrypt-resource',
resource: encryptWechatRefundResource({
mchid: '1900000001',
out_trade_no: wechatOrder.item.orderNo,
transaction_id: `wx-trade-${wechatOrder.item.orderNo}`,
out_refund_no: 'RF-WECHAT-PROVIDER-001',
refund_id: 'refund-RF-WECHAT-PROVIDER-001',
refund_status: 'SUCCESS',
success_time: '2026-06-28T00:00:00+08:00',
amount: { refund: 100, total: wechatOrder.item.amountCents, payer_total: wechatOrder.item.amountCents, payer_refund: 100 },
}),
};
const wechatRefundRaw = JSON.stringify(wechatRefundNotifyBody);
const wechatRefundTimestamp = String(Math.floor(Date.now() / 1000));
const wechatRefundNonce = 'nonce-refund-smoke';
const wechatRefundNotify = await request('/api/commerce/refunds/notify/wechat_pay', {
userId: false,
method: 'POST',
query: { tenantId: MAIN_TENANT_ID },
headers: {
'wechatpay-timestamp': wechatRefundTimestamp,
'wechatpay-nonce': wechatRefundNonce,
'wechatpay-signature': signWechatNotification(wechatRefundRaw, wechatRefundTimestamp, wechatRefundNonce),
'wechatpay-serial': 'platform-serial-smoke',
},
body: wechatRefundNotifyBody,
});
assert.equal(wechatRefundNotify.item?.status, 'succeeded', 'WeChat refund notify should confirm provider success');
assert.equal(wechatRefundNotify.item?.refundNo, 'RF-WECHAT-PROVIDER-001', 'WeChat refund notify should return refund number');
const wechatRefundNotifyAgain = await request('/api/commerce/refunds/notify/wechat_pay', {
userId: false,
method: 'POST',
query: { tenantId: MAIN_TENANT_ID },
headers: {
'wechatpay-timestamp': wechatRefundTimestamp,
'wechatpay-nonce': wechatRefundNonce,
'wechatpay-signature': signWechatNotification(wechatRefundRaw, wechatRefundTimestamp, wechatRefundNonce),
'wechatpay-serial': 'platform-serial-smoke',
},
body: wechatRefundNotifyBody,
});
assert.equal(wechatRefundNotifyAgain.item?.idempotent, true, 'duplicate WeChat refund notify should be idempotent');
const wechatRefundQueryAfterNotify = await request('/api/commerce/refunds/status', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
refundId: wechatRefund.item.id,
action: 'query_provider_refund',
},
expectStatus: 409,
});
assert.equal(wechatRefundQueryAfterNotify.code, 'REFUND_STATUS_INVALID', 'succeeded WeChat refund must not be queried and applied twice');
const wechatPartiallyRefundedStatus = await request('/api/commerce/orders/status', {
query: { orderNo: wechatOrder.item.orderNo },
});
assert.equal(wechatPartiallyRefundedStatus.item?.status, 'partially_refunded', 'confirmed WeChat partial refund should update order status');
assert.equal(wechatPartiallyRefundedStatus.item?.refundedAmountCents, 100, 'confirmed WeChat partial refund should update refunded amount');
const wechatQueryOrder = await request('/api/commerce/orders', {
method: 'POST',
body: {
planId: ids.plan,
payProvider: 'wechat_pay',
payMethod: 'jsapi',
regionId: ids.region,
},
});
await request('/api/commerce/payments/create', {
method: 'POST',
body: {
orderNo: wechatQueryOrder.item.orderNo,
provider: 'wechat_pay',
openId: 'openid-pay-query-smoke',
},
});
const wechatQueryNotificationBody = {
id: `evt-${wechatQueryOrder.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: wechatQueryOrder.item.orderNo,
transaction_id: `wx-trade-${wechatQueryOrder.item.orderNo}`,
trade_state: 'SUCCESS',
success_time: '2026-06-28T00:00:00+08:00',
amount: { total: wechatQueryOrder.item.amountCents, currency: 'CNY' },
}),
};
const wechatQueryRaw = JSON.stringify(wechatQueryNotificationBody);
const wechatQueryTimestamp = String(Math.floor(Date.now() / 1000));
await request('/api/commerce/payments/notify/wechat_pay', {
userId: false,
method: 'POST',
query: { tenantId: MAIN_TENANT_ID },
headers: {
'wechatpay-timestamp': wechatQueryTimestamp,
'wechatpay-nonce': 'nonce-pay-query-smoke',
'wechatpay-signature': signWechatNotification(wechatQueryRaw, wechatQueryTimestamp, 'nonce-pay-query-smoke'),
'wechatpay-serial': 'platform-serial-smoke',
},
body: wechatQueryNotificationBody,
});
const wechatQueryRefund = await request('/api/commerce/refunds', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
orderNo: wechatQueryOrder.item.orderNo,
refundNo: 'RF-WECHAT-QUERY-001',
amountCents: 100,
reason: 'provider refund query smoke',
},
});
await request('/api/commerce/refunds/status', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
refundId: wechatQueryRefund.item.id,
action: 'approve',
},
});
await request('/api/commerce/refunds/status', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
refundId: wechatQueryRefund.item.id,
action: 'submit_provider_refund',
},
});
const wechatQueriedRefund = await request('/api/commerce/refunds/status', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
refundId: wechatQueryRefund.item.id,
action: 'query_provider_refund',
},
});
assert.equal(wechatQueriedRefund.item?.status, 'succeeded', 'WeChat refund query should confirm provider success');
const wechatRefundQueryRequest = fakeWechatPay.requests.find(item => item.method === 'GET' && item.pathname.endsWith('/RF-WECHAT-QUERY-001'));
assert.ok(wechatRefundQueryRequest, 'WeChat refund query should call query-by-out-refund-no endpoint');
const fakeAlipay = await startFakeAlipayServer();
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',
endpoint: fakeAlipay.endpoint,
},
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 alipayRefund = await request('/api/commerce/refunds', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
orderNo: alipayOrder.item.orderNo,
refundNo: 'RF-ALIPAY-PROVIDER-001',
amountCents: alipayOrder.item.amountCents,
reason: 'alipay provider refund smoke',
},
});
await request('/api/commerce/refunds/status', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
refundId: alipayRefund.item.id,
action: 'approve',
},
});
const alipaySubmittedRefund = await request('/api/commerce/refunds/status', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
refundId: alipayRefund.item.id,
action: 'submit_provider_refund',
},
});
assert.equal(alipaySubmittedRefund.item?.status, 'succeeded', 'Alipay provider refund should finish synchronously on success');
assert.equal(alipaySubmittedRefund.item?.providerRefundNo, `ali-trade-${alipayOrder.item.orderNo}`, 'Alipay refund should record provider trade number');
const alipayRefundRequest = fakeAlipay.requests.find(item => item.params?.method === 'alipay.trade.refund');
const alipayRefundBiz = JSON.parse(alipayRefundRequest?.params?.biz_content || '{}');
assert.equal(alipayRefundBiz.out_request_no, 'RF-ALIPAY-PROVIDER-001', 'Alipay refund should use out_request_no');
assert.equal(alipayRefundBiz.refund_amount, (alipayOrder.item.amountCents / 100).toFixed(2), 'Alipay refund should send yuan amount');
const alipayRefundedStatus = await request('/api/commerce/orders/status', {
query: { orderNo: alipayOrder.item.orderNo },
});
assert.equal(alipayRefundedStatus.item?.status, 'refunded', 'synchronous Alipay refund should update order status');
const alipayRefundNotifyBody = {
notify_id: `alipay-refund-notify-${alipayOrder.item.orderNo}`,
notify_time: '2026-06-28 00:00:00',
notify_type: 'trade_status_sync',
app_id: 'alipay-smoke-appid',
trade_no: `ali-trade-${alipayOrder.item.orderNo}`,
out_trade_no: alipayOrder.item.orderNo,
out_biz_no: 'RF-ALIPAY-PROVIDER-001',
refund_fee: (alipayOrder.item.amountCents / 100).toFixed(2),
fund_change: 'Y',
charset: 'utf-8',
version: '1.0',
};
alipayRefundNotifyBody.sign_type = 'RSA2';
alipayRefundNotifyBody.sign = signAlipayParams(alipayRefundNotifyBody);
const alipayRefundNotify = await request('/api/commerce/refunds/notify/alipay', {
userId: false,
method: 'POST',
query: { tenantId: MAIN_TENANT_ID },
body: alipayRefundNotifyBody,
});
assert.equal(alipayRefundNotify.item?.idempotent, true, 'Alipay refund notify for already succeeded refund should be idempotent');
const alipayRefundedAfterNotify = await request('/api/commerce/orders/status', {
query: { orderNo: alipayOrder.item.orderNo },
});
assert.equal(alipayRefundedAfterNotify.item?.refundedAmountCents, alipayOrder.item.amountCents, 'duplicate Alipay refund notify must not add refunded amount twice');
const alipayRefundQueryRejected = await request('/api/commerce/refunds/status', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
refundId: alipayRefund.item.id,
action: 'query_provider_refund',
},
expectStatus: 409,
});
assert.equal(alipayRefundQueryRejected.code, 'REFUND_STATUS_INVALID', 'succeeded provider refund must not be queried and applied twice');
assert.ok(
!fakeAlipay.requests.some(item => item.params?.method === 'alipay.trade.fastpay.refund.query'),
'already succeeded refund query should be rejected before calling Alipay',
);
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');
fakeAlipay.close();
}
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əˈː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: '基础加法。',
subQuestions: [
{
type: 'choice',
content: '集成测试子题1 + 1 = ?',
options: ['1', '2'],
correctOptionIndices: [1],
answerText: '2',
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 exportDenied = await request('/api/tenant-content/exports/questions', {
method: 'POST',
body: { scopeType: 'collection', scopeId: collection.item.id },
expectStatus: 403,
});
assert.equal(exportDenied.code, 'TENANT_CONTENT_EDITOR_REQUIRED', 'student should not export tenant question bank content');
const exportNoAnswers = await request('/api/tenant-content/exports/questions', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
scopeType: 'collection',
scopeId: collection.item.id,
format: 'json',
exportType: 'questions',
includeAnswers: false,
includeExplanations: false,
options: { title: '集成测试无答案题库导出' },
},
});
assert.equal(exportNoAnswers.job?.status, 'completed', 'question export should create a completed export job');
assert.equal(exportNoAnswers.export?.summary?.questionCount, 1, 'question export should include collection questions');
assert.ok(exportNoAnswers.export?.files?.[0]?.contentBase64, 'question export should return a downloadable JSON payload');
assert.equal(
Object.hasOwn(exportNoAnswers.export?.questions?.[0] || {}, 'answerText'),
false,
'question export should omit answers when includeAnswers is false',
);
assert.equal(
Object.hasOwn(exportNoAnswers.export?.questions?.[0] || {}, 'explanation'),
false,
'question export should omit explanations when includeExplanations is false',
);
assert.equal(
Object.hasOwn(exportNoAnswers.export?.questions?.[0]?.subQuestions?.[0] || {}, 'correctOptionIndices'),
false,
'question export should omit sub question answers when includeAnswers is false',
);
assert.equal(
Object.hasOwn(exportNoAnswers.export?.questions?.[0]?.subQuestions?.[0] || {}, 'answerText'),
false,
'question export should omit sub question answer text when includeAnswers is false',
);
assert.equal(
Object.hasOwn(exportNoAnswers.export?.questions?.[0]?.subQuestions?.[0] || {}, 'explanation'),
false,
'question export should omit sub question explanations when includeExplanations is false',
);
const decodedExport = JSON.parse(Buffer.from(exportNoAnswers.export.files[0].contentBase64, 'base64').toString('utf8'));
assert.equal(decodedExport._tikuExport, '3.0', 'question export JSON should use the new export contract');
assert.equal(decodedExport.questions?.[0]?.content, '集成测试题2 + 2 = ?', 'question export JSON should include question content');
const exportBadLimit = await request('/api/tenant-content/exports/questions', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
scopeType: 'collection',
scopeId: collection.item.id,
limit: 'not-a-number',
},
});
assert.equal(exportBadLimit.export?.summary?.questionCount, 1, 'question export should tolerate invalid limit input with a safe default');
const paperExport = await request('/api/tenant-content/exports/questions', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
scopeType: 'content_node',
scopeId: childNode.item.id,
format: 'paper_json',
exportType: 'paper',
includeAnswers: true,
includeExplanations: true,
options: {
title: '集成测试试卷版导出',
durationMinutes: 90,
watermarkText: '仅供内部测试',
},
},
});
assert.equal(paperExport.export?.renderHints?.pdfLayout, 'paper', 'paper export should expose paper render hints');
assert.ok(
paperExport.export?.sections?.some(section => section.questions?.some(item => item.id === question.item.id)),
'paper export should group the created question by section',
);
const exportedQuestionWithAnswer = paperExport.export?.questions?.find(item => item.id === question.item.id);
assert.equal(exportedQuestionWithAnswer?.answerText, '4', 'paper export should include answers when requested');
const dailyPracticeExport = await request('/api/tenant-content/exports/questions', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
scopeType: 'collection',
scopeId: collection.item.id,
format: 'json',
exportType: 'daily_practice',
includeAnswers: true,
includeExplanations: false,
limit: 99,
options: {
title: '第 1 期每日一练',
issue: '每日一练 第 1 期',
date: '2026-06-29',
theme: 'ink',
cardFormat: '1:1',
brand: {
name: '恭学教育',
english: 'GONGXUE EDU',
slogan: '专注高职升本',
ctaLine: '每日一练 · 稳步上岸',
},
},
},
});
assert.equal(dailyPracticeExport.export?.exportType, 'daily_practice', 'daily practice export should use daily_practice type');
assert.equal(dailyPracticeExport.export?.summary?.issue, '每日一练 第 1 期', 'daily practice export should expose issue metadata');
assert.equal(dailyPracticeExport.export?.summary?.cardCount, 1, 'daily practice export should expose card count');
assert.equal(dailyPracticeExport.export?.dailyPractice?.slots?.some(item => item.slot === 4 && item.kind === 'center'), true, 'daily practice export should include center slot metadata');
assert.ok(
dailyPracticeExport.export?.questions?.length <= 8,
'daily practice export should cap question count for nine-grid material',
);
const invalidDailyPracticeZip = await request('/api/tenant-content/exports/questions', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
scopeType: 'collection',
scopeId: collection.item.id,
format: 'daily_practice_zip',
exportType: 'paper',
},
expectStatus: 400,
});
assert.equal(
invalidDailyPracticeZip.code,
'DAILY_PRACTICE_ZIP_REQUIRES_DAILY_PRACTICE',
'daily practice ZIP export should reject non-daily-practice export types',
);
const dailyPracticeZipJob = await request('/api/tenant-content/exports/questions', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
scopeType: 'collection',
scopeId: collection.item.id,
format: 'daily_practice_zip',
exportType: 'daily_practice',
includeAnswers: false,
includeExplanations: false,
limit: 99,
options: {
title: '集成测试每日一练图片包',
issue: '每日一练 图片包',
date: '2026-06-29',
theme: 'default',
publishToAssets: true,
assetVisibility: 'tenant',
},
},
});
assert.equal(dailyPracticeZipJob.job?.status, 'pending', 'daily practice ZIP export should create a pending async job');
assert.equal(dailyPracticeZipJob.export, null, 'daily practice ZIP export should not return inline ZIP payload');
const pdfExportJob = await request('/api/tenant-content/exports/questions', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
scopeType: 'collection',
scopeId: collection.item.id,
format: 'pdf',
exportType: 'paper',
includeAnswers: false,
includeExplanations: false,
options: {
title: '集成测试 PDF 异步导出',
watermarkText: '仅供内部测试',
publishToAssets: true,
assetVisibility: 'tenant',
},
},
});
assert.equal(pdfExportJob.job?.status, 'pending', 'PDF export should create a pending async export job');
assert.equal(pdfExportJob.export, null, 'PDF export should not return inline binary payload');
const docxExportJob = await request('/api/tenant-content/exports/questions', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
scopeType: 'collection',
scopeId: collection.item.id,
format: 'docx',
exportType: 'paper',
includeAnswers: true,
includeExplanations: true,
options: {
title: '集成测试 Word 异步导出',
watermarkText: '仅供内部测试',
},
},
});
assert.equal(docxExportJob.job?.status, 'pending', 'DOCX export should create a pending async export job');
const partnerExportDenied = await request('/api/tenant-content/exports/questions', {
tenantId: PARTNER_TENANT_ID,
userId: PARTNER_TENANT_ADMIN_USER_ID,
method: 'POST',
body: { scopeType: 'collection', scopeId: collection.item.id },
expectStatus: 404,
});
assert.equal(partnerExportDenied.code, 'QUESTION_COLLECTION_NOT_FOUND', 'question export must not read another tenant collection');
const exportJobs = await request('/api/tenant-content/exports/jobs', {
userId: TENANT_ADMIN_USER_ID,
query: { scopeType: 'collection', scopeId: collection.item.id, limit: 10 },
});
assert.ok(
exportJobs.items?.some(item => item.id === exportNoAnswers.job.id && item.questionCount === 1),
'question export jobs should list completed export audit records',
);
const listedPdfJob = exportJobs.items?.find(item => item.id === pdfExportJob.job.id);
assert.equal(listedPdfJob?.status, 'pending', 'question export jobs should expose pending binary export jobs');
assert.equal(listedPdfJob?.assetId, null, 'pending binary export job should not have an asset yet');
assert.equal(listedPdfJob?.attemptCount, 0, 'pending binary export job should expose worker attempt count');
const listedDailyPracticeZipJob = exportJobs.items?.find(item => item.id === dailyPracticeZipJob.job.id);
assert.equal(listedDailyPracticeZipJob?.format, 'daily_practice_zip', 'export jobs should list pending daily practice ZIP jobs');
assert.equal(listedDailyPracticeZipJob?.status, 'pending', 'pending daily practice ZIP job should be visible to tenant content admins');
const invalidExportJobsScope = await request('/api/tenant-content/exports/jobs', {
userId: TENANT_ADMIN_USER_ID,
query: { scopeType: 'not_supported' },
expectStatus: 400,
});
assert.equal(invalidExportJobsScope.code, 'INVALID_EXPORT_SCOPE', 'question export jobs should validate scope type filters');
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',
fileSizeBytes: 4096,
checksumSha256: 'a'.repeat(64),
},
});
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', providerManagedAccess: true },
},
});
assert.equal(asset.item?.visibility, 'svip', 'tenant admin should create svip asset');
const lockedCdnAsset = await request('/api/tenant-content/assets', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
title: '集成测试未授权 CDN SVIP 资料',
assetType: 'pdf',
storageProvider: 'external_url',
cdnUrl: 'https://example.test/resources/locked-without-provider-managed.pdf',
fileName: 'locked-without-provider-managed.pdf',
mimeType: 'application/pdf',
visibility: 'svip',
regionId: ids.region,
subjectId: ids.subject,
categoryId: ids.category,
metadata: { source: 'api-integration' },
},
});
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');
assert.equal(download.download?.expiresInSec, 300, 'locked student asset downloads should use short TTL');
assert.equal(download.watermark?.mode, 'visible_overlay', 'locked student asset downloads should include a visible watermark context');
assert.equal(download.watermark?.required, true, 'locked student asset watermark should be required');
assert.ok(download.watermark?.traceId, 'locked student asset watermark should include a trace id');
const deniedLockedCdn = await request('/api/catalog/assets/download', {
query: { assetId: lockedCdnAsset.item.id },
expectStatus: 409,
});
assert.equal(deniedLockedCdn.code, 'ASSET_CDN_ACCESS_NOT_ALLOWED', 'locked cdn asset should require explicit provider-managed access');
const accessEventsAfterLockedDeny = await request('/api/tenant-content/assets/access-events', {
userId: TENANT_ADMIN_USER_ID,
query: { assetId: lockedCdnAsset.item.id },
});
assert.ok(
accessEventsAfterLockedDeny.items?.some(item => item.result === 'denied' && item.denyCode === 'ASSET_CDN_ACCESS_NOT_ALLOWED'),
'locked cdn denial should be recorded as an asset access event',
);
const accessEventsAfterDownload = await request('/api/tenant-content/assets/access-events', {
userId: TENANT_ADMIN_USER_ID,
query: { assetId: asset.item.id },
});
assert.ok(
accessEventsAfterDownload.items?.some(item => (
item.accessType === 'download' &&
item.result === 'granted' &&
item.metadata?.watermark?.traceId === download.watermark?.traceId
)),
'successful student asset download should record the watermark trace id',
);
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,
checksumSha256: upload.assetDraft.checksumSha256,
visibility: 'tenant',
},
});
assert.equal(localAsset.item?.objectKey, upload.assetDraft.objectKey, 'asset upsert should keep normalized object key');
assert.equal(localAsset.item?.status, 'draft', 'managed object asset should stay draft before upload confirmation');
assert.equal(localAsset.item?.uploadStatus, 'pending', 'managed object asset should be pending before upload confirmation');
assert.equal(localAsset.item?.securityScanStatus, 'pending', 'managed object asset should require security scan before publishing');
const unconfirmedDownload = await request('/api/catalog/assets/download', {
query: { assetId: localAsset.item.id },
expectStatus: 404,
});
assert.equal(unconfirmedDownload.code, 'ASSET_NOT_FOUND', 'unconfirmed draft asset should not be downloadable by students');
const confirmLocal = await request('/api/tenant-content/assets/confirm-upload', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
assetId: localAsset.item.id,
fileSizeBytes: upload.assetDraft.fileSizeBytes,
checksumSha256: upload.assetDraft.checksumSha256,
mimeType: 'application/pdf',
publish: true,
},
});
assert.equal(confirmLocal.item?.status, 'draft', 'confirmed managed asset should stay draft until security scan passes');
assert.equal(confirmLocal.item?.uploadStatus, 'verified', 'confirm upload should mark asset verified');
assert.equal(confirmLocal.item?.securityScanStatus, 'pending', 'confirm upload should mark asset security scan pending');
assert.equal(confirmLocal.item?.verifiedChecksumSha256, 'a'.repeat(64), 'confirm upload should persist checksum');
const scanPendingEvents = await request('/api/tenant-content/assets/security-scan-events', {
userId: TENANT_ADMIN_USER_ID,
query: { assetId: localAsset.item.id },
});
assert.ok(Array.isArray(scanPendingEvents.items), 'tenant admin should list asset security scan events');
const localDownload = await request('/api/catalog/assets/download', {
query: { assetId: localAsset.item.id },
expectStatus: 404,
});
assert.equal(localDownload.code, 'ASSET_NOT_FOUND', 'unscanned draft asset should not be downloadable');
await markAssetSecurityScanPassed(localAsset.item.id);
const publishScannedLocal = await request('/api/tenant-content/assets', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
id: localAsset.item.id,
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,
checksumSha256: upload.assetDraft.checksumSha256,
visibility: 'tenant',
status: 'active',
},
});
assert.equal(publishScannedLocal.item?.status, 'active', 'scanned managed asset should publish');
assert.equal(publishScannedLocal.item?.securityScanStatus, 'passed', 'published managed asset should keep passed scan status');
const scanEventsAfterPass = await request('/api/tenant-content/assets/security-scan-events', {
userId: TENANT_ADMIN_USER_ID,
query: { assetId: localAsset.item.id },
});
assert.ok(
scanEventsAfterPass.items?.some(item => item.scanStatus === 'passed' && item.provider === 'api_integration_test'),
'asset security scan pass event should be visible to tenant admin',
);
const scannedLocalDownload = await request('/api/catalog/assets/download', {
query: { assetId: localAsset.item.id },
});
assert.equal(scannedLocalDownload.download?.method, 'GET', 'object asset download should sign GET');
assert.equal(scannedLocalDownload.download?.signatureMode, 'local-placeholder', 'local object asset should use local placeholder signer');
assert.ok(scannedLocalDownload.download?.url?.includes(encodeURIComponent(localAsset.item.bucket)), 'object asset download should include bucket');
assert.equal(scannedLocalDownload.watermark?.mode, 'visible_overlay', 'student object asset download should include watermark context');
const localPreview = await request('/api/catalog/assets/preview', {
query: { assetId: localAsset.item.id },
});
assert.equal(localPreview.preview?.method, 'GET', 'asset preview should sign GET');
assert.equal(localPreview.preview?.signatureMode, 'local-placeholder', 'local asset preview should use local placeholder signer');
assert.ok(localPreview.preview?.url?.includes('disposition=inline'), 'asset preview should request inline disposition');
assert.equal(localPreview.preview?.expiresInSec, 300, 'student inline previews should use short TTL');
assert.equal(localPreview.watermark?.mode, 'visible_overlay', 'student inline previews should include watermark context');
assert.equal(localPreview.watermark?.traceId?.length, 12, 'student inline preview watermark should include short trace id');
const adminPreview = await request('/api/tenant-content/assets/sign-preview', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: { assetId: localAsset.item.id },
});
assert.ok(adminPreview.preview?.url?.includes('disposition=inline'), 'admin preview should request inline disposition');
assert.equal(adminPreview.watermark?.mode, 'visible_overlay', 'admin preview should include internal watermark context');
assert.ok(adminPreview.watermark?.text?.includes('内部资料'), 'admin preview watermark should use internal wording');
const localAccessEvents = await request('/api/tenant-content/assets/access-events', {
userId: TENANT_ADMIN_USER_ID,
query: { assetId: localAsset.item.id },
});
assert.ok(
localAccessEvents.items?.some(item => (
item.accessType === 'preview' &&
item.expiresInSec === 300 &&
item.metadata?.watermark?.traceId === localPreview.watermark?.traceId
)),
'student preview access event should include short TTL and watermark trace id',
);
assert.ok(
localAccessEvents.items?.some(item => (
item.accessType === 'admin_preview' &&
item.metadata?.watermark?.traceId === adminPreview.watermark?.traceId
)),
'admin preview access event should include watermark trace id',
);
const mediaSummary = await request('/api/tenant-content/media-analytics/summary', {
userId: TENANT_ADMIN_USER_ID,
query: { timeRange: '30d', assetId: localAsset.item.id, traceId: localPreview.watermark.traceId },
});
assert.equal(mediaSummary.assetAccess?.previews >= 1, true, 'media analytics summary should count asset previews');
assert.equal(mediaSummary.assetAccess?.watermarkEvents >= 1, true, 'media analytics summary should count watermark asset events');
assert.equal(mediaSummary.traceMatches?.assets >= 1, true, 'media analytics summary should support trace id asset lookup');
assert.ok(mediaSummary.daily?.some(item => item.assetEvents >= 1), 'media analytics daily series should include asset events');
const mediaAssetEvents = await request('/api/tenant-content/media-analytics/asset-events', {
userId: TENANT_ADMIN_USER_ID,
query: { timeRange: '30d', traceId: localPreview.watermark.traceId, limit: 5 },
});
assert.ok(
mediaAssetEvents.items?.some(item => item.assetId === localAsset.item.id && item.watermarkTraceId === localPreview.watermark.traceId),
'media analytics asset event list should search by watermark trace id',
);
assert.ok(!JSON.stringify(mediaAssetEvents).includes('signature='), 'media analytics asset events should not leak signed URLs');
const mediaVideoSummary = await request('/api/tenant-content/media-analytics/summary', {
userId: TENANT_ADMIN_USER_ID,
query: { timeRange: '30d', videoId: ids.video },
});
assert.equal(mediaVideoSummary.videoPlay?.svipPlays >= 1, true, 'media analytics summary should count SVIP video plays');
assert.ok(mediaVideoSummary.videoTop?.some(item => item.videoId === ids.video), 'media analytics summary should include top videos');
const mediaQuotaVideoSummary = await request('/api/tenant-content/media-analytics/summary', {
userId: TENANT_ADMIN_USER_ID,
query: { timeRange: '30d', videoId: ids.quotaVideo },
});
assert.equal(mediaQuotaVideoSummary.videoPlay?.completedEvents >= 1, true, 'media analytics summary should count completed video events');
assert.equal(mediaQuotaVideoSummary.videoPlay?.watchedSeconds >= 90, true, 'media analytics summary should count watched seconds');
const mediaVideoEventsByVideo = await request('/api/tenant-content/media-analytics/video-events', {
userId: TENANT_ADMIN_USER_ID,
query: { timeRange: '30d', videoId: ids.video, limit: 5 },
});
const videoTraceId = mediaVideoEventsByVideo.items?.find(item => item.videoId === ids.video)?.watermarkTraceId;
assert.ok(videoTraceId, 'media analytics video event list should include watermark trace id');
const mediaVideoEvents = await request('/api/tenant-content/media-analytics/video-events', {
userId: TENANT_ADMIN_USER_ID,
query: { timeRange: '30d', traceId: videoTraceId, limit: 5 },
});
assert.ok(
mediaVideoEvents.items?.some(item => item.videoId === ids.video && item.watermarkTraceId === videoTraceId),
'media analytics video event list should search by watermark trace id',
);
assert.ok(!JSON.stringify(mediaVideoEvents).includes('vp_'), 'media analytics video events should not leak play tokens');
const teacherAnalyticsDenied = await request('/api/tenant-content/media-analytics/summary', {
userId: TENANT_TEACHER_USER_ID,
expectStatus: 403,
});
assert.equal(
teacherAnalyticsDenied.code,
'TENANT_CONTENT_PERMISSION_REQUIRED',
'teacher without analytics permission should not access media audit analytics',
);
const failedAsset = 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: `${MAIN_TENANT_ID}/pdf/failed-${Date.now()}.pdf`,
fileName: 'failed.pdf',
mimeType: 'application/pdf',
fileSizeBytes: 100,
checksumSha256: 'b'.repeat(64),
visibility: 'tenant',
},
});
const failedConfirm = await request('/api/tenant-content/assets/confirm-upload', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
assetId: failedAsset.item.id,
fileSizeBytes: 101,
checksumSha256: 'b'.repeat(64),
mimeType: 'application/pdf',
publish: true,
},
expectStatus: 409,
});
assert.equal(failedConfirm.code, 'UPLOAD_VERIFICATION_FAILED', 'confirm upload should reject mismatched declared metadata');
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 deniedTemplate = await request('/api/tenant-content/imports/templates', {
query: { importType: 'questions', format: 'csv' },
expectStatus: 403,
});
assert.equal(deniedTemplate.code, 'TENANT_CONTENT_EDITOR_REQUIRED', 'student should not download import templates');
const questionFieldMapping = await request('/api/tenant-content/imports/field-mapping', {
userId: TENANT_ADMIN_USER_ID,
query: { importType: 'questions' },
});
assert.ok(
questionFieldMapping.item?.fields?.some(field => field.field === 'content' && field.required === true),
'question field mapping should describe required content field',
);
assert.ok(
questionFieldMapping.item?.fields?.some(field => field.aliases?.includes('题干')),
'question field mapping should include Chinese aliases for old import operators',
);
const scorelineTemplate = await request('/api/tenant-content/imports/templates', {
userId: TENANT_ADMIN_USER_ID,
query: { importType: 'scoreline', format: 'csv' },
});
assert.equal(scorelineTemplate.item?.fileName, 'scoreline-import-template.csv', 'template endpoint should return deterministic filename');
const scorelineTemplateText = Buffer.from(scorelineTemplate.item?.contentBase64 || '', 'base64').toString('utf8');
assert.ok(scorelineTemplateText.includes('kind,legacyId'), 'CSV template should include scoreline headers');
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 questionPostCheck = await request('/api/tenant-content/imports/post-check', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: { jobId: validPreview.job.id },
});
assert.equal(questionPostCheck.item?.status, 'passed', 'question import post-check should pass after completed import');
assert.equal(questionPostCheck.item?.counts?.processedItemCount, 2, 'question post-check should count processed rows');
assert.equal(questionPostCheck.item?.counts?.collectionBindingCount, 2, 'question post-check should verify collection bindings');
const questionPostCheckStatus = await request('/api/tenant-content/imports/post-check', {
userId: TENANT_ADMIN_USER_ID,
query: { jobId: validPreview.job.id },
});
assert.equal(questionPostCheckStatus.item?.importPostCheck?.status, 'passed', 'post-check result should be readable from job summary');
const questionImportDetail = await request('/api/tenant-content/imports/detail', {
userId: TENANT_ADMIN_USER_ID,
query: { jobId: validPreview.job.id },
});
assert.equal(questionImportDetail.item?.id, validPreview.job.id, 'import detail should return selected job');
assert.equal(questionImportDetail.importPostCheck?.status, 'passed', 'import detail should expose post-check summary');
assert.equal(questionImportDetail.itemStatusSummary?.inserted, 2, 'import detail should expose item status summary');
const crossTenantImportDetailDenied = await request('/api/tenant-content/imports/detail', {
tenantId: PARTNER_TENANT_ID,
userId: PARTNER_TENANT_ADMIN_USER_ID,
query: { jobId: validPreview.job.id },
expectStatus: 404,
});
assert.equal(crossTenantImportDetailDenied.code, 'IMPORT_JOB_NOT_FOUND', 'import detail should be tenant isolated');
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' && item.summary?.importPostCheck?.status === 'passed'),
'import job list should include completed job with post-check summary',
);
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 questionCsv = [
['legacyId', '题型', '题干', '选项A', '选项B', '选项C', '选项D', '答案', '解析', '难度', '标签'].join(','),
['integration-import-csv-choice-001', 'choice', 'CSV导入题多租户数据隔离应主要依赖什么', '前端隐藏菜单', '后端权限和RLS', '浏览器缓存', '静态页面', 'B', '最终权限以后端和RLS为准。', '2', 'csv|import'].join(','),
].join('\n');
const csvQuestionPreview = await request('/api/tenant-content/imports/preview/questions', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
sourceFormat: 'csv',
sourceName: 'questions.csv',
csvText: questionCsv,
subjectId: ids.subject,
categoryId: ids.category,
regionId: ids.region,
entryId: ids.contentEntry,
contentNodeId: ids.contentNodeSchoolTarget,
collectionId: ids.questionCollection,
},
});
assert.equal(csvQuestionPreview.job?.errorCount, 0, 'CSV question preview should have no errors');
assert.equal(csvQuestionPreview.job?.validCount, 1, 'CSV question preview should normalize one row');
assert.equal(csvQuestionPreview.items?.[0]?.normalized?.correctOptionIndices?.[0], 1, 'CSV answer B should map to option index 1');
const mappedQuestionCsv = [
['旧编号', '题型显示', '自定义题干', '左选项', '右选项', '正确项', '讲解'].join(','),
['integration-import-csv-mapped-001', 'choice', 'CSV字段映射题导入别名应由谁最终校验', '前端页面', '后端导入管线', 'B', '字段映射只能进入后端白名单字段。'].join(','),
].join('\n');
const mappedCsvPreview = await request('/api/tenant-content/imports/preview/questions', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
sourceFormat: 'csv',
sourceName: 'questions-mapped.csv',
csvText: mappedQuestionCsv,
subjectId: ids.subject,
categoryId: ids.category,
regionId: ids.region,
entryId: ids.contentEntry,
contentNodeId: ids.contentNodeSchoolTarget,
collectionId: ids.questionCollection,
fieldMappingOverrides: {
legacyId: ['旧编号'],
type: ['题型显示'],
content: ['自定义题干'],
optionA: ['左选项'],
optionB: ['右选项'],
answer: ['正确项'],
explanation: ['讲解'],
},
},
});
assert.equal(mappedCsvPreview.job?.errorCount, 0, 'CSV field mapping override preview should have no errors');
assert.equal(mappedCsvPreview.items?.[0]?.normalized?.content, 'CSV字段映射题导入别名应由谁最终校验', 'field mapping override should map custom stem header');
assert.equal(mappedCsvPreview.items?.[0]?.normalized?.correctOptionIndices?.[0], 1, 'field mapping override should map custom answer header');
const unsafeMappingOverrides = Object.fromEntries([['__proto__', ['旧编号']]]);
const deniedMappingTarget = await request('/api/tenant-content/imports/preview/questions', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
sourceFormat: 'csv',
sourceName: 'questions-bad-mapping.csv',
csvText: mappedQuestionCsv,
subjectId: ids.subject,
categoryId: ids.category,
regionId: ids.region,
fieldMappingOverrides: unsafeMappingOverrides,
},
expectStatus: 400,
});
assert.equal(deniedMappingTarget.code, 'IMPORT_HEADER_UNSAFE', 'field mapping override should reject unsafe target fields');
const csvQuestionImport = await request('/api/tenant-content/imports/questions', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: { previewJobId: csvQuestionPreview.job.id },
});
assert.equal(csvQuestionImport.item?.status, 'completed', 'CSV question import should complete');
const csvQuestionJobs = await request('/api/tenant-content/imports', {
userId: TENANT_ADMIN_USER_ID,
query: { importType: 'questions', limit: 20 },
});
assert.ok(
csvQuestionJobs.items?.some(item => item.id === csvQuestionPreview.job.id && item.sourceFormat === 'csv'),
'CSV question import job should retain sourceFormat',
);
const importedCsvQuestions = await request('/api/catalog/questions', {
query: { collectionId: ids.questionCollection, limit: 150 },
});
assert.ok(
importedCsvQuestions.items?.some(item => item.content === 'CSV导入题多租户数据隔离应主要依赖什么'),
'catalog should expose imported CSV question',
);
const asyncQuestionPreview = await request('/api/tenant-content/imports/preview/questions', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
sourceName: 'async-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-async-choice-001',
type: 'choice',
content: '异步导入排队题:大批量导入应该交给谁执行?',
options: ['前端循环写表', '导入 worker', '用户刷新页面', '浏览器缓存'],
correctOptionIndices: [1],
explanation: '大批量导入由后端 worker 消费,避免请求超时和规则漂移。',
difficulty: 2,
tags: ['async-import'],
},
],
},
});
assert.equal(asyncQuestionPreview.job?.errorCount, 0, 'async preview should have no errors');
const asyncQuestionQueued = await request('/api/tenant-content/imports/questions', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: { previewJobId: asyncQuestionPreview.job.id, executionMode: 'async' },
});
assert.equal(asyncQuestionQueued.item?.status, 'pending', 'async import should queue the job');
assert.equal(asyncQuestionQueued.item?.executionMode, 'async', 'async import response should expose execution mode');
const queuedImportDetail = await request('/api/tenant-content/imports/detail', {
userId: TENANT_ADMIN_USER_ID,
query: { jobId: asyncQuestionPreview.job.id },
});
assert.equal(queuedImportDetail.item?.status, 'pending', 'import detail should expose queued status');
assert.equal(queuedImportDetail.worker?.executionMode, 'async', 'import detail should expose async worker metadata');
assert.equal(queuedImportDetail.worker?.attemptCount, 0, 'queued import detail should expose attempt count before worker starts');
assert.equal(queuedImportDetail.itemStatusSummary?.valid, 1, 'queued import detail should expose preview item statuses');
const queuedAgain = await request('/api/tenant-content/imports/questions', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: { previewJobId: asyncQuestionPreview.job.id, executionMode: 'async' },
});
assert.equal(queuedAgain.item?.idempotent, true, 'queueing the same async job should be idempotent');
const queuedSyncExecution = await request('/api/tenant-content/imports/questions', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: { previewJobId: asyncQuestionPreview.job.id },
expectStatus: 409,
});
assert.equal(queuedSyncExecution.code, 'IMPORT_JOB_QUEUED', 'queued import job should not be executed synchronously');
const queuedPostCheck = await request('/api/tenant-content/imports/post-check', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: { jobId: asyncQuestionPreview.job.id },
expectStatus: 409,
});
assert.equal(queuedPostCheck.code, 'IMPORT_JOB_NOT_COMPLETED', 'queued import job should not allow post-check before worker completion');
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 vocabularyPostCheck = await request('/api/tenant-content/imports/post-check', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: { jobId: vocabularyPreview.job.id },
});
assert.equal(vocabularyPostCheck.item?.status, 'passed', 'vocabulary import post-check should pass');
assert.equal(vocabularyPostCheck.item?.counts?.processedItemCount, 1, 'vocabulary post-check should count unit import items');
assert.equal(vocabularyPostCheck.item?.counts?.vocabularyWordCount >= 1, true, 'vocabulary post-check should verify imported words');
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 vocabularyCsv = [
['unitLegacyId', 'unitName', 'wordLegacyId', 'word', 'phonetic', 'meaning', 'example', 'difficulty', 'tags'].join(','),
['integration-vocab-csv-unit-001', 'CSV 高频词单元', 'integration-vocab-csv-word-scale', 'scale', '/skeɪl/', 'n. 规模;等级', 'SaaS platforms must scale safely.', '2', 'csv|高频'].join(','),
].join('\n');
const vocabularyCsvPreview = await request('/api/tenant-content/imports/preview/vocabulary', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
sourceFormat: 'csv',
sourceName: 'vocabulary.csv',
csvText: vocabularyCsv,
regionId: ids.region,
entryId: vocabEntry.item.id,
contentNodeId: vocabRoot.item.id,
},
});
assert.equal(vocabularyCsvPreview.job?.errorCount, 0, 'CSV vocabulary preview should have no errors');
assert.equal(vocabularyCsvPreview.items?.[0]?.normalized?.words?.[0]?.word, 'scale', 'CSV vocabulary should group words under units');
const vocabularyCsvImport = await request('/api/tenant-content/imports/vocabulary', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: { previewJobId: vocabularyCsvPreview.job.id },
});
assert.equal(vocabularyCsvImport.item?.status, 'completed', 'CSV vocabulary import should complete');
const vocabularyUnitsAfterCsv = await request('/api/catalog/vocabulary-units', {
query: { regionId: ids.region },
});
const csvVocabularyUnit = vocabularyUnitsAfterCsv.items?.find(item => item.legacyId === 'integration-vocab-csv-unit-001');
assert.ok(csvVocabularyUnit, 'catalog should expose CSV imported vocabulary unit');
const csvVocabularyWords = await request('/api/catalog/vocabulary-words', {
query: { unitId: csvVocabularyUnit.id },
});
assert.ok(csvVocabularyWords.items?.some(item => item.word === 'scale'), 'catalog should expose CSV imported vocabulary word');
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 handbookPostCheck = await request('/api/tenant-content/imports/post-check', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: { jobId: handbookPreview.job.id },
});
assert.equal(handbookPostCheck.item?.status, 'passed', 'handbook import post-check should pass');
assert.equal(handbookPostCheck.item?.counts?.handbookEntryCount >= 1, true, 'handbook post-check should verify entries');
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 invalidScorelinePreview = await request('/api/tenant-content/imports/preview/scoreline', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
regionId: ids.region,
items: [{ kind: 'record', year: 2026, score: 180 }],
},
});
assert.ok(
invalidScorelinePreview.issues?.some(issue => issue.code === 'SCORELINE_RECORD_SCHOOL_REQUIRED'),
'invalid scoreline preview should validate school reference',
);
const scorelinePreview = await request('/api/tenant-content/imports/preview/scoreline', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
sourceName: 'scoreline-batch-import.json',
regionId: ids.region,
fields: [
{
legacyId: 'integration-scoreline-field-min',
fieldKey: 'minScore',
fieldName: '最低分',
fieldType: 'number',
unit: '分',
isFilter: true,
isTrend: true,
order: 1,
},
],
schools: [
{
legacyId: 'integration-scoreline-school-a',
name: '集成测试学院',
shortName: '集测学院',
type: 'public',
isHot: true,
},
],
majors: [
{
legacyId: 'integration-scoreline-major-a',
schoolLegacyId: 'integration-scoreline-school-a',
name: '软件工程',
hasRestriction: false,
},
],
records: [
{
legacyId: 'integration-scoreline-record-a-2025',
schoolLegacyId: 'integration-scoreline-school-a',
majorLegacyId: 'integration-scoreline-major-a',
schoolName: '集成测试学院',
majorName: '软件工程',
year: 2025,
fieldValues: { minScore: 188, planCount: 60 },
},
],
},
});
assert.equal(scorelinePreview.job?.errorCount, 0, 'valid scoreline preview should have no errors');
assert.equal(scorelinePreview.job?.validCount, 4, 'scoreline preview should normalize fields, schools, majors, and records');
const scorelineImport = await request('/api/tenant-content/imports/scoreline', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: { previewJobId: scorelinePreview.job.id },
});
assert.equal(scorelineImport.item?.status, 'completed', 'scoreline import should complete');
assert.ok(
(scorelineImport.item?.insertedCount || 0) + (scorelineImport.item?.updatedCount || 0) + (scorelineImport.item?.skippedCount || 0) >= 4,
'scoreline import should process all valid rows',
);
const scorelinePostCheck = await request('/api/tenant-content/imports/post-check', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: { jobId: scorelinePreview.job.id },
});
assert.equal(scorelinePostCheck.item?.status, 'passed', 'scoreline import post-check should pass');
assert.equal(scorelinePostCheck.item?.counts?.scorelineRecordExistingCount, 1, 'scoreline post-check should verify record target');
const importedScorelineRecords = await request('/api/scoreline/records', {
query: { regionId: ids.region, year: 2025, pageSize: 50 },
});
assert.ok(
importedScorelineRecords.items?.some(item => item.schoolName === '集成测试学院' && item.fieldValues?.minScore === 188),
'public scoreline records should expose imported record',
);
const mixedScorelinePreview = await request('/api/tenant-content/imports/preview/scoreline', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
sourceName: 'scoreline-mixed-items.json',
regionId: ids.region,
items: [
{
kind: 'record',
legacyId: 'integration-scoreline-mixed-record-2026',
schoolLegacyId: 'integration-scoreline-mixed-school',
majorLegacyId: 'integration-scoreline-mixed-major',
year: 2026,
minScore: 211,
},
{
kind: 'major',
legacyId: 'integration-scoreline-mixed-major',
schoolLegacyId: 'integration-scoreline-mixed-school',
name: '混排专业',
},
{
kind: 'school',
legacyId: 'integration-scoreline-mixed-school',
name: '混排测试学院',
},
],
},
});
assert.equal(mixedScorelinePreview.job?.errorCount, 0, 'mixed scoreline preview should accept out-of-order items');
const mixedScorelineImport = await request('/api/tenant-content/imports/scoreline', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: { previewJobId: mixedScorelinePreview.job.id },
});
assert.equal(mixedScorelineImport.item?.status, 'completed', 'mixed scoreline import should order dependent rows');
const mixedScorelineRecords = await request('/api/scoreline/records', {
query: { regionId: ids.region, year: 2026, pageSize: 50 },
});
assert.ok(
mixedScorelineRecords.items?.some(item => item.schoolName === '混排测试学院' && item.fieldValues?.minScore === 211),
'mixed scoreline import should create record after school and major',
);
const scorelineWorkbook = new ExcelJS.Workbook();
scorelineWorkbook.addWorksheet('fields').addRows([
['legacyId', 'fieldKey', 'fieldName', 'fieldType', 'isTrend'],
['integration-scoreline-xlsx-field-min', 'xlsxMinScore', 'Excel最低分', 'number', '是'],
]);
scorelineWorkbook.addWorksheet('schools').addRows([
['legacyId', 'schoolName', 'isHot'],
['integration-scoreline-xlsx-school', 'Excel导入学院', 'true'],
]);
scorelineWorkbook.addWorksheet('majors').addRows([
['legacyId', 'schoolLegacyId', 'majorName'],
['integration-scoreline-xlsx-major', 'integration-scoreline-xlsx-school', 'Excel专业'],
]);
scorelineWorkbook.addWorksheet('records').addRows([
['legacyId', 'schoolLegacyId', 'majorLegacyId', 'year', 'xlsxMinScore'],
['integration-scoreline-xlsx-record-2027', 'integration-scoreline-xlsx-school', 'integration-scoreline-xlsx-major', 2027, 233],
]);
const scorelineExcelBuffer = await scorelineWorkbook.xlsx.writeBuffer();
const scorelineExcelPreview = await request('/api/tenant-content/imports/preview/scoreline', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
sourceFormat: 'excel',
sourceName: 'scoreline.xlsx',
fileBase64: Buffer.from(scorelineExcelBuffer).toString('base64'),
regionId: ids.region,
},
});
assert.equal(scorelineExcelPreview.job?.errorCount, 0, 'Excel scoreline preview should have no errors');
assert.equal(scorelineExcelPreview.job?.validCount, 4, 'Excel scoreline preview should parse multi-sheet workbook');
const scorelineExcelImport = await request('/api/tenant-content/imports/scoreline', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: { previewJobId: scorelineExcelPreview.job.id },
});
assert.equal(scorelineExcelImport.item?.status, 'completed', 'Excel scoreline import should complete');
const excelScorelineRecords = await request('/api/scoreline/records', {
query: { regionId: ids.region, year: 2027, pageSize: 50 },
});
assert.ok(
excelScorelineRecords.items?.some(item => item.schoolName === 'Excel导入学院' && item.fieldValues?.xlsxMinScore === 233),
'public scoreline records should expose imported Excel record',
);
const invalidVideoPreview = await request('/api/tenant-content/imports/preview/videos', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
videos: [{ title: '缺少题目绑定', bindings: [{ videoType: 'specific' }] }],
},
});
assert.ok(
invalidVideoPreview.issues?.some(issue => issue.code === 'VIDEO_BINDING_QUESTION_REQUIRED'),
'invalid video preview should validate question binding',
);
const videoPreview = await request('/api/tenant-content/imports/preview/videos', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
sourceName: 'video-batch-import.json',
videos: [
{
legacyId: 'integration-video-import-001',
title: '批量导入视频精讲',
description: '通过内容导入管线导入并绑定题目',
videoUrl: 'https://example.test/videos/imported.mp4',
thumbnailUrl: 'https://example.test/videos/imported.jpg',
durationSeconds: 120,
knowledgeTags: ['批量导入', '视频精讲'],
isGeneral: false,
subjectId: ids.subject,
accessMode: 'free',
bindings: [
{
legacyId: 'integration-video-binding-001',
questionId: ids.question,
videoType: 'specific',
order: 1,
},
],
},
],
},
});
assert.equal(videoPreview.job?.errorCount, 0, 'valid video preview should have no errors');
const videoImport = await request('/api/tenant-content/imports/videos', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: { previewJobId: videoPreview.job.id },
});
assert.equal(videoImport.item?.status, 'completed', 'video import should complete');
assert.ok(
(videoImport.item?.insertedCount || 0) + (videoImport.item?.updatedCount || 0) + (videoImport.item?.skippedCount || 0) >= 2,
'video import should process video and question binding',
);
const videoPostCheck = await request('/api/tenant-content/imports/post-check', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: { jobId: videoPreview.job.id },
});
assert.equal(videoPostCheck.item?.status, 'passed', 'video import post-check should pass');
assert.equal(videoPostCheck.item?.counts?.videoBindingCount >= 1, true, 'video post-check should verify question video bindings');
const questionVideosAfterImport = await request(`/api/questions/${ids.question}/videos`);
assert.ok(
questionVideosAfterImport.videos?.some(item => item.legacyId === 'integration-video-import-001' && item.title === '批量导入视频精讲'),
'question video API should expose imported video binding',
);
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 testPublicQuestionBankAdoption() {
const studentDenied = await request('/api/tenant-content/public-question-banks', {
expectStatus: 403,
});
assert.equal(studentDenied.code, 'TENANT_CONTENT_EDITOR_REQUIRED', 'student should not browse adoptable public banks');
const platformBanks = await request('/api/platform-admin/question-banks', {
userId: false,
headers: { 'x-platform-admin-key': 'local-platform-admin-key' },
query: { q: '烟测公共题库' },
});
assert.ok(platformBanks.items?.some(item => item.id === ids.questionBank && item.sourceScope === 'platform'), 'platform admin should list platform public banks');
const grant = await request('/api/platform-admin/question-bank-grants', {
userId: false,
headers: { 'x-platform-admin-key': 'local-platform-admin-key' },
method: 'PUT',
body: {
id: ids.publicQuestionBankGrant,
sourceQuestionBankId: ids.questionBank,
grantScope: 'plans',
allowedPlanCodes: ['starter_yearly', 'pro_yearly'],
status: 'active',
metadata: { source: 'integration-test' },
},
});
assert.equal(grant.item?.sourceQuestionBankId, ids.questionBank, 'platform admin should upsert public bank grant');
const grants = await request('/api/platform-admin/question-bank-grants', {
userId: false,
headers: { 'x-platform-admin-key': 'local-platform-admin-key' },
query: { questionBankId: ids.questionBank },
});
assert.ok(grants.items?.some(item => item.id === ids.publicQuestionBankGrant), 'platform admin should list public bank grants');
const partnerBanks = await request('/api/tenant-content/public-question-banks', {
tenantId: PARTNER_TENANT_ID,
userId: PARTNER_TENANT_ADMIN_USER_ID,
});
const adoptable = partnerBanks.items?.find(item => item.grantId === ids.publicQuestionBankGrant);
assert.ok(adoptable, 'partner tenant should see public bank granted by SaaS plan');
assert.equal(adoptable.adoptedId, null, 'public bank should start as not adopted after smoke seed');
const adopted = await request('/api/tenant-content/public-question-banks/adopt', {
tenantId: PARTNER_TENANT_ID,
userId: PARTNER_TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
grantId: ids.publicQuestionBankGrant,
entryName: '合作商采纳烟测公共题库',
collectionName: '合作商公共题库题目',
copyLimit: 3,
metadata: { source: 'integration-test' },
},
});
assert.equal(adopted.item?.sourceQuestionBankId, ids.questionBank, 'adoption should bind source public question bank');
assert.equal(adopted.item?.copiedQuestionCount, 3, 'adoption should copy a snapshot of published questions');
assert.ok(adopted.item?.targetEntryId, 'adoption should create tenant content entry');
assert.ok(adopted.item?.targetCollectionId, 'adoption should create tenant question collection');
const repeated = await request('/api/tenant-content/public-question-banks/adopt', {
tenantId: PARTNER_TENANT_ID,
userId: PARTNER_TENANT_ADMIN_USER_ID,
method: 'POST',
body: { grantId: ids.publicQuestionBankGrant },
expectStatus: 409,
});
assert.equal(repeated.code, 'QUESTION_BANK_ALREADY_ADOPTED', 'tenant should not adopt the same public bank twice');
const adoptedList = await request('/api/tenant-content/public-question-banks', {
tenantId: PARTNER_TENANT_ID,
userId: PARTNER_TENANT_ADMIN_USER_ID,
});
const adoptedItem = adoptedList.items?.find(item => item.grantId === ids.publicQuestionBankGrant);
assert.equal(adoptedItem?.adoptionStatus, 'active', 'adoptable list should expose adoption status');
const entries = await request('/api/catalog/content-entries', {
tenantId: PARTNER_TENANT_ID,
userId: false,
query: { entryType: 'question_practice' },
});
assert.ok(entries.items?.some(item => item.id === adopted.item.targetEntryId), 'adopted public bank should appear in tenant catalog entries');
const collections = await request('/api/catalog/question-collections', {
tenantId: PARTNER_TENANT_ID,
userId: false,
query: { entryId: adopted.item.targetEntryId },
});
assert.ok(collections.items?.some(item => item.id === adopted.item.targetCollectionId && item.questionCount === 3), 'adopted public bank should expose a tenant collection');
const session = await request('/api/learning/practice-sessions', {
tenantId: PARTNER_TENANT_ID,
userId: PARTNER_TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
collectionId: adopted.item.targetCollectionId,
mode: 'sequential',
questionLimit: 2,
},
});
assert.equal(session.item?.questionCount, 2, 'adopted public bank collection should be usable for practice sessions');
const studentSyncDenied = await request('/api/tenant-content/public-question-banks/sync', {
tenantId: PARTNER_TENANT_ID,
method: 'POST',
body: { adoptionId: adopted.item.id },
expectStatus: 403,
});
assert.equal(studentSyncDenied.code, 'TENANT_CONTENT_EDITOR_REQUIRED', 'student should not sync adopted public banks');
const crossTenantSyncDenied = await request('/api/tenant-content/public-question-banks/sync', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: { adoptionId: adopted.item.id },
expectStatus: 404,
});
assert.equal(crossTenantSyncDenied.code, 'QUESTION_BANK_ADOPTION_NOT_FOUND', 'public bank sync must be tenant isolated');
const syncSourceHash = `public-sync-source-${Date.now()}`;
const sourceQuestion = await request('/api/tenant-content/questions', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
questionBankId: ids.questionBank,
subjectId: ids.subject,
categoryId: ids.category,
entryId: ids.contentEntry,
contentNodeId: ids.contentNodeSchoolTarget,
primaryCollectionId: ids.questionCollection,
legacyId: `public-sync-source-${Date.now()}`,
type: 'choice',
typeLabel: '单选题',
difficulty: 1,
content: '平台公共题库新增同步题5 + 5 = ?',
options: [
{ label: 'A', text: '9' },
{ label: 'B', text: '10' },
],
correctOptionIndex: 1,
correctOptionIndices: [1],
answerText: '10',
explanation: '新增公共题应同步到已采纳租户。',
status: 'published',
sourceHash: syncSourceHash,
},
});
assert.ok(sourceQuestion.item?.id, 'platform content editor should create a new source public question');
const synced = await request('/api/tenant-content/public-question-banks/sync', {
tenantId: PARTNER_TENANT_ID,
userId: PARTNER_TENANT_ADMIN_USER_ID,
method: 'POST',
body: { adoptionId: adopted.item.id, copyLimit: 20 },
});
assert.equal(synced.sync?.status, 'synced', 'public bank sync should finish without conflicts for platform-only changes');
assert.equal(synced.item?.syncStatus, 'synced', 'adoption sync status should be synced');
assert.ok(
synced.sync?.results?.some(item => item.sourceQuestionId === sourceQuestion.item.id && item.action === 'inserted'),
'public bank sync should insert newly published source questions',
);
const syncNotifications = await request('/api/tenant-content/notifications', {
tenantId: PARTNER_TENANT_ID,
userId: PARTNER_TENANT_ADMIN_USER_ID,
query: { notificationType: 'public_question_bank_synced', adoptionId: adopted.item.id },
});
const syncNotification = syncNotifications.items?.find(item => item.adoptionId === adopted.item.id && item.status === 'unread');
assert.ok(syncNotification?.id, 'public bank sync with inserted questions should create an unread tenant notification');
assert.equal(syncNotification?.severity, 'success', 'successful public bank sync notification should be success severity');
assert.equal(syncNotifications.summary?.unread >= 1, true, 'notification summary should include unread public bank sync notification');
assert.ok(!JSON.stringify(syncNotification).includes('新增公共题应同步到已采纳租户'), 'public bank notification should not leak question explanations');
const markedSyncNotification = await request('/api/tenant-content/notifications/status', {
tenantId: PARTNER_TENANT_ID,
userId: PARTNER_TENANT_ADMIN_USER_ID,
method: 'POST',
body: { notificationIds: [syncNotification.id], status: 'read' },
});
assert.equal(markedSyncNotification.item?.updatedCount, 1, 'tenant admin should mark content notification as read');
const invalidNotificationId = await request('/api/tenant-content/notifications/status', {
tenantId: PARTNER_TENANT_ID,
userId: PARTNER_TENANT_ADMIN_USER_ID,
method: 'POST',
body: { notificationIds: ['not-a-uuid'], status: 'read' },
expectStatus: 400,
});
assert.equal(invalidNotificationId.code, 'CONTENT_NOTIFICATION_ID_INVALID', 'notification status API should reject malformed UUIDs before querying');
const crossTenantNotifications = await request('/api/tenant-content/notifications', {
userId: TENANT_ADMIN_USER_ID,
query: { adoptionId: adopted.item.id },
});
assert.equal(crossTenantNotifications.items?.length, 0, 'content notifications must not expose partner tenant rows to another tenant admin');
const crossTenantNotificationsDenied = await request('/api/tenant-content/notifications', {
tenantId: PARTNER_TENANT_ID,
userId: TENANT_ADMIN_USER_ID,
query: { adoptionId: adopted.item.id },
expectStatus: 403,
});
assert.equal(crossTenantNotificationsDenied.code, 'TENANT_CONTENT_EDITOR_REQUIRED', 'content notifications must require tenant content permission in the current tenant');
const collectionAfterSync = await request('/api/catalog/question-collections/questions', {
tenantId: PARTNER_TENANT_ID,
userId: false,
query: { collectionId: adopted.item.targetCollectionId, limit: 50 },
});
const syncedLegacyId = `public:${MAIN_TENANT_ID}:${sourceQuestion.item.id}`;
assert.ok(
collectionAfterSync.items?.some(item => item.legacyId === syncedLegacyId && item.content === '平台公共题库新增同步题5 + 5 = ?'),
'tenant collection should expose newly synced public question copy',
);
const conflictTarget = collectionAfterSync.items?.find(item => item.legacyId === `public:${MAIN_TENANT_ID}:${ids.question}`);
assert.ok(conflictTarget?.id, 'adopted tenant collection should include a copied seed question for conflict checks');
const tenantCustomContent = `租户自定义公共题副本 ${Date.now()}`;
await request('/api/tenant-content/questions', {
tenantId: PARTNER_TENANT_ID,
userId: PARTNER_TENANT_ADMIN_USER_ID,
method: 'PATCH',
body: {
questionId: conflictTarget.id,
createVersion: true,
content: tenantCustomContent,
options: conflictTarget.options || [],
correctOptionIndex: conflictTarget.correctOptionIndex,
correctOptionIndices: conflictTarget.correctOptionIndices || [],
answerText: conflictTarget.answerText,
explanation: '租户已经对采纳题目做了本地改写,同步时必须保留。',
sourceHash: `tenant-custom-${Date.now()}`,
},
});
const platformChangedHash = `platform-source-update-${Date.now()}`;
await request('/api/tenant-content/questions', {
userId: TENANT_ADMIN_USER_ID,
method: 'PATCH',
body: {
questionId: ids.question,
createVersion: true,
content: '平台公共题库更新1 + 1 = ?',
options: [
{ label: 'A', text: '1' },
{ label: 'B', text: '2' },
],
correctOptionIndex: 1,
correctOptionIndices: [1],
answerText: '2',
explanation: '平台公共题库发布了新版本。',
sourceHash: platformChangedHash,
},
});
const conflictSync = await request('/api/tenant-content/public-question-banks/sync', {
tenantId: PARTNER_TENANT_ID,
userId: PARTNER_TENANT_ADMIN_USER_ID,
method: 'POST',
body: { adoptionId: adopted.item.id, copyLimit: 20 },
});
assert.equal(conflictSync.sync?.status, 'conflict', 'public bank sync should report conflicts when tenant changed copied content');
assert.equal(conflictSync.item?.syncStatus, 'failed', 'adoption should keep failed sync status when conflicts need operator review');
assert.ok(
conflictSync.sync?.results?.some(item => item.sourceQuestionId === ids.question && item.action === 'conflict'),
'public bank sync should identify the source question that conflicts with tenant edits',
);
const conflictNotifications = await request('/api/tenant-content/notifications', {
tenantId: PARTNER_TENANT_ID,
userId: PARTNER_TENANT_ADMIN_USER_ID,
query: { notificationType: 'public_question_bank_conflict', adoptionId: adopted.item.id },
});
const conflictNotification = conflictNotifications.items?.find(item => item.adoptionId === adopted.item.id && item.status === 'unread');
assert.ok(conflictNotification?.id, 'public bank conflicts should create an unread tenant notification');
assert.equal(conflictNotification?.severity, 'warning', 'public bank conflict notification should be warning severity');
assert.equal(conflictNotification?.metadata?.syncStatus, 'conflict', 'public bank conflict notification should expose conflict status metadata');
const conflictList = await request('/api/tenant-content/public-question-banks/conflicts', {
tenantId: PARTNER_TENANT_ID,
userId: PARTNER_TENANT_ADMIN_USER_ID,
query: { adoptionId: adopted.item.id },
});
assert.equal(conflictList.item?.syncStatus, 'failed', 'conflict list should expose failed adoption sync status');
assert.ok(conflictList.item?.conflictCount >= 1, 'conflict list should expose conflict count');
assert.ok(
conflictList.item?.conflicts?.some(item => item.sourceQuestionId === ids.question && item.action === 'conflict'),
'conflict list should expose source question conflict details',
);
const crossTenantConflictListDenied = await request('/api/tenant-content/public-question-banks/conflicts', {
userId: TENANT_ADMIN_USER_ID,
query: { adoptionId: adopted.item.id },
expectStatus: 404,
});
assert.equal(crossTenantConflictListDenied.code, 'QUESTION_BANK_ADOPTION_NOT_FOUND', 'public bank conflicts must be tenant isolated');
const collectionAfterConflict = await request('/api/catalog/question-collections/questions', {
tenantId: PARTNER_TENANT_ID,
userId: false,
query: { collectionId: adopted.item.targetCollectionId, limit: 50 },
});
const conflictTargetAfter = collectionAfterConflict.items?.find(item => item.id === conflictTarget.id);
assert.equal(conflictTargetAfter?.content, tenantCustomContent, 'public bank sync must not overwrite tenant-customized copied question');
const crossTenantResolveDenied = await request('/api/tenant-content/public-question-banks/conflicts/resolve', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
adoptionId: adopted.item.id,
sourceQuestionId: ids.question,
resolution: 'accept_platform',
},
expectStatus: 404,
});
assert.equal(crossTenantResolveDenied.code, 'QUESTION_BANK_ADOPTION_NOT_FOUND', 'public bank conflict resolution must be tenant isolated');
const acceptedConflict = await request('/api/tenant-content/public-question-banks/conflicts/resolve', {
tenantId: PARTNER_TENANT_ID,
userId: PARTNER_TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
adoptionId: adopted.item.id,
sourceQuestionId: ids.question,
resolution: 'accept_platform',
},
});
assert.equal(acceptedConflict.item?.resolution, 'accept_platform', 'tenant admin should accept platform version for a conflict');
assert.equal(acceptedConflict.item?.remainingConflictCount, 0, 'accepting the only conflict should clear conflict count');
assert.equal(acceptedConflict.item?.syncStatus, 'synced', 'accepted conflict should mark adoption synced when no conflicts remain');
const collectionAfterAccept = await request('/api/catalog/question-collections/questions', {
tenantId: PARTNER_TENANT_ID,
userId: false,
query: { collectionId: adopted.item.targetCollectionId, limit: 50 },
});
const acceptedTarget = collectionAfterAccept.items?.find(item => item.id === conflictTarget.id);
assert.equal(acceptedTarget?.content, '平台公共题库更新1 + 1 = ?', 'accepting platform conflict should update tenant copy to platform content');
const conflictListAfterAccept = await request('/api/tenant-content/public-question-banks/conflicts', {
tenantId: PARTNER_TENANT_ID,
userId: PARTNER_TENANT_ADMIN_USER_ID,
query: { adoptionId: adopted.item.id },
});
assert.equal(conflictListAfterAccept.item?.conflictCount, 0, 'resolved conflict list should have no remaining conflicts');
const conflictNotificationsAfterAccept = await request('/api/tenant-content/notifications', {
tenantId: PARTNER_TENANT_ID,
userId: PARTNER_TENANT_ADMIN_USER_ID,
query: { notificationType: 'public_question_bank_conflict', adoptionId: adopted.item.id, status: 'resolved' },
});
assert.ok(
conflictNotificationsAfterAccept.items?.some(item => item.id === conflictNotification.id && item.status === 'resolved'),
'resolving all public bank conflicts should mark related notifications resolved',
);
const keepLocalContent = `租户再次保留本地公共题副本 ${Date.now()}`;
await request('/api/tenant-content/questions', {
tenantId: PARTNER_TENANT_ID,
userId: PARTNER_TENANT_ADMIN_USER_ID,
method: 'PATCH',
body: {
questionId: conflictTarget.id,
createVersion: true,
content: keepLocalContent,
options: acceptedTarget.options || [],
correctOptionIndex: acceptedTarget.correctOptionIndex,
correctOptionIndices: acceptedTarget.correctOptionIndices || [],
answerText: acceptedTarget.answerText,
explanation: '租户这一次选择保留本地版本。',
sourceHash: `tenant-keep-local-${Date.now()}`,
},
});
const platformSecondChangedHash = `platform-source-update-keep-local-${Date.now()}`;
await request('/api/tenant-content/questions', {
userId: TENANT_ADMIN_USER_ID,
method: 'PATCH',
body: {
questionId: ids.question,
createVersion: true,
content: '平台公共题库二次更新1 + 2 = ?',
options: [
{ label: 'A', text: '2' },
{ label: 'B', text: '3' },
],
correctOptionIndex: 1,
correctOptionIndices: [1],
answerText: '3',
explanation: '平台公共题库发布了第二个新版本。',
sourceHash: platformSecondChangedHash,
},
});
const secondConflictSync = await request('/api/tenant-content/public-question-banks/sync', {
tenantId: PARTNER_TENANT_ID,
userId: PARTNER_TENANT_ADMIN_USER_ID,
method: 'POST',
body: { adoptionId: adopted.item.id, copyLimit: 20 },
});
assert.equal(secondConflictSync.sync?.status, 'conflict', 'second platform update should conflict with tenant-local edit');
const keptLocalConflict = await request('/api/tenant-content/public-question-banks/conflicts/resolve', {
tenantId: PARTNER_TENANT_ID,
userId: PARTNER_TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
adoptionId: adopted.item.id,
sourceQuestionId: ids.question,
resolution: 'keep_local',
},
});
assert.equal(keptLocalConflict.item?.resolution, 'keep_local', 'tenant admin should keep local version for a conflict');
assert.equal(keptLocalConflict.item?.syncStatus, 'synced', 'keeping the only conflict should mark adoption synced');
const afterKeepLocalSync = await request('/api/tenant-content/public-question-banks/sync', {
tenantId: PARTNER_TENANT_ID,
userId: PARTNER_TENANT_ADMIN_USER_ID,
method: 'POST',
body: { adoptionId: adopted.item.id, copyLimit: 20 },
});
assert.equal(afterKeepLocalSync.sync?.status, 'synced', 'future sync should not repeat a keep-local conflict for the same platform hash');
assert.ok(
afterKeepLocalSync.sync?.results?.some(item => item.sourceQuestionId === ids.question && item.action === 'skipped' && item.resolution === 'keep_local'),
'future sync should expose keep-local skip decision',
);
const collectionAfterKeepLocal = await request('/api/catalog/question-collections/questions', {
tenantId: PARTNER_TENANT_ID,
userId: false,
query: { collectionId: adopted.item.targetCollectionId, limit: 50 },
});
const keptLocalTarget = collectionAfterKeepLocal.items?.find(item => item.id === conflictTarget.id);
assert.equal(keptLocalTarget?.content, keepLocalContent, 'keep-local resolution should preserve tenant customized content');
const batchTargetOne = keptLocalTarget;
const batchTargetTwo = collectionAfterKeepLocal.items?.find(item => item.legacyId === syncedLegacyId);
assert.ok(batchTargetOne?.id && batchTargetTwo?.id, 'batch conflict test needs two adopted tenant question copies');
const batchLocalOneContent = `租户批量冲突本地题一 ${Date.now()}`;
const batchLocalTwoContent = `租户批量冲突本地题二 ${Date.now()}`;
await request('/api/tenant-content/questions', {
tenantId: PARTNER_TENANT_ID,
userId: PARTNER_TENANT_ADMIN_USER_ID,
method: 'PATCH',
body: {
questionId: batchTargetOne.id,
createVersion: true,
content: batchLocalOneContent,
options: batchTargetOne.options || [],
correctOptionIndex: batchTargetOne.correctOptionIndex,
correctOptionIndices: batchTargetOne.correctOptionIndices || [],
answerText: batchTargetOne.answerText,
explanation: '租户批量冲突测试本地版本一。',
sourceHash: `tenant-batch-local-one-${Date.now()}`,
},
});
await request('/api/tenant-content/questions', {
tenantId: PARTNER_TENANT_ID,
userId: PARTNER_TENANT_ADMIN_USER_ID,
method: 'PATCH',
body: {
questionId: batchTargetTwo.id,
createVersion: true,
content: batchLocalTwoContent,
options: batchTargetTwo.options || [],
correctOptionIndex: batchTargetTwo.correctOptionIndex,
correctOptionIndices: batchTargetTwo.correctOptionIndices || [],
answerText: batchTargetTwo.answerText,
explanation: '租户批量冲突测试本地版本二。',
sourceHash: `tenant-batch-local-two-${Date.now()}`,
},
});
const batchPlatformOneContent = '平台公共题库批量更新1 + 3 = ?';
const batchPlatformTwoContent = '平台公共题库批量更新5 + 6 = ?';
await request('/api/tenant-content/questions', {
userId: TENANT_ADMIN_USER_ID,
method: 'PATCH',
body: {
questionId: ids.question,
createVersion: true,
content: batchPlatformOneContent,
options: [
{ label: 'A', text: '3' },
{ label: 'B', text: '4' },
],
correctOptionIndex: 1,
correctOptionIndices: [1],
answerText: '4',
explanation: '平台公共题库批量冲突测试题一。',
sourceHash: `platform-batch-conflict-one-${Date.now()}`,
},
});
await request('/api/tenant-content/questions', {
userId: TENANT_ADMIN_USER_ID,
method: 'PATCH',
body: {
questionId: sourceQuestion.item.id,
createVersion: true,
content: batchPlatformTwoContent,
options: [
{ label: 'A', text: '11' },
{ label: 'B', text: '12' },
],
correctOptionIndex: 0,
correctOptionIndices: [0],
answerText: '11',
explanation: '平台公共题库批量冲突测试题二。',
sourceHash: `platform-batch-conflict-two-${Date.now()}`,
},
});
const batchConflictSync = await request('/api/tenant-content/public-question-banks/sync', {
tenantId: PARTNER_TENANT_ID,
userId: PARTNER_TENANT_ADMIN_USER_ID,
method: 'POST',
body: { adoptionId: adopted.item.id, copyLimit: 20 },
});
assert.equal(batchConflictSync.sync?.status, 'conflict', 'batch setup should produce public bank conflicts');
const batchConflictIds = (batchConflictSync.sync?.results || [])
.filter(item => item.action === 'conflict' && [ids.question, sourceQuestion.item.id].includes(item.sourceQuestionId))
.map(item => item.sourceQuestionId);
assert.deepEqual(new Set(batchConflictIds), new Set([ids.question, sourceQuestion.item.id]), 'batch setup should produce two expected conflicts');
const crossTenantBatchDenied = await request('/api/tenant-content/public-question-banks/conflicts/resolve-batch', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
adoptionId: adopted.item.id,
sourceQuestionIds: [ids.question, sourceQuestion.item.id],
resolution: 'accept_platform',
},
expectStatus: 404,
});
assert.equal(crossTenantBatchDenied.code, 'QUESTION_BANK_ADOPTION_NOT_FOUND', 'public bank batch conflict resolution must be tenant isolated');
const batchAccepted = await request('/api/tenant-content/public-question-banks/conflicts/resolve-batch', {
tenantId: PARTNER_TENANT_ID,
userId: PARTNER_TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
adoptionId: adopted.item.id,
sourceQuestionIds: [ids.question, sourceQuestion.item.id],
resolution: 'accept_platform',
},
});
assert.equal(batchAccepted.item?.resolution, 'accept_platform', 'batch conflict resolution should expose resolution');
assert.equal(batchAccepted.item?.processedCount, 2, 'batch conflict resolution should process both conflicts');
assert.equal(batchAccepted.item?.remainingConflictCount, 0, 'batch conflict resolution should clear all conflicts');
assert.equal(batchAccepted.item?.syncStatus, 'synced', 'batch conflict resolution should mark adoption synced');
const collectionAfterBatchAccept = await request('/api/catalog/question-collections/questions', {
tenantId: PARTNER_TENANT_ID,
userId: false,
query: { collectionId: adopted.item.targetCollectionId, limit: 50 },
});
const batchAcceptedOne = collectionAfterBatchAccept.items?.find(item => item.id === batchTargetOne.id);
const batchAcceptedTwo = collectionAfterBatchAccept.items?.find(item => item.id === batchTargetTwo.id);
assert.equal(batchAcceptedOne?.content, batchPlatformOneContent, 'batch accept should update first tenant copy to platform content');
assert.equal(batchAcceptedTwo?.content, batchPlatformTwoContent, 'batch accept should update second tenant copy to platform content');
const conflictListAfterBatchAccept = await request('/api/tenant-content/public-question-banks/conflicts', {
tenantId: PARTNER_TENANT_ID,
userId: PARTNER_TENANT_ADMIN_USER_ID,
query: { adoptionId: adopted.item.id },
});
assert.equal(conflictListAfterBatchAccept.item?.conflictCount, 0, 'batch-resolved conflict list should have no remaining conflicts');
const mainTenantNotAdopted = await request('/api/tenant-content/public-question-banks', {
userId: TENANT_ADMIN_USER_ID,
query: { onlyNotAdopted: 'true' },
});
assert.ok(!mainTenantNotAdopted.items?.some(item => item.grantId === ids.publicQuestionBankGrant), 'platform-owned main tenant should not see plan grant without matching SaaS subscription');
}
async function testTenantAdminOps() {
const fakeWechat = await startFakeWechatServer();
const fakeQq = await startFakeQqServer();
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 dashboardDenied = await request('/api/tenant-admin/dashboard', {
expectStatus: 403,
});
assert.equal(dashboardDenied.code, 'TENANT_ADMIN_REQUIRED', 'student should not access tenant dashboard');
const dashboard = await request('/api/tenant-admin/dashboard', {
userId: TENANT_ADMIN_USER_ID,
query: { timeRange: '7d', regionId: ids.region },
});
assert.equal(dashboard.item?.scope?.tenantId, MAIN_TENANT_ID, 'tenant dashboard should be tenant scoped');
assert.equal(dashboard.item?.scope?.regionId, ids.region, 'tenant dashboard should support region filter');
assert.equal(dashboard.item?.cards?.content?.questions >= 3, true, 'tenant dashboard should count published questions');
assert.equal(dashboard.item?.cards?.learning?.answers >= 1, true, 'tenant dashboard should aggregate answers');
assert.equal(dashboard.item?.trends?.length, 7, 'tenant dashboard should return complete 7-day trend buckets');
assert.equal(dashboard.item?.activeHours?.length, 24, 'tenant dashboard should return 24 hourly activity buckets');
assert.ok(
dashboard.item?.questionDistribution?.some(item => item.type === 'choice'),
'tenant dashboard should include question type distribution',
);
assert.equal(typeof dashboard.item?.paymentStats?.revenueCentsInRange, 'number', 'tenant dashboard should expose payment stats');
assert.ok(Array.isArray(dashboard.item?.recentActivities), 'tenant dashboard should expose recent activities');
const invalidDashboardRange = await request('/api/tenant-admin/dashboard', {
userId: TENANT_ADMIN_USER_ID,
query: { timeRange: '365d' },
expectStatus: 400,
});
assert.equal(invalidDashboardRange.code, 'INVALID_DASHBOARD_RANGE', 'tenant dashboard should reject unsupported time ranges');
const partnerDashboardDenied = await request('/api/tenant-admin/dashboard', {
tenantId: PARTNER_TENANT_ID,
userId: TENANT_ADMIN_USER_ID,
expectStatus: 403,
});
assert.equal(partnerDashboardDenied.code, 'TENANT_ADMIN_REQUIRED', 'tenant dashboard must be tenant isolated');
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 themeTemplatesDenied = await request('/api/tenant-admin/theme-templates', {
expectStatus: 403,
});
assert.equal(themeTemplatesDenied.code, 'TENANT_ADMIN_REQUIRED', 'student should not list tenant theme templates');
const themeTemplatesPermissionDenied = await request('/api/tenant-admin/theme-templates', {
userId: TENANT_OPERATOR_USER_ID,
expectStatus: 403,
});
assert.equal(themeTemplatesPermissionDenied.code, 'TENANT_PERMISSION_REQUIRED', 'operator without theme permission should not list theme templates');
const themeTemplates = await request('/api/tenant-admin/theme-templates', {
userId: TENANT_ADMIN_USER_ID,
});
assert.equal(themeTemplates.items?.length >= 3, true, 'tenant admin should list platform theme templates');
assert.ok(themeTemplates.items?.some(item => item.code === 'classic'), 'theme templates should include classic default');
const invalidThemeKey = await request('/api/tenant-admin/theme/preview', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
templateCode: 'classic',
theme: {
rawCss: 'body{display:none}',
},
},
expectStatus: 400,
});
assert.equal(invalidThemeKey.code, 'INVALID_THEME_KEY', 'theme preview should reject unknown theme keys');
const unsafeThemeAsset = await request('/api/tenant-admin/theme/preview', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
templateCode: 'classic',
publicAssets: {
shareImageUrl: 'javascript:alert(1)',
},
},
expectStatus: 400,
});
assert.equal(unsafeThemeAsset.code, 'PUBLIC_CONFIG_SECRET_REJECTED', 'theme public assets should reject unsafe public strings');
const unsafeCssVar = await request('/api/tenant-admin/theme/preview', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
templateCode: 'classic',
theme: {
customCssVars: {
'--tiku-card-bg': 'url(https://example.test/tracker.png)',
},
},
},
expectStatus: 400,
});
assert.equal(unsafeCssVar.code, 'PUBLIC_CONFIG_SECRET_REJECTED', 'theme custom CSS variables should reject unsafe CSS values');
const previewTheme = await request('/api/tenant-admin/theme/preview', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
templateCode: 'focus',
theme: {
primaryColor: '#123abc',
accentColor: '#f59e0b',
borderRadius: 10,
buttonRadius: 10,
customCssVars: {
'--tiku-focus-ring': '#123abc',
},
icons: {
home: 'book-open',
},
},
publicAssets: {
logoUrl: '/assets/tenant/logo.png',
iconSet: 'focus',
shareCardStyle: 'study',
},
},
});
assert.equal(previewTheme.item?.draftTemplateCode, 'focus', 'theme preview should persist draft template code');
assert.equal(previewTheme.item?.draftTheme?.primaryColor, '#123abc', 'theme preview should merge color overrides');
assert.equal(previewTheme.item?.draftPublicAssets?.logoUrl, '/assets/tenant/logo.png', 'theme preview should persist safe public asset references');
const tenantTheme = await request('/api/tenant-admin/theme', {
userId: TENANT_ADMIN_USER_ID,
});
assert.equal(tenantTheme.item?.draftTemplateCode, 'focus', 'tenant theme detail should expose draft theme');
assert.equal(tenantTheme.item?.draftTheme?.customCssVars?.['--tiku-focus-ring'], '#123abc', 'tenant theme detail should expose safe CSS variables');
const publishedTheme = await request('/api/tenant-admin/theme/publish', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
useDraft: true,
},
});
assert.equal(publishedTheme.item?.activeTemplateCode, 'focus', 'theme publish should activate draft template');
assert.equal(publishedTheme.item?.activeTheme?.primaryColor, '#123abc', 'theme publish should activate draft tokens');
assert.equal(publishedTheme.item?.status, 'published', 'theme publish should mark config published');
const tenantThemeAfterPublish = await request('/api/tenant-admin/theme', {
userId: TENANT_ADMIN_USER_ID,
});
assert.equal(tenantThemeAfterPublish.item?.draftTemplateCode, null, 'theme publish should clear draft template');
assert.equal(tenantThemeAfterPublish.item?.activePublicAssets?.iconSet, 'focus', 'theme publish should expose active public assets');
const resolvedTenantTheme = await request('/api/tenant/resolve', {
userId: false,
tenantId: false,
query: { tenantCode: 'master' },
});
assert.equal(resolvedTenantTheme.branding?.theme?.primaryColor, '#123abc', 'tenant resolve should return published theme tokens to frontend');
const themeAuditLogs = await request('/api/tenant-admin/audit-logs', {
userId: TENANT_ADMIN_USER_ID,
query: { action: 'tenant.theme', limit: 20 },
});
assert.ok(themeAuditLogs.items?.some(item => item.action === 'tenant.theme.previewed'), 'audit logs should include theme preview');
assert.ok(themeAuditLogs.items?.some(item => item.action === 'tenant.theme.published'), 'audit logs should include theme publish');
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 wechatWebProvider = await request('/api/tenant-admin/auth-providers', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
provider: 'wechat-web',
displayName: '微信网页登录',
status: 'testing',
configPublic: {
appId: 'wx-smoke-appid',
endpoint: fakeWechat.webTokenEndpoint,
userInfoEndpoint: fakeWechat.webUserInfoEndpoint,
},
secret: {
secretValue: 'wechat-app-secret-smoke',
},
},
});
assert.equal(wechatWebProvider.item?.provider, 'wechat-web', 'tenant admin should upsert WeChat web provider');
assert.equal(wechatWebProvider.item?.configPublic?.secretRef, 'app_private.tenant_secrets:oauth:wechat-web', 'WeChat web provider should expose only secretRef');
const wechatWebLogin = await request('/api/auth/oauth/wechat', {
userId: false,
method: 'POST',
body: {
code: 'web-code-001',
lang: 'zh_CN',
},
});
assert.equal(wechatWebLogin.provider, 'wechat_web', 'wechat web login should return canonical provider');
assert.equal(wechatWebLogin.user?.id, miniappLogin.user.id, 'wechat web login should merge with miniapp account by unionId');
assert.ok(wechatWebLogin.session?.token?.startsWith('tk_'), 'wechat web login should issue API session token');
assert.equal(wechatWebLogin.identity?.openId, 'wechat-web-openid-web-code-001', 'wechat web login should expose openId');
assert.equal(wechatWebLogin.identity?.unionId, 'unionid-smoke-user', 'wechat web login should expose unionId');
assert.ok(fakeWechat.requests.some(item => item.pathname === '/sns/oauth2/access_token'), 'wechat web login should exchange code server-side');
assert.ok(fakeWechat.requests.some(item => item.pathname === '/sns/userinfo'), 'wechat web login should fetch userinfo server-side');
assert.ok(!JSON.stringify(wechatWebLogin).includes('wechat-web-token-web-code-001'), 'wechat web response must not leak access_token');
assert.ok(!JSON.stringify(wechatWebLogin).includes('wechat-app-secret-smoke'), 'wechat web response must not leak app secret');
const qqProvider = await request('/api/tenant-admin/auth-providers', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
provider: 'qq-oauth',
displayName: 'QQ网页登录',
status: 'testing',
configPublic: {
appId: 'qq-smoke-appid',
redirectUri: 'https://h5.example.test/auth/qq/callback',
endpoint: fakeQq.tokenEndpoint,
openIdEndpoint: fakeQq.openIdEndpoint,
userInfoEndpoint: fakeQq.userInfoEndpoint,
},
secret: {
secretValue: 'qq-client-secret-smoke',
},
},
});
assert.equal(qqProvider.item?.provider, 'qq-oauth', 'tenant admin should upsert QQ OAuth provider');
assert.equal(qqProvider.item?.configPublic?.secretRef, 'app_private.tenant_secrets:oauth:qq-oauth', 'QQ provider should expose only secretRef');
const qqLogin = await request('/api/auth/oauth/qq', {
userId: false,
method: 'POST',
body: {
code: 'qq-code-001',
redirectUri: 'https://h5.example.test/auth/qq/callback',
},
});
assert.equal(qqLogin.provider, 'qq', 'qq login should return canonical provider');
assert.ok(qqLogin.user?.id, 'qq login should create or resolve user');
assert.ok(qqLogin.session?.token?.startsWith('tk_'), 'qq login should issue API session token');
assert.equal(qqLogin.identity?.openId, 'qq-openid-qq-code-001', 'qq login should expose openId');
assert.ok(fakeQq.requests.some(item => item.pathname === '/oauth2.0/token'), 'qq login should exchange code server-side');
assert.ok(fakeQq.requests.some(item => item.pathname === '/oauth2.0/me'), 'qq login should fetch openid server-side');
assert.ok(fakeQq.requests.some(item => item.pathname === '/user/get_user_info'), 'qq login should fetch userinfo server-side');
assert.ok(!JSON.stringify(qqLogin).includes('qq-token-qq-code-001'), 'qq response must not leak access_token');
assert.ok(!JSON.stringify(qqLogin).includes('qq-client-secret-smoke'), 'qq response must not leak client secret');
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 studentBadgeAdminDenied = await request('/api/tenant-admin/badges', {
expectStatus: 403,
});
assert.equal(studentBadgeAdminDenied.code, 'TENANT_ADMIN_REQUIRED', 'student should not access badge admin');
const badge = await request('/api/tenant-admin/badges', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
id: ids.tenantBadge,
legacyId: 'integration-badge-first-practice',
name: '集成测试首练勋章',
description: '完成首次练习后展示',
category: 'practice',
iconUrl: 'https://example.test/badges/first-practice.png',
level: 1,
unlockType: 'manual',
conditionField: 'practice.completedCount',
conditionOperator: 'gte',
conditionValue: 1,
conditionExtra: { mode: 'any' },
metadata: { source: 'integration-test' },
order: 3,
isActive: true,
},
});
assert.equal(badge.item?.id, ids.tenantBadge, 'tenant admin should create badge with stable id');
assert.equal(badge.item?.category, 'practice', 'badge should persist category');
const badgeByLegacy = await request('/api/tenant-admin/badges', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
legacyId: 'integration-badge-first-practice',
name: '集成测试首练勋章升级',
description: '同 legacyId 应幂等更新原勋章',
category: 'practice',
iconUrl: 'https://example.test/badges/first-practice-v2.png',
level: 2,
unlockType: 'manual',
conditionField: 'practice.completedCount',
conditionOperator: 'gte',
conditionValue: 1,
conditionExtra: { mode: 'any', version: 2 },
metadata: { source: 'integration-test', updated: true },
order: 2,
isActive: true,
},
});
assert.equal(badgeByLegacy.item?.id, ids.tenantBadge, 'badge legacyId should update existing badge idempotently');
assert.equal(badgeByLegacy.item?.level, 2, 'badge update should persist new level');
const badgeConflict = await request('/api/tenant-admin/badges', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
id: '00000000-0000-0000-0000-000000000872',
legacyId: 'integration-badge-first-practice',
name: '冲突勋章',
},
expectStatus: 409,
});
assert.equal(badgeConflict.code, 'BADGE_ID_CONFLICT', 'badge id and legacyId conflict should be explicit');
const invalidBadgeCategory = await request('/api/tenant-admin/badges', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
name: '非法分类勋章',
category: 'unsafe-category',
},
expectStatus: 400,
});
assert.equal(invalidBadgeCategory.code, 'INVALID_FIELD_VALUE', 'badge admin should reject invalid category');
const invalidBadgeNumber = await request('/api/tenant-admin/badges', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
name: '非法数值勋章',
category: 'practice',
conditionValue: 'not-a-number',
},
expectStatus: 400,
});
assert.equal(invalidBadgeNumber.code, 'INVALID_NUMBER', 'badge admin should reject invalid numeric condition');
const invalidBadgeId = await request('/api/tenant-admin/badges', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
id: 'not-a-uuid',
name: '非法 ID 勋章',
},
expectStatus: 400,
});
assert.equal(invalidBadgeId.code, 'INVALID_UUID', 'badge admin should reject invalid badge id before database cast');
const badges = await request('/api/tenant-admin/badges', {
userId: TENANT_ADMIN_USER_ID,
query: { category: 'practice', includeInactive: 'true', limit: 20 },
});
assert.ok(badges.items?.some(item => item.id === ids.tenantBadge), 'tenant admin should list badge by category');
const grant = await request('/api/tenant-admin/badge-grants', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
badgeId: ids.tenantBadge,
userId: USER_ID,
note: '集成测试手动授予',
metadata: { source: 'integration-test', firstGrant: true },
},
});
assert.equal(grant.item?.badgeId, ids.tenantBadge, 'tenant admin should grant badge');
assert.equal(grant.item?.userId, USER_ID, 'badge grant should target student');
const manualBadgeNotifications = await request('/api/profile/notifications', {
query: { notificationType: 'badge_granted', limit: 50 },
});
assert.ok(
manualBadgeNotifications.items?.some(
item => item.metadata?.source === 'tenant_admin_badge_grant' && item.metadata?.badgeId === ids.tenantBadge,
),
'manual badge grant should create a student notification',
);
const repeatedGrant = await request('/api/tenant-admin/badge-grants', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
badgeId: ids.tenantBadge,
userId: USER_ID,
note: '重复授予不应产生重复记录',
metadata: { source: 'integration-test-repeat', repeated: true },
},
});
assert.equal(repeatedGrant.item?.id, grant.item?.id, 'repeated badge grant should be idempotent');
const badgeGrants = await request('/api/tenant-admin/badge-grants', {
userId: TENANT_ADMIN_USER_ID,
query: { userId: USER_ID, badgeId: ids.tenantBadge, limit: 20 },
});
assert.equal(
badgeGrants.items?.filter(item => item.userId === USER_ID && item.badgeId === ids.tenantBadge).length,
1,
'badge grants should not duplicate per user and badge',
);
const profileBadges = await request('/api/profile/badges', {
query: { includeLocked: 'true', category: 'practice', limit: 20 },
});
const profileBadge = profileBadges.items?.find(item => item.badgeId === ids.tenantBadge);
assert.equal(profileBadge?.unlocked, true, 'profile badges should expose granted badge as unlocked');
assert.equal(profileBadges.summary?.unlocked >= 1, true, 'profile badges summary should count unlocked badges');
const profileBadgesInvalid = await request('/api/profile/badges', {
query: { category: 'unsafe-category' },
expectStatus: 400,
});
assert.equal(profileBadgesInvalid.code, 'INVALID_BADGE_CATEGORY', 'profile badges should reject invalid category');
const badgeGrantInvalidId = await request('/api/tenant-admin/badge-grants', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
badgeId: 'not-a-uuid',
userId: USER_ID,
},
expectStatus: 400,
});
assert.equal(badgeGrantInvalidId.code, 'INVALID_UUID', 'badge grant should reject invalid UUID input');
const badgeCrossTenantDenied = await request('/api/tenant-admin/badge-grants', {
tenantId: PARTNER_TENANT_ID,
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
badgeId: ids.tenantBadge,
userId: USER_ID,
},
expectStatus: 403,
});
assert.equal(badgeCrossTenantDenied.code, 'TENANT_ADMIN_REQUIRED', 'badge grant must be tenant isolated');
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 feedbackBadge = await request('/api/tenant-admin/badges', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
id: ids.tenantFeedbackBadge,
legacyId: 'integration-badge-feedback-auto',
name: '集成测试纠错勋章',
description: '反馈被解决达到规则后自动发放',
category: 'feedback',
iconUrl: 'https://example.test/badges/feedback-auto.png',
level: 1,
unlockType: 'feedback_resolved',
conditionField: 'feedbackResolvedCount',
conditionOperator: 'gte',
conditionValue: 1,
metadata: { source: 'integration-test', trigger: 'feedback_resolved' },
order: 1,
isActive: true,
},
});
assert.equal(feedbackBadge.item?.id, ids.tenantFeedbackBadge, 'tenant admin should create automatic feedback badge');
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');
assert.ok(
resolvedFeedback.item?.autoBadges?.some(item => item.badgeId === ids.tenantFeedbackBadge),
'resolved feedback should auto grant feedback badge',
);
const feedbackNotifications = await request('/api/profile/notifications', {
query: { limit: 80 },
});
assert.ok(
feedbackNotifications.items?.some(
item => item.notificationType === 'feedback_status_updated' && item.sourceId === pendingFeedback.id && item.status === 'unread',
),
'feedback status update should create unread student notification',
);
assert.ok(
feedbackNotifications.items?.some(
item => item.notificationType === 'feedback_reward_granted' && item.sourceId === pendingFeedback.id,
),
'feedback reward should create student reward notification',
);
const tenantUserNotifications = await request('/api/tenant-admin/user-notifications', {
userId: TENANT_ADMIN_USER_ID,
query: { userId: USER_ID, notificationType: 'feedback_status_updated', limit: 20 },
});
assert.ok(
tenantUserNotifications.items?.some(item => item.sourceId === pendingFeedback.id && item.userId === USER_ID),
'tenant admin should inspect student notifications with permission',
);
const tenantNotificationDenied = await request('/api/tenant-admin/user-notifications', {
userId: TENANT_TEACHER_USER_ID,
query: { userId: USER_ID, limit: 20 },
expectStatus: 403,
});
assert.equal(tenantNotificationDenied.code, 'TENANT_PERMISSION_REQUIRED', 'tenant user notification list requires explicit permission');
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');
assert.equal(
repeatedReward.item?.autoBadges?.filter(item => item.badgeId === ids.tenantFeedbackBadge).length || 0,
0,
'repeated feedback resolution should not duplicate auto feedback badge',
);
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 feedbackProfileBadges = await request('/api/profile/badges', {
query: { includeLocked: 'true', category: 'feedback', limit: 20 },
});
assert.equal(
feedbackProfileBadges.items?.find(item => item.badgeId === ids.tenantFeedbackBadge)?.unlocked,
true,
'profile badges should expose auto feedback badge as unlocked',
);
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,
minOrderAmountCents: 100,
maxDiscountCents: 10,
perUserLimit: 2,
allowedPlanIds: ['00000000-0000-0000-0000-000000000201'],
allowedRegionIds: [ids.region],
campaignName: 'tenant-admin-coupon-smoke',
metadata: { channel: 'integration' },
source: 'integration-test',
},
});
assert.equal(coupon.item?.code, 'IT-COUPON-001', 'tenant admin should upsert coupon');
assert.equal(coupon.item?.campaignName, 'tenant-admin-coupon-smoke', 'tenant admin should persist coupon campaign');
assert.equal(coupon.item?.perUserLimit, 2, 'tenant admin should persist coupon per-user limit');
assert.ok(coupon.item?.allowedRegionIds?.includes(ids.region), 'tenant admin should persist coupon region scope');
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 testPointActivitiesAndExchange() {
const activityBadge = await request('/api/tenant-admin/badges', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
id: ids.tenantActivityBadge,
legacyId: 'integration-badge-activity-auto',
name: '集成测试活动任务勋章',
description: '完成积分活动任务后自动发放',
category: 'activity',
iconUrl: 'https://example.test/badges/activity-auto.png',
level: 1,
unlockType: 'activity_reward',
conditionField: 'activityRewardCount',
conditionOperator: 'gte',
conditionValue: 1,
metadata: { source: 'integration-test', trigger: 'activity_reward' },
order: 4,
isActive: true,
},
});
assert.equal(activityBadge.item?.id, ids.tenantActivityBadge, 'tenant admin should create activity reward badge');
const manualTask = await request('/api/tenant-admin/point-activity-tasks', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
id: ids.pointActivityTask,
legacyId: 'integration-point-task-manual',
code: 'integration-manual-task',
title: '集成测试积分任务',
description: '用于验证学生端领取积分任务',
taskType: 'manual',
rewardPoints: 18,
claimLimitPerUser: 1,
periodType: 'once',
metadata: { source: 'integration-test' },
order: 1,
status: 'active',
},
});
assert.equal(manualTask.item?.id, ids.pointActivityTask, 'tenant admin should upsert point activity task');
const systemTask = await request('/api/tenant-admin/point-activity-tasks', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
id: ids.pointSystemTask,
legacyId: 'integration-point-task-feedback-resolved',
code: 'integration-system-feedback-task',
title: '集成测试系统发放任务',
taskType: 'feedback_resolved',
rewardPoints: 6,
periodType: 'once',
status: 'active',
},
});
assert.equal(systemTask.item?.taskType, 'feedback_resolved', 'tenant admin should allow system-granted task config');
const tasks = await request('/api/profile/activity-tasks', { query: { limit: 20 } });
assert.ok(tasks.items?.some(item => item.id === ids.pointActivityTask), 'student should list active point activity task');
const systemClaimDenied = await request('/api/profile/activity-tasks/claim', {
method: 'POST',
body: { taskId: ids.pointSystemTask },
expectStatus: 403,
});
assert.equal(systemClaimDenied.code, 'POINT_TASK_SYSTEM_GRANTED_ONLY', 'student must not self-claim system workflow tasks');
const claimed = await request('/api/profile/activity-tasks/claim', {
method: 'POST',
body: {
taskId: ids.pointActivityTask,
idempotencyKey: 'integration-manual-claim',
metadata: { source: 'integration-test' },
},
});
assert.equal(claimed.item?.pointsAdded, 18, 'claiming activity task should add configured points');
assert.equal(claimed.item?.ledger?.eventType, 'activity_reward', 'activity task claim should write activity_reward ledger');
assert.equal(claimed.item?.claim?.taskId, ids.pointActivityTask, 'activity claim should bind task id');
assert.ok(
claimed.item?.autoBadges?.some(item => item.badgeId === ids.tenantActivityBadge),
'activity claim should auto grant activity badge',
);
const duplicateClaim = await request('/api/profile/activity-tasks/claim', {
method: 'POST',
body: {
taskId: ids.pointActivityTask,
idempotencyKey: 'integration-manual-claim',
},
expectStatus: 409,
});
assert.equal(duplicateClaim.code, 'POINT_TASK_CLAIM_LIMIT_REACHED', 'activity task should enforce claim limit');
const scoreEventsAfterClaim = await request('/api/profile/score-events', { query: { limit: 80 } });
assert.ok(
scoreEventsAfterClaim.items?.some(item => item.eventType === 'activity_reward' && item.sourceId === ids.pointActivityTask),
'score ledger should include activity reward source',
);
const activityClaims = await request('/api/tenant-admin/point-activity-claims', {
userId: TENANT_ADMIN_USER_ID,
query: { taskId: ids.pointActivityTask, limit: 20 },
});
assert.ok(activityClaims.items?.some(item => item.userId === USER_ID), 'tenant admin should list activity claims');
const exchangeCoupon = await request('/api/tenant-admin/coupons', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
code: 'IT-POINT-EXCHANGE-COUPON',
planId: ids.couponOnlyPlan,
discountType: 'fixed',
discountValue: 100,
maxUses: 10,
perUserLimit: 10,
campaignName: 'integration-point-exchange',
metadata: { source: 'integration-test' },
source: 'points-exchange',
},
});
assert.equal(exchangeCoupon.item?.code, 'IT-POINT-EXCHANGE-COUPON', 'tenant admin should create coupon for point exchange');
const exchangeItem = await request('/api/tenant-admin/point-exchange-items', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
id: ids.pointExchangeItem,
legacyId: 'integration-point-exchange-item',
code: 'integration-coupon-exchange',
title: '集成测试积分兑换券',
description: '用积分兑换优惠券',
costPoints: 12,
itemType: 'coupon',
couponId: exchangeCoupon.item.id,
stockTotal: 1,
perUserLimit: 1,
metadata: { source: 'integration-test' },
status: 'active',
},
});
assert.equal(exchangeItem.item?.id, ids.pointExchangeItem, 'tenant admin should upsert point exchange item');
const exchangeList = await request('/api/profile/exchange-items', { query: { limit: 20 } });
assert.ok(exchangeList.items?.some(item => item.id === ids.pointExchangeItem), 'student should list active exchange item');
const redeemed = await request('/api/profile/exchange-items/redeem', {
method: 'POST',
body: {
itemId: ids.pointExchangeItem,
idempotencyKey: 'integration-exchange-redeem',
metadata: { source: 'integration-test' },
},
});
assert.equal(redeemed.item?.pointsSpent, 12, 'exchange should spend configured points');
assert.equal(redeemed.item?.ledger?.eventType, 'redeem_cost', 'exchange should write redeem_cost ledger');
assert.equal(redeemed.item?.order?.itemId, ids.pointExchangeItem, 'exchange order should bind item');
assert.ok(redeemed.item?.couponRedemption?.id, 'coupon exchange should create coupon redemption');
const exchangeNotifications = await request('/api/profile/notifications', {
query: { notificationType: 'point_exchange_completed', status: 'unread', limit: 20 },
});
assert.ok(
exchangeNotifications.items?.some(item => item.sourceId === redeemed.item?.order?.id && item.metadata?.itemId === ids.pointExchangeItem),
'completed point exchange should create unread student notification',
);
const repeatedRedeem = await request('/api/profile/exchange-items/redeem', {
method: 'POST',
body: {
itemId: ids.pointExchangeItem,
idempotencyKey: 'integration-exchange-redeem',
},
});
assert.equal(repeatedRedeem.item?.idempotent, true, 'same exchange idempotency key should return existing order');
const redeemLimitDenied = await request('/api/profile/exchange-items/redeem', {
method: 'POST',
body: {
itemId: ids.pointExchangeItem,
idempotencyKey: 'integration-exchange-redeem-2',
},
expectStatus: 409,
});
assert.equal(redeemLimitDenied.code, 'POINT_EXCHANGE_USER_LIMIT_REACHED', 'exchange should enforce per-user limit');
const stockDenied = await request('/api/profile/exchange-items/redeem', {
userId: SECOND_STUDENT_USER_ID,
method: 'POST',
body: {
itemId: ids.pointExchangeItem,
idempotencyKey: 'integration-exchange-second-student',
},
expectStatus: 409,
});
assert.equal(stockDenied.code, 'POINT_EXCHANGE_OUT_OF_STOCK', 'exchange should enforce stock before charging another user');
const expensiveItem = await request('/api/tenant-admin/point-exchange-items', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
id: ids.pointExpensiveExchangeItem,
legacyId: 'integration-point-exchange-expensive',
code: 'integration-expensive-exchange',
title: '集成测试高积分兑换',
costPoints: 9999,
itemType: 'manual',
stockTotal: 20,
perUserLimit: 1,
status: 'active',
},
});
assert.equal(expensiveItem.item?.id, ids.pointExpensiveExchangeItem, 'tenant admin should create high-cost exchange item');
const insufficientScoreDenied = await request('/api/profile/exchange-items/redeem', {
userId: SECOND_STUDENT_USER_ID,
method: 'POST',
body: {
itemId: ids.pointExpensiveExchangeItem,
idempotencyKey: 'integration-expensive-second-student',
},
expectStatus: 409,
});
assert.equal(insufficientScoreDenied.code, 'POINT_EXCHANGE_INSUFFICIENT_SCORE', 'exchange should reject insufficient score balance');
const exchangeOrders = await request('/api/tenant-admin/point-exchange-orders', {
userId: TENANT_ADMIN_USER_ID,
query: { itemId: ids.pointExchangeItem, limit: 20 },
});
assert.ok(exchangeOrders.items?.some(item => item.userId === USER_ID), 'tenant admin should list exchange orders');
const partnerExchangeDenied = await request('/api/tenant-admin/point-exchange-items', {
tenantId: PARTNER_TENANT_ID,
userId: TENANT_ADMIN_USER_ID,
expectStatus: 403,
});
assert.equal(partnerExchangeDenied.code, 'TENANT_ADMIN_REQUIRED', 'point exchange admin must be tenant isolated');
}
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 === 'marketing:points:read'), 'permission matrix should expose point marketing read permission');
assert.ok(permissionMatrix.permissions?.some(item => item.key === 'marketing:points:write'), 'permission matrix should expose point marketing write permission');
assert.ok(permissionMatrix.permissions?.some(item => item.key === 'notifications:read'), 'permission matrix should expose user notification read permission');
assert.ok(permissionMatrix.permissions?.some(item => item.key === 'dashboard:read'), 'permission matrix should expose dashboard read permission');
assert.ok(permissionMatrix.permissions?.some(item => item.key === 'roles:write'), 'permission matrix should expose role template permission');
assert.ok(permissionMatrix.permissions?.some(item => item.key === 'badges:grant'), 'permission matrix should expose badge grant permission');
assert.ok(permissionMatrix.permissions?.some(item => item.key === 'tenant:reconciliation:read'), 'permission matrix should expose reconciliation read permission');
assert.ok(permissionMatrix.permissions?.some(item => item.key === 'tenant:reconciliation:write'), 'permission matrix should expose reconciliation write permission');
assert.ok(permissionMatrix.permissions?.some(item => item.key === 'tenant:reconciliation:review'), 'permission matrix should expose reconciliation review permission');
assert.ok(permissionMatrix.menuGroups?.some(item => item.key === 'sales'), 'permission matrix should expose menu groups');
assert.ok(permissionMatrix.roleDefaults?.tenant_operator?.includes('dashboard:read'), 'tenant operator defaults should include dashboard read');
assert.ok(permissionMatrix.roleDefaults?.tenant_operator?.includes('marketing:*'), 'permission matrix should include role defaults');
assert.ok(permissionMatrix.roleDefaults?.tenant_operator?.includes('badges:*'), 'tenant operator defaults should include badge management');
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 operatorDashboard = await request('/api/tenant-admin/dashboard', {
userId: TENANT_OPERATOR_USER_ID,
query: { timeRange: '7d' },
});
assert.equal(operatorDashboard.item?.scope?.tenantId, MAIN_TENANT_ID, 'tenant operator should read dashboard through default permission');
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:read': true,
'coupons:write': 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:read': true,
'coupons:write': 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,
assignmentMode: 'round_robin',
assignmentPool: [TENANT_SALES_USER_ID, TENANT_AGENT_USER_ID],
},
});
assert.equal(crmConfig.item?.enabled, true, 'tenant admin should enable CRM config');
assert.equal(crmConfig.item?.assignmentMode, 'round_robin', 'CRM config should persist lead assignment mode');
assert.deepEqual(crmConfig.item?.assignmentPool, [TENANT_SALES_USER_ID, TENANT_AGENT_USER_ID], 'CRM config should persist assignment pool');
assert.ok(!JSON.stringify(crmConfig).includes('crm-secret-smoke'), 'CRM config response should not leak secret');
const invalidCrmAssignment = await request('/api/crm/config', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
enabled: true,
url: 'https://crm.example.test/webhook',
assignmentMode: 'direct',
assignmentPool: [PARTNER_TENANT_ADMIN_USER_ID],
},
expectStatus: 400,
});
assert.equal(invalidCrmAssignment.code, 'CRM_ASSIGNMENT_POOL_INVALID', 'CRM assignment pool must reject cross-tenant users');
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.equal(tracked.lead?.item?.assignedToUserId, TENANT_SALES_USER_ID, 'round-robin should assign first lead to first sales member');
assert.equal(tracked.lead?.assignment?.userId, TENANT_SALES_USER_ID, 'track response should expose assignment assignee');
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');
assert.equal(manual.lead?.item?.assignedToUserId, TENANT_AGENT_USER_ID, 'round-robin should assign second lead to next pool member');
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 commissionSettings = await request('/api/commission/settings', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
defaultRate: 0.2,
minSettlementCents: 0,
settlementCycle: 'monthly',
config: { source: 'integration-test' },
},
});
assert.equal(Number(commissionSettings.item?.defaultRate), 0.2, 'tenant admin should configure default commission rate');
const agentRate = await request('/api/commission/member-rate', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
userId: TENANT_AGENT_USER_ID,
commissionRate: 0.35,
commissionConfig: { note: 'integration agent rate' },
},
});
assert.equal(Number(agentRate.item?.commissionRate), 0.35, 'tenant admin should configure agent commission rate');
const commissionOrder = await request('/api/commerce/orders', {
method: 'POST',
body: {
planId: ids.plan,
payProvider: 'manual',
payMethod: 'manual',
regionId: ids.region,
},
});
assert.equal(commissionOrder.item?.status, 'pending', 'commission smoke order should start pending');
const commissionPaid = await request('/api/commerce/payments/manual-confirm', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
orderNo: commissionOrder.item.orderNo,
amountCents: commissionOrder.item.amountCents,
providerTradeNo: `commission-${commissionOrder.item.orderNo}`,
},
});
assert.equal(commissionPaid.item?.status, 'paid', 'commission smoke order should be paid');
const commissionBatch = await request('/api/tenant-admin/code-batches', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
name: '集成测试分佣批次',
saleType: 'agent',
defaultUnitPriceCents: 2000,
days: 30,
regionId: ids.region,
commissionRate: 0.45,
},
});
assert.equal(Number(commissionBatch.item?.commissionRate), 0.45, 'code batch should persist commission rate');
const commissionCode = await request('/api/tenant-admin/activation-codes', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
code: 'IT-COMMISSION-001',
days: 30,
batchId: commissionBatch.item.id,
unitPriceCents: 2000,
usedRegionId: ids.region,
remark: 'commission integration code',
},
});
assert.equal(commissionCode.item?.code, 'IT-COMMISSION-001', 'tenant admin should create commission activation code');
const commissionRedeemed = await request('/api/commerce/activation-codes/redeem', {
method: 'POST',
body: { code: 'IT-COMMISSION-001', regionId: ids.region },
});
assert.ok(commissionRedeemed.item?.entitlement?.id, 'commission activation code should be redeemable');
const today = shanghaiDateKey();
const agentCommissionSummary = await request('/api/commission/summary', {
userId: TENANT_AGENT_USER_ID,
query: { startDate: today, endDate: today },
});
assert.equal(agentCommissionSummary.item?.referrerUserId, TENANT_AGENT_USER_ID, 'agent commission summary should be scoped to self');
assert.equal(agentCommissionSummary.item?.sourceCount >= 2, true, 'agent commission summary should include order and activation code');
assert.equal(agentCommissionSummary.item?.grossAmountCents >= 2990, true, 'agent commission summary should include gross amount');
assert.equal(agentCommissionSummary.item?.commissionAmountCents >= 1217, true, 'agent commission should apply member and batch rates');
const salesOtherCommissionDenied = await request('/api/commission/summary', {
userId: TENANT_AGENT_USER_ID,
query: { startDate: today, endDate: today, referrerUserId: TENANT_SALES_USER_ID },
expectStatus: 403,
});
assert.equal(salesOtherCommissionDenied.code, 'COMMISSION_SCOPE_REQUIRED', 'agent should not read another referrer commission');
const salesAgentCommissionDenied = await request('/api/commission/summary', {
userId: TENANT_SALES_USER_ID,
query: { startDate: today, endDate: today, referrerUserId: TENANT_AGENT_USER_ID },
expectStatus: 403,
});
assert.equal(salesAgentCommissionDenied.code, 'COMMISSION_SCOPE_REQUIRED', 'sales should not read agent commission without commission:read');
const commissionOrders = await request('/api/commission/orders', {
userId: TENANT_ADMIN_USER_ID,
query: { startDate: today, endDate: today, referrerUserId: TENANT_AGENT_USER_ID, limit: 20 },
});
assert.ok(commissionOrders.items?.some(item => item.sourceType === 'order' && item.rateSource === 'member'), 'commission orders should include paid order with member rate');
assert.ok(commissionOrders.items?.some(item => item.sourceType === 'activation_code' && item.rateSource === 'batch'), 'commission orders should include activation code with batch rate');
assert.ok(!JSON.stringify(commissionOrders).includes('crm-secret-smoke'), 'commission order list must not leak CRM secret');
const generatedSettlement = await request('/api/commission/settlements/generate', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
referrerUserId: TENANT_AGENT_USER_ID,
startDate: today,
endDate: today,
status: 'pending_review',
remark: 'integration settlement',
},
});
assert.equal(generatedSettlement.item?.referrerUserId, TENANT_AGENT_USER_ID, 'tenant admin should generate agent settlement');
assert.equal(generatedSettlement.item?.status, 'pending_review', 'generated settlement should enter review flow');
assert.equal(generatedSettlement.item?.sourceCount >= 2, true, 'generated settlement should include unsettled sources');
const duplicateSettlement = await request('/api/commission/settlements/generate', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
referrerUserId: TENANT_AGENT_USER_ID,
startDate: today,
endDate: today,
},
expectStatus: 409,
});
assert.equal(duplicateSettlement.code, 'COMMISSION_NO_UNSETTLED_SOURCES', 'settlement generation should not duplicate settled sources');
const approvedSettlement = await request('/api/commission/settlements/status', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
settlementId: generatedSettlement.item.id,
status: 'approved',
remark: 'integration approved',
},
});
assert.equal(approvedSettlement.item?.status, 'approved', 'tenant admin should approve settlement');
assert.equal(approvedSettlement.item?.reviewedBy, TENANT_ADMIN_USER_ID, 'settlement approval should record reviewer');
const paidSettlement = await request('/api/commission/settlements/status', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
settlementId: generatedSettlement.item.id,
status: 'paid',
paymentMethod: 'offline_bank',
paymentAccount: 'masked-bank-account',
metadata: { source: 'integration-test' },
},
});
assert.equal(paidSettlement.item?.status, 'paid', 'tenant admin should mark settlement paid');
assert.equal(paidSettlement.item?.paidBy, TENANT_ADMIN_USER_ID, 'settlement paid status should record operator');
assert.equal(paidSettlement.item?.paymentMethod, 'offline_bank', 'settlement paid status should persist payment method');
const exportedSettlement = await request('/api/commission/settlements/export', {
userId: TENANT_ADMIN_USER_ID,
query: { settlementId: generatedSettlement.item.id, format: 'csv' },
});
assert.equal(exportedSettlement.item?.format, 'csv', 'tenant admin should export settlement csv');
assert.equal(exportedSettlement.item?.rowCount >= 2, true, 'settlement export should include item rows');
assert.ok(exportedSettlement.item?.sha256, 'settlement export should include content hash');
assert.ok(Buffer.from(exportedSettlement.item?.contentBase64 || '', 'base64').toString('utf8').includes('commissionAmountCents'), 'settlement export should include csv headers');
const settlementProof = await request('/api/commission/settlements/proofs', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
settlementId: generatedSettlement.item.id,
proofType: 'payment',
title: '集成测试打款凭证',
externalUrl: 'https://finance.example.test/proofs/commission-paid.png',
amountCents: paidSettlement.item.commissionAmountCents,
paymentMethod: 'offline_bank',
paymentAccount: 'masked-bank-account',
metadata: { source: 'integration-test' },
},
});
assert.equal(settlementProof.item?.status, 'submitted', 'tenant admin should submit payment proof');
assert.equal(settlementProof.item?.submittedBy, TENANT_ADMIN_USER_ID, 'proof should record submitter');
const approvedProof = await request('/api/commission/settlements/proofs/status', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
proofId: settlementProof.item.id,
status: 'approved',
reviewNote: 'integration proof reviewed',
},
});
assert.equal(approvedProof.item?.status, 'approved', 'tenant admin should approve payment proof');
assert.equal(approvedProof.item?.reviewedBy, TENANT_ADMIN_USER_ID, 'proof approval should record reviewer');
const proofList = await request('/api/commission/settlements/proofs', {
userId: TENANT_AGENT_USER_ID,
query: { settlementId: generatedSettlement.item.id },
});
assert.ok(proofList.items?.some(item => item.id === settlementProof.item.id), 'agent should read own settlement proof');
assert.ok(!JSON.stringify(proofList).includes('crm-secret-smoke'), 'settlement proofs must not leak CRM secret');
const salesExportDenied = await request('/api/commission/settlements/export', {
userId: TENANT_SALES_USER_ID,
query: { settlementId: generatedSettlement.item.id, format: 'json' },
expectStatus: 403,
});
assert.equal(salesExportDenied.code, 'COMMISSION_SCOPE_REQUIRED', 'sales must not export another referrer settlement');
const paidSettlementLocked = await request('/api/commission/settlements/status', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
settlementId: generatedSettlement.item.id,
status: 'rejected',
},
expectStatus: 409,
});
assert.equal(paidSettlementLocked.code, 'COMMISSION_SETTLEMENT_LOCKED', 'paid settlement should not be mutable');
const settlementList = await request('/api/commission/settlements', {
userId: TENANT_AGENT_USER_ID,
query: { startDate: today, endDate: today },
});
assert.ok(settlementList.items?.some(item => item.id === generatedSettlement.item.id && item.status === 'paid'), 'agent should see own paid settlement');
const studentCommissionDenied = await request('/api/commission/summary', {
query: { startDate: today, endDate: today },
expectStatus: 403,
});
assert.equal(studentCommissionDenied.code, 'TENANT_ADMIN_REQUIRED', 'student should not access commission summary');
const partnerCommissionDenied = await request('/api/commission/summary', {
tenantId: PARTNER_TENANT_ID,
userId: TENANT_AGENT_USER_ID,
query: { startDate: today, endDate: today },
expectStatus: 403,
});
assert.equal(partnerCommissionDenied.code, 'TENANT_ADMIN_REQUIRED', 'commission APIs must be tenant isolated');
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(crmQueue.items?.some(item => item.payload?.assignee?.id === TENANT_AGENT_USER_ID), 'CRM queue payload should include assigned follow-up user');
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('phone binding', testPhoneBinding);
await check('Supabase JWT identity', testSupabaseJwtIdentity);
await check('Supabase JWKS JWT identity', testSupabaseJwksIdentity);
await check('legacy auth headers disabled', testLegacyAuthHeadersDisabled);
await check('platform tenant operations and audit', testPlatformTenantOperationsAndAudit);
await check('catalog and learning', testCatalogAndLearning);
await check('composite practice questions', testCompositePracticeQuestions);
await check('profile', testProfile);
await check('learning leaderboard', testLearningLeaderboard);
await check('scoreline', testScoreline);
await check('AI school recommendation SVIP gate', testAiSchoolRecommendationRequiresSvip);
await check('question videos', testVideos);
await check('vocabulary', testVocabulary);
await check('commerce', testCommerce);
await check('AI school recommendation', testAiSchoolRecommendationAfterSvip);
await check('tenant isolation', testTenantIsolation);
await check('tenant content admin', testTenantContentAdmin);
await check('tenant content assets and imports', testTenantContentAssetsAndImports);
await check('public question bank adoption', testPublicQuestionBankAdoption);
await check('tenant admin operations', testTenantAdminOps);
await check('point activities and exchange', testPointActivitiesAndExchange);
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;
});