Files
lindyson-ai/apps/api/tests/tkPromptEngineCredits.test.ts
Juno ChowandClaude Fable 5.1 f665082dad chore: 建立产品仓库,完成开源前的敏感信息清理
前端(Vite + React)、API/worker(Fastify + BullMQ)、api-contracts 契约包、
迁移脚本、测试与自托管 docker-compose 示例。

开源准备:运维脚本与机器信息已拆到私有仓库;公司名、备案号、隐私邮箱改读
publicCompliance.local.json(仓库只保留占位模板);站点地址与客服二维码改为
VITE_ 环境变量;fixtures 中的真实用户标识已匿名化;三个 package.json 标注
Apache-2.0。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-16 02:10:46 +08:00

229 lines
8.3 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
setUserAvailableCreditsWithClient: vi.fn(),
}));
vi.mock('../src/domain/financialCredits.js', () => ({
setUserAvailableCreditsWithClient: mocks.setUserAvailableCreditsWithClient,
}));
import { releaseTkPromptCreditsWithClient } from '../src/domain/tkPromptEngineCredits.js';
const userId = '10000000-0000-4000-8000-000000000001';
const generationId = '30000000-0000-4000-8000-000000000001';
describe('TK Prompt Engine 失败全额退款', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.setUserAvailableCreditsWithClient.mockResolvedValue({
mainCredits: 54,
addonCredits: 46,
balance: 100,
});
});
it('冻结期间主积分到期时补发等额 lot,returned + compensated 等于整批积分', async () => {
const client = scriptedClient((sql, params) => {
if (sql.includes('FROM tk_prompt_generations generation')) {
return rows([{
credits: 7,
replacement_main_expires_at: '2026-09-01T00:00:00.000Z',
}]);
}
if (sql.includes('FROM credit_allocations allocation')) {
expect(sql).toContain('lot.expires_at <= clock_timestamp() AS is_expired');
expect(sql).toContain('allocation.user_id = $3');
expect(sql).toContain('lot.user_id = $3');
expect(params).toEqual([
`tk-prompt-engine-hold:${generationId}`,
generationId,
userId,
]);
return rows([
// 即使 Node 进程认为 2099 尚未到期,也只服从数据库锁定时返回的 is_expired。
allocation('allocation-main', 'lot-main', 'main', 4, 'held', true),
allocation('allocation-addon', 'lot-addon', 'addon', 3, 'held', false),
]);
}
if (sql.includes('expired_amount = expired_amount + $2')) return rows([{}]);
if (sql.includes('available_amount = available_amount + $2')) return rows([{}]);
if (sql.includes('UPDATE credit_allocations SET status')) return rows([{}]);
if (sql.includes('INSERT INTO credit_lots')) {
expect(sql).toContain("'tk-prompt-engine-refund'");
expect(params).toEqual([
userId,
'main',
`tk-prompt-engine-refund-replacement:${generationId}:allocation-main`,
4,
'2026-09-01T00:00:00.000Z',
]);
return rows([{ id: 'replacement-main' }]);
}
if (sql.includes('UPDATE credit_ledger')) return rows([{}]);
if (sql.includes('INSERT INTO credit_ledger')) {
expect(params?.[1]).toBe('mixed');
expect(params?.[2]).toBe(7);
expect(params?.[4]).toContain('全额恢复');
return rows([{}]);
}
throw new Error(`未处理 SQL: ${sql}`);
});
await expect(releaseTkPromptCreditsWithClient(
client as any,
userId,
generationId,
)).resolves.toEqual({ returned: 3, compensated: 4 });
expect(mocks.setUserAvailableCreditsWithClient).toHaveBeenCalledWith(client, userId);
expect(client.calls.some(call => call.sql.includes('tk_prompt_provider_requests'))).toBe(false);
});
it('退款重放复用既有补偿 lot,不重复补发也不改变 Provider 成本账本', async () => {
const client = scriptedClient((sql, params) => {
if (sql.includes('FROM tk_prompt_generations generation')) {
return rows([{ credits: 7, replacement_main_expires_at: null }]);
}
if (sql.includes('FROM credit_allocations allocation')) {
return rows([
allocation('allocation-main', 'lot-main', 'main', 4, 'expired', true),
allocation('allocation-addon', 'lot-addon', 'addon', 3, 'released', false),
]);
}
if (sql.includes('FROM credit_lots') && sql.includes('source_event_key')) {
expect(params).toEqual([
`tk-prompt-engine-refund-replacement:${generationId}:allocation-main`,
]);
return rows([{
user_id: userId,
bucket: 'main',
source_type: 'tk-prompt-engine-refund',
original_amount: 4,
available_amount: 1,
}]);
}
if (sql.includes('UPDATE credit_ledger')) return rows([{}]);
if (sql.includes('INSERT INTO credit_ledger')) return rows([], 0);
throw new Error(`未处理 SQL: ${sql}`);
});
await expect(releaseTkPromptCreditsWithClient(
client as any,
userId,
generationId,
)).resolves.toEqual({ returned: 3, compensated: 4 });
expect(client.calls.some(call => call.sql.includes('INSERT INTO credit_lots'))).toBe(false);
expect(client.calls.some(call => call.sql.includes('tk_prompt_provider_requests'))).toBe(false);
});
it('allocation 总额不是整批价格时整笔事务失败,不生成部分退款', async () => {
const client = scriptedClient((sql) => {
if (sql.includes('FROM tk_prompt_generations generation')) {
return rows([{ credits: 7, replacement_main_expires_at: null }]);
}
if (sql.includes('FROM credit_allocations allocation')) {
return rows([
allocation('allocation-main', 'lot-main', 'main', 6, 'held', false),
]);
}
throw new Error(`未处理 SQL: ${sql}`);
});
await expect(releaseTkPromptCreditsWithClient(
client as any,
userId,
generationId,
)).rejects.toMatchObject({ code: 'TK_PROMPT_CREDIT_INVARIANT_VIOLATION' });
expect(mocks.setUserAvailableCreditsWithClient).not.toHaveBeenCalled();
expect(client.calls).toHaveLength(2);
});
it('allocation 已标记 expired 但缺少补偿 lot 时拒绝伪造已退款结果', async () => {
const client = scriptedClient((sql) => {
if (sql.includes('FROM tk_prompt_generations generation')) {
return rows([{ credits: 4, replacement_main_expires_at: null }]);
}
if (sql.includes('FROM credit_allocations allocation')) {
return rows([
allocation('allocation-main', 'lot-main', 'main', 4, 'expired', true),
]);
}
if (sql.includes('FROM credit_lots') && sql.includes('source_event_key')) return rows([]);
throw new Error(`未处理 SQL: ${sql}`);
});
await expect(releaseTkPromptCreditsWithClient(
client as any,
userId,
generationId,
)).rejects.toMatchObject({
code: 'TK_PROMPT_CREDIT_INVARIANT_VIOLATION',
message: 'TK Prompt Engine到期积分缺少等额补偿批次',
});
expect(mocks.setUserAvailableCreditsWithClient).not.toHaveBeenCalled();
});
it('跨用户 allocation/lot 即使被伪造进查询结果也在写 lot 前拒绝', async () => {
const client = scriptedClient((sql) => {
if (sql.includes('FROM tk_prompt_generations generation')) {
return rows([{ credits: 4, replacement_main_expires_at: null }]);
}
if (sql.includes('FROM credit_allocations allocation')) {
return rows([{
...allocation('allocation-main', 'lot-main', 'main', 4, 'held', false),
allocation_user_id: '10000000-0000-4000-8000-000000000002',
lot_user_id: '10000000-0000-4000-8000-000000000002',
}]);
}
throw new Error(`跨用户退款不应执行写 SQL: ${sql}`);
});
await expect(releaseTkPromptCreditsWithClient(
client as any,
userId,
generationId,
)).rejects.toMatchObject({
code: 'TK_PROMPT_CREDIT_INVARIANT_VIOLATION',
message: 'TK Prompt Engine积分分配用户归属不一致',
});
expect(mocks.setUserAvailableCreditsWithClient).not.toHaveBeenCalled();
expect(client.calls).toHaveLength(2);
});
});
function allocation(
id: string,
lotId: string,
bucket: 'main' | 'addon',
amount: number,
status: string,
isExpired: boolean,
) {
return {
id,
lot_id: lotId,
amount,
status,
allocation_user_id: userId,
lot_user_id: userId,
bucket,
is_expired: isExpired,
};
}
function scriptedClient(handler: (sql: string, params?: unknown[]) => ReturnType<typeof rows>) {
const calls: Array<{ sql: string; params?: unknown[] }> = [];
return {
calls,
async query(sql: string, params?: unknown[]) {
const normalized = sql.replace(/\s+/gu, ' ').trim();
calls.push({ sql: normalized, params });
return handler(normalized, params);
},
};
}
function rows<T>(items: T[], rowCount = items.length) {
return { rows: items, rowCount };
}