623 lines
12 KiB
JavaScript
623 lines
12 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import {
|
|
attachPortalBillingRoutes,
|
|
} from './portal-billing-routes.mjs';
|
|
|
|
function createResponse() {
|
|
return {
|
|
statusCode: 200,
|
|
body: undefined,
|
|
status(code) {
|
|
this.statusCode = code;
|
|
return this;
|
|
},
|
|
json(body) {
|
|
this.body = body;
|
|
return this;
|
|
},
|
|
};
|
|
}
|
|
|
|
function createSetup(overrides = {}) {
|
|
const routes = [];
|
|
const calls = [];
|
|
let userAuth = {
|
|
async getMe(token) {
|
|
calls.push(['get-me', token]);
|
|
return {
|
|
id: 'user-1',
|
|
status: 'active',
|
|
};
|
|
},
|
|
async listBillingLedger(options) {
|
|
calls.push(['ledger', options]);
|
|
return [{ id: 'entry-1' }];
|
|
},
|
|
async getUserById(userId) {
|
|
calls.push(['get-user', userId]);
|
|
return {
|
|
id: userId,
|
|
balance_cents: 8800,
|
|
};
|
|
},
|
|
async purchaseSpaceQuota(userId, sizeMb) {
|
|
calls.push([
|
|
'space-purchase',
|
|
userId,
|
|
sizeMb,
|
|
]);
|
|
return {
|
|
ok: true,
|
|
quota: {
|
|
quotaBytes: 1024,
|
|
},
|
|
balanceCents: 5000,
|
|
};
|
|
},
|
|
};
|
|
let rechargeService = {
|
|
async getBillingConfig(userId) {
|
|
calls.push(['billing-config', userId]);
|
|
return {
|
|
minRechargeCents: 1000,
|
|
};
|
|
},
|
|
async createOrder(options) {
|
|
calls.push(['create-order', options]);
|
|
return {
|
|
ok: true,
|
|
order: {
|
|
id: 'order-1',
|
|
status: 'pending',
|
|
},
|
|
};
|
|
},
|
|
async getOrderForUser(userId, orderId) {
|
|
calls.push([
|
|
'get-order',
|
|
userId,
|
|
orderId,
|
|
]);
|
|
return {
|
|
id: orderId,
|
|
status: 'paid',
|
|
};
|
|
},
|
|
};
|
|
let subscriptionService = {
|
|
async getActiveSubscription(userId) {
|
|
calls.push(['subscription', userId]);
|
|
return {
|
|
planType: 'pro',
|
|
};
|
|
},
|
|
async purchaseSubscription(
|
|
userId,
|
|
planType,
|
|
autoRenew,
|
|
) {
|
|
calls.push([
|
|
'subscribe',
|
|
userId,
|
|
planType,
|
|
autoRenew,
|
|
]);
|
|
return {
|
|
ok: true,
|
|
subscription: {
|
|
planType,
|
|
autoRenew,
|
|
},
|
|
balanceCents: 6800,
|
|
};
|
|
},
|
|
async setAutoRenew(userId, enabled) {
|
|
calls.push([
|
|
'auto-renew',
|
|
userId,
|
|
enabled,
|
|
]);
|
|
return {
|
|
ok: true,
|
|
enabled,
|
|
};
|
|
},
|
|
};
|
|
let mindSpace = {
|
|
async getQuota(userId) {
|
|
calls.push(['quota', userId]);
|
|
return {
|
|
quotaBytes: 2048,
|
|
};
|
|
},
|
|
};
|
|
const jsonBody = (_req, _res, next) => next();
|
|
const app = {
|
|
get(path, ...handlers) {
|
|
routes.push({
|
|
method: 'get',
|
|
path,
|
|
handlers,
|
|
});
|
|
},
|
|
post(path, ...handlers) {
|
|
routes.push({
|
|
method: 'post',
|
|
path,
|
|
handlers,
|
|
});
|
|
},
|
|
};
|
|
const options = {
|
|
app,
|
|
jsonBody,
|
|
userAuthReady: Promise.resolve(),
|
|
getUserAuth: () => userAuth,
|
|
getRechargeService: () => rechargeService,
|
|
getSubscriptionService: () =>
|
|
subscriptionService,
|
|
getMindSpace: () => mindSpace,
|
|
userToken: () => 'request-token',
|
|
planCatalogFallback: {
|
|
free: {
|
|
planType: 'free',
|
|
priceCents: 0,
|
|
},
|
|
pro: {
|
|
planType: 'pro',
|
|
priceCents: 5000,
|
|
},
|
|
},
|
|
...overrides,
|
|
};
|
|
attachPortalBillingRoutes(options);
|
|
|
|
return {
|
|
routes,
|
|
calls,
|
|
jsonBody,
|
|
setUserAuth(value) {
|
|
userAuth = value;
|
|
},
|
|
setRechargeService(value) {
|
|
rechargeService = value;
|
|
},
|
|
setSubscriptionService(value) {
|
|
subscriptionService = value;
|
|
},
|
|
setMindSpace(value) {
|
|
mindSpace = value;
|
|
},
|
|
route(method, path) {
|
|
return routes.find(
|
|
(route) =>
|
|
route.method === method &&
|
|
route.path === path,
|
|
);
|
|
},
|
|
};
|
|
}
|
|
|
|
async function invoke(
|
|
setup,
|
|
method,
|
|
path,
|
|
req = {},
|
|
) {
|
|
const route = setup.route(method, path);
|
|
assert.ok(
|
|
route,
|
|
`${method.toUpperCase()} ${path}`,
|
|
);
|
|
const res = createResponse();
|
|
await route.handlers.at(-1)(
|
|
{
|
|
body: {},
|
|
query: {},
|
|
params: {},
|
|
ip: '127.0.0.1',
|
|
...req,
|
|
},
|
|
res,
|
|
);
|
|
return res;
|
|
}
|
|
|
|
test('registers the billing route inventory and JSON middleware order', () => {
|
|
const setup = createSetup();
|
|
assert.deepEqual(
|
|
setup.routes.map(({ method, path }) => [
|
|
method,
|
|
path,
|
|
]),
|
|
[
|
|
['get', '/auth/billing/ledger'],
|
|
['get', '/auth/billing/config'],
|
|
['get', '/auth/billing/subscription'],
|
|
['get', '/auth/billing/plans'],
|
|
['post', '/auth/billing/subscribe'],
|
|
['post', '/auth/billing/space-purchase'],
|
|
['post', '/auth/billing/auto-renew'],
|
|
['post', '/auth/billing/recharge-orders'],
|
|
[
|
|
'get',
|
|
'/auth/billing/recharge-orders/:orderId',
|
|
],
|
|
],
|
|
);
|
|
for (const route of setup.routes.filter(
|
|
({ method }) => method === 'post',
|
|
)) {
|
|
assert.equal(route.handlers[0], setup.jsonBody);
|
|
}
|
|
});
|
|
|
|
test('preserves billing ledger scope and billing configuration', async () => {
|
|
const setup = createSetup();
|
|
const ledger = await invoke(
|
|
setup,
|
|
'get',
|
|
'/auth/billing/ledger',
|
|
{
|
|
query: { limit: '500' },
|
|
},
|
|
);
|
|
assert.deepEqual(ledger.body, {
|
|
entries: [{ id: 'entry-1' }],
|
|
});
|
|
assert.ok(
|
|
setup.calls.some(
|
|
(call) =>
|
|
call[0] === 'ledger' &&
|
|
call[1].limit === 100 &&
|
|
call[1].types.join(',') ===
|
|
'recharge,adjust,refund',
|
|
),
|
|
);
|
|
|
|
const config = await invoke(
|
|
setup,
|
|
'get',
|
|
'/auth/billing/config',
|
|
);
|
|
assert.deepEqual(config.body, {
|
|
minRechargeCents: 1000,
|
|
subscription: {
|
|
planType: 'pro',
|
|
},
|
|
});
|
|
});
|
|
|
|
test('preserves subscription detail and plan catalog projections', async () => {
|
|
const setup = createSetup();
|
|
setup.setSubscriptionService(null);
|
|
const subscription = await invoke(
|
|
setup,
|
|
'get',
|
|
'/auth/billing/subscription',
|
|
);
|
|
assert.deepEqual(subscription.body, {
|
|
subscription: null,
|
|
plans: {
|
|
free: {
|
|
planType: 'free',
|
|
priceCents: 0,
|
|
},
|
|
pro: {
|
|
planType: 'pro',
|
|
priceCents: 5000,
|
|
},
|
|
},
|
|
});
|
|
|
|
const plans = await invoke(
|
|
setup,
|
|
'get',
|
|
'/auth/billing/plans',
|
|
);
|
|
assert.deepEqual(plans.body, {
|
|
plans: [
|
|
{
|
|
key: 'pro',
|
|
planType: 'pro',
|
|
priceCents: 5000,
|
|
},
|
|
],
|
|
subscription: null,
|
|
balanceCents: 8800,
|
|
});
|
|
|
|
const dynamic = createSetup();
|
|
dynamic.setSubscriptionService({
|
|
_planCatalogService: {
|
|
async listPlans(options) {
|
|
dynamic.calls.push([
|
|
'catalog',
|
|
options,
|
|
]);
|
|
return [
|
|
{
|
|
planType: 'free',
|
|
priceCents: 0,
|
|
},
|
|
{
|
|
planType: 'team',
|
|
priceCents: 12000,
|
|
},
|
|
];
|
|
},
|
|
},
|
|
async getActiveSubscription() {
|
|
return null;
|
|
},
|
|
});
|
|
const dynamicPlans = await invoke(
|
|
dynamic,
|
|
'get',
|
|
'/auth/billing/plans',
|
|
);
|
|
assert.deepEqual(dynamicPlans.body.plans, [
|
|
{
|
|
key: 'team',
|
|
planType: 'team',
|
|
priceCents: 12000,
|
|
},
|
|
]);
|
|
});
|
|
|
|
test('preserves subscription purchase validation and error mappings', async () => {
|
|
const missing = createSetup();
|
|
const missingRes = await invoke(
|
|
missing,
|
|
'post',
|
|
'/auth/billing/subscribe',
|
|
);
|
|
assert.equal(missingRes.statusCode, 400);
|
|
|
|
const limited = createSetup();
|
|
limited.setSubscriptionService({
|
|
async purchaseSubscription() {
|
|
return {
|
|
ok: false,
|
|
code: 'INSUFFICIENT_BALANCE',
|
|
message: '余额不足',
|
|
balanceCents: 100,
|
|
requiredCents: 5000,
|
|
shortfallCents: 4900,
|
|
};
|
|
},
|
|
});
|
|
const limitedRes = await invoke(
|
|
limited,
|
|
'post',
|
|
'/auth/billing/subscribe',
|
|
{
|
|
body: {
|
|
planType: 'pro',
|
|
autoRenew: true,
|
|
},
|
|
},
|
|
);
|
|
assert.equal(limitedRes.statusCode, 402);
|
|
assert.equal(limitedRes.body.shortfallCents, 4900);
|
|
|
|
const downgrade = createSetup();
|
|
downgrade.setSubscriptionService({
|
|
async purchaseSubscription() {
|
|
return {
|
|
ok: false,
|
|
code: 'DOWNGRADE_NOT_ALLOWED',
|
|
message: '不可降级',
|
|
currentPlanType: 'team',
|
|
};
|
|
},
|
|
});
|
|
const downgradeRes = await invoke(
|
|
downgrade,
|
|
'post',
|
|
'/auth/billing/subscribe',
|
|
{
|
|
body: { planType: 'pro' },
|
|
},
|
|
);
|
|
assert.equal(downgradeRes.statusCode, 409);
|
|
|
|
const success = createSetup();
|
|
const successRes = await invoke(
|
|
success,
|
|
'post',
|
|
'/auth/billing/subscribe',
|
|
{
|
|
body: {
|
|
planType: 'pro',
|
|
autoRenew: 1,
|
|
},
|
|
},
|
|
);
|
|
assert.deepEqual(successRes.body, {
|
|
subscription: {
|
|
planType: 'pro',
|
|
autoRenew: true,
|
|
},
|
|
balanceCents: 6800,
|
|
});
|
|
});
|
|
|
|
test('preserves space purchase success and insufficient-balance details', async () => {
|
|
const setup = createSetup();
|
|
const success = await invoke(
|
|
setup,
|
|
'post',
|
|
'/auth/billing/space-purchase',
|
|
{
|
|
body: { sizeMb: '20' },
|
|
},
|
|
);
|
|
assert.deepEqual(success.body, {
|
|
quota: {
|
|
quotaBytes: 2048,
|
|
},
|
|
balanceCents: 5000,
|
|
purchasedMb: 20,
|
|
costCents: 4000,
|
|
});
|
|
|
|
const limited = createSetup();
|
|
limited.setUserAuth({
|
|
async getMe() {
|
|
return { id: 'user-1' };
|
|
},
|
|
async purchaseSpaceQuota() {
|
|
return {
|
|
ok: false,
|
|
code: 'INSUFFICIENT_BALANCE',
|
|
message: '余额不足',
|
|
balanceCents: 20,
|
|
minRechargeCents: 1000,
|
|
suggestedTiers: [1000, 5000],
|
|
};
|
|
},
|
|
});
|
|
const limitedRes = await invoke(
|
|
limited,
|
|
'post',
|
|
'/auth/billing/space-purchase',
|
|
{
|
|
body: { sizeMb: 10 },
|
|
},
|
|
);
|
|
assert.equal(limitedRes.statusCode, 402);
|
|
assert.equal(
|
|
limitedRes.body.details.minRechargeCents,
|
|
1000,
|
|
);
|
|
});
|
|
|
|
test('preserves auto-renew validation and update', async () => {
|
|
const setup = createSetup();
|
|
const invalid = await invoke(
|
|
setup,
|
|
'post',
|
|
'/auth/billing/auto-renew',
|
|
{
|
|
body: { enabled: 'true' },
|
|
},
|
|
);
|
|
assert.equal(invalid.statusCode, 400);
|
|
|
|
const updated = await invoke(
|
|
setup,
|
|
'post',
|
|
'/auth/billing/auto-renew',
|
|
{
|
|
body: { enabled: false },
|
|
},
|
|
);
|
|
assert.deepEqual(updated.body, {
|
|
ok: true,
|
|
enabled: false,
|
|
});
|
|
});
|
|
|
|
test('preserves recharge order creation scene mapping and errors', async () => {
|
|
const setup = createSetup();
|
|
const res = await invoke(
|
|
setup,
|
|
'post',
|
|
'/auth/billing/recharge-orders',
|
|
{
|
|
body: {
|
|
amountCents: '3000',
|
|
payScene: 'invalid',
|
|
},
|
|
ip: '203.0.113.10',
|
|
},
|
|
);
|
|
assert.equal(res.statusCode, 201);
|
|
assert.ok(
|
|
setup.calls.some(
|
|
(call) =>
|
|
call[0] === 'create-order' &&
|
|
call[1].amountCents === 3000 &&
|
|
call[1].payScene === 'native' &&
|
|
call[1].clientIp === '203.0.113.10',
|
|
),
|
|
);
|
|
|
|
const failing = createSetup();
|
|
failing.setRechargeService({
|
|
async createOrder() {
|
|
return {
|
|
ok: false,
|
|
message: '金额无效',
|
|
};
|
|
},
|
|
});
|
|
const failingRes = await invoke(
|
|
failing,
|
|
'post',
|
|
'/auth/billing/recharge-orders',
|
|
);
|
|
assert.equal(failingRes.statusCode, 400);
|
|
});
|
|
|
|
test('preserves recharge order lookup and paid balance projection', async () => {
|
|
const setup = createSetup();
|
|
const res = await invoke(
|
|
setup,
|
|
'get',
|
|
'/auth/billing/recharge-orders/:orderId',
|
|
{
|
|
params: { orderId: 'order-1' },
|
|
},
|
|
);
|
|
assert.deepEqual(res.body, {
|
|
order: {
|
|
id: 'order-1',
|
|
status: 'paid',
|
|
},
|
|
balanceCents: 8800,
|
|
});
|
|
|
|
const missing = createSetup();
|
|
missing.setRechargeService({
|
|
async getOrderForUser() {
|
|
return null;
|
|
},
|
|
});
|
|
const missingRes = await invoke(
|
|
missing,
|
|
'get',
|
|
'/auth/billing/recharge-orders/:orderId',
|
|
{
|
|
params: { orderId: 'missing' },
|
|
},
|
|
);
|
|
assert.equal(missingRes.statusCode, 404);
|
|
});
|
|
|
|
test('preserves billing service availability and authentication gates', async () => {
|
|
const unavailable = createSetup();
|
|
unavailable.setUserAuth(null);
|
|
const unavailableRes = await invoke(
|
|
unavailable,
|
|
'get',
|
|
'/auth/billing/ledger',
|
|
);
|
|
assert.equal(unavailableRes.statusCode, 503);
|
|
|
|
const unauthorized = createSetup();
|
|
unauthorized.setUserAuth({
|
|
async getMe() {
|
|
return null;
|
|
},
|
|
});
|
|
const unauthorizedRes = await invoke(
|
|
unauthorized,
|
|
'get',
|
|
'/auth/billing/plans',
|
|
);
|
|
assert.equal(unauthorizedRes.statusCode, 401);
|
|
});
|