Files
memind/billing-subscription.test.mjs
T
john 9b4a25799f Add smart ACK provider for WeChat MP replies
Replace fixed ackText with a rule-based AckProvider that picks
response templates by message type and intent (translate, summary,
rewrite, poster, ppt, mindmap, code, search, schedule). Pure sync,
zero I/O, auto-falls back to config.ackText on any error.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 15:19:03 +08:00

214 lines
7.0 KiB
JavaScript

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, /未知套餐/);
});
});
});