import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import { PLAN_CATALOG, createSubscriptionService, 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('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(150_000), 50); // free ~50 calls assert.equal(tokensToCallsApprox(1_200_000), 400); // lite ~400 calls }); }); // 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, /未知套餐/); }); }); });