Files
memind/billing-subscription.test.mjs
2026-06-29 22:20:04 +08:00

301 lines
10 KiB
JavaScript

import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import {
PLAN_CATALOG,
createSubscriptionService,
ensurePlanCatalogSchema,
getPlanDef,
tokensToCallsApprox,
} from './billing-subscription.mjs';
describe('PLAN_CATALOG', () => {
it('has expected plan types', () => {
assert.ok(PLAN_CATALOG.free);
assert.ok(PLAN_CATALOG.lite);
assert.ok(PLAN_CATALOG.standard);
assert.ok(PLAN_CATALOG.pro);
});
it('free plan has positive token limit', () => {
assert.ok(PLAN_CATALOG.free.periodTokens > 0);
assert.equal(PLAN_CATALOG.free.priceCents, 0);
});
it('uses expanded token allowance defaults', () => {
assert.equal(PLAN_CATALOG.free.periodTokens, 450_000);
assert.equal(PLAN_CATALOG.lite.periodTokens, 3_600_000);
assert.equal(PLAN_CATALOG.standard.periodTokens, 13_500_000);
});
it('pro plan has unlimited tokens (0)', () => {
assert.equal(PLAN_CATALOG.pro.periodTokens, 0);
});
it('all plans have valid overageRate', () => {
for (const [, plan] of Object.entries(PLAN_CATALOG)) {
assert.ok(plan.overageRate > 0 && plan.overageRate <= 1.0, `overageRate out of range: ${plan.overageRate}`);
}
});
});
describe('getPlanDef', () => {
it('returns plan for known type', () => {
assert.deepEqual(getPlanDef('standard'), PLAN_CATALOG.standard);
});
it('returns null for unknown type', () => {
assert.equal(getPlanDef('enterprise'), null);
});
});
describe('tokensToCallsApprox', () => {
it('returns null for unlimited (0)', () => {
assert.equal(tokensToCallsApprox(0), null);
});
it('calculates approximate calls', () => {
assert.equal(tokensToCallsApprox(450_000), 150); // free ~150 calls
assert.equal(tokensToCallsApprox(3_600_000), 1200); // lite ~1200 calls
});
});
describe('ensurePlanCatalogSchema', () => {
it('expands legacy default token allowances without overwriting custom values', async () => {
const queries = [];
const pool = {
async query(sql, params = []) {
queries.push({ sql, params });
if (sql.includes('COUNT(*) AS cnt')) return [[{ cnt: 1 }]];
return [{ affectedRows: 1 }];
},
};
await ensurePlanCatalogSchema(pool);
const planUpdates = queries.filter(({ sql }) => sql.includes('UPDATE h5_plan_catalog'));
const subUpdates = queries.filter(({ sql }) => sql.includes('UPDATE h5_subscriptions'));
assert.ok(planUpdates.some(({ params }) => params[0] === 450_000 && params[2] === 'free' && params[3] === 150_000));
assert.ok(planUpdates.some(({ params }) => params[0] === 3_600_000 && params[2] === 'lite' && params[3] === 1_200_000));
assert.ok(planUpdates.some(({ params }) => params[0] === 13_500_000 && params[2] === 'standard' && params[3] === 4_500_000));
assert.equal(subUpdates.length, 3);
});
});
// Mock pool helper
function makePool(subRow = null) {
const queries = [];
const conn = {
queries: [],
async query(sql, params) {
this.queries.push({ sql, params });
if (sql.includes('FOR UPDATE') && subRow) {
return [[subRow]];
}
if (sql.includes('UPDATE h5_subscriptions')) {
return [{ affectedRows: 1 }];
}
return [[subRow].filter(Boolean)];
},
async beginTransaction() {},
async commit() {},
async rollback() {},
release() {},
};
return {
queries,
async query(sql, params) {
queries.push({ sql, params });
if (sql.includes('SELECT * FROM h5_subscriptions') && subRow) return [[subRow]];
if (sql.includes('COUNT(*)')) return [[{ total: subRow ? 1 : 0 }]];
if (sql.includes('UPDATE h5_subscriptions') || sql.includes('UPDATE h5_users')) return [{ affectedRows: 1 }];
if (sql.includes('INSERT INTO h5_subscriptions')) return [{ insertId: 1 }];
return [subRow ? [subRow] : []];
},
async getConnection() { return conn; },
_conn: conn,
};
}
function makeSubRow(overrides = {}) {
const now = Date.now();
return {
id: 'sub-1',
user_id: 'user-1',
plan_type: 'standard',
status: 'active',
period_tokens_limit: 4_500_000,
period_tokens_used: 0,
period_images_limit: 50,
period_images_used: 0,
period_start: now,
period_end: now + 30 * 24 * 60 * 60 * 1000,
expires_at: now + 30 * 24 * 60 * 60 * 1000,
overage_rate: 0.70,
operator_id: null,
note: null,
created_at: now,
updated_at: now,
...overrides,
};
}
describe('createSubscriptionService', () => {
describe('getActiveSubscription', () => {
it('returns null when no active subscription', async () => {
const pool = makePool(null);
const svc = createSubscriptionService(pool);
const result = await svc.getActiveSubscription('user-1');
assert.equal(result, null);
});
it('returns mapped subscription when active', async () => {
const pool = makePool(makeSubRow());
const svc = createSubscriptionService(pool);
const result = await svc.getActiveSubscription('user-1');
assert.ok(result);
assert.equal(result.planType, 'standard');
assert.equal(result.periodTokensLimit, 4_500_000);
assert.equal(result.overageRate, 0.70);
});
});
describe('consumeQuota', () => {
it('returns fullyCovers=true for unlimited plan (limit=0)', async () => {
const subRow = makeSubRow({ plan_type: 'pro', period_tokens_limit: 0, period_tokens_used: 0 });
const pool = makePool(subRow);
const svc = createSubscriptionService(pool);
const coverage = await svc.consumeQuota('user-1', 5000, pool._conn);
assert.equal(coverage.fullyCovers, true);
});
it('returns fullyCovers=true when quota has enough tokens', async () => {
const subRow = makeSubRow({ period_tokens_limit: 4_500_000, period_tokens_used: 0 });
const pool = makePool(subRow);
const svc = createSubscriptionService(pool);
const coverage = await svc.consumeQuota('user-1', 2000, pool._conn);
assert.equal(coverage.fullyCovers, true);
});
it('returns fullyCovers=false when quota exhausted', async () => {
const subRow = makeSubRow({ period_tokens_limit: 4_500_000, period_tokens_used: 4_500_000 });
const pool = makePool(subRow);
const svc = createSubscriptionService(pool);
const coverage = await svc.consumeQuota('user-1', 2000, pool._conn);
assert.equal(coverage.fullyCovers, false);
assert.equal(coverage.overageRate, 0.70);
});
it('returns fullyCovers=false and overageRate=1.0 when no subscription', async () => {
const pool = makePool(null);
const svc = createSubscriptionService(pool);
const coverage = await svc.consumeQuota('user-1', 2000, pool._conn);
assert.equal(coverage.fullyCovers, false);
assert.equal(coverage.overageRate, 1.0);
});
it('returns fullyCovers=true for zero delta tokens', async () => {
const pool = makePool(null);
const svc = createSubscriptionService(pool);
const coverage = await svc.consumeQuota('user-1', 0, pool._conn);
assert.equal(coverage.fullyCovers, true);
});
});
describe('consumeImageQuota', () => {
it('deducts one image via its own transaction when quota exists', async () => {
const subRow = makeSubRow({ period_images_limit: 50, period_images_used: 0 });
const pool = makePool(subRow);
const svc = createSubscriptionService(pool);
const coverage = await svc.consumeImageQuota('user-1', 1);
assert.equal(coverage.fullyCovers, true);
assert.ok(
pool._conn.queries.some(
({ sql, params }) =>
sql.includes('SET period_images_used = period_images_used + ?') &&
params?.[0] === 1,
),
);
});
});
describe('cancelSubscription', () => {
it('returns cancelled=true when active sub exists', async () => {
const pool = makePool(makeSubRow());
const svc = createSubscriptionService(pool);
const result = await svc.cancelSubscription('user-1');
assert.equal(result.ok, true);
assert.equal(result.cancelled, true);
});
});
describe('grantSubscription', () => {
it('returns error for unknown plan type', async () => {
const pool = makePool(null);
const svc = createSubscriptionService(pool);
const result = await svc.grantSubscription('user-1', 'enterprise', 30);
assert.equal(result.ok, false);
assert.match(result.message, /未知套餐/);
});
});
describe('processAutoRenewals', () => {
it('uses DB-backed plan definitions for renewed subscriptions', async () => {
const expiredSub = makeSubRow({
plan_type: 'lite',
auto_renew: 1,
expires_at: Date.now() - 1000,
balance_cents: 9999,
});
const conn = {
queries: [],
async query(sql, params) {
this.queries.push({ sql, params });
if (sql.includes('SELECT balance_cents')) return [[{ balance_cents: 9999 }]];
return [{ affectedRows: 1 }];
},
async beginTransaction() {},
async commit() {},
async rollback() {},
release() {},
};
const pool = {
async query(sql) {
if (sql.includes('FROM h5_subscriptions s')) return [[expiredSub]];
return [[]];
},
async getConnection() { return conn; },
};
const svc = createSubscriptionService(pool, {
getPlanAsync: async (planType) => planType === 'lite'
? {
name: '轻量版',
priceCents: 990,
periodTokens: 9_999_000,
periodImages: 50,
modelTier: 'basic',
overageRate: 0.45,
periodDays: 30,
}
: null,
});
const originalLog = console.log;
console.log = () => {};
try {
const result = await svc.processAutoRenewals();
assert.equal(result.renewed, 1);
} finally {
console.log = originalLog;
}
const insert = conn.queries.find(({ sql }) => sql.includes('INSERT INTO h5_subscriptions'));
assert.ok(insert);
assert.equal(insert.params[3], 9_999_000);
assert.equal(insert.params[8], 0.45);
});
});
});