forked from wangziqi/gongxue-base
10644 lines
448 KiB
JavaScript
10644 lines
448 KiB
JavaScript
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 writeXlsxFile from 'write-excel-file/node';
|
||
|
||
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_RESTRICTED_PLATFORM_ADMIN_USER_ID = '00000000-0000-0000-0000-00000000a998';
|
||
const AUTH_PLATFORM_STAFF_USER_ID = '00000000-0000-4000-8000-00000000a997';
|
||
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',
|
||
tenantPracticeBadge: '00000000-0000-0000-0000-000000000881',
|
||
tenantVocabularyBadge: '00000000-0000-0000-0000-000000000882',
|
||
tenantMockExamBadge: '00000000-0000-0000-0000-000000000883',
|
||
dailyCheckInTask: '00000000-0000-0000-0000-000000000884',
|
||
streakCheckInTask: '00000000-0000-0000-0000-000000000885',
|
||
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',
|
||
secondPublicRegion: '00000000-0000-0000-0000-0000000003f1',
|
||
secondPublicQuestionBank: '00000000-0000-0000-0000-0000000004f1',
|
||
secondPublicQuestion: '00000000-0000-0000-0000-0000000004f2',
|
||
secondPublicQuestionVersion: '00000000-0000-0000-0000-0000000004f3',
|
||
secondPublicQuestionBankGrant: '00000000-0000-0000-0000-0000000009f1',
|
||
publicQuestionBankGrant: '00000000-0000-0000-0000-000000000906',
|
||
partnerSubscription: '00000000-0000-0000-0000-000000000902',
|
||
platformOverdueInvoice: crypto.randomUUID(),
|
||
crmDeadLetterQueue: crypto.randomUUID(),
|
||
};
|
||
|
||
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();
|
||
}
|
||
}
|
||
|
||
async function setTenantFeatureFlag(tenantId, flag, enabled) {
|
||
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL || DEFAULT_DATABASE_URL });
|
||
try {
|
||
await pool.query(
|
||
`
|
||
insert into public.tenant_settings (tenant_id, feature_flags, admin_feature_flags, public_config)
|
||
values ($1, jsonb_build_object($2::text, $3::boolean), '{}'::jsonb, '{}'::jsonb)
|
||
on conflict (tenant_id)
|
||
do update set feature_flags = jsonb_set(
|
||
coalesce(public.tenant_settings.feature_flags, '{}'::jsonb),
|
||
array[$2::text],
|
||
to_jsonb($3::boolean),
|
||
true
|
||
),
|
||
updated_at = now()
|
||
`,
|
||
[tenantId, flag, enabled],
|
||
);
|
||
} 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 testPlatformAdminPermissions() {
|
||
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL || DEFAULT_DATABASE_URL });
|
||
const restrictedUserId = crypto.randomUUID();
|
||
try {
|
||
await pool.query(
|
||
`
|
||
insert into auth.users (
|
||
id, aud, role, phone, phone_confirmed_at, raw_app_meta_data, raw_user_meta_data,
|
||
created_at, updated_at
|
||
)
|
||
values (
|
||
$1, 'authenticated', 'authenticated', '13999999998', now(),
|
||
'{"provider":"phone","providers":["phone"],"app_role":"platform_admin"}'::jsonb,
|
||
'{}'::jsonb, now(), now()
|
||
)
|
||
on conflict (id)
|
||
do update set phone = excluded.phone,
|
||
raw_app_meta_data = excluded.raw_app_meta_data,
|
||
updated_at = now()
|
||
`,
|
||
[AUTH_RESTRICTED_PLATFORM_ADMIN_USER_ID],
|
||
);
|
||
await pool.query(
|
||
`
|
||
insert into public.platform_users (
|
||
id, auth_user_id, username, phone, name, primary_role, platform_permissions, raw_profile
|
||
)
|
||
values (
|
||
$1, $2, 'restricted_platform_reader', '13999999998', 'Restricted Platform Reader',
|
||
'platform_admin',
|
||
'{"platform:overview:read":true,"platform:tenant:read":true,"platform:plan:read":true}'::jsonb,
|
||
'{"source":"api-integration-test"}'::jsonb
|
||
)
|
||
on conflict (auth_user_id)
|
||
do update set username = excluded.username,
|
||
phone = excluded.phone,
|
||
name = excluded.name,
|
||
primary_role = excluded.primary_role,
|
||
platform_permissions = excluded.platform_permissions,
|
||
updated_at = now()
|
||
`,
|
||
[restrictedUserId, AUTH_RESTRICTED_PLATFORM_ADMIN_USER_ID],
|
||
);
|
||
} finally {
|
||
await pool.end();
|
||
}
|
||
|
||
const restrictedJwt = await createSupabaseJwt(AUTH_RESTRICTED_PLATFORM_ADMIN_USER_ID, {
|
||
phone: '13999999998',
|
||
appRole: 'platform_admin',
|
||
tenantId: false,
|
||
});
|
||
const restrictedHeaders = { authorization: `Bearer ${restrictedJwt}` };
|
||
|
||
const permissionSummary = await request('/api/platform-admin/permissions', {
|
||
tenantId: false,
|
||
userId: false,
|
||
headers: restrictedHeaders,
|
||
});
|
||
assert.equal(permissionSummary.item?.effective?.['platform:tenant:read'], true, 'restricted platform admin should expose granted tenant read permission');
|
||
assert.equal(permissionSummary.item?.effective?.['platform:tenant:write'], false, 'restricted platform admin should not expose tenant write permission');
|
||
assert.equal(permissionSummary.item?.effective?.['platform:audit:export'], false, 'restricted platform admin should not expose audit export permission');
|
||
assert.ok(!JSON.stringify(permissionSummary).includes('local-platform-admin-key'), 'platform permission summary must not leak platform admin key');
|
||
|
||
const overview = await request('/api/platform-admin/overview', {
|
||
tenantId: false,
|
||
userId: false,
|
||
headers: restrictedHeaders,
|
||
});
|
||
assert.ok(overview.item?.tenants?.total >= 1, 'restricted platform admin should access explicitly granted overview');
|
||
|
||
const tenants = await request('/api/platform-admin/tenants', {
|
||
tenantId: false,
|
||
userId: false,
|
||
headers: restrictedHeaders,
|
||
});
|
||
assert.ok(Array.isArray(tenants.items), 'restricted platform admin should list tenants with tenant read permission');
|
||
|
||
const createDenied = await request('/api/platform-admin/tenants', {
|
||
tenantId: false,
|
||
userId: false,
|
||
headers: restrictedHeaders,
|
||
method: 'POST',
|
||
body: {
|
||
slug: `restricted-denied-${Date.now().toString(36)}`,
|
||
name: 'Restricted Denied Tenant',
|
||
},
|
||
expectStatus: 403,
|
||
});
|
||
assert.equal(createDenied.code, 'PLATFORM_PERMISSION_REQUIRED', 'tenant write must require platform tenant write permission');
|
||
|
||
const auditExportDenied = await request('/api/platform-admin/audit-logs/export', {
|
||
tenantId: false,
|
||
userId: false,
|
||
headers: restrictedHeaders,
|
||
query: { format: 'csv' },
|
||
expectStatus: 403,
|
||
});
|
||
assert.equal(auditExportDenied.code, 'PLATFORM_PERMISSION_REQUIRED', 'audit export must require explicit platform audit export permission');
|
||
|
||
const paymentDenied = await request('/api/platform-admin/invoices/payments/manual-confirm', {
|
||
tenantId: false,
|
||
userId: false,
|
||
headers: restrictedHeaders,
|
||
method: 'POST',
|
||
body: {
|
||
tenantId: PARTNER_TENANT_ID,
|
||
invoiceId: ids.platformOverdueInvoice,
|
||
amountCents: 1,
|
||
},
|
||
expectStatus: 403,
|
||
});
|
||
assert.equal(paymentDenied.code, 'PLATFORM_PERMISSION_REQUIRED', 'manual service-fee payment must require billing payment permission');
|
||
|
||
const publicBankOpsDenied = await request('/api/platform-admin/question-bank-sync-status', {
|
||
tenantId: false,
|
||
userId: false,
|
||
headers: restrictedHeaders,
|
||
expectStatus: 403,
|
||
});
|
||
assert.equal(publicBankOpsDenied.code, 'PLATFORM_PERMISSION_REQUIRED', 'public bank sync ops should require dedicated platform question bank ops permission');
|
||
}
|
||
|
||
async function testPlatformStaffManagement() {
|
||
const adminHeaders = { 'x-platform-admin-key': 'local-platform-admin-key' };
|
||
const staffSuffix = Date.now().toString(36);
|
||
const staffEmail = `platform.staff.${staffSuffix}@example.test`;
|
||
const staffPhone = `139${String(Date.now()).slice(-8)}`;
|
||
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL || DEFAULT_DATABASE_URL });
|
||
try {
|
||
await pool.query(
|
||
`
|
||
insert into auth.users (
|
||
id, aud, role, phone, email, phone_confirmed_at, email_confirmed_at,
|
||
raw_app_meta_data, raw_user_meta_data, created_at, updated_at
|
||
)
|
||
values (
|
||
$1, 'authenticated', 'authenticated', $2, $3,
|
||
now(), now(),
|
||
'{"provider":"phone","providers":["phone"],"app_role":"platform_admin"}'::jsonb,
|
||
'{}'::jsonb, now(), now()
|
||
)
|
||
on conflict (id)
|
||
do update set phone = excluded.phone,
|
||
email = excluded.email,
|
||
raw_app_meta_data = excluded.raw_app_meta_data,
|
||
updated_at = now()
|
||
`,
|
||
[AUTH_PLATFORM_STAFF_USER_ID, staffPhone, staffEmail],
|
||
);
|
||
} finally {
|
||
await pool.end();
|
||
}
|
||
|
||
const created = await request('/api/platform-admin/staff', {
|
||
tenantId: false,
|
||
userId: false,
|
||
headers: adminHeaders,
|
||
method: 'PUT',
|
||
body: {
|
||
authUserId: AUTH_PLATFORM_STAFF_USER_ID,
|
||
username: 'platform_staff_operator',
|
||
email: staffEmail,
|
||
phone: staffPhone,
|
||
name: 'Platform Staff Operator',
|
||
status: 'active',
|
||
platformPermissions: {
|
||
'platform:staff:read': true,
|
||
'platform:overview:read': true,
|
||
'platform:tenant:read': true,
|
||
},
|
||
metadata: {
|
||
title: 'operations',
|
||
secretToken: 'should-not-be-returned-through-audit',
|
||
},
|
||
},
|
||
});
|
||
assert.ok(created.item?.id, 'platform admin should create platform staff');
|
||
assert.equal(created.item?.platformPermissions?.['platform:tenant:read'], true, 'platform staff response should include granted permissions');
|
||
assert.ok(!JSON.stringify(created).includes('local-platform-admin-key'), 'platform staff response must not leak platform key');
|
||
|
||
const invalidPermission = await request('/api/platform-admin/staff', {
|
||
tenantId: false,
|
||
userId: false,
|
||
headers: adminHeaders,
|
||
method: 'PUT',
|
||
body: {
|
||
authUserId: AUTH_PLATFORM_STAFF_USER_ID,
|
||
username: 'invalid_platform_staff',
|
||
name: 'Invalid Platform Staff',
|
||
platformPermissions: {
|
||
'platform:unknown:write': true,
|
||
},
|
||
},
|
||
expectStatus: 400,
|
||
});
|
||
assert.equal(invalidPermission.code, 'INVALID_PLATFORM_PERMISSION', 'platform staff permissions should reject unknown permission keys');
|
||
|
||
const missingAuthUser = await request('/api/platform-admin/staff', {
|
||
tenantId: false,
|
||
userId: false,
|
||
headers: adminHeaders,
|
||
method: 'PUT',
|
||
body: {
|
||
authUserId: '00000000-0000-4000-8000-00000000dead',
|
||
username: 'missing_auth_platform_staff',
|
||
name: 'Missing Auth Platform Staff',
|
||
platformPermissions: {
|
||
'platform:overview:read': true,
|
||
},
|
||
},
|
||
expectStatus: 404,
|
||
});
|
||
assert.equal(missingAuthUser.code, 'AUTH_USER_NOT_FOUND', 'platform staff must bind an existing Supabase Auth user');
|
||
|
||
const missingAuthBinding = await request('/api/platform-admin/staff', {
|
||
tenantId: false,
|
||
userId: false,
|
||
headers: adminHeaders,
|
||
method: 'PUT',
|
||
body: {
|
||
username: 'unbound_platform_staff',
|
||
name: 'Unbound Platform Staff',
|
||
platformPermissions: {
|
||
'platform:overview:read': true,
|
||
},
|
||
},
|
||
expectStatus: 400,
|
||
});
|
||
assert.equal(missingAuthBinding.code, 'REQUIRED_FIELD', 'platform staff authUserId should be required');
|
||
|
||
const emptyPermissionDenied = await request('/api/platform-admin/staff', {
|
||
tenantId: false,
|
||
userId: false,
|
||
headers: adminHeaders,
|
||
method: 'PUT',
|
||
body: {
|
||
authUserId: AUTH_PLATFORM_STAFF_USER_ID,
|
||
username: 'empty_permission_platform_staff',
|
||
name: 'Empty Permission Platform Staff',
|
||
status: 'active',
|
||
platformPermissions: {},
|
||
},
|
||
expectStatus: 400,
|
||
});
|
||
assert.equal(emptyPermissionDenied.code, 'PLATFORM_PERMISSION_EMPTY', 'active platform staff should require explicit permissions');
|
||
|
||
const staffList = await request('/api/platform-admin/staff', {
|
||
tenantId: false,
|
||
userId: false,
|
||
headers: adminHeaders,
|
||
query: { q: 'platform_staff_operator' },
|
||
});
|
||
assert.ok(staffList.items?.some(item => item.id === created.item.id), 'platform staff list should include created staff');
|
||
|
||
const staffJwt = await createSupabaseJwt(AUTH_PLATFORM_STAFF_USER_ID, {
|
||
phone: staffPhone,
|
||
appRole: 'platform_admin',
|
||
tenantId: false,
|
||
});
|
||
const staffHeaders = { authorization: `Bearer ${staffJwt}` };
|
||
|
||
const staffPermissions = await request('/api/platform-admin/permissions', {
|
||
tenantId: false,
|
||
userId: false,
|
||
headers: staffHeaders,
|
||
});
|
||
assert.equal(staffPermissions.item?.effective?.['platform:staff:read'], true, 'staff should expose its staff read permission');
|
||
assert.equal(staffPermissions.item?.effective?.['platform:tenant:write'], false, 'staff should not expose ungranted tenant write permission');
|
||
|
||
const staffCanListTenants = await request('/api/platform-admin/tenants', {
|
||
tenantId: false,
|
||
userId: false,
|
||
headers: staffHeaders,
|
||
});
|
||
assert.ok(Array.isArray(staffCanListTenants.items), 'staff should list tenants with tenant read permission');
|
||
|
||
const staffCreateTenantDenied = await request('/api/platform-admin/tenants', {
|
||
tenantId: false,
|
||
userId: false,
|
||
headers: staffHeaders,
|
||
method: 'POST',
|
||
body: {
|
||
slug: `staff-denied-${Date.now().toString(36)}`,
|
||
name: 'Staff Denied Tenant',
|
||
},
|
||
expectStatus: 403,
|
||
});
|
||
assert.equal(staffCreateTenantDenied.code, 'PLATFORM_PERMISSION_REQUIRED', 'platform staff should not create tenants without tenant write permission');
|
||
|
||
const staffWriteDenied = await request('/api/platform-admin/staff', {
|
||
tenantId: false,
|
||
userId: false,
|
||
headers: staffHeaders,
|
||
method: 'PUT',
|
||
body: {
|
||
username: 'staff_cannot_create_staff',
|
||
name: 'Staff Cannot Create Staff',
|
||
platformPermissions: { 'platform:overview:read': true },
|
||
},
|
||
expectStatus: 403,
|
||
});
|
||
assert.equal(staffWriteDenied.code, 'PLATFORM_PERMISSION_REQUIRED', 'staff write should require explicit platform staff write permission');
|
||
|
||
const platformAdminJwt = await createSupabaseJwt(AUTH_PLATFORM_ADMIN_USER_ID, {
|
||
phone: '13999999999',
|
||
appRole: 'platform_admin',
|
||
tenantId: false,
|
||
});
|
||
const platformAdminHeaders = { authorization: `Bearer ${platformAdminJwt}` };
|
||
|
||
const selfDowngradeDenied = await request('/api/platform-admin/staff', {
|
||
tenantId: false,
|
||
userId: false,
|
||
headers: platformAdminHeaders,
|
||
method: 'PUT',
|
||
body: {
|
||
id: '00000000-0000-0000-0000-000000000999',
|
||
authUserId: AUTH_PLATFORM_ADMIN_USER_ID,
|
||
username: 'smoke_platform_admin',
|
||
phone: '13999999999',
|
||
name: 'Smoke Platform Admin',
|
||
status: 'active',
|
||
platformPermissions: { 'platform:overview:read': true },
|
||
},
|
||
expectStatus: 400,
|
||
});
|
||
assert.equal(selfDowngradeDenied.code, 'CANNOT_DOWNGRADE_SELF', 'platform admin should not remove its own super permission');
|
||
|
||
const disabled = await request('/api/platform-admin/staff/status', {
|
||
tenantId: false,
|
||
userId: false,
|
||
headers: adminHeaders,
|
||
method: 'PATCH',
|
||
body: {
|
||
staffId: created.item.id,
|
||
status: 'disabled',
|
||
reason: 'integration disable test',
|
||
revokeSessions: true,
|
||
},
|
||
});
|
||
assert.equal(disabled.item?.status, 'disabled', 'platform admin should disable platform staff');
|
||
|
||
const disabledStaffDenied = await request('/api/platform-admin/overview', {
|
||
tenantId: false,
|
||
userId: false,
|
||
headers: staffHeaders,
|
||
expectStatus: 403,
|
||
});
|
||
assert.equal(disabledStaffDenied.code, 'PLATFORM_ADMIN_REQUIRED', 'disabled platform staff JWT should no longer authenticate as platform admin');
|
||
|
||
const audit = await request('/api/platform-admin/audit-logs', {
|
||
tenantId: false,
|
||
userId: false,
|
||
headers: adminHeaders,
|
||
query: { targetType: 'platform_user', q: 'platform.staff', limit: 20 },
|
||
});
|
||
assert.ok(
|
||
audit.items?.some(item => item.targetId === created.item.id && item.action === 'platform.staff.status_updated'),
|
||
'platform staff status changes should be audited',
|
||
);
|
||
assert.ok(!JSON.stringify(audit).includes('should-not-be-returned-through-audit'), 'platform staff audit should not leak token-like metadata');
|
||
}
|
||
|
||
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 statusAuditLog = audit.items?.find(item => item.action === 'platform.tenant.status_updated');
|
||
assert.ok(statusAuditLog?.id, 'platform tenant status audit should expose audit log id');
|
||
|
||
const auditExport = await request('/api/platform-admin/audit-logs/export', {
|
||
tenantId: false,
|
||
userId: false,
|
||
headers: adminHeaders,
|
||
query: { tenantId, format: 'csv', limit: 50 },
|
||
});
|
||
assert.equal(auditExport.item?.format, 'csv', 'platform audit export should support csv');
|
||
assert.equal(auditExport.item?.rowCount >= 3, true, 'platform audit export should include tenant audit rows');
|
||
assert.ok(auditExport.item?.sha256, 'platform audit export should include content hash');
|
||
assert.ok(Buffer.from(auditExport.item?.contentBase64 || '', 'base64').toString('utf8').includes('platform.tenant.created'), 'platform audit export should contain audit actions');
|
||
|
||
const invalidAuditExportFormat = await request('/api/platform-admin/audit-logs/export', {
|
||
tenantId: false,
|
||
userId: false,
|
||
headers: adminHeaders,
|
||
query: { tenantId, format: 'xlsx' },
|
||
expectStatus: 400,
|
||
});
|
||
assert.equal(invalidAuditExportFormat.code, 'INVALID_EXPORT_FORMAT', 'platform audit export should reject invalid formats');
|
||
|
||
const alertRules = await request('/api/platform-admin/audit-alert-rules', {
|
||
tenantId: false,
|
||
userId: false,
|
||
headers: adminHeaders,
|
||
query: { enabled: true },
|
||
});
|
||
const statusAlertRule = alertRules.items?.find(item => item.code === 'platform_tenant_status_changed');
|
||
assert.ok(statusAlertRule?.id, 'platform audit alert rules should include default tenant status rule');
|
||
assert.ok(
|
||
alertRules.items?.some(item => item.code === 'platform_invoice_payment_confirmed'),
|
||
'platform audit alert rules should include manual payment confirmation rule',
|
||
);
|
||
|
||
const alertPool = new pg.Pool({ connectionString: process.env.DATABASE_URL || DEFAULT_DATABASE_URL });
|
||
let platformAlertId = '';
|
||
try {
|
||
const insertedAlert = await alertPool.query(
|
||
`
|
||
insert into public.platform_audit_alerts (
|
||
rule_id, audit_log_id, tenant_id, severity, status,
|
||
action, target_type, target_id, title, summary, details,
|
||
first_seen_at, last_seen_at
|
||
)
|
||
values (
|
||
$1, $2, $3::uuid, 'high', 'open',
|
||
'platform.tenant.status_updated', 'tenant', $4,
|
||
'租户状态变更告警', 'integration platform audit alert',
|
||
'{"source":"api-integration-test","accessToken":"must-not-leak","nested":{"password":"must-not-leak"}}'::jsonb,
|
||
now(), now()
|
||
)
|
||
on conflict (rule_id, audit_log_id) do update
|
||
set status = 'open',
|
||
updated_at = now()
|
||
returning id
|
||
`,
|
||
[statusAlertRule.id, statusAuditLog.id, tenantId, tenantId],
|
||
);
|
||
platformAlertId = insertedAlert.rows[0].id;
|
||
} finally {
|
||
await alertPool.end();
|
||
}
|
||
|
||
const auditAlerts = await request('/api/platform-admin/audit-alerts', {
|
||
tenantId: false,
|
||
userId: false,
|
||
headers: adminHeaders,
|
||
query: { tenantId, status: 'open', limit: 20 },
|
||
});
|
||
assert.ok(
|
||
auditAlerts.items?.some(item => item.id === platformAlertId && item.ruleCode === 'platform_tenant_status_changed'),
|
||
'platform admin should list platform audit alerts',
|
||
);
|
||
const listedAlert = auditAlerts.items?.find(item => item.id === platformAlertId);
|
||
assert.equal(listedAlert?.details?.accessToken, '[REDACTED]', 'platform audit alert list should redact token-like details');
|
||
assert.equal(listedAlert?.details?.nested?.password, '[REDACTED]', 'platform audit alert list should redact nested password-like details');
|
||
assert.ok(!JSON.stringify(listedAlert).includes('must-not-leak'), 'platform audit alert list must not leak sensitive details');
|
||
|
||
const updatedAuditAlert = await request('/api/platform-admin/audit-alerts/status', {
|
||
tenantId: false,
|
||
userId: false,
|
||
headers: adminHeaders,
|
||
method: 'POST',
|
||
body: {
|
||
alertId: platformAlertId,
|
||
status: 'resolved',
|
||
resolutionNote: 'integration resolved',
|
||
},
|
||
});
|
||
assert.equal(updatedAuditAlert.item?.status, 'resolved', 'platform admin should resolve audit alerts');
|
||
assert.equal(updatedAuditAlert.item?.resolutionNote, 'integration resolved', 'audit alert resolution note should persist');
|
||
assert.equal(updatedAuditAlert.item?.details?.accessToken, '[REDACTED]', 'audit alert status response should redact token-like details');
|
||
assert.ok(!JSON.stringify(updatedAuditAlert).includes('must-not-leak'), 'audit alert status response must not leak sensitive details');
|
||
|
||
const invalidAuditAlertStatus = await request('/api/platform-admin/audit-alerts/status', {
|
||
tenantId: false,
|
||
userId: false,
|
||
headers: adminHeaders,
|
||
method: 'POST',
|
||
body: {
|
||
alertId: platformAlertId,
|
||
status: 'open',
|
||
},
|
||
expectStatus: 400,
|
||
});
|
||
assert.equal(invalidAuditAlertStatus.code, 'INVALID_ALERT_STATUS', 'audit alerts should not be reopened through status endpoint');
|
||
|
||
const notificationChannel = await request('/api/platform-admin/audit-notification-channels', {
|
||
tenantId: false,
|
||
userId: false,
|
||
headers: adminHeaders,
|
||
method: 'PUT',
|
||
body: {
|
||
channelCode: 'integration_platform_audit',
|
||
name: '集成测试平台审计通知',
|
||
provider: 'generic',
|
||
webhookUrl: 'https://ops.example.test/platform-audit',
|
||
secret: 'integration-platform-audit-notification-secret',
|
||
minSeverity: 'medium',
|
||
statusFilter: ['open'],
|
||
actionPatterns: ['platform.tenant.*'],
|
||
tenantIds: [tenantId],
|
||
timeoutSec: 5,
|
||
metadata: { owner: 'security' },
|
||
},
|
||
});
|
||
assert.equal(notificationChannel.item?.channelCode, 'integration_platform_audit', 'platform admin should upsert audit notification channel');
|
||
assert.equal(notificationChannel.item?.secretRef, 'app_private.platform_secrets:webhook:integration_platform_audit', 'channel should expose only platform secretRef');
|
||
assert.equal(notificationChannel.item?.webhook?.host, 'ops.example.test', 'channel response should expose safe webhook host');
|
||
assert.equal(notificationChannel.item?.webhookUrl, undefined, 'channel response must not expose raw webhook URL');
|
||
assert.ok(!JSON.stringify(notificationChannel).includes('integration-platform-audit-notification-secret'), 'channel response must not leak webhook secret');
|
||
|
||
const notificationChannels = await request('/api/platform-admin/audit-notification-channels', {
|
||
tenantId: false,
|
||
userId: false,
|
||
headers: adminHeaders,
|
||
query: { enabled: true, provider: 'generic', limit: 20 },
|
||
});
|
||
assert.ok(
|
||
notificationChannels.items?.some(item => item.channelCode === 'integration_platform_audit'),
|
||
'platform admin should list audit notification channels',
|
||
);
|
||
assert.ok(!JSON.stringify(notificationChannels).includes('integration-platform-audit-notification-secret'), 'channel list must not leak webhook secret');
|
||
|
||
const invalidNotificationChannel = await request('/api/platform-admin/audit-notification-channels', {
|
||
tenantId: false,
|
||
userId: false,
|
||
headers: adminHeaders,
|
||
method: 'PUT',
|
||
body: {
|
||
channelCode: 'bad_channel',
|
||
name: 'bad channel',
|
||
provider: 'generic',
|
||
webhookUrl: 'ftp://ops.example.test/hook',
|
||
},
|
||
expectStatus: 400,
|
||
});
|
||
assert.equal(invalidNotificationChannel.code, 'INVALID_WEBHOOK_URL', 'audit notification channel should reject unsafe webhook URL');
|
||
|
||
const eventPool = new pg.Pool({ connectionString: process.env.DATABASE_URL || DEFAULT_DATABASE_URL });
|
||
let platformNotificationEventId = '';
|
||
try {
|
||
const channelRow = await eventPool.query(
|
||
"select id from public.platform_audit_notification_channels where channel_code = 'integration_platform_audit' limit 1",
|
||
);
|
||
const insertedEvent = await eventPool.query(
|
||
`
|
||
insert into public.platform_audit_notification_events (
|
||
channel_id, alert_id, audit_log_id, provider, status, attempts,
|
||
last_http_code, request_payload, last_response_summary
|
||
)
|
||
values (
|
||
$1, $2, $3, 'generic', 'sent', 1,
|
||
200,
|
||
'{"body":{"token":"must-not-leak","nested":{"password":"must-not-leak"}}}'::jsonb,
|
||
'{"ok":true}'
|
||
)
|
||
on conflict (channel_id, alert_id)
|
||
do update set status = excluded.status,
|
||
attempts = excluded.attempts,
|
||
request_payload = excluded.request_payload,
|
||
updated_at = now()
|
||
returning id
|
||
`,
|
||
[channelRow.rows[0].id, platformAlertId, statusAuditLog.id],
|
||
);
|
||
platformNotificationEventId = insertedEvent.rows[0].id;
|
||
} finally {
|
||
await eventPool.end();
|
||
}
|
||
|
||
const notificationEvents = await request('/api/platform-admin/audit-notification-events', {
|
||
tenantId: false,
|
||
userId: false,
|
||
headers: adminHeaders,
|
||
query: { alertId: platformAlertId, status: 'sent', limit: 20 },
|
||
});
|
||
assert.ok(
|
||
notificationEvents.items?.some(item => item.id === platformNotificationEventId),
|
||
'platform admin should list audit notification events',
|
||
);
|
||
const listedNotificationEvent = notificationEvents.items?.find(item => item.id === platformNotificationEventId);
|
||
assert.equal(listedNotificationEvent?.requestPayload?.body?.token, '[REDACTED]', 'notification event list should redact token-like payload details');
|
||
assert.equal(listedNotificationEvent?.requestPayload?.body?.nested?.password, '[REDACTED]', 'notification event list should redact nested password-like payload details');
|
||
assert.ok(!JSON.stringify(notificationEvents).includes('must-not-leak'), 'notification event list must not leak sensitive details');
|
||
|
||
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 usageOveragePeriodStart = '2026-08-01';
|
||
const usageOveragePeriodEnd = '2026-08-31';
|
||
const usageOveragePool = new pg.Pool({ connectionString: process.env.DATABASE_URL || DEFAULT_DATABASE_URL });
|
||
try {
|
||
await usageOveragePool.query(
|
||
`
|
||
update public.tenant_subscriptions
|
||
set metadata = metadata || $2::jsonb,
|
||
updated_at = now()
|
||
where id = $1
|
||
`,
|
||
[
|
||
ids.partnerSubscription,
|
||
JSON.stringify({
|
||
includedQuotas: { students: 100, questions: 500, storage_gb: 2 },
|
||
overagePrices: {
|
||
students: { unitAmountCents: 200, unitSize: 1 },
|
||
questions: { unitAmountCents: 50, unitSize: 10 },
|
||
storage_gb: { unitAmountCents: 12000, unitSize: 1 },
|
||
},
|
||
}),
|
||
],
|
||
);
|
||
await usageOveragePool.query(
|
||
`
|
||
delete from public.tenant_usage_records
|
||
where tenant_id = $1
|
||
and period_start = $2::date
|
||
and period_end = $3::date
|
||
`,
|
||
[PARTNER_TENANT_ID, usageOveragePeriodStart, usageOveragePeriodEnd],
|
||
);
|
||
await usageOveragePool.query(
|
||
`
|
||
insert into public.tenant_usage_records (tenant_id, metric_key, metric_value, period_start, period_end, metadata)
|
||
values
|
||
($1, 'students', 120, $2::date, $3::date, '{"source":"platform_usage_worker","workerId":"api-integration-usage-overage"}'::jsonb),
|
||
($1, 'questions', 860, $2::date, $3::date, '{"source":"platform_usage_worker","workerId":"api-integration-usage-overage"}'::jsonb),
|
||
($1, 'storage_gb', 3.5, $2::date, $3::date, '{"source":"platform_usage_worker","workerId":"api-integration-usage-overage"}'::jsonb)
|
||
`,
|
||
[PARTNER_TENANT_ID, usageOveragePeriodStart, usageOveragePeriodEnd],
|
||
);
|
||
await usageOveragePool.query(
|
||
`
|
||
delete from public.tenant_invoices
|
||
where tenant_id = $1
|
||
and invoice_type = 'usage_overage'
|
||
and billing_period_start = $2::date
|
||
and billing_period_end = $3::date
|
||
`,
|
||
[PARTNER_TENANT_ID, usageOveragePeriodStart, usageOveragePeriodEnd],
|
||
);
|
||
} finally {
|
||
await usageOveragePool.end();
|
||
}
|
||
|
||
const usageOverageCandidates = await request('/api/platform-admin/invoices/usage-overage-candidates', {
|
||
tenantId: false,
|
||
userId: false,
|
||
headers: adminHeaders,
|
||
query: {
|
||
tenantIds: PARTNER_TENANT_ID,
|
||
periodStart: usageOveragePeriodStart,
|
||
periodEnd: usageOveragePeriodEnd,
|
||
limit: 20,
|
||
},
|
||
});
|
||
const usageOverageCandidate = usageOverageCandidates.items?.find(item => item.tenantId === PARTNER_TENANT_ID);
|
||
assert.ok(usageOverageCandidate, 'platform admin should preview usage-overage invoice candidates');
|
||
assert.equal(usageOverageCandidate.hasExistingInvoice, false, 'usage-overage candidate should not have an existing invoice before generation');
|
||
assert.ok(usageOverageCandidate.totalCents > 0, 'usage-overage candidate should expose billable amount');
|
||
assert.ok(
|
||
usageOverageCandidate.items?.some(item => item.metadata?.metricKey === 'students'),
|
||
'usage-overage candidate should include student overage item',
|
||
);
|
||
assert.ok(
|
||
usageOverageCandidate.items?.some(item => item.metadata?.metricKey === 'storage_gb'),
|
||
'usage-overage candidate should include storage overage item',
|
||
);
|
||
|
||
const invalidUsageOverageDate = await request('/api/platform-admin/invoices/usage-overage-candidates', {
|
||
tenantId: false,
|
||
userId: false,
|
||
headers: adminHeaders,
|
||
query: {
|
||
tenantIds: PARTNER_TENANT_ID,
|
||
periodStart: '2026-06-31',
|
||
periodEnd: '2026-06-30',
|
||
},
|
||
expectStatus: 400,
|
||
});
|
||
assert.equal(invalidUsageOverageDate.code, 'INVALID_DATE', 'usage-overage candidate query should reject invalid dates');
|
||
|
||
const usageOverageDryRun = await request('/api/platform-admin/invoices/from-usage-overage', {
|
||
tenantId: false,
|
||
userId: false,
|
||
headers: adminHeaders,
|
||
method: 'POST',
|
||
body: {
|
||
tenantIds: [PARTNER_TENANT_ID],
|
||
periodStart: usageOveragePeriodStart,
|
||
periodEnd: usageOveragePeriodEnd,
|
||
dueDate: '2026-09-10',
|
||
note: 'integration usage overage dry run',
|
||
dryRun: true,
|
||
},
|
||
});
|
||
assert.equal(usageOverageDryRun.item?.dryRun, true, 'usage-overage dry-run should not create invoices');
|
||
assert.equal(usageOverageDryRun.item?.createdCount, 0, 'usage-overage dry-run should not create records');
|
||
assert.ok(usageOverageDryRun.item?.totalCents > 0, 'usage-overage dry-run should expose total cents');
|
||
|
||
const usageOverageCreated = await request('/api/platform-admin/invoices/from-usage-overage', {
|
||
tenantId: false,
|
||
userId: false,
|
||
headers: adminHeaders,
|
||
method: 'POST',
|
||
body: {
|
||
tenantIds: [PARTNER_TENANT_ID],
|
||
periodStart: usageOveragePeriodStart,
|
||
periodEnd: usageOveragePeriodEnd,
|
||
dueDate: '2026-09-10',
|
||
note: 'integration usage overage invoice',
|
||
status: 'issued',
|
||
},
|
||
});
|
||
assert.equal(usageOverageCreated.item?.createdCount, 1, 'usage-overage invoice generation should create one invoice');
|
||
assert.equal(usageOverageCreated.item?.skippedCount, 0, 'first usage-overage invoice generation should not skip');
|
||
const usageOverageInvoice = usageOverageCreated.item?.items?.[0]?.invoice;
|
||
assert.equal(usageOverageInvoice?.invoiceType, 'usage_overage', 'usage-overage invoice should use usage_overage type');
|
||
assert.ok(usageOverageInvoice?.invoiceNo, 'usage-overage invoice should expose invoice number');
|
||
|
||
const usageOverageAfterCreate = await request('/api/platform-admin/invoices/usage-overage-candidates', {
|
||
tenantId: false,
|
||
userId: false,
|
||
headers: adminHeaders,
|
||
query: {
|
||
tenantIds: PARTNER_TENANT_ID,
|
||
periodStart: usageOveragePeriodStart,
|
||
periodEnd: usageOveragePeriodEnd,
|
||
includeExisting: 'true',
|
||
limit: 20,
|
||
},
|
||
});
|
||
const existingOverageCandidate = usageOverageAfterCreate.items?.find(item => item.tenantId === PARTNER_TENANT_ID);
|
||
assert.equal(existingOverageCandidate?.hasExistingInvoice, true, 'usage-overage candidate should expose existing invoice after generation');
|
||
assert.equal(existingOverageCandidate?.existingInvoiceNo, usageOverageInvoice?.invoiceNo, 'usage-overage candidate should identify the generated invoice');
|
||
|
||
const duplicateUsageOverage = await request('/api/platform-admin/invoices/from-usage-overage', {
|
||
tenantId: false,
|
||
userId: false,
|
||
headers: adminHeaders,
|
||
method: 'POST',
|
||
body: {
|
||
tenantIds: [PARTNER_TENANT_ID],
|
||
periodStart: usageOveragePeriodStart,
|
||
periodEnd: usageOveragePeriodEnd,
|
||
},
|
||
});
|
||
assert.equal(duplicateUsageOverage.item?.createdCount, 0, 'duplicate usage-overage run should not create another invoice');
|
||
|
||
const usageOverageAudit = await request('/api/platform-admin/audit-logs', {
|
||
tenantId: false,
|
||
userId: false,
|
||
headers: adminHeaders,
|
||
query: { q: 'platform.invoice.usage_overage', limit: 20 },
|
||
});
|
||
assert.ok(
|
||
usageOverageAudit.items?.some(item => item.action === 'platform.invoice.usage_overage_created'),
|
||
'usage-overage invoice generation should write invoice audit',
|
||
);
|
||
assert.ok(
|
||
usageOverageAudit.items?.some(item => item.action === 'platform.invoice.usage_overage_batch_created'),
|
||
'usage-overage invoice generation should write batch audit',
|
||
);
|
||
|
||
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL || DEFAULT_DATABASE_URL });
|
||
try {
|
||
await pool.query('delete from public.tenant_invoice_reminders where tenant_id = $1 and invoice_id = $2', [tenantId, ids.platformOverdueInvoice]);
|
||
await pool.query('delete from public.tenant_invoice_items where tenant_id = $1 and invoice_id = $2', [tenantId, ids.platformOverdueInvoice]);
|
||
await pool.query('delete from public.tenant_invoices where tenant_id = $1 and id = $2', [tenantId, ids.platformOverdueInvoice]);
|
||
await pool.query(
|
||
`
|
||
insert into public.tenant_invoices (
|
||
id, tenant_id, invoice_no, invoice_type, status, currency,
|
||
subtotal_cents, total_cents, paid_cents, balance_cents,
|
||
due_date, issued_at, note, metadata
|
||
)
|
||
values (
|
||
$1, $2, $3, 'service_fee', 'issued', 'CNY',
|
||
120000, 120000, 0, 120000,
|
||
current_date - interval '2 days', now(), 'integration overdue invoice',
|
||
'{"source":"api-integration-overdue"}'::jsonb
|
||
)
|
||
`,
|
||
[ids.platformOverdueInvoice, tenantId, `OD${Date.now()}`],
|
||
);
|
||
} finally {
|
||
await pool.end();
|
||
}
|
||
|
||
const overdueDryRun = await request('/api/platform-admin/invoices/process-overdue', {
|
||
tenantId: false,
|
||
userId: false,
|
||
headers: adminHeaders,
|
||
method: 'POST',
|
||
body: {
|
||
dryRun: true,
|
||
limit: 20,
|
||
},
|
||
});
|
||
assert.equal(overdueDryRun.item?.dryRun, true, 'overdue processing dry-run should be supported');
|
||
assert.ok(overdueDryRun.item?.items?.some(item => item.id === ids.platformOverdueInvoice && item.wouldCreateReminder), 'dry-run should preview overdue reminder creation');
|
||
|
||
const overdueProcessed = await request('/api/platform-admin/invoices/process-overdue', {
|
||
tenantId: false,
|
||
userId: false,
|
||
headers: adminHeaders,
|
||
method: 'POST',
|
||
body: {
|
||
channel: 'internal',
|
||
limit: 20,
|
||
},
|
||
});
|
||
assert.equal(overdueProcessed.item?.markedOverdue >= 1, true, 'overdue processing should mark issued overdue invoices');
|
||
assert.equal(overdueProcessed.item?.reminderCreated >= 1, true, 'overdue processing should create reminder records');
|
||
|
||
const reminders = await request('/api/platform-admin/invoices/reminders', {
|
||
tenantId: false,
|
||
userId: false,
|
||
headers: adminHeaders,
|
||
query: { tenantId, invoiceId: ids.platformOverdueInvoice, limit: 10 },
|
||
});
|
||
assert.ok(reminders.items?.some(item => item.invoiceId === ids.platformOverdueInvoice && item.reminderType === 'overdue'), 'platform admin should list invoice reminders');
|
||
const platformReminder = reminders.items?.find(item => item.invoiceId === ids.platformOverdueInvoice && item.reminderType === 'overdue');
|
||
assert.ok(platformReminder?.id, 'platform overdue reminder should expose reminder id');
|
||
|
||
const dunningChannel = await request('/api/platform-admin/dunning-notification-channels', {
|
||
tenantId: false,
|
||
userId: false,
|
||
headers: adminHeaders,
|
||
method: 'PUT',
|
||
body: {
|
||
channelCode: 'integration_platform_dunning',
|
||
name: '集成测试平台催缴通知',
|
||
provider: 'generic',
|
||
webhookUrl: 'https://ops.example.test/platform-dunning',
|
||
secret: 'integration-platform-dunning-notification-secret',
|
||
reminderTypes: ['overdue'],
|
||
reminderChannels: ['internal'],
|
||
minReminderLevel: 1,
|
||
tenantIds: [tenantId],
|
||
timeoutSec: 5,
|
||
metadata: { owner: 'finance' },
|
||
},
|
||
});
|
||
assert.equal(dunningChannel.item?.channelCode, 'integration_platform_dunning', 'platform admin should upsert dunning notification channel');
|
||
assert.equal(dunningChannel.item?.secretRef, 'app_private.platform_secrets:webhook:platform_dunning_integration_platform_dunning', 'dunning channel should expose only platform secretRef');
|
||
assert.equal(dunningChannel.item?.webhook?.host, 'ops.example.test', 'dunning channel response should expose safe webhook host');
|
||
assert.equal(dunningChannel.item?.webhookUrl, undefined, 'dunning channel response must not expose raw webhook URL');
|
||
assert.ok(!JSON.stringify(dunningChannel).includes('integration-platform-dunning-notification-secret'), 'dunning channel response must not leak webhook secret');
|
||
|
||
const dunningChannels = await request('/api/platform-admin/dunning-notification-channels', {
|
||
tenantId: false,
|
||
userId: false,
|
||
headers: adminHeaders,
|
||
query: { enabled: true, provider: 'generic', limit: 20 },
|
||
});
|
||
assert.ok(
|
||
dunningChannels.items?.some(item => item.channelCode === 'integration_platform_dunning'),
|
||
'platform admin should list dunning notification channels',
|
||
);
|
||
assert.ok(!JSON.stringify(dunningChannels).includes('integration-platform-dunning-notification-secret'), 'dunning channel list must not leak webhook secret');
|
||
|
||
const invalidDunningChannel = await request('/api/platform-admin/dunning-notification-channels', {
|
||
tenantId: false,
|
||
userId: false,
|
||
headers: adminHeaders,
|
||
method: 'PUT',
|
||
body: {
|
||
channelCode: 'bad_dunning_channel',
|
||
name: 'bad dunning channel',
|
||
provider: 'generic',
|
||
webhookUrl: 'ftp://ops.example.test/hook',
|
||
},
|
||
expectStatus: 400,
|
||
});
|
||
assert.equal(invalidDunningChannel.code, 'INVALID_WEBHOOK_URL', 'dunning notification channel should reject unsafe webhook URL');
|
||
|
||
const dunningEventPool = new pg.Pool({ connectionString: process.env.DATABASE_URL || DEFAULT_DATABASE_URL });
|
||
let platformDunningNotificationEventId = '';
|
||
try {
|
||
const channelRow = await dunningEventPool.query(
|
||
"select id from public.platform_dunning_notification_channels where channel_code = 'integration_platform_dunning' limit 1",
|
||
);
|
||
const insertedEvent = await dunningEventPool.query(
|
||
`
|
||
insert into public.platform_dunning_notification_events (
|
||
channel_id, reminder_id, invoice_id, tenant_id, provider, status, attempts,
|
||
last_http_code, request_payload, last_response_summary
|
||
)
|
||
values (
|
||
$1, $2, $3, $4, 'generic', 'sent', 1,
|
||
200,
|
||
'{"body":{"token":"must-not-leak","nested":{"password":"must-not-leak"}}}'::jsonb,
|
||
'{"ok":true}'
|
||
)
|
||
on conflict (channel_id, reminder_id)
|
||
do update set status = excluded.status,
|
||
attempts = excluded.attempts,
|
||
request_payload = excluded.request_payload,
|
||
updated_at = now()
|
||
returning id
|
||
`,
|
||
[channelRow.rows[0].id, platformReminder.id, ids.platformOverdueInvoice, tenantId],
|
||
);
|
||
platformDunningNotificationEventId = insertedEvent.rows[0].id;
|
||
} finally {
|
||
await dunningEventPool.end();
|
||
}
|
||
|
||
const dunningEvents = await request('/api/platform-admin/dunning-notification-events', {
|
||
tenantId: false,
|
||
userId: false,
|
||
headers: adminHeaders,
|
||
query: { reminderId: platformReminder.id, status: 'sent', limit: 20 },
|
||
});
|
||
assert.ok(
|
||
dunningEvents.items?.some(item => item.id === platformDunningNotificationEventId),
|
||
'platform admin should list dunning notification events',
|
||
);
|
||
const listedDunningEvent = dunningEvents.items?.find(item => item.id === platformDunningNotificationEventId);
|
||
assert.equal(listedDunningEvent?.requestPayload?.body?.token, '[REDACTED]', 'dunning event list should redact token-like payload details');
|
||
assert.equal(listedDunningEvent?.requestPayload?.body?.nested?.password, '[REDACTED]', 'dunning event list should redact nested password-like payload details');
|
||
assert.ok(!JSON.stringify(dunningEvents).includes('must-not-leak'), 'dunning event list must not leak sensitive details');
|
||
|
||
const overdueAudit = await request('/api/platform-admin/audit-logs', {
|
||
tenantId: false,
|
||
userId: false,
|
||
headers: adminHeaders,
|
||
query: { tenantId, q: 'platform.invoice.overdue', limit: 20 },
|
||
});
|
||
assert.ok(overdueAudit.items?.some(item => item.action === 'platform.invoice.overdue_processed'), 'overdue processing should write invoice audit');
|
||
|
||
const invalidOverdueChannel = await request('/api/platform-admin/invoices/process-overdue', {
|
||
tenantId: false,
|
||
userId: false,
|
||
headers: adminHeaders,
|
||
method: 'POST',
|
||
body: { channel: 'unsafe-channel' },
|
||
expectStatus: 400,
|
||
});
|
||
assert.equal(invalidOverdueChannel.code, 'INVALID_REMINDER_CHANNEL', 'overdue processing should reject invalid reminder channels');
|
||
|
||
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');
|
||
|
||
const studentAuditExportDenied = await request('/api/platform-admin/audit-logs/export', {
|
||
tenantId: false,
|
||
userId: USER_ID,
|
||
expectStatus: 403,
|
||
});
|
||
assert.equal(studentAuditExportDenied.code, 'PLATFORM_ADMIN_REQUIRED', 'student must not export platform audit logs');
|
||
|
||
const studentAuditAlertDenied = await request('/api/platform-admin/audit-alerts', {
|
||
tenantId: false,
|
||
userId: USER_ID,
|
||
expectStatus: 403,
|
||
});
|
||
assert.equal(studentAuditAlertDenied.code, 'PLATFORM_ADMIN_REQUIRED', 'student must not read platform audit alerts');
|
||
|
||
const studentAuditAlertStatusDenied = await request('/api/platform-admin/audit-alerts/status', {
|
||
tenantId: false,
|
||
userId: USER_ID,
|
||
method: 'POST',
|
||
body: {
|
||
alertId: platformAlertId,
|
||
status: 'acknowledged',
|
||
},
|
||
expectStatus: 403,
|
||
});
|
||
assert.equal(studentAuditAlertStatusDenied.code, 'PLATFORM_ADMIN_REQUIRED', 'student must not update platform audit alerts');
|
||
|
||
const studentAuditNotificationChannelDenied = await request('/api/platform-admin/audit-notification-channels', {
|
||
tenantId: false,
|
||
userId: USER_ID,
|
||
expectStatus: 403,
|
||
});
|
||
assert.equal(studentAuditNotificationChannelDenied.code, 'PLATFORM_ADMIN_REQUIRED', 'student must not read platform audit notification channels');
|
||
|
||
const studentAuditNotificationEventDenied = await request('/api/platform-admin/audit-notification-events', {
|
||
tenantId: false,
|
||
userId: USER_ID,
|
||
expectStatus: 403,
|
||
});
|
||
assert.equal(studentAuditNotificationEventDenied.code, 'PLATFORM_ADMIN_REQUIRED', 'student must not read platform audit notification events');
|
||
|
||
const studentDunningNotificationChannelDenied = await request('/api/platform-admin/dunning-notification-channels', {
|
||
tenantId: false,
|
||
userId: USER_ID,
|
||
expectStatus: 403,
|
||
});
|
||
assert.equal(studentDunningNotificationChannelDenied.code, 'PLATFORM_ADMIN_REQUIRED', 'student must not read platform dunning notification channels');
|
||
|
||
const studentDunningNotificationEventDenied = await request('/api/platform-admin/dunning-notification-events', {
|
||
tenantId: false,
|
||
userId: USER_ID,
|
||
expectStatus: 403,
|
||
});
|
||
assert.equal(studentDunningNotificationEventDenied.code, 'PLATFORM_ADMIN_REQUIRED', 'student must not read platform dunning notification events');
|
||
|
||
const studentReminderDenied = await request('/api/platform-admin/invoices/reminders', {
|
||
tenantId: false,
|
||
userId: USER_ID,
|
||
expectStatus: 403,
|
||
});
|
||
assert.equal(studentReminderDenied.code, 'PLATFORM_ADMIN_REQUIRED', 'student must not read platform invoice reminders');
|
||
}
|
||
|
||
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 practiceBadge = await request('/api/tenant-admin/badges', {
|
||
userId: TENANT_ADMIN_USER_ID,
|
||
method: 'PUT',
|
||
body: {
|
||
id: ids.tenantPracticeBadge,
|
||
legacyId: 'integration-badge-practice-count-auto',
|
||
name: '集成测试练习完成勋章',
|
||
description: '完成一次练习报告后自动发放',
|
||
category: 'practice',
|
||
iconUrl: 'https://example.test/badges/practice-count-auto.png',
|
||
level: 1,
|
||
unlockType: 'practice_count',
|
||
conditionField: 'practiceCount',
|
||
conditionOperator: 'gte',
|
||
conditionValue: 1,
|
||
metadata: { source: 'integration-test', trigger: 'practice_count' },
|
||
order: 3,
|
||
isActive: true,
|
||
},
|
||
});
|
||
assert.equal(practiceBadge.item?.id, ids.tenantPracticeBadge, 'tenant admin should create automatic practice count badge');
|
||
|
||
const mockExamBadge = await request('/api/tenant-admin/badges', {
|
||
userId: TENANT_ADMIN_USER_ID,
|
||
method: 'PUT',
|
||
body: {
|
||
id: ids.tenantMockExamBadge,
|
||
legacyId: 'integration-badge-mock-exam-score-auto',
|
||
name: '集成测试模考成绩勋章',
|
||
description: '模考分数达到规则后自动发放',
|
||
category: 'mock_exam',
|
||
iconUrl: 'https://example.test/badges/mock-exam-score-auto.png',
|
||
level: 1,
|
||
unlockType: 'mock_exam_score',
|
||
conditionField: 'mockExamScore',
|
||
conditionOperator: 'gte',
|
||
conditionValue: 4,
|
||
metadata: { source: 'integration-test', trigger: 'mock_exam_score' },
|
||
order: 4,
|
||
isActive: true,
|
||
},
|
||
});
|
||
assert.equal(mockExamBadge.item?.id, ids.tenantMockExamBadge, 'tenant admin should create automatic mock exam score badge');
|
||
|
||
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',
|
||
);
|
||
assert.ok(
|
||
mockReport.item?.autoBadges?.some(item => item.badgeId === ids.tenantPracticeBadge),
|
||
'mock report submit should auto grant practice count badge',
|
||
);
|
||
assert.ok(
|
||
mockReport.item?.autoBadges?.some(item => item.badgeId === ids.tenantMockExamBadge),
|
||
'mock report submit should auto grant mock exam score badge',
|
||
);
|
||
|
||
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');
|
||
assert.equal(
|
||
idempotentReport.item?.autoBadges?.filter(item => item.badgeId === ids.tenantMockExamBadge).length || 0,
|
||
0,
|
||
'idempotent mock submit should not duplicate automatic mock exam badges',
|
||
);
|
||
|
||
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');
|
||
assert.equal(payload.item?.avatarPreset, 'male', 'profile should default to the male preset avatar');
|
||
assert.equal(payload.item?.avatar?.preset, 'male', 'profile avatar object should expose preset');
|
||
assert.ok(payload.item?.avatar?.displayUrl?.includes('/assets/avatars/default-male.svg'), 'profile should expose the default male avatar asset path');
|
||
|
||
const directAvatarRejected = await request('/api/profile/me', {
|
||
method: 'PATCH',
|
||
body: { avatarUrl: 'https://example.test/not-allowed-avatar.png' },
|
||
expectStatus: 400,
|
||
});
|
||
assert.equal(
|
||
directAvatarRejected.code,
|
||
'PROFILE_AVATAR_URL_DIRECT_UPDATE_REJECTED',
|
||
'students must not set arbitrary avatar URLs directly',
|
||
);
|
||
|
||
const invalidAvatarPatch = await request('/api/profile/me', {
|
||
method: 'PATCH',
|
||
body: { avatarPreset: 'custom' },
|
||
expectStatus: 400,
|
||
});
|
||
assert.equal(invalidAvatarPatch.code, 'INVALID_AVATAR_PRESET', 'profile must reject unknown avatar presets');
|
||
|
||
const avatarPresetUpdated = await request('/api/profile/me', {
|
||
method: 'PATCH',
|
||
body: { avatarPreset: 'female' },
|
||
});
|
||
assert.equal(avatarPresetUpdated.item?.avatarPreset, 'female', 'student should update preset avatar choice');
|
||
const profileAfterAvatarPreset = await request('/api/profile/me');
|
||
assert.equal(profileAfterAvatarPreset.item?.avatar?.preset, 'female', 'profile should expose updated preset avatar');
|
||
assert.ok(
|
||
profileAfterAvatarPreset.item?.avatar?.displayUrl?.includes('/assets/avatars/default-female.svg'),
|
||
'profile should expose the default female avatar asset path',
|
||
);
|
||
|
||
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 dailyCheckInTask = await request('/api/tenant-admin/point-activity-tasks', {
|
||
userId: TENANT_ADMIN_USER_ID,
|
||
method: 'PUT',
|
||
body: {
|
||
id: ids.dailyCheckInTask,
|
||
legacyId: 'integration-point-task-daily-check-in',
|
||
code: 'integration-daily-check-in',
|
||
title: '每日签到基础奖励',
|
||
taskType: 'daily_check_in',
|
||
rewardPoints: 12,
|
||
periodType: 'daily',
|
||
claimLimitPerUser: 1,
|
||
metadata: { source: 'integration-test' },
|
||
order: 1,
|
||
status: 'active',
|
||
},
|
||
});
|
||
assert.equal(dailyCheckInTask.item?.id, ids.dailyCheckInTask, 'tenant admin should configure daily check-in reward task');
|
||
|
||
const streakCheckInTask = await request('/api/tenant-admin/point-activity-tasks', {
|
||
userId: TENANT_ADMIN_USER_ID,
|
||
method: 'PUT',
|
||
body: {
|
||
id: ids.streakCheckInTask,
|
||
legacyId: 'integration-point-task-check-in-streak-day-one',
|
||
code: 'integration-check-in-streak-day-one',
|
||
title: '连续签到首日奖励',
|
||
taskType: 'daily_check_in',
|
||
rewardPoints: 5,
|
||
periodType: 'daily',
|
||
claimLimitPerUser: 1,
|
||
metadata: { source: 'integration-test', requiredStreak: 1 },
|
||
order: 2,
|
||
status: 'active',
|
||
},
|
||
});
|
||
assert.equal(streakCheckInTask.item?.id, ids.streakCheckInTask, 'tenant admin should configure streak check-in reward task');
|
||
|
||
const checkIn = await request('/api/profile/check-in', { method: 'POST' });
|
||
assert.equal(checkIn.item?.checkedIn, true, 'student should be able to check in');
|
||
assert.equal(checkIn.item?.rewardMode, 'configured_tasks', 'check-in should use configured tenant reward tasks');
|
||
assert.equal(checkIn.item?.pointsAdded, 17, 'configured check-in tasks should add total 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.equal(checkIn.item?.ledger?.sourceId, ids.streakCheckInTask, 'compat check-in ledger should expose the latest configured task ledger');
|
||
assert.ok(
|
||
checkIn.item?.rewardBreakdown?.some(item => item.taskId === ids.dailyCheckInTask && item.points === 12),
|
||
'check-in should include the daily task reward breakdown',
|
||
);
|
||
assert.ok(
|
||
checkIn.item?.rewardBreakdown?.some(item => item.taskId === ids.streakCheckInTask && item.points === 5),
|
||
'check-in should include the streak task reward breakdown',
|
||
);
|
||
assert.equal(checkIn.item?.ledgers?.length, 2, 'check-in should write one ledger per configured reward task');
|
||
assert.ok(
|
||
checkIn.item?.ledgers?.some(item => item.sourceId === ids.dailyCheckInTask && item.points === 12),
|
||
'check-in ledgers should include the daily task source',
|
||
);
|
||
assert.ok(
|
||
checkIn.item?.ledgers?.some(item => item.sourceId === ids.streakCheckInTask && item.points === 5),
|
||
'check-in ledgers should include the streak task source',
|
||
);
|
||
assert.equal(checkIn.item?.claims?.length, 2, 'check-in should write one claim per configured reward task');
|
||
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');
|
||
assert.deepEqual(duplicateCheckIn.item?.rewardBreakdown, [], 'duplicate check-in should not return reward tasks');
|
||
|
||
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');
|
||
assert.ok(
|
||
scoreEvents.items?.some(item => item.eventType === 'check_in' && item.sourceId === ids.dailyCheckInTask),
|
||
'score ledger should include daily check-in task source',
|
||
);
|
||
assert.ok(
|
||
scoreEvents.items?.some(item => item.eventType === 'check_in' && item.sourceId === ids.streakCheckInTask),
|
||
'score ledger should include streak check-in task source',
|
||
);
|
||
|
||
const dailyCheckInClaims = await request('/api/tenant-admin/point-activity-claims', {
|
||
userId: TENANT_ADMIN_USER_ID,
|
||
query: { taskId: ids.dailyCheckInTask, limit: 20 },
|
||
});
|
||
assert.equal(
|
||
dailyCheckInClaims.items?.filter(item => item.userId === USER_ID).length,
|
||
1,
|
||
'tenant admin should see one daily check-in claim for the student',
|
||
);
|
||
|
||
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() {
|
||
await setTenantFeatureFlag(MAIN_TENANT_ID, 'enableLeaderboard', false);
|
||
const disabled = await request('/api/learning/leaderboard', {
|
||
query: { metric: 'questions', period: 'all', limit: 10 },
|
||
expectStatus: 403,
|
||
});
|
||
assert.equal(disabled.code, 'LEADERBOARD_DISABLED', 'leaderboard should be disabled by default for tenants');
|
||
|
||
await setTenantFeatureFlag(MAIN_TENANT_ID, 'enableLeaderboard', true);
|
||
try {
|
||
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(Array.isArray(questions.items), 'question leaderboard should return list items');
|
||
const currentQuestions = questions.currentUser;
|
||
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 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 secondClassScoped = await request('/api/learning/leaderboard', {
|
||
query: { metric: 'questions', classId: ids.tenantClassOther, limit: 10 },
|
||
});
|
||
const secondQuestions = secondClassScoped.items?.find(item => item.userId === SECOND_STUDENT_USER_ID);
|
||
assert.ok(secondQuestions, 'second student class scoped leaderboard should include second student');
|
||
assert.ok(secondQuestions?.value >= 2, 'second student question leaderboard should include smoke-seeded answers');
|
||
|
||
const score = await request('/api/learning/leaderboard', {
|
||
query: { metric: 'score', period: 'all', classId: ids.tenantClassOther, 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');
|
||
|
||
const currentScore = await request('/api/learning/leaderboard', {
|
||
query: { metric: 'score', period: 'all', classId: ids.tenantClass, limit: 10 },
|
||
});
|
||
assert.ok(currentScore.currentUser?.value >= 10, 'score leaderboard should include current user score');
|
||
|
||
const vocabulary = await request('/api/learning/leaderboard', {
|
||
query: { metric: 'vocabulary', period: 'all', classId: ids.tenantClassOther, 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', classId: ids.tenantClassOther, 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');
|
||
|
||
const currentMockExam = await request('/api/learning/leaderboard', {
|
||
query: { metric: 'mock_exam', period: '30d', classId: ids.tenantClass, limit: 10 },
|
||
});
|
||
assert.ok(currentMockExam.currentUser?.value >= 70, 'mock exam leaderboard should include current user best score');
|
||
|
||
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');
|
||
|
||
} finally {
|
||
await setTenantFeatureFlag(MAIN_TENANT_ID, 'enableLeaderboard', false);
|
||
}
|
||
const disabledAfterRestore = await request('/api/learning/leaderboard', {
|
||
query: { metric: 'questions', period: 'all', limit: 10 },
|
||
expectStatus: 403,
|
||
});
|
||
assert.equal(disabledAfterRestore.code, 'LEADERBOARD_DISABLED', 'leaderboard feature flag should be restorable after tests');
|
||
}
|
||
|
||
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 filteredRecords = await request('/api/scoreline/records', {
|
||
query: { regionId: ids.region, 'field.minScore': 188, pageSize: 5 },
|
||
});
|
||
assert.ok(filteredRecords.total >= 1, 'scoreline dynamic exact field filter should return records');
|
||
assert.ok(filteredRecords.items?.every(item => item.fieldValues?.minScore === 188), 'scoreline exact field filter should only return matching records');
|
||
|
||
const rangeFilteredRecords = await request('/api/scoreline/records', {
|
||
query: { regionId: ids.region, 'min.minScore': 180, 'max.minScore': 190, pageSize: 5 },
|
||
});
|
||
assert.ok(rangeFilteredRecords.items?.some(item => item.fieldValues?.minScore === 188), 'scoreline dynamic range filter should return matching records');
|
||
|
||
const unknownField = await request('/api/scoreline/records', {
|
||
query: { regionId: ids.region, 'field.unknownScore': 188 },
|
||
expectStatus: 400,
|
||
});
|
||
assert.equal(unknownField.code, 'SCORELINE_FIELD_FILTER_NOT_ALLOWED', 'scoreline records should reject unknown dynamic field filters');
|
||
|
||
const unsafeField = await request('/api/scoreline/records', {
|
||
query: { regionId: ids.region, 'field.minScore);drop': 188 },
|
||
expectStatus: 400,
|
||
});
|
||
assert.equal(unsafeField.code, 'SCORELINE_FIELD_FILTER_KEY_INVALID', 'scoreline records should reject unsafe dynamic field filter keys');
|
||
|
||
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 markdownExport = await request('/api/ai/school-recommendations/export', {
|
||
query: { reportId: generated.item.id, format: 'markdown' },
|
||
});
|
||
assert.equal(markdownExport.item?.format, 'markdown', 'AI report export should support markdown');
|
||
assert.ok(markdownExport.item?.fileName?.endsWith('.md'), 'AI markdown export should return a markdown filename');
|
||
assert.ok(markdownExport.item?.sha256, 'AI report export should include content hash');
|
||
const markdownReport = Buffer.from(markdownExport.item?.contentBase64 || '', 'base64').toString('utf8');
|
||
assert.ok(markdownReport.includes('AI 择校推荐报告'), 'AI markdown export should include report title');
|
||
assert.ok(markdownReport.includes('烟测学院'), 'AI markdown export should include recommended school');
|
||
assert.ok(markdownReport.includes('免责声明'), 'AI markdown export should include disclaimers');
|
||
assert.ok(!markdownReport.includes('secret'), 'AI markdown export should not leak provider secrets');
|
||
|
||
const htmlExport = await request('/api/ai/school-recommendations/export', {
|
||
query: { reportId: generated.item.id, format: 'html' },
|
||
});
|
||
assert.equal(htmlExport.item?.format, 'html', 'AI report export should support html');
|
||
assert.ok(Buffer.from(htmlExport.item?.contentBase64 || '', 'base64').toString('utf8').includes('<!doctype html>'), 'AI html export should return HTML content');
|
||
|
||
const invalidExportFormat = await request('/api/ai/school-recommendations/export', {
|
||
query: { reportId: generated.item.id, format: 'pdf' },
|
||
expectStatus: 400,
|
||
});
|
||
assert.equal(invalidExportFormat.code, 'AI_REPORT_EXPORT_FORMAT_INVALID', 'AI report export should reject unsupported formats');
|
||
|
||
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 otherStudentExportDenied = await request('/api/ai/school-recommendations/export', {
|
||
userId: SECOND_STUDENT_USER_ID,
|
||
query: { reportId: generated.item.id, format: 'markdown' },
|
||
expectStatus: 404,
|
||
});
|
||
assert.equal(otherStudentExportDenied.code, 'AI_REPORT_NOT_FOUND', 'students must not export 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 vocabularyBadge = await request('/api/tenant-admin/badges', {
|
||
userId: TENANT_ADMIN_USER_ID,
|
||
method: 'PUT',
|
||
body: {
|
||
id: ids.tenantVocabularyBadge,
|
||
legacyId: 'integration-badge-vocabulary-mastered-auto',
|
||
name: '集成测试单词掌握勋章',
|
||
description: '掌握单词达到规则后自动发放',
|
||
category: 'vocabulary',
|
||
iconUrl: 'https://example.test/badges/vocabulary-mastered-auto.png',
|
||
level: 1,
|
||
unlockType: 'vocabulary_mastered',
|
||
conditionField: 'vocabularyMasteredCount',
|
||
conditionOperator: 'gte',
|
||
conditionValue: 1,
|
||
metadata: { source: 'integration-test', trigger: 'vocabulary_mastered' },
|
||
order: 5,
|
||
isActive: true,
|
||
},
|
||
});
|
||
assert.equal(vocabularyBadge.item?.id, ids.tenantVocabularyBadge, 'tenant admin should create automatic vocabulary badge');
|
||
|
||
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');
|
||
assert.ok(
|
||
progress.item?.autoBadges?.some(item => item.badgeId === ids.tenantVocabularyBadge),
|
||
'mastered word progress should auto grant vocabulary badge',
|
||
);
|
||
|
||
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');
|
||
assert.equal(
|
||
reviewKnown.item?.autoBadges?.filter(item => item.badgeId === ids.tenantVocabularyBadge).length || 0,
|
||
0,
|
||
'repeated mastered word review should not duplicate vocabulary badge',
|
||
);
|
||
|
||
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əˈmɜːrʃl/',
|
||
meaning: '商业的',
|
||
tags: ['integration'],
|
||
},
|
||
});
|
||
assert.equal(word.item?.word, 'commercial', 'tenant admin should create vocabulary word');
|
||
|
||
const video = await request('/api/tenant-content/videos', {
|
||
userId: TENANT_ADMIN_USER_ID,
|
||
method: 'PUT',
|
||
body: {
|
||
title: '集成测试视频',
|
||
videoUrl: 'https://example.test/videos/integration.mp4',
|
||
knowledgeTags: ['integration'],
|
||
isGeneral: true,
|
||
},
|
||
});
|
||
assert.ok(video.item?.id, 'tenant admin should create video');
|
||
|
||
const question = await request('/api/tenant-content/questions', {
|
||
userId: TENANT_ADMIN_USER_ID,
|
||
method: 'POST',
|
||
body: {
|
||
subjectId: '00000000-0000-0000-0000-000000000501',
|
||
categoryId: '00000000-0000-0000-0000-000000000601',
|
||
entryId: entry.item.id,
|
||
contentNodeId: childNode.item.id,
|
||
type: 'choice',
|
||
typeLabel: '单选题',
|
||
difficulty: 2,
|
||
content: '集成测试题:2 + 2 = ?',
|
||
options: [
|
||
{ label: 'A', text: '3' },
|
||
{ label: 'B', text: '4' },
|
||
],
|
||
correctOptionIndex: 1,
|
||
correctOptionIndices: [1],
|
||
answerText: '4',
|
||
explanation: '基础加法。',
|
||
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 scorelineExcelBuffer = await writeXlsxFile([
|
||
{
|
||
sheet: 'fields',
|
||
data: [
|
||
['legacyId', 'fieldKey', 'fieldName', 'fieldType', 'isTrend'],
|
||
['integration-scoreline-xlsx-field-min', 'xlsxMinScore', 'Excel最低分', 'number', '是'],
|
||
],
|
||
},
|
||
{
|
||
sheet: 'schools',
|
||
data: [
|
||
['legacyId', 'schoolName', 'isHot'],
|
||
['integration-scoreline-xlsx-school', 'Excel导入学院', 'true'],
|
||
],
|
||
},
|
||
{
|
||
sheet: 'majors',
|
||
data: [
|
||
['legacyId', 'schoolLegacyId', 'majorName'],
|
||
['integration-scoreline-xlsx-major', 'integration-scoreline-xlsx-school', 'Excel专业'],
|
||
],
|
||
},
|
||
{
|
||
sheet: 'records',
|
||
data: [
|
||
['legacyId', 'schoolLegacyId', 'majorLegacyId', 'year', 'xlsxMinScore'],
|
||
['integration-scoreline-xlsx-record-2027', 'integration-scoreline-xlsx-school', 'integration-scoreline-xlsx-major', 2027, 233],
|
||
],
|
||
},
|
||
]).toBuffer();
|
||
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 pool = new pg.Pool({ connectionString: process.env.DATABASE_URL || DEFAULT_DATABASE_URL });
|
||
try {
|
||
await pool.query(
|
||
`
|
||
insert into public.regions (id, tenant_id, legacy_id, name, code, sort_order, is_active)
|
||
values ($1, $2, 'integration-second-region', '烟测第二地区', 'SMOKE-2', 2, true)
|
||
on conflict (id)
|
||
do update set tenant_id = excluded.tenant_id,
|
||
name = excluded.name,
|
||
code = excluded.code,
|
||
sort_order = excluded.sort_order,
|
||
is_active = excluded.is_active
|
||
`,
|
||
[ids.secondPublicRegion, MAIN_TENANT_ID],
|
||
);
|
||
await pool.query(
|
||
`
|
||
insert into public.question_banks (id, tenant_id, region_id, name, source_scope, status, metadata)
|
||
values ($1, $2, $3, '烟测第二地区公共题库', 'platform', 'active', '{"source":"integration-test","commercialScope":"second_region_bank"}'::jsonb)
|
||
on conflict (id)
|
||
do update set tenant_id = excluded.tenant_id,
|
||
region_id = excluded.region_id,
|
||
name = excluded.name,
|
||
source_scope = excluded.source_scope,
|
||
status = excluded.status,
|
||
metadata = excluded.metadata,
|
||
updated_at = now()
|
||
`,
|
||
[ids.secondPublicQuestionBank, MAIN_TENANT_ID, ids.secondPublicRegion],
|
||
);
|
||
await pool.query(
|
||
`
|
||
insert into public.questions (
|
||
id, tenant_id, question_bank_id, subject_id, category_id,
|
||
legacy_id, type, type_label, difficulty, tags, status
|
||
)
|
||
values ($1, $2, $3, $4, $5, 'integration-second-region-question', 'choice', '单选题', 1, '[]'::jsonb, 'published')
|
||
on conflict (id)
|
||
do update set question_bank_id = excluded.question_bank_id,
|
||
subject_id = excluded.subject_id,
|
||
category_id = excluded.category_id,
|
||
status = excluded.status,
|
||
updated_at = now()
|
||
`,
|
||
[ids.secondPublicQuestion, MAIN_TENANT_ID, ids.secondPublicQuestionBank, ids.subject, ids.category],
|
||
);
|
||
await pool.query(
|
||
`
|
||
insert into public.question_versions (
|
||
id, tenant_id, question_id, version_no, content, options,
|
||
correct_option_index, correct_option_indices, answer_text, explanation, source_hash, created_by
|
||
)
|
||
values (
|
||
$1, $2, $3, 1, '第二地区公共题库题目:2 + 2 = ?',
|
||
'[{"label":"A","text":"3"},{"label":"B","text":"4"}]'::jsonb,
|
||
1, '[1]'::jsonb, '4', '第二地区题库只应授权给购买该地区或全国版的租户。',
|
||
'integration-second-region-v1', $4
|
||
)
|
||
on conflict (id)
|
||
do update set content = excluded.content,
|
||
options = excluded.options,
|
||
correct_option_index = excluded.correct_option_index,
|
||
correct_option_indices = excluded.correct_option_indices,
|
||
answer_text = excluded.answer_text,
|
||
explanation = excluded.explanation,
|
||
source_hash = excluded.source_hash
|
||
`,
|
||
[ids.secondPublicQuestionVersion, MAIN_TENANT_ID, ids.secondPublicQuestion, TENANT_ADMIN_USER_ID],
|
||
);
|
||
await pool.query(
|
||
'update public.questions set current_version_id = $2, updated_at = now() where id = $1',
|
||
[ids.secondPublicQuestion, ids.secondPublicQuestionVersion],
|
||
);
|
||
await pool.query(
|
||
`
|
||
insert into public.question_bank_grants (
|
||
id, source_question_bank_id, grant_scope, allowed_plan_codes,
|
||
allowed_region_ids, status, metadata
|
||
)
|
||
values (
|
||
$1, $2, 'plans', array['starter_yearly','pro_yearly']::text[],
|
||
array[$3::uuid]::uuid[], 'active', '{"source":"integration-test","scope":"second_region"}'::jsonb
|
||
)
|
||
on conflict (id)
|
||
do update set source_question_bank_id = excluded.source_question_bank_id,
|
||
grant_scope = excluded.grant_scope,
|
||
allowed_plan_codes = excluded.allowed_plan_codes,
|
||
allowed_region_ids = excluded.allowed_region_ids,
|
||
status = excluded.status,
|
||
metadata = excluded.metadata,
|
||
updated_at = now()
|
||
`,
|
||
[ids.secondPublicQuestionBankGrant, ids.secondPublicQuestionBank, ids.secondPublicRegion],
|
||
);
|
||
} finally {
|
||
await pool.end();
|
||
}
|
||
|
||
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.accessPlanCode, 'starter_yearly', 'adoptable public bank should expose the subscription plan that grants access');
|
||
assert.equal(adoptable.accessMode, 'limited_regions', 'adoptable public bank should expose limited region access mode');
|
||
assert.equal(adoptable.adoptedId, null, 'public bank should start as not adopted after smoke seed');
|
||
assert.equal(
|
||
partnerBanks.items?.some(item => item.grantId === ids.secondPublicQuestionBankGrant),
|
||
false,
|
||
'starter tenant restricted to one region must not see second-region public banks',
|
||
);
|
||
|
||
const secondRegionAdoptDenied = await request('/api/tenant-content/public-question-banks/adopt', {
|
||
tenantId: PARTNER_TENANT_ID,
|
||
userId: PARTNER_TENANT_ADMIN_USER_ID,
|
||
method: 'POST',
|
||
body: { grantId: ids.secondPublicQuestionBankGrant },
|
||
expectStatus: 403,
|
||
});
|
||
assert.equal(secondRegionAdoptDenied.code, 'QUESTION_BANK_GRANT_NOT_AVAILABLE', 'restricted-region SaaS plan must not adopt out-of-scope public banks');
|
||
|
||
const upgradePool = new pg.Pool({ connectionString: process.env.DATABASE_URL || DEFAULT_DATABASE_URL });
|
||
try {
|
||
const upgradeResult = await upgradePool.query(
|
||
`
|
||
update public.tenant_subscriptions
|
||
set plan_code = 'pro_yearly',
|
||
metadata = jsonb_build_object(
|
||
'source', 'integration-test-upgrade',
|
||
'publicQuestionBankAccess', jsonb_build_object('mode', 'national', 'allowAllRegions', true)
|
||
),
|
||
updated_at = now()
|
||
where id = $1
|
||
`,
|
||
[ids.partnerSubscription],
|
||
);
|
||
assert.equal(upgradeResult.rowCount, 1, 'integration test should upgrade exactly one partner subscription');
|
||
const upgradedSubscription = await upgradePool.query(
|
||
'select plan_code, metadata from public.tenant_subscriptions where id = $1',
|
||
[ids.partnerSubscription],
|
||
);
|
||
assert.equal(upgradedSubscription.rows[0]?.plan_code, 'pro_yearly', 'partner subscription upgrade should persist before public bank list');
|
||
assert.equal(
|
||
upgradedSubscription.rows[0]?.metadata?.publicQuestionBankAccess?.mode,
|
||
'national',
|
||
'partner subscription upgrade should persist national public bank access mode',
|
||
);
|
||
} finally {
|
||
await upgradePool.end();
|
||
}
|
||
|
||
const upgradedPartnerBanks = await request('/api/tenant-content/public-question-banks', {
|
||
tenantId: PARTNER_TENANT_ID,
|
||
userId: PARTNER_TENANT_ADMIN_USER_ID,
|
||
});
|
||
const secondRegionVisible = upgradedPartnerBanks.items?.find(item => item.grantId === ids.secondPublicQuestionBankGrant);
|
||
assert.ok(secondRegionVisible, 'pro/national SaaS subscription should see second-region public banks');
|
||
assert.equal(secondRegionVisible.accessPlanCode, 'pro_yearly', 'upgraded public bank access should be attributed to pro_yearly');
|
||
assert.equal(secondRegionVisible.accessMode, 'national', 'upgraded public bank access should expose national mode');
|
||
|
||
const restoreSubscriptionPool = new pg.Pool({ connectionString: process.env.DATABASE_URL || DEFAULT_DATABASE_URL });
|
||
try {
|
||
const restoreResult = await restoreSubscriptionPool.query(
|
||
`
|
||
update public.tenant_subscriptions
|
||
set plan_code = 'starter_yearly',
|
||
metadata = jsonb_build_object(
|
||
'source', 'integration-test-restore',
|
||
'publicQuestionBankAccess', jsonb_build_object(
|
||
'mode', 'limited_regions',
|
||
'allowedRegionIds', jsonb_build_array($2::text)
|
||
)
|
||
),
|
||
updated_at = now()
|
||
where id = $1
|
||
`,
|
||
[ids.partnerSubscription, ids.region],
|
||
);
|
||
assert.equal(restoreResult.rowCount, 1, 'integration test should restore partner subscription after national access check');
|
||
} finally {
|
||
await restoreSubscriptionPool.end();
|
||
}
|
||
|
||
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 platformSyncStatus = await request('/api/platform-admin/question-bank-sync-status', {
|
||
userId: false,
|
||
headers: { 'x-platform-admin-key': 'local-platform-admin-key' },
|
||
query: { tenantId: PARTNER_TENANT_ID, sourceQuestionBankId: ids.questionBank },
|
||
});
|
||
const platformSyncStatusItem = platformSyncStatus.items?.find(item => item.id === adopted.item.id);
|
||
assert.equal(platformSyncStatusItem?.syncStatus, 'synced', 'platform admin should inspect public bank sync status across tenants');
|
||
assert.equal(platformSyncStatus.summary?.synced >= 1, true, 'platform sync status should include summary counts');
|
||
assert.ok(!JSON.stringify(platformSyncStatusItem).includes('新增公共题应同步到已采纳租户'), 'platform sync status must not leak source question explanations');
|
||
|
||
const studentPlatformSyncStatusDenied = await request('/api/platform-admin/question-bank-sync-status', {
|
||
query: { tenantId: PARTNER_TENANT_ID },
|
||
expectStatus: 403,
|
||
});
|
||
assert.equal(studentPlatformSyncStatusDenied.code, 'PLATFORM_ADMIN_REQUIRED', 'public bank sync ops endpoint must require platform admin access');
|
||
|
||
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 failedPlatformSyncStatus = await request('/api/platform-admin/question-bank-sync-status', {
|
||
userId: false,
|
||
headers: { 'x-platform-admin-key': 'local-platform-admin-key' },
|
||
query: { tenantId: PARTNER_TENANT_ID, syncStatus: 'failed', onlyOpenIssues: 'true' },
|
||
});
|
||
const failedPlatformSyncStatusItem = failedPlatformSyncStatus.items?.find(item => item.id === adopted.item.id);
|
||
assert.equal(failedPlatformSyncStatusItem?.syncStatus, 'failed', 'platform sync ops should expose failed public bank adoptions');
|
||
assert.equal(failedPlatformSyncStatusItem?.openConflictNotificationCount >= 1, true, 'platform sync ops should expose open conflict notification count');
|
||
|
||
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');
|
||
assert.equal(miniappLogin.user?.avatarUrl, null, 'wechat miniapp login must not persist client avatarUrl');
|
||
assert.ok(!JSON.stringify(miniappLogin).includes('https://example.test/avatar.png'), 'wechat miniapp login response must not echo client avatarUrl');
|
||
|
||
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');
|
||
assert.equal(miniappMe.user?.avatarUrl, null, 'wechat session auth/me should keep avatarUrl empty');
|
||
|
||
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');
|
||
assert.ok(!JSON.stringify(wechatWebLogin).includes('wechat-web-avatar.png'), 'wechat web response must not echo provider avatar');
|
||
|
||
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');
|
||
assert.equal(qqLogin.user?.avatarUrl, null, 'qq login must not persist provider avatarUrl');
|
||
assert.ok(!JSON.stringify(qqLogin).includes('qq-avatar.png'), 'qq login response must not echo provider avatar');
|
||
|
||
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 feedbackReport = await request('/api/tenant-admin/feedbacks/report', {
|
||
userId: TENANT_ADMIN_USER_ID,
|
||
query: { timeRange: '30d', limit: 20 },
|
||
});
|
||
assert.ok(feedbackReport.item?.summary?.total >= 1, 'tenant admin feedback report should count created feedback');
|
||
assert.ok(feedbackReport.item?.summary?.handledInRange >= 1, 'tenant admin feedback report should count handled feedback');
|
||
assert.ok(feedbackReport.item?.summary?.rewardPoints >= 5, 'tenant admin feedback report should include feedback reward points');
|
||
assert.ok(
|
||
feedbackReport.item?.byStatus?.some(item => item.status === 'resolved'),
|
||
'tenant admin feedback report should group by resolved status',
|
||
);
|
||
assert.ok(Array.isArray(feedbackReport.item?.recentUnhandled), 'tenant admin feedback report should expose recent unhandled list');
|
||
|
||
const invalidFeedbackReportRange = await request('/api/tenant-admin/feedbacks/report', {
|
||
userId: TENANT_ADMIN_USER_ID,
|
||
query: { timeRange: '365d' },
|
||
expectStatus: 400,
|
||
});
|
||
assert.equal(invalidFeedbackReportRange.code, 'INVALID_FEEDBACK_REPORT_RANGE', 'feedback report should reject unsupported range');
|
||
|
||
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 partnerFeedbackReportDenied = await request('/api/tenant-admin/feedbacks/report', {
|
||
tenantId: PARTNER_TENANT_ID,
|
||
userId: TENANT_ADMIN_USER_ID,
|
||
expectStatus: 403,
|
||
});
|
||
assert.equal(partnerFeedbackReportDenied.code, 'TENANT_ADMIN_REQUIRED', 'feedback report 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 riskReport = await request('/api/tenant-admin/points-risk-report', {
|
||
userId: TENANT_ADMIN_USER_ID,
|
||
query: {
|
||
timeRange: '30d',
|
||
highEarnedThreshold: 10,
|
||
highClaimThreshold: 1,
|
||
highEventPointsThreshold: 10,
|
||
highRedeemThreshold: 10,
|
||
limit: 20,
|
||
},
|
||
});
|
||
assert.equal(riskReport.item?.range?.timeRange, '30d', 'points risk report should echo range');
|
||
assert.ok(riskReport.item?.summary?.eventCount >= 2, 'points risk report should include point ledger summary');
|
||
assert.ok(
|
||
riskReport.item?.eventBreakdown?.some(item => item.eventType === 'activity_reward'),
|
||
'points risk report should include activity reward breakdown',
|
||
);
|
||
assert.ok(
|
||
riskReport.item?.suspiciousUsers?.some(item => item.userId === USER_ID && item.riskFlags?.includes('high_earned_points')),
|
||
'points risk report should flag high earned point users',
|
||
);
|
||
assert.ok(
|
||
riskReport.item?.suspiciousEvents?.some(item => item.sourceId === ids.pointActivityTask),
|
||
'points risk report should flag large activity reward events',
|
||
);
|
||
assert.ok(
|
||
riskReport.item?.suspiciousTasks?.some(item => item.taskId === ids.pointActivityTask),
|
||
'points risk report should flag high-frequency point tasks',
|
||
);
|
||
assert.ok(
|
||
riskReport.item?.suspiciousExchanges?.some(item => item.itemId === ids.pointExchangeItem),
|
||
'points risk report should flag high-cost point exchanges',
|
||
);
|
||
|
||
const invalidRiskRange = await request('/api/tenant-admin/points-risk-report', {
|
||
userId: TENANT_ADMIN_USER_ID,
|
||
query: { timeRange: '365d' },
|
||
expectStatus: 400,
|
||
});
|
||
assert.equal(invalidRiskRange.code, 'INVALID_POINTS_RISK_RANGE', 'points risk report should reject unsupported ranges');
|
||
|
||
const partnerRiskDenied = await request('/api/tenant-admin/points-risk-report', {
|
||
tenantId: PARTNER_TENANT_ID,
|
||
userId: TENANT_ADMIN_USER_ID,
|
||
expectStatus: 403,
|
||
});
|
||
assert.equal(partnerRiskDenied.code, 'TENANT_ADMIN_REQUIRED', 'points risk report must be tenant isolated');
|
||
|
||
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 === 'marketing:points:risk:read'), 'permission matrix should expose point risk report read 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',
|
||
},
|
||
{
|
||
username: 'integration_bulk_student_avatar',
|
||
phone: '13800000020',
|
||
name: 'Integration Bulk Student Avatar',
|
||
regionId: ids.region,
|
||
avatarUrl: 'https://example.test/not-allowed-student-avatar.png',
|
||
},
|
||
],
|
||
},
|
||
});
|
||
assert.equal(bulk.total, 3, 'bulk student upsert should report total');
|
||
assert.equal(bulk.successCount, 1, 'bulk student upsert should import valid student');
|
||
assert.equal(bulk.errorCount, 2, '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');
|
||
assert.ok(
|
||
bulk.errors?.some(item => item.code === 'STUDENT_AVATAR_URL_UNSUPPORTED'),
|
||
'bulk student upsert should reject row-level avatar URLs',
|
||
);
|
||
|
||
const singleAvatarDenied = await request('/api/tenant-admin/students', {
|
||
userId: TENANT_ADMIN_USER_ID,
|
||
method: 'PUT',
|
||
body: {
|
||
username: 'integration_avatar_denied',
|
||
phone: '13800000021',
|
||
name: 'Integration Avatar Denied',
|
||
regionId: ids.region,
|
||
avatarUrl: 'https://example.test/direct-student-avatar.png',
|
||
},
|
||
expectStatus: 400,
|
||
});
|
||
assert.equal(
|
||
singleAvatarDenied.code,
|
||
'STUDENT_AVATAR_URL_UNSUPPORTED',
|
||
'tenant student upsert must reject direct avatar URLs',
|
||
);
|
||
|
||
const primaryRoleDenied = await request('/api/tenant-admin/students', {
|
||
userId: TENANT_ADMIN_USER_ID,
|
||
method: 'PUT',
|
||
body: {
|
||
username: 'integration_primary_role_denied',
|
||
phone: '13800000022',
|
||
name: 'Integration Primary Role Denied',
|
||
regionId: ids.region,
|
||
primaryRole: 'platform_admin',
|
||
},
|
||
expectStatus: 400,
|
||
});
|
||
assert.equal(
|
||
primaryRoleDenied.code,
|
||
'STUDENT_PRIMARY_ROLE_UNSUPPORTED',
|
||
'tenant student upsert must not allow platform primary role changes',
|
||
);
|
||
|
||
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 teacherFollowupReport = await request('/api/tenant-admin/students/followups/report', {
|
||
userId: TENANT_TEACHER_USER_ID,
|
||
query: { timeRange: '30d', limit: 10 },
|
||
});
|
||
assert.equal(teacherFollowupReport.item?.filters?.scoped, true, 'teacher follow-up report should be class scoped');
|
||
assert.equal(teacherFollowupReport.item?.summary?.total >= 1, true, 'teacher follow-up report should include scoped follow-up');
|
||
assert.ok(
|
||
teacherFollowupReport.item?.byAssignee?.some(item => item.assignedToUserId === TENANT_TEACHER_USER_ID),
|
||
'teacher follow-up report should include scoped assignee summary',
|
||
);
|
||
assert.ok(
|
||
!teacherFollowupReport.item?.overdueItems?.some(item => item.studentUserId === SECOND_STUDENT_USER_ID),
|
||
'teacher follow-up report must not expose unscoped students',
|
||
);
|
||
|
||
const supervisionPreview = await request('/api/tenant-admin/students/supervision/preview', {
|
||
userId: TENANT_TEACHER_USER_ID,
|
||
query: {
|
||
classId: ids.tenantClass,
|
||
inactivityDays: 1,
|
||
wrongQuestionThreshold: 1,
|
||
minAnswers: 1,
|
||
lowAccuracyThreshold: 0.99,
|
||
vocabularyDueThreshold: 1,
|
||
limit: 20,
|
||
},
|
||
});
|
||
assert.equal(supervisionPreview.item?.filters?.scoped, true, 'teacher supervision preview should be scoped');
|
||
assert.ok(
|
||
supervisionPreview.item?.candidates?.some(item => item.studentUserId === USER_ID),
|
||
'supervision preview should include scoped risky student',
|
||
);
|
||
assert.ok(
|
||
!supervisionPreview.item?.candidates?.some(item => item.studentUserId === SECOND_STUDENT_USER_ID),
|
||
'supervision preview must not expose unscoped student',
|
||
);
|
||
const supervisionCandidate = supervisionPreview.item?.candidates?.find(item => item.studentUserId === USER_ID);
|
||
assert.ok(
|
||
supervisionCandidate?.reasons?.some(item => ['wrong_backlog', 'low_accuracy', 'vocabulary_due', 'stale_session', 'inactive'].includes(item.code)),
|
||
'supervision candidate should include rule-based reasons',
|
||
);
|
||
|
||
const generatedSupervision = await request('/api/tenant-admin/students/supervision/generate', {
|
||
userId: TENANT_TEACHER_USER_ID,
|
||
method: 'POST',
|
||
body: {
|
||
classId: ids.tenantClass,
|
||
studentUserIds: [USER_ID],
|
||
batchKey: 'integration-supervision-teacher',
|
||
rules: {
|
||
inactivityDays: 1,
|
||
wrongQuestionThreshold: 1,
|
||
minAnswers: 1,
|
||
lowAccuracyThreshold: 0.99,
|
||
vocabularyDueThreshold: 1,
|
||
},
|
||
limit: 5,
|
||
},
|
||
});
|
||
assert.equal(generatedSupervision.successCount, 1, 'supervision generation should create follow-up for scoped student');
|
||
assert.equal(generatedSupervision.items?.[0]?.studentUserId, USER_ID, 'supervision generation should target requested student');
|
||
|
||
const repeatedSupervision = await request('/api/tenant-admin/students/supervision/generate', {
|
||
userId: TENANT_TEACHER_USER_ID,
|
||
method: 'POST',
|
||
body: {
|
||
classId: ids.tenantClass,
|
||
studentUserIds: [USER_ID],
|
||
batchKey: 'integration-supervision-teacher',
|
||
rules: {
|
||
inactivityDays: 1,
|
||
wrongQuestionThreshold: 1,
|
||
minAnswers: 1,
|
||
lowAccuracyThreshold: 0.99,
|
||
vocabularyDueThreshold: 1,
|
||
},
|
||
limit: 5,
|
||
},
|
||
});
|
||
assert.equal(
|
||
repeatedSupervision.items?.[0]?.followupId,
|
||
generatedSupervision.items?.[0]?.followupId,
|
||
'supervision generation should be idempotent for the same batch and student',
|
||
);
|
||
|
||
const supervisionRule = await request('/api/tenant-admin/students/supervision/rules', {
|
||
userId: TENANT_TEACHER_USER_ID,
|
||
method: 'PUT',
|
||
body: {
|
||
name: '集成测试教师班级督导规则',
|
||
classId: ids.tenantClass,
|
||
status: 'active',
|
||
rules: {
|
||
inactivityDays: 1,
|
||
wrongQuestionThreshold: 1,
|
||
minAnswers: 1,
|
||
lowAccuracyThreshold: 0.99,
|
||
vocabularyDueThreshold: 1,
|
||
},
|
||
schedule: {
|
||
enabled: true,
|
||
frequency: 'daily',
|
||
hour: 9,
|
||
minute: 0,
|
||
},
|
||
limit: 5,
|
||
},
|
||
});
|
||
assert.ok(supervisionRule.item?.id, 'teacher should create class-scoped supervision rule');
|
||
assert.equal(supervisionRule.item?.classId, ids.tenantClass, 'supervision rule should bind scoped class');
|
||
assert.ok(supervisionRule.item?.nextRunAt, 'active scheduled supervision rule should compute next run time');
|
||
|
||
const supervisionRules = await request('/api/tenant-admin/students/supervision/rules', {
|
||
userId: TENANT_TEACHER_USER_ID,
|
||
query: { limit: 20 },
|
||
});
|
||
assert.equal(supervisionRules.scoped, true, 'teacher supervision rule list should be scoped');
|
||
assert.ok(
|
||
supervisionRules.items?.some(item => item.id === supervisionRule.item.id),
|
||
'teacher should list own scoped supervision rule',
|
||
);
|
||
|
||
const teacherGlobalSupervisionRuleDenied = await request('/api/tenant-admin/students/supervision/rules', {
|
||
userId: TENANT_TEACHER_USER_ID,
|
||
method: 'PUT',
|
||
body: {
|
||
name: '不应保存全局规则',
|
||
status: 'active',
|
||
rules: { inactivityDays: 1 },
|
||
schedule: { enabled: true, frequency: 'daily' },
|
||
limit: 5,
|
||
},
|
||
expectStatus: 403,
|
||
});
|
||
assert.equal(teacherGlobalSupervisionRuleDenied.code, 'CLASS_SCOPE_REQUIRED', 'teacher should not create global supervision rules');
|
||
|
||
const teacherOtherSupervisionDenied = await request('/api/tenant-admin/students/supervision/generate', {
|
||
userId: TENANT_TEACHER_USER_ID,
|
||
method: 'POST',
|
||
body: {
|
||
classId: ids.tenantClassOther,
|
||
studentUserIds: [SECOND_STUDENT_USER_ID],
|
||
batchKey: 'integration-supervision-denied',
|
||
rules: { inactivityDays: 1 },
|
||
},
|
||
expectStatus: 403,
|
||
});
|
||
assert.equal(teacherOtherSupervisionDenied.code, 'CLASS_SCOPE_REQUIRED', 'teacher supervision generation must reject unscoped class');
|
||
|
||
const operatorSupervisionDenied = await request('/api/tenant-admin/students/supervision/preview', {
|
||
userId: TENANT_OPERATOR_USER_ID,
|
||
query: { limit: 5 },
|
||
expectStatus: 403,
|
||
});
|
||
assert.equal(operatorSupervisionDenied.code, 'TENANT_PERMISSION_REQUIRED', 'operator without supervision permission should be denied');
|
||
|
||
const operatorSupervisionRuleDenied = await request('/api/tenant-admin/students/supervision/rules', {
|
||
userId: TENANT_OPERATOR_USER_ID,
|
||
query: { limit: 5 },
|
||
expectStatus: 403,
|
||
});
|
||
assert.equal(operatorSupervisionRuleDenied.code, 'TENANT_PERMISSION_REQUIRED', 'operator without supervision permission should not list rules');
|
||
|
||
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 invalidFollowupReportRange = await request('/api/tenant-admin/students/followups/report', {
|
||
userId: TENANT_ADMIN_USER_ID,
|
||
query: { timeRange: '365d' },
|
||
expectStatus: 400,
|
||
});
|
||
assert.equal(invalidFollowupReportRange.code, 'INVALID_FOLLOWUP_REPORT_RANGE', 'follow-up report should validate time range');
|
||
|
||
const invalidFollowupReportDate = await request('/api/tenant-admin/students/followups/report', {
|
||
userId: TENANT_ADMIN_USER_ID,
|
||
query: { startDate: '2026-02-31', endDate: '2026-03-01' },
|
||
expectStatus: 400,
|
||
});
|
||
assert.equal(invalidFollowupReportDate.code, 'INVALID_DATE_RANGE', 'follow-up report should reject impossible calendar dates');
|
||
|
||
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');
|
||
assert.ok(auditLogs.items?.some(item => item.action === 'tenant.students.supervision_generated'), 'audit logs should include supervision generation');
|
||
assert.ok(auditLogs.items?.some(item => item.action === 'tenant.students.supervision_rule_upserted'), 'audit logs should include supervision rule 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 studentCrmPush = await request('/api/tenant-admin/students/crm-push', {
|
||
userId: TENANT_ADMIN_USER_ID,
|
||
method: 'POST',
|
||
body: {
|
||
studentUserIds: [USER_ID, SECOND_STUDENT_USER_ID],
|
||
source: 'integration_batch',
|
||
title: '集成测试批量跟进',
|
||
message: '请销售今日联系并记录转化情况',
|
||
followupType: 'sales',
|
||
priority: 'high',
|
||
assignedToUserId: TENANT_SALES_USER_ID,
|
||
dueAt: '2026-07-02T10:00:00.000Z',
|
||
idempotencyKey: 'integration-student-crm-push',
|
||
metadata: { source: 'integration-test' },
|
||
},
|
||
});
|
||
assert.equal(studentCrmPush.total, 2, 'student CRM push should report total');
|
||
assert.equal(studentCrmPush.successCount, 2, 'student CRM push should enqueue valid students');
|
||
assert.equal(studentCrmPush.errorCount, 0, 'student CRM push should not fail valid students');
|
||
assert.ok(studentCrmPush.items?.every(item => item.queueId && item.followupId), 'student CRM push should return queue and follow-up ids');
|
||
|
||
const repeatedStudentCrmPush = await request('/api/tenant-admin/students/crm-push', {
|
||
userId: TENANT_ADMIN_USER_ID,
|
||
method: 'POST',
|
||
body: {
|
||
studentUserIds: [USER_ID, SECOND_STUDENT_USER_ID],
|
||
title: '集成测试批量跟进',
|
||
message: '重复提交不应重复创建队列',
|
||
followupType: 'sales',
|
||
priority: 'high',
|
||
assignedToUserId: TENANT_SALES_USER_ID,
|
||
idempotencyKey: 'integration-student-crm-push',
|
||
},
|
||
});
|
||
assert.deepEqual(
|
||
repeatedStudentCrmPush.items?.map(item => item.queueId).sort(),
|
||
studentCrmPush.items?.map(item => item.queueId).sort(),
|
||
'student CRM push idempotency should reuse queue ids',
|
||
);
|
||
assert.deepEqual(
|
||
repeatedStudentCrmPush.items?.map(item => item.followupId).sort(),
|
||
studentCrmPush.items?.map(item => item.followupId).sort(),
|
||
'student CRM push idempotency should reuse follow-up ids',
|
||
);
|
||
|
||
const studentCrmPushClassError = await request('/api/tenant-admin/students/crm-push', {
|
||
userId: TENANT_ADMIN_USER_ID,
|
||
method: 'POST',
|
||
body: {
|
||
studentUserIds: [USER_ID, SECOND_STUDENT_USER_ID],
|
||
title: '集成测试班级范围跟进',
|
||
classId: ids.tenantClass,
|
||
idempotencyKey: 'integration-student-crm-push-class-scope',
|
||
},
|
||
});
|
||
assert.equal(studentCrmPushClassError.total, 2, 'student CRM class scoped push should report total');
|
||
assert.equal(studentCrmPushClassError.successCount, 1, 'student CRM class scoped push should enqueue in-class student');
|
||
assert.ok(
|
||
studentCrmPushClassError.errors?.some(item => item.studentUserId === SECOND_STUDENT_USER_ID && item.code === 'STUDENT_CLASS_REQUIRED'),
|
||
'student CRM class scoped push should reject students outside the selected class',
|
||
);
|
||
|
||
const followupReport = await request('/api/tenant-admin/students/followups/report', {
|
||
userId: TENANT_ADMIN_USER_ID,
|
||
query: { timeRange: '30d', limit: 10 },
|
||
});
|
||
assert.equal(followupReport.item?.summary?.crmPushTasks >= 2, true, 'follow-up report should count CRM push tasks');
|
||
assert.equal(followupReport.item?.crmQueue?.total >= 2, true, 'follow-up report should include student CRM queue totals');
|
||
assert.ok(
|
||
followupReport.item?.byAssignee?.some(item => item.assignedToUserId === TENANT_SALES_USER_ID && Number(item.crmPushTasks || 0) >= 2),
|
||
'follow-up report should group CRM push tasks by assignee',
|
||
);
|
||
assert.ok(
|
||
followupReport.item?.dailyTrend?.some(item => Number(item.crmPushCreated || 0) >= 1),
|
||
'follow-up report should include CRM push daily trend',
|
||
);
|
||
|
||
const classFollowupReport = await request('/api/tenant-admin/students/followups/report', {
|
||
userId: TENANT_ADMIN_USER_ID,
|
||
query: { timeRange: '30d', classId: ids.tenantClass, limit: 10 },
|
||
});
|
||
assert.equal(
|
||
classFollowupReport.item?.summary?.crmPushTasks >= 1,
|
||
true,
|
||
'class-scoped follow-up report should include in-class CRM push tasks',
|
||
);
|
||
assert.ok(
|
||
!classFollowupReport.item?.overdueItems?.some(item => item.studentUserId === SECOND_STUDENT_USER_ID),
|
||
'class-scoped follow-up report should not expose another class student overdue items',
|
||
);
|
||
|
||
const operatorCrmPushDenied = await request('/api/tenant-admin/students/crm-push', {
|
||
userId: TENANT_OPERATOR_USER_ID,
|
||
method: 'POST',
|
||
body: {
|
||
studentUserIds: [USER_ID],
|
||
title: '不应允许普通运营推送',
|
||
},
|
||
expectStatus: 403,
|
||
});
|
||
assert.equal(operatorCrmPushDenied.code, 'TENANT_PERMISSION_REQUIRED', 'student CRM push should require crm:write permission');
|
||
|
||
const partnerStudentCrmPushDenied = await request('/api/tenant-admin/students/crm-push', {
|
||
tenantId: PARTNER_TENANT_ID,
|
||
userId: TENANT_ADMIN_USER_ID,
|
||
method: 'POST',
|
||
body: {
|
||
studentUserIds: [USER_ID],
|
||
title: '不应跨租户推送',
|
||
},
|
||
expectStatus: 403,
|
||
});
|
||
assert.equal(partnerStudentCrmPushDenied.code, 'TENANT_ADMIN_REQUIRED', 'student CRM push must be tenant isolated');
|
||
|
||
const studentCrmPushDenied = await request('/api/tenant-admin/students/crm-push', {
|
||
userId: USER_ID,
|
||
method: 'POST',
|
||
body: {
|
||
studentUserIds: [USER_ID],
|
||
title: '学生端不应推送 CRM',
|
||
},
|
||
expectStatus: 403,
|
||
});
|
||
assert.equal(studentCrmPushDenied.code, 'TENANT_ADMIN_REQUIRED', 'student CRM push should reject student users');
|
||
|
||
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 conversionReport = await request('/api/referral/conversion-report', {
|
||
userId: TENANT_ADMIN_USER_ID,
|
||
query: { startDate: today, endDate: today, referrerUserId: TENANT_AGENT_USER_ID, limit: 10 },
|
||
});
|
||
assert.equal(conversionReport.item?.range?.referrerUserId, TENANT_AGENT_USER_ID, 'conversion report should support referrer filter');
|
||
assert.equal(conversionReport.item?.summary?.leadCount >= 1, true, 'conversion report should include protected leads');
|
||
assert.equal(conversionReport.item?.summary?.paidSourceCount >= 2, true, 'conversion report should include paid order and activation code');
|
||
assert.equal(conversionReport.item?.summary?.grossAmountCents >= 2990, true, 'conversion report should include gross amount');
|
||
assert.equal(conversionReport.item?.summary?.commissionAmountCents >= 1217, true, 'conversion report should reuse commission rate precedence');
|
||
assert.equal(conversionReport.item?.summary?.conversionRate > 0, true, 'conversion report should calculate conversion rate');
|
||
assert.ok(conversionReport.item?.byReferrer?.some(item => item.referrerUserId === TENANT_AGENT_USER_ID), 'conversion report should group by referrer');
|
||
assert.ok(conversionReport.item?.dailyTrend?.some(item => item.date === today && Number(item.paidSourceCount || 0) >= 2), 'conversion report should include daily paid sources');
|
||
assert.ok(conversionReport.item?.funnel?.some(item => item.key === 'converted'), 'conversion report should include funnel summary');
|
||
assert.ok(!JSON.stringify(conversionReport).includes('crm-secret-smoke'), 'conversion report must not leak CRM secret');
|
||
|
||
const agentConversionReport = await request('/api/referral/conversion-report', {
|
||
userId: TENANT_AGENT_USER_ID,
|
||
query: { startDate: today, endDate: today },
|
||
});
|
||
assert.equal(agentConversionReport.item?.range?.referrerUserId, TENANT_AGENT_USER_ID, 'agent conversion report should be scoped to self');
|
||
assert.ok(agentConversionReport.item?.byReferrer?.every(item => item.referrerUserId === TENANT_AGENT_USER_ID), 'agent conversion report should not expose other referrers');
|
||
|
||
const agentOtherConversionDenied = await request('/api/referral/conversion-report', {
|
||
userId: TENANT_AGENT_USER_ID,
|
||
query: { startDate: today, endDate: today, referrerUserId: TENANT_SALES_USER_ID },
|
||
expectStatus: 403,
|
||
});
|
||
assert.equal(agentOtherConversionDenied.code, 'REFERRAL_SCOPE_REQUIRED', 'agent should not read another referrer conversion report');
|
||
|
||
const studentConversionDenied = await request('/api/referral/conversion-report', {
|
||
query: { startDate: today, endDate: today },
|
||
expectStatus: 403,
|
||
});
|
||
assert.equal(studentConversionDenied.code, 'TENANT_ADMIN_REQUIRED', 'student should not access conversion report');
|
||
|
||
const partnerConversionDenied = await request('/api/referral/conversion-report', {
|
||
tenantId: PARTNER_TENANT_ID,
|
||
userId: TENANT_AGENT_USER_ID,
|
||
query: { startDate: today, endDate: today },
|
||
expectStatus: 403,
|
||
});
|
||
assert.equal(partnerConversionDenied.code, 'TENANT_ADMIN_REQUIRED', 'conversion report APIs must be tenant isolated');
|
||
|
||
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(
|
||
crmQueue.items?.some(item => item.payload?.eventType === 'student.crm_push' && item.payload?.assignee?.userId === TENANT_SALES_USER_ID),
|
||
'CRM queue should include student CRM push task',
|
||
);
|
||
assert.ok(
|
||
crmQueue.items?.some(item => item.payload?.eventType === 'student.crm_push' && item.payload?.student?.avatarPreset && !item.payload?.student?.avatarUrl),
|
||
'student CRM push payload should use preset avatars only',
|
||
);
|
||
assert.ok(!JSON.stringify(crmQueue).includes('crm-secret-smoke'), 'CRM queue should not leak secret');
|
||
|
||
const crmDeadLetterPool = new pg.Pool({ connectionString: process.env.DATABASE_URL || DEFAULT_DATABASE_URL });
|
||
try {
|
||
await crmDeadLetterPool.query(
|
||
`
|
||
insert into public.crm_webhook_queue (
|
||
id, tenant_id, record_id, status, scheduled_at, attempts, next_attempt_at,
|
||
last_error, last_http_code, lead_id, source, payload, idempotency_key,
|
||
target_url, provider, last_attempt_at, last_response_summary, dead_lettered_at
|
||
)
|
||
values (
|
||
$1::uuid, $2, $3, 'failed', now(), 3, null,
|
||
$4, 500, $5, 'tenant.student.crm_push',
|
||
$6::jsonb, $7, $8, 'dingtalk', now(), $9, now()
|
||
)
|
||
on conflict (id) do update set status = excluded.status,
|
||
last_error = excluded.last_error,
|
||
target_url = excluded.target_url,
|
||
payload = excluded.payload,
|
||
dead_lettered_at = now(),
|
||
updated_at = now()
|
||
`,
|
||
[
|
||
ids.crmDeadLetterQueue,
|
||
MAIN_TENANT_ID,
|
||
USER_ID,
|
||
'HTTP 500 access_token=dead-letter-secret should be redacted',
|
||
manual.lead.item.id,
|
||
JSON.stringify({
|
||
eventType: 'student.crm_push',
|
||
token: 'payload-secret-token',
|
||
student: { userId: USER_ID, avatarPreset: 'male' },
|
||
}),
|
||
'integration-crm-dead-letter',
|
||
'https://oapi.dingtalk.com/robot/send?access_token=dead-letter-secret',
|
||
'response contains access_token=dead-letter-secret',
|
||
],
|
||
);
|
||
await crmDeadLetterPool.query(
|
||
`
|
||
insert into public.crm_webhook_log (
|
||
tenant_id, queue_id, record_id, http_code, outcome, error_message, lead_id,
|
||
request_body, request_payload, response_summary, signed_at, attempt
|
||
)
|
||
values (
|
||
$1, $2::uuid, $3, 500, 'failed', $4, $5,
|
||
$6, $7::jsonb, $8, now(), 3
|
||
)
|
||
`,
|
||
[
|
||
MAIN_TENANT_ID,
|
||
ids.crmDeadLetterQueue,
|
||
USER_ID,
|
||
'access_token=dead-letter-secret failed',
|
||
manual.lead.item.id,
|
||
'{"access_token":"dead-letter-secret","student":{"avatarPreset":"male"}}',
|
||
JSON.stringify({ url: 'https://oapi.dingtalk.com/robot/send?access_token=dead-letter-secret', token: 'dead-letter-secret' }),
|
||
'remote response access_token=dead-letter-secret',
|
||
],
|
||
);
|
||
} finally {
|
||
await crmDeadLetterPool.end();
|
||
}
|
||
|
||
const crmDeadLetters = await request('/api/crm/dead-letters', {
|
||
userId: TENANT_ADMIN_USER_ID,
|
||
query: { limit: 10 },
|
||
});
|
||
const deadLetterItem = crmDeadLetters.items?.find(item => item.id === ids.crmDeadLetterQueue);
|
||
assert.ok(deadLetterItem, 'CRM dead-letter list should include failed task');
|
||
assert.equal(crmDeadLetters.summary?.failed >= 1, true, 'CRM dead-letter summary should count failed tasks');
|
||
assert.ok(!JSON.stringify(crmDeadLetters).includes('dead-letter-secret'), 'CRM dead-letter list must redact webhook secrets');
|
||
assert.equal(deadLetterItem?.payload?.token, '[redacted]', 'CRM dead-letter payload should redact token fields');
|
||
|
||
const crmDeadLetterLogs = await request('/api/crm/queue/logs', {
|
||
userId: TENANT_ADMIN_USER_ID,
|
||
query: { queueId: ids.crmDeadLetterQueue, limit: 10 },
|
||
});
|
||
assert.ok(crmDeadLetterLogs.items?.some(item => item.outcome === 'failed'), 'CRM queue logs should include failed attempts');
|
||
assert.ok(!JSON.stringify(crmDeadLetterLogs).includes('dead-letter-secret'), 'CRM queue logs must redact webhook secrets');
|
||
|
||
const crmRetryDenied = await request('/api/crm/queue/action', {
|
||
userId: TENANT_OPERATOR_USER_ID,
|
||
method: 'POST',
|
||
body: {
|
||
queueId: ids.crmDeadLetterQueue,
|
||
action: 'retry',
|
||
},
|
||
expectStatus: 403,
|
||
});
|
||
assert.equal(crmRetryDenied.code, 'TENANT_PERMISSION_REQUIRED', 'CRM retry should require crm:write');
|
||
|
||
const crmCrossTenantDenied = await request('/api/crm/queue/logs', {
|
||
tenantId: PARTNER_TENANT_ID,
|
||
userId: PARTNER_TENANT_ADMIN_USER_ID,
|
||
query: { queueId: ids.crmDeadLetterQueue },
|
||
expectStatus: 404,
|
||
});
|
||
assert.equal(crmCrossTenantDenied.code, 'CRM_QUEUE_TASK_NOT_FOUND', 'CRM queue logs must be tenant isolated');
|
||
|
||
const crmRetried = await request('/api/crm/queue/action', {
|
||
userId: TENANT_ADMIN_USER_ID,
|
||
method: 'POST',
|
||
body: {
|
||
queueId: ids.crmDeadLetterQueue,
|
||
action: 'retry',
|
||
note: 'integration retry',
|
||
metadata: { source: 'integration-test', accessToken: 'dead-letter-secret' },
|
||
},
|
||
});
|
||
assert.equal(crmRetried.item?.status, 'pending', 'CRM retry should reset failed task to pending');
|
||
assert.equal(crmRetried.item?.lastError, null, 'CRM retry should clear last error');
|
||
assert.equal(crmRetried.item?.lastOperatorAction, 'retry', 'CRM retry should record operator action');
|
||
assert.ok(!JSON.stringify(crmRetried).includes('dead-letter-secret'), 'CRM retry response must redact metadata secrets');
|
||
|
||
const crmRetriedLogs = await request('/api/crm/queue/logs', {
|
||
userId: TENANT_ADMIN_USER_ID,
|
||
query: { queueId: ids.crmDeadLetterQueue, limit: 10 },
|
||
});
|
||
assert.ok(crmRetriedLogs.items?.some(item => item.operation === 'retry'), 'CRM retry should append operator log');
|
||
assert.ok(!JSON.stringify(crmRetriedLogs).includes('dead-letter-secret'), 'CRM retry operator log must redact metadata secrets');
|
||
|
||
const crmIgnorePending = await request('/api/crm/queue/action', {
|
||
userId: TENANT_ADMIN_USER_ID,
|
||
method: 'POST',
|
||
body: {
|
||
queueId: ids.crmDeadLetterQueue,
|
||
action: 'ignore',
|
||
},
|
||
expectStatus: 409,
|
||
});
|
||
assert.equal(crmIgnorePending.code, 'CRM_QUEUE_STATUS_NOT_IGNORABLE', 'CRM ignore should only apply to dead-letter tasks');
|
||
|
||
const crmDeadLetterResetPool = new pg.Pool({ connectionString: process.env.DATABASE_URL || DEFAULT_DATABASE_URL });
|
||
try {
|
||
await crmDeadLetterResetPool.query(
|
||
`
|
||
update public.crm_webhook_queue
|
||
set status = 'failed',
|
||
attempts = 4,
|
||
last_error = 'retry failed with access_token=dead-letter-secret',
|
||
last_http_code = 500,
|
||
dead_lettered_at = now(),
|
||
updated_at = now()
|
||
where tenant_id = $1 and id = $2::uuid
|
||
`,
|
||
[MAIN_TENANT_ID, ids.crmDeadLetterQueue],
|
||
);
|
||
} finally {
|
||
await crmDeadLetterResetPool.end();
|
||
}
|
||
|
||
const crmIgnored = await request('/api/crm/queue/action', {
|
||
userId: TENANT_ADMIN_USER_ID,
|
||
method: 'POST',
|
||
body: {
|
||
queueId: ids.crmDeadLetterQueue,
|
||
action: 'ignore',
|
||
note: 'integration ignored after manual review',
|
||
},
|
||
});
|
||
assert.equal(crmIgnored.item?.status, 'discarded', 'CRM ignore should move failed task to discarded');
|
||
assert.equal(crmIgnored.item?.ignoredBy, TENANT_ADMIN_USER_ID, 'CRM ignore should record operator');
|
||
assert.equal(crmIgnored.item?.lastOperatorAction, 'ignore', 'CRM ignore should record operator action');
|
||
|
||
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('platform admin permissions', testPlatformAdminPermissions);
|
||
await check('platform staff management', testPlatformStaffManagement);
|
||
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;
|
||
});
|