Files
memind/server/portal-billing-routes.test.mjs

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);
});