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