Files
lisglosips/tests/api/remote-customers-balance.mjs
T

349 lines
14 KiB
JavaScript

import { mkdir, writeFile } from 'node:fs/promises';
import { request } from 'node:https';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const baseUrl = (process.env.LISGLOSIPS_BASE_URL || 'https://100.90.90.91').replace(/\/$/, '');
const username = process.env.LISGLOSIPS_AUTH_USERNAME || 'admin';
const password = process.env.LISGLOSIPS_AUTH_PASSWORD;
const lowUsername = process.env.LISGLOSIPS_LOW_AUTH_USERNAME || 'codex.low';
const lowPassword = process.env.LISGLOSIPS_LOW_AUTH_PASSWORD || `${password}!low`;
const timeoutMs = Number(process.env.LISGLOSIPS_CUSTOMERS_TIMEOUT_MS || 30000);
const reportDir = resolve(dirname(fileURLToPath(import.meta.url)), '../reports');
const customerName = process.env.LISGLOSIPS_CUSTOMER_TEST_NAME || '自动化8.2客户';
const customerDomain = process.env.LISGLOSIPS_CUSTOMER_TEST_DOMAIN || 'codex-82.example.test';
if (!password) {
console.error('LISGLOSIPS_AUTH_PASSWORD is required.');
process.exit(2);
}
function requestApi(path, options = {}) {
return new Promise((resolveRequest) => {
const startedAt = Date.now();
const url = new URL(path, baseUrl);
const method = options.method || 'GET';
const body = options.body === undefined ? undefined : JSON.stringify(options.body);
const headers = {
Accept: 'application/json',
'User-Agent': 'lisglosips-remote-customers-balance/1.0',
...(body ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) } : {}),
...(options.accessToken ? { Authorization: `Bearer ${options.accessToken}` } : {}),
};
console.log(`REQ ${method} ${path}`);
let settled = false;
let req;
const hardTimer = setTimeout(() => {
req?.destroy(new Error(`Request exceeded hard timeout after ${timeoutMs}ms`));
}, timeoutMs);
const finish = (result) => {
if (settled) {
return;
}
settled = true;
clearTimeout(hardTimer);
console.log(`RES ${method} ${path} ${result.statusCode || 'ERR'} ${result.durationMs}ms`);
resolveRequest(result);
};
req = request(
url,
{
method,
rejectUnauthorized: process.env.LISGLOSIPS_REJECT_UNAUTHORIZED === '1',
timeout: timeoutMs,
headers,
},
(res) => {
const chunks = [];
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () => {
finish({
path,
method,
ok: true,
statusCode: res.statusCode || 0,
durationMs: Date.now() - startedAt,
contentType: String(res.headers['content-type'] || ''),
body: Buffer.concat(chunks).toString('utf8'),
});
});
}
);
req.setTimeout(timeoutMs, () => {
req.destroy(new Error(`Request timed out after ${timeoutMs}ms`));
});
req.on('timeout', () => {
req.destroy(new Error(`Request timed out after ${timeoutMs}ms`));
});
req.on('error', (error) => {
finish({
path,
method,
ok: false,
statusCode: 0,
durationMs: Date.now() - startedAt,
contentType: '',
body: '',
error: error.message,
});
});
if (body) {
req.write(body);
}
req.end();
});
}
function parseJson(result) {
try {
return JSON.parse(result.body);
} catch {
return null;
}
}
function decodeCaptcha(imageDataUrl) {
const encoded = String(imageDataUrl || '').split(',', 2)[1];
if (!encoded) {
return '';
}
const svg = Buffer.from(encoded, 'base64').toString('utf8');
return [...svg.matchAll(/<text\b[^>]*>([^<]+)<\/text>/g)].map((match) => match[1]).join('');
}
async function login(loginUsername, loginPassword) {
const captcha = await requestApi('/api/v2/auth/captcha');
const captchaBody = parseJson(captcha);
const captchaCode = decodeCaptcha(captchaBody?.imageDataUrl);
const result = await requestApi('/api/v2/auth/login', {
method: 'POST',
body: {
username: loginUsername,
password: loginPassword,
captchaId: captchaBody?.captchaId,
captchaCode,
},
});
return { captcha, captchaCode, result, body: parseJson(result) };
}
function micros(value) {
const raw = String(value);
const negative = raw.startsWith('-');
const normalized = negative ? raw.slice(1) : raw;
const [integerPart, fractionPart = ''] = normalized.split('.');
const amount = BigInt(integerPart || '0') * 1_000_000n + BigInt(fractionPart.padEnd(6, '0').slice(0, 6) || '0');
return negative ? -amount : amount;
}
function fixed(value) {
const negative = value < 0n;
const absolute = negative ? -value : value;
const integerPart = absolute / 1_000_000n;
const fractionPart = String(absolute % 1_000_000n).padStart(6, '0');
return `${negative ? '-' : ''}${integerPart}.${fractionPart}`;
}
function addDecimal(left, right) {
return fixed(micros(left) + micros(right));
}
function check(name, pass, detail) {
return { name, pass, detail };
}
function statusCheck(name, result, expectedStatus) {
return check(name, result.ok && result.statusCode === expectedStatus, result.error || `status=${result.statusCode}, duration=${result.durationMs}ms`);
}
function reportLine(item) {
return `| ${item.pass ? 'PASS' : 'FAIL'} | ${item.name} | ${String(item.detail).replace(/\|/g, '\\|')} |`;
}
async function findCustomer(accessToken) {
const list = await requestApi('/api/v2/customers', { accessToken });
const body = parseJson(list);
const customer = Array.isArray(body) ? body.find((item) => item.name === customerName || item.domain === customerDomain) : null;
return { list, customer };
}
async function ensureCustomer(accessToken) {
const existing = await findCustomer(accessToken);
if (!existing.customer) {
const created = await requestApi('/api/v2/customers', {
method: 'POST',
accessToken,
body: {
name: customerName,
contactName: 'Codex测试联系人',
phone: '13800138200',
email: 'codex-82@example.test',
domain: customerDomain,
billingMode: 'PREPAID',
creditLimit: '100.000000',
minBalance: '5.000000',
notes: 'Codex 8.2 automated customer',
},
});
return { action: 'created', result: created, customer: parseJson(created) };
}
const updated = await requestApi(`/api/v2/customers/${encodeURIComponent(existing.customer.id)}`, {
method: 'PATCH',
accessToken,
body: {
name: customerName,
contactName: 'Codex测试联系人',
phone: '13800138200',
email: 'codex-82@example.test',
domain: customerDomain,
billingMode: 'PREPAID',
creditLimit: '100.000000',
minBalance: '5.000000',
notes: 'Codex 8.2 automated customer',
},
});
return { action: 'updated', result: updated, customer: parseJson(updated) };
}
const checks = [];
const adminLogin = await login(username, password);
checks.push(statusCheck('admin can login', adminLogin.result, 200));
checks.push(check('admin login returns access token', typeof adminLogin.body?.accessToken === 'string', `tokenLength=${adminLogin.body?.accessToken?.length || 0}`));
const accessToken = adminLogin.body?.accessToken;
const lowLogin = await login(lowUsername, lowPassword);
checks.push(statusCheck('low-privilege user can login for RBAC checks', lowLogin.result, 200));
const lowCustomersList = await requestApi('/api/v2/customers', { accessToken: lowLogin.body?.accessToken });
checks.push(statusCheck('low-privilege user cannot list customers', lowCustomersList, 403));
const customerSetup = await ensureCustomer(accessToken);
checks.push(statusCheck(`test customer is ${customerSetup.action}`, customerSetup.result, customerSetup.action === 'created' ? 201 : 200));
checks.push(check('test customer has expected credit/min balance', customerSetup.customer?.creditLimit === '100.000000' && customerSetup.customer?.minBalance === '5.000000', `creditLimit=${customerSetup.customer?.creditLimit}, minBalance=${customerSetup.customer?.minBalance}`));
const customerId = customerSetup.customer?.id;
const customerGet = await requestApi(`/api/v2/customers/${encodeURIComponent(customerId)}`, { accessToken });
const customerBefore = parseJson(customerGet);
checks.push(statusCheck('customer detail can be fetched', customerGet, 200));
checks.push(check('available balance equals balance plus credit limit', customerBefore?.availableBalance === addDecimal(customerBefore?.balance || '0.000000', customerBefore?.creditLimit || '0.000000'), `balance=${customerBefore?.balance}, creditLimit=${customerBefore?.creditLimit}, available=${customerBefore?.availableBalance}`));
const disable = await requestApi(`/api/v2/customers/${encodeURIComponent(customerId)}/disable`, { method: 'POST', accessToken });
checks.push(statusCheck('customer can be disabled', disable, 201));
checks.push(check('disabled customer status is DISABLED', parseJson(disable)?.status === 'DISABLED', `status=${parseJson(disable)?.status}`));
const enable = await requestApi(`/api/v2/customers/${encodeURIComponent(customerId)}/enable`, { method: 'POST', accessToken });
checks.push(statusCheck('customer can be enabled', enable, 201));
checks.push(check('enabled customer status is ENABLED', parseJson(enable)?.status === 'ENABLED', `status=${parseJson(enable)?.status}`));
const positiveKey = `codex82:positive:${Date.now()}`;
const positiveRecharge = await requestApi(`/api/v2/customers/${encodeURIComponent(customerId)}/recharges`, {
method: 'POST',
accessToken,
body: {
amount: '10.000000',
idempotencyKey: positiveKey,
remark: 'Codex 8.2 positive recharge',
},
});
const positiveBody = parseJson(positiveRecharge);
checks.push(statusCheck('positive customer recharge succeeds', positiveRecharge, 201));
checks.push(check('positive recharge balance delta is +10.000000', positiveBody?.afterBalance === addDecimal(positiveBody?.beforeBalance || '0.000000', '10.000000'), `before=${positiveBody?.beforeBalance}, after=${positiveBody?.afterBalance}`));
const duplicatePositive = await requestApi(`/api/v2/customers/${encodeURIComponent(customerId)}/recharges`, {
method: 'POST',
accessToken,
body: {
amount: '10.000000',
idempotencyKey: positiveKey,
remark: 'Codex 8.2 positive recharge',
},
});
const duplicatePositiveBody = parseJson(duplicatePositive);
checks.push(statusCheck('same idempotency key with same body returns cached success', duplicatePositive, 201));
checks.push(check('idempotent duplicate returns same recharge id', duplicatePositiveBody?.id === positiveBody?.id, `first=${positiveBody?.id}, duplicate=${duplicatePositiveBody?.id}`));
const conflictingIdempotency = await requestApi(`/api/v2/customers/${encodeURIComponent(customerId)}/recharges`, {
method: 'POST',
accessToken,
body: {
amount: '11.000000',
idempotencyKey: positiveKey,
remark: 'Codex 8.2 idempotency conflict',
},
});
checks.push(statusCheck('same idempotency key with different body is rejected', conflictingIdempotency, 409));
const negativeKey = `codex82:negative:${Date.now()}`;
const negativeRecharge = await requestApi(`/api/v2/customers/${encodeURIComponent(customerId)}/recharges`, {
method: 'POST',
accessToken,
body: {
amount: '-3.000000',
idempotencyKey: negativeKey,
remark: 'Codex 8.2 negative adjustment',
},
});
const negativeBody = parseJson(negativeRecharge);
checks.push(statusCheck('negative customer recharge deducts balance', negativeRecharge, 201));
checks.push(check('negative recharge balance delta is -3.000000', negativeBody?.afterBalance === addDecimal(negativeBody?.beforeBalance || '0.000000', '-3.000000'), `before=${negativeBody?.beforeBalance}, after=${negativeBody?.afterBalance}, code=${negativeBody?.code || 'n/a'}`));
const rechargeList = await requestApi(`/api/v2/recharges?accountType=CUSTOMER&accountId=${encodeURIComponent(customerId)}&take=20`, { accessToken });
const rechargeListBody = parseJson(rechargeList);
checks.push(statusCheck('customer recharge list can be filtered by account', rechargeList, 200));
checks.push(check('recharge list contains positive recharge record', Array.isArray(rechargeListBody?.items) && rechargeListBody.items.some((item) => item.id === positiveBody?.id), `total=${rechargeListBody?.total}`));
const lowRecharge = await requestApi(`/api/v2/customers/${encodeURIComponent(customerId)}/recharges`, {
method: 'POST',
accessToken: lowLogin.body?.accessToken,
body: {
amount: '1.000000',
idempotencyKey: `codex82:low:${Date.now()}`,
remark: 'Should be forbidden',
},
});
checks.push(statusCheck('low-privilege user cannot recharge customer', lowRecharge, 403));
const now = new Date();
const stamp = now.toISOString().replace(/[-:]/g, '').replace(/\..+$/, 'Z');
const reportPath = resolve(reportDir, `REMOTE_CUSTOMERS_BALANCE_${stamp}.md`);
const failed = checks.filter((item) => !item.pass);
const report = [
'# Remote Customers, Recharge, and Balance Test Report',
'',
`Date: ${now.toISOString()}`,
`Base URL: ${baseUrl}`,
`Username: ${username}`,
`Low-Privilege Username: ${lowUsername}`,
`Customer Name: ${customerName}`,
`Customer Domain: ${customerDomain}`,
'',
'| Result | Check | Detail |',
'| --- | --- | --- |',
...checks.map(reportLine),
'',
'## Notes',
'',
'- Password and token values are intentionally omitted.',
'- Negative recharge is asserted as a required business rule: negative amount should deduct customer balance.',
'',
].join('\n');
await mkdir(reportDir, { recursive: true });
await writeFile(reportPath, report, 'utf8');
for (const item of checks) {
console.log(`${item.pass ? 'PASS' : 'FAIL'} ${item.name} - ${item.detail}`);
}
console.log(`Report: ${reportPath}`);
if (failed.length > 0) {
process.exitCode = 1;
}