9b4a25799f
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>
214 lines
7.0 KiB
JavaScript
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, /未知套餐/);
|
|
});
|
|
});
|
|
});
|