补齐平台订阅、外部登录、CRM 同步与用户端页面;Nginx 转发用户端接口,登录页使用 Google 与 Apple 标志。 Co-authored-by: Cursor <cursoragent@cursor.com>
672 行
25 KiB
TypeScript
672 行
25 KiB
TypeScript
import * as crypto from 'crypto';
|
||
import axios from 'axios';
|
||
import { CoolCommException } from '@cool-midway/core';
|
||
import { TeamStripeService } from '../../../src/modules/team/service/stripe';
|
||
import { TeamSubscriptionEntity } from '../../../src/modules/team/entity/subscription';
|
||
import { AdminTeamBillingController } from '../../../src/modules/team/controller/admin/billing';
|
||
import {
|
||
StripeCheckoutParams,
|
||
StripeWebhookEvent,
|
||
} from '../../../src/comm/types/omni';
|
||
|
||
jest.mock('axios');
|
||
const mockedAxios = axios as jest.Mocked<typeof axios>;
|
||
|
||
describe('Point 21: Stripe 国际信用卡订阅支付与 Webhook 自动化入账', () => {
|
||
let stripeService: TeamStripeService;
|
||
let subscriptions: TeamSubscriptionEntity[];
|
||
let processedStripeEvents: { id?: number; eventId: string; eventType?: string }[];
|
||
const testWebhookSecret = 'whsec_test_secret_key_1234567890abcdef';
|
||
const originalStripeSecret = process.env.STRIPE_SECRET_KEY;
|
||
|
||
const createMockSubscriptionRepo = () => ({
|
||
findOne: jest.fn(async ({ where }: { where: { tenantId?: number | string; stripeSubscriptionId?: string } }) => {
|
||
return subscriptions.find(s => {
|
||
if (where.tenantId !== undefined && s.tenantId === Number(where.tenantId)) return true;
|
||
if (where.stripeSubscriptionId && s.stripeSubscriptionId === where.stripeSubscriptionId) return true;
|
||
return false;
|
||
}) || null;
|
||
}),
|
||
findOneBy: jest.fn(async (q: { tenantId?: number | string; stripeSubscriptionId?: string }) => {
|
||
return subscriptions.find(s => {
|
||
if (q.tenantId !== undefined && s.tenantId === Number(q.tenantId)) return true;
|
||
if (q.stripeSubscriptionId && s.stripeSubscriptionId === q.stripeSubscriptionId) return true;
|
||
return false;
|
||
}) || null;
|
||
}),
|
||
save: jest.fn(async (entity: TeamSubscriptionEntity) => {
|
||
if (!entity.id) {
|
||
entity.id = subscriptions.length ? Math.max(...subscriptions.map(s => s.id || 0)) + 1 : 1;
|
||
subscriptions.push(entity);
|
||
return entity;
|
||
}
|
||
const idx = subscriptions.findIndex(s => s.id === entity.id);
|
||
if (idx >= 0) {
|
||
Object.assign(subscriptions[idx], entity);
|
||
return subscriptions[idx];
|
||
}
|
||
subscriptions.push(entity);
|
||
return entity;
|
||
}),
|
||
});
|
||
|
||
const createMockStripeEventRepo = () => ({
|
||
findOne: jest.fn(async ({ where }: { where: { eventId?: string } }) => {
|
||
return processedStripeEvents.find(e => e.eventId === where.eventId) || null;
|
||
}),
|
||
save: jest.fn(async (entity: { id?: number; eventId: string; eventType?: string }) => {
|
||
const existing = processedStripeEvents.find(e => e.eventId === entity.eventId);
|
||
if (existing) {
|
||
Object.assign(existing, entity);
|
||
return existing;
|
||
}
|
||
entity.id =
|
||
processedStripeEvents.length
|
||
? Math.max(...processedStripeEvents.map(e => e.id || 0)) + 1
|
||
: 1;
|
||
processedStripeEvents.push(entity);
|
||
return entity;
|
||
}),
|
||
});
|
||
|
||
beforeEach(() => {
|
||
subscriptions = [];
|
||
processedStripeEvents = [];
|
||
delete process.env.STRIPE_SECRET_KEY;
|
||
mockedAxios.post.mockReset();
|
||
|
||
stripeService = new TeamStripeService();
|
||
stripeService.teamSubscriptionEntity =
|
||
createMockSubscriptionRepo() as unknown as TeamStripeService['teamSubscriptionEntity'];
|
||
stripeService.teamStripeEventEntity =
|
||
createMockStripeEventRepo() as unknown as TeamStripeService['teamStripeEventEntity'];
|
||
stripeService.webhookSecret = testWebhookSecret;
|
||
});
|
||
|
||
afterEach(() => {
|
||
if (originalStripeSecret === undefined) {
|
||
delete process.env.STRIPE_SECRET_KEY;
|
||
} else {
|
||
process.env.STRIPE_SECRET_KEY = originalStripeSecret;
|
||
}
|
||
});
|
||
|
||
// =========================================================================
|
||
// 1. Checkout 会话创建 (createCheckoutSession)
|
||
// =========================================================================
|
||
describe('1. Checkout 会话创建 (createCheckoutSession)', () => {
|
||
it('1.0 未配置 STRIPE_SECRET_KEY 时抛出 CoolCommException,不返回 sessionId/url', async () => {
|
||
await expect(
|
||
stripeService.createCheckoutSession({
|
||
tenantId: 101,
|
||
plan: 'pro',
|
||
billingCycle: 'monthly',
|
||
})
|
||
).rejects.toThrow(CoolCommException);
|
||
|
||
await expect(
|
||
stripeService.createCheckoutSession({
|
||
tenantId: 101,
|
||
plan: 'pro',
|
||
billingCycle: 'monthly',
|
||
})
|
||
).rejects.toThrow(/未配置 Stripe/);
|
||
|
||
expect(mockedAxios.post).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('1.1 有密钥时通过 Stripe API 创建 Pro 月付结算会话,映射 id/url', async () => {
|
||
process.env.STRIPE_SECRET_KEY = 'sk_test_unit_mock_key';
|
||
mockedAxios.post.mockResolvedValue({
|
||
data: {
|
||
id: 'cs_test_pro_monthly_abc',
|
||
url: 'https://checkout.stripe.com/c/pay/cs_test_pro_monthly_abc',
|
||
},
|
||
});
|
||
|
||
const session = await stripeService.createCheckoutSession({
|
||
tenantId: 101,
|
||
plan: 'pro',
|
||
billingCycle: 'monthly',
|
||
});
|
||
|
||
expect(session).toBeDefined();
|
||
expect(session.tenantId).toBe(101);
|
||
expect(session.plan).toBe('pro');
|
||
expect(session.billingCycle).toBe('monthly');
|
||
expect(session.sessionId).toBe('cs_test_pro_monthly_abc');
|
||
expect(session.url).toBe(
|
||
'https://checkout.stripe.com/c/pay/cs_test_pro_monthly_abc'
|
||
);
|
||
expect(session.currency).toBe('usd');
|
||
expect(session.amount).toBe(1900); // $19.00 / 月
|
||
expect(mockedAxios.post).toHaveBeenCalledWith(
|
||
'https://api.stripe.com/v1/checkout/sessions',
|
||
expect.any(String),
|
||
expect.objectContaining({
|
||
headers: expect.objectContaining({
|
||
Authorization: 'Bearer sk_test_unit_mock_key',
|
||
'Content-Type': 'application/x-www-form-urlencoded',
|
||
}),
|
||
})
|
||
);
|
||
});
|
||
|
||
it('1.2 有密钥时创建 Enterprise 年付结算会话,金额折算符合 SaaS 阶梯定价', async () => {
|
||
process.env.STRIPE_SECRET_KEY = 'sk_test_unit_mock_key';
|
||
mockedAxios.post.mockResolvedValue({
|
||
data: {
|
||
id: 'cs_test_ent_yearly_xyz',
|
||
url: 'https://checkout.stripe.com/c/pay/cs_test_ent_yearly_xyz',
|
||
},
|
||
});
|
||
|
||
const session = await stripeService.createCheckoutSession({
|
||
tenantId: 202,
|
||
plan: 'enterprise',
|
||
billingCycle: 'yearly',
|
||
});
|
||
|
||
expect(session.tenantId).toBe(202);
|
||
expect(session.plan).toBe('enterprise');
|
||
expect(session.billingCycle).toBe('yearly');
|
||
expect(session.amount).toBe(99000); // $990.00 / 年 (优惠2个月)
|
||
expect(session.sessionId).toBe('cs_test_ent_yearly_xyz');
|
||
expect(session.url).toContain('checkout.stripe.com');
|
||
});
|
||
|
||
it('1.3 有密钥时默认周期为 monthly,且兼容独立参数传参 (tenantId, plan, billingCycle)', async () => {
|
||
process.env.STRIPE_SECRET_KEY = 'sk_test_unit_mock_key';
|
||
mockedAxios.post.mockResolvedValue({
|
||
data: {
|
||
id: 'cs_test_default_monthly',
|
||
url: 'https://checkout.stripe.com/c/pay/cs_test_default_monthly',
|
||
},
|
||
});
|
||
|
||
const session = await stripeService.createCheckoutSession(303, 'pro');
|
||
|
||
expect(session.tenantId).toBe(303);
|
||
expect(session.billingCycle).toBe('monthly');
|
||
expect(session.amount).toBe(1900);
|
||
});
|
||
|
||
it('1.4 参数校验:缺少 tenantId 抛出 CoolCommException 异常', async () => {
|
||
await expect(
|
||
stripeService.createCheckoutSession({
|
||
tenantId: null as unknown as number,
|
||
plan: 'pro',
|
||
})
|
||
).rejects.toThrow(CoolCommException);
|
||
|
||
await expect(
|
||
stripeService.createCheckoutSession(0, 'pro')
|
||
).rejects.toThrow(CoolCommException);
|
||
});
|
||
|
||
it('1.5 参数校验:无需为 free 套餐创建结算,或非法套餐拒绝创建', async () => {
|
||
await expect(
|
||
stripeService.createCheckoutSession({
|
||
tenantId: 101,
|
||
plan: 'free',
|
||
})
|
||
).rejects.toThrow('无需为免费套餐创建结算');
|
||
|
||
await expect(
|
||
stripeService.createCheckoutSession({
|
||
tenantId: 101,
|
||
plan: 'invalid_plan' as unknown as StripeCheckoutParams['plan'],
|
||
})
|
||
).rejects.toThrow();
|
||
});
|
||
});
|
||
|
||
// =========================================================================
|
||
// 2. Webhook 签名验证与防伪机制 (verifyWebhookSignature)
|
||
// =========================================================================
|
||
describe('2. Webhook 签名验证与防伪机制 (verifyWebhookSignature)', () => {
|
||
const generateStripeSignature = (
|
||
payload: string,
|
||
secret: string,
|
||
timestamp: number
|
||
): string => {
|
||
const signedPayload = `${timestamp}.${payload}`;
|
||
const hmac = crypto.createHmac('sha256', secret);
|
||
hmac.update(signedPayload, 'utf8');
|
||
const v1Signature = hmac.digest('hex');
|
||
return `t=${timestamp},v1=${v1Signature}`;
|
||
};
|
||
|
||
it('2.1 基于 HMAC-SHA256 正确验证 Stripe 官方签名头', () => {
|
||
const now = Math.floor(Date.now() / 1000);
|
||
const payload = JSON.stringify({ id: 'evt_test_1', type: 'checkout.session.completed' });
|
||
const signature = generateStripeSignature(payload, testWebhookSecret, now);
|
||
|
||
const isValid = stripeService.verifyWebhookSignature(payload, signature, testWebhookSecret, now);
|
||
expect(isValid).toBe(true);
|
||
});
|
||
|
||
it('2.2 篡改 payload 内容时验签失败,防伪拦截', () => {
|
||
const now = Math.floor(Date.now() / 1000);
|
||
const originalPayload = JSON.stringify({ id: 'evt_test_1', amount: 1900 });
|
||
const tamperedPayload = JSON.stringify({ id: 'evt_test_1', amount: 9900 });
|
||
const signature = generateStripeSignature(originalPayload, testWebhookSecret, now);
|
||
|
||
const isValid = stripeService.verifyWebhookSignature(tamperedPayload, signature, testWebhookSecret, now);
|
||
expect(isValid).toBe(false);
|
||
});
|
||
|
||
it('2.3 密钥 secret 错误时验签失败', () => {
|
||
const now = Math.floor(Date.now() / 1000);
|
||
const payload = JSON.stringify({ id: 'evt_test_1' });
|
||
const signature = generateStripeSignature(payload, 'wrong_secret', now);
|
||
|
||
const isValid = stripeService.verifyWebhookSignature(payload, signature, testWebhookSecret, now);
|
||
expect(isValid).toBe(false);
|
||
});
|
||
|
||
it('2.4 签名头缺失或格式错误 (缺少 t 或 v1) 时安全拒绝', () => {
|
||
const payload = JSON.stringify({ id: 'evt_test_1' });
|
||
expect(stripeService.verifyWebhookSignature(payload, '', testWebhookSecret)).toBe(false);
|
||
expect(stripeService.verifyWebhookSignature(payload, 'invalid_header', testWebhookSecret)).toBe(false);
|
||
expect(stripeService.verifyWebhookSignature(payload, 't=123456', testWebhookSecret)).toBe(false);
|
||
expect(stripeService.verifyWebhookSignature(payload, 'v1=abcdef', testWebhookSecret)).toBe(false);
|
||
});
|
||
|
||
it('2.5 防重放攻击:签名时间戳超出 tolerance 宽容度时判定失效', () => {
|
||
const pastTime = Math.floor(Date.now() / 1000) - 600; // 10分钟前
|
||
const now = Math.floor(Date.now() / 1000);
|
||
const payload = JSON.stringify({ id: 'evt_replay' });
|
||
const signature = generateStripeSignature(payload, testWebhookSecret, pastTime);
|
||
|
||
// 宽容度 300 秒,pastTime 差 600 秒应拒绝
|
||
const isValid = stripeService.verifyWebhookSignature(payload, signature, testWebhookSecret, now);
|
||
expect(isValid).toBe(false);
|
||
});
|
||
});
|
||
|
||
// =========================================================================
|
||
// 3. 三大核心 Webhook 事件流转与状态机断言 (handleWebhookEvent)
|
||
// =========================================================================
|
||
describe('3. 三大核心 Webhook 事件流转与状态机断言 (handleWebhookEvent)', () => {
|
||
it('3.1 处理 checkout.session.completed:套餐即时升级入账,生成或激活订阅', async () => {
|
||
const event: StripeWebhookEvent = {
|
||
id: 'evt_checkout_success',
|
||
type: 'checkout.session.completed',
|
||
created: Math.floor(Date.now() / 1000),
|
||
data: {
|
||
object: {
|
||
id: 'cs_test_session_123',
|
||
client_reference_id: '1001',
|
||
customer: 'cus_stripe_1001',
|
||
subscription: 'sub_stripe_1001',
|
||
metadata: {
|
||
tenantId: '1001',
|
||
plan: 'pro',
|
||
billingCycle: 'monthly',
|
||
},
|
||
},
|
||
},
|
||
};
|
||
|
||
const result = await stripeService.handleWebhookEvent(event);
|
||
|
||
expect(result.success).toBe(true);
|
||
expect(result.eventType).toBe('checkout.session.completed');
|
||
expect(result.tenantId).toBe(1001);
|
||
expect(result.plan).toBe('pro');
|
||
expect(result.action).toBe('upgraded');
|
||
|
||
// 状态机断言:订阅记录入库
|
||
expect(subscriptions.length).toBe(1);
|
||
const sub = subscriptions[0];
|
||
expect(sub.tenantId).toBe(1001);
|
||
expect(sub.planType).toBe('pro');
|
||
expect(sub.status).toBe('active');
|
||
expect(sub.stripeCustomerId).toBe('cus_stripe_1001');
|
||
expect(sub.stripeSubscriptionId).toBe('sub_stripe_1001');
|
||
expect(sub.currentPeriodEnd).toBeDefined();
|
||
});
|
||
|
||
it('3.2 处理 invoice.payment_succeeded:周期续费成功,延长订阅有效期并维持 active', async () => {
|
||
// 预置已有 Pro 订阅
|
||
const existingSub = new TeamSubscriptionEntity();
|
||
existingSub.id = 1;
|
||
existingSub.tenantId = 1001;
|
||
existingSub.planType = 'pro';
|
||
existingSub.status = 'active';
|
||
existingSub.billingCycle = 'monthly';
|
||
existingSub.stripeSubscriptionId = 'sub_stripe_1001';
|
||
existingSub.currentPeriodEnd = '2026-09-20T00:00:00.000Z';
|
||
subscriptions.push(existingSub);
|
||
|
||
const event: StripeWebhookEvent = {
|
||
id: 'evt_invoice_paid',
|
||
type: 'invoice.payment_succeeded',
|
||
created: Math.floor(Date.now() / 1000),
|
||
data: {
|
||
object: {
|
||
id: 'in_test_123',
|
||
customer: 'cus_stripe_1001',
|
||
subscription: 'sub_stripe_1001',
|
||
lines: {
|
||
data: [
|
||
{
|
||
period: {
|
||
end: Math.floor(Date.now() / 1000) + 30 * 86400,
|
||
},
|
||
},
|
||
],
|
||
},
|
||
metadata: {
|
||
tenantId: '1001',
|
||
},
|
||
},
|
||
},
|
||
};
|
||
|
||
const result = await stripeService.handleWebhookEvent(event);
|
||
|
||
expect(result.success).toBe(true);
|
||
expect(result.eventType).toBe('invoice.payment_succeeded');
|
||
expect(result.tenantId).toBe(1001);
|
||
expect(result.action).toBe('renewed');
|
||
|
||
// 状态机断言:周期已刷新且保持 active
|
||
const sub = subscriptions[0];
|
||
expect(sub.status).toBe('active');
|
||
expect(new Date(sub.currentPeriodEnd!).getTime()).toBeGreaterThan(
|
||
new Date('2026-09-20T00:00:00.000Z').getTime()
|
||
);
|
||
});
|
||
|
||
it('3.3 处理 customer.subscription.deleted:订阅终止,租户安全降级为 free 并置为 canceled', async () => {
|
||
// 预置 Enterprise 订阅
|
||
const activeSub = new TeamSubscriptionEntity();
|
||
activeSub.id = 2;
|
||
activeSub.tenantId = 2002;
|
||
activeSub.planType = 'enterprise';
|
||
activeSub.status = 'active';
|
||
activeSub.stripeSubscriptionId = 'sub_stripe_2002';
|
||
subscriptions.push(activeSub);
|
||
|
||
const event: StripeWebhookEvent = {
|
||
id: 'evt_sub_deleted',
|
||
type: 'customer.subscription.deleted',
|
||
created: Math.floor(Date.now() / 1000),
|
||
data: {
|
||
object: {
|
||
id: 'sub_stripe_2002',
|
||
metadata: {
|
||
tenantId: '2002',
|
||
},
|
||
},
|
||
},
|
||
};
|
||
|
||
const result = await stripeService.handleWebhookEvent(event);
|
||
|
||
expect(result.success).toBe(true);
|
||
expect(result.eventType).toBe('customer.subscription.deleted');
|
||
expect(result.tenantId).toBe(2002);
|
||
expect(result.plan).toBe('free');
|
||
expect(result.action).toBe('downgraded');
|
||
|
||
// 状态机断言:降级并标记已取消
|
||
const sub = subscriptions.find(s => s.tenantId === 2002);
|
||
expect(sub?.planType).toBe('free');
|
||
expect(sub?.status).toBe('canceled');
|
||
});
|
||
|
||
it('3.4 其它不相关事件安全被忽略,不改变系统状态', async () => {
|
||
const event: StripeWebhookEvent = {
|
||
id: 'evt_other',
|
||
type: 'charge.succeeded',
|
||
created: Math.floor(Date.now() / 1000),
|
||
data: { object: {} },
|
||
};
|
||
|
||
const result = await stripeService.handleWebhookEvent(event);
|
||
expect(result.success).toBe(true);
|
||
expect(result.action).toBe('ignored');
|
||
expect(result.ignored).toBe(true);
|
||
});
|
||
});
|
||
|
||
// =========================================================================
|
||
// 4. 控制器端点集成与调用测试 (AdminTeamBillingController)
|
||
// =========================================================================
|
||
describe('4. 控制器端点集成 (AdminTeamBillingController)', () => {
|
||
let controller: AdminTeamBillingController;
|
||
let mockStripeService: Partial<TeamStripeService>;
|
||
|
||
beforeEach(() => {
|
||
mockStripeService = {
|
||
createCheckoutSession: jest.fn(async (params) => {
|
||
const tenantId = typeof params === 'object' ? params.tenantId : params;
|
||
return {
|
||
sessionId: 'cs_test_ctrl_123',
|
||
url: 'https://checkout.stripe.com/c/pay/cs_test_ctrl_123',
|
||
tenantId,
|
||
plan: 'pro' as const,
|
||
billingCycle: 'monthly' as const,
|
||
amount: 1900,
|
||
currency: 'usd',
|
||
};
|
||
}),
|
||
verifyWebhookSignature: jest.fn((_payload, signature) => {
|
||
return signature === 'valid_stripe_sig';
|
||
}),
|
||
handleWebhookEvent: jest.fn(async (event) => ({
|
||
success: true,
|
||
eventType: event.type,
|
||
tenantId: 999,
|
||
action: 'upgraded' as const,
|
||
})),
|
||
};
|
||
|
||
controller = new AdminTeamBillingController();
|
||
controller.teamStripeService = mockStripeService as TeamStripeService;
|
||
controller.ctx = {
|
||
admin: { tenantId: 999 },
|
||
get: jest.fn((header: string) => {
|
||
if (header.toLowerCase() === 'stripe-signature') return 'valid_stripe_sig';
|
||
return '';
|
||
}),
|
||
headers: {
|
||
'stripe-signature': 'valid_stripe_sig',
|
||
},
|
||
request: {
|
||
body: {
|
||
id: 'evt_123',
|
||
type: 'checkout.session.completed',
|
||
created: 1700000000,
|
||
data: { object: {} },
|
||
},
|
||
},
|
||
} as unknown as AdminTeamBillingController['ctx'];
|
||
|
||
controller.ok = (data: unknown) => ({ code: 1000, message: 'success', data } as any);
|
||
controller.fail = (message: string, code = 1001) => ({ code, message, data: undefined } as any);
|
||
});
|
||
|
||
it('4.1 POST /admin/team/billing/stripe-checkout 返回有效结账链接', async () => {
|
||
const res = await controller.createCheckout({
|
||
plan: 'pro',
|
||
billingCycle: 'monthly',
|
||
});
|
||
|
||
expect(res.code).toBe(1000);
|
||
expect((res as unknown as { data: { sessionId: string } }).data.sessionId).toBe('cs_test_ctrl_123');
|
||
expect(mockStripeService.createCheckoutSession).toHaveBeenCalled();
|
||
});
|
||
|
||
it('4.2 POST /admin/team/billing/webhook 验签合法时入账成功', async () => {
|
||
const res = await controller.webhook({
|
||
id: 'evt_123',
|
||
type: 'checkout.session.completed',
|
||
created: 1700000000,
|
||
data: { object: {} },
|
||
});
|
||
|
||
expect(res.code).toBe(1000);
|
||
expect((res as unknown as { data: { received: boolean } }).data.received).toBe(true);
|
||
expect(mockStripeService.handleWebhookEvent).toHaveBeenCalled();
|
||
});
|
||
|
||
it('4.3 POST /admin/team/billing/webhook 验签失败时拦截并返回 400', async () => {
|
||
// 模拟非法签名
|
||
(controller.ctx.get as jest.Mock).mockReturnValue('invalid_sig');
|
||
controller.ctx.headers['stripe-signature'] = 'invalid_sig';
|
||
|
||
const res = await controller.webhook({
|
||
id: 'evt_fake',
|
||
type: 'checkout.session.completed',
|
||
created: 1700000000,
|
||
data: { object: {} },
|
||
});
|
||
|
||
expect(res.code).toBe(400);
|
||
expect(res.message).toContain('签名验证失败');
|
||
});
|
||
});
|
||
|
||
// =========================================================================
|
||
// 5. Webhook 幂等去重与异常事件状态机 (Idempotency & Failure Grace Period)
|
||
// =========================================================================
|
||
describe('5. Webhook 幂等去重与异常事件状态机', () => {
|
||
it('5.1 同一 event.id 重复推送时触发幂等拦截,防止重复入账', async () => {
|
||
const event: StripeWebhookEvent = {
|
||
id: 'evt_unique_1001',
|
||
type: 'checkout.session.completed',
|
||
created: 1700000000,
|
||
data: {
|
||
object: {
|
||
client_reference_id: 88,
|
||
metadata: { tenantId: '88', plan: 'pro' },
|
||
},
|
||
},
|
||
};
|
||
|
||
const firstRes = await stripeService.handleWebhookEvent(event);
|
||
expect(firstRes.action).toBe('upgraded');
|
||
expect(subscriptions.length).toBe(1);
|
||
expect(processedStripeEvents.some(e => e.eventId === 'evt_unique_1001')).toBe(
|
||
true
|
||
);
|
||
|
||
// 重复推送相同事件:只入账一次
|
||
const secondRes = await stripeService.handleWebhookEvent(event);
|
||
expect(secondRes.success).toBe(true);
|
||
expect(secondRes.action).toBe('idempotent_ignored');
|
||
expect(subscriptions.length).toBe(1);
|
||
expect(
|
||
(
|
||
stripeService.teamSubscriptionEntity
|
||
.save as unknown as jest.Mock
|
||
).mock.calls.length
|
||
).toBe(1);
|
||
});
|
||
|
||
it('5.2 收到 invoice.payment_failed 扣款失败时流转为 past_due 宽限期', async () => {
|
||
// 先建立活跃订阅
|
||
subscriptions.push({
|
||
id: 99,
|
||
tenantId: 66,
|
||
planType: 'pro',
|
||
billingCycle: 'monthly',
|
||
status: 'active',
|
||
stripeSubscriptionId: 'sub_pastdue_test',
|
||
currentPeriodEnd: new Date().toISOString(),
|
||
} as TeamSubscriptionEntity);
|
||
|
||
const failEvent: StripeWebhookEvent = {
|
||
id: 'evt_fail_999',
|
||
type: 'invoice.payment_failed',
|
||
created: 1700000000,
|
||
data: {
|
||
object: {
|
||
subscription: 'sub_pastdue_test',
|
||
metadata: { tenantId: '66' },
|
||
},
|
||
},
|
||
};
|
||
|
||
const res = await stripeService.handleWebhookEvent(failEvent);
|
||
expect(res.status).toBe('past_due');
|
||
expect(res.action).toBe('payment_failed');
|
||
|
||
const updated = subscriptions.find(s => s.tenantId === 66);
|
||
expect(updated?.status).toBe('past_due');
|
||
});
|
||
|
||
it('5.3 进程重启后凭持久化记录仍识别已处理 event.id,不重复入账', async () => {
|
||
const event: StripeWebhookEvent = {
|
||
id: 'evt_persist_restart_2002',
|
||
type: 'checkout.session.completed',
|
||
created: 1700000000,
|
||
data: {
|
||
object: {
|
||
client_reference_id: '2002',
|
||
metadata: { tenantId: '2002', plan: 'pro' },
|
||
},
|
||
},
|
||
};
|
||
|
||
const firstRes = await stripeService.handleWebhookEvent(event);
|
||
expect(firstRes.action).toBe('upgraded');
|
||
expect(subscriptions.length).toBe(1);
|
||
expect(processedStripeEvents).toEqual(
|
||
expect.arrayContaining([
|
||
expect.objectContaining({ eventId: 'evt_persist_restart_2002' }),
|
||
])
|
||
);
|
||
|
||
// 模拟进程重启:新 Service 实例清空内存 Set,但共享同一持久化表
|
||
const restarted = new TeamStripeService();
|
||
restarted.teamSubscriptionEntity =
|
||
createMockSubscriptionRepo() as unknown as TeamStripeService['teamSubscriptionEntity'];
|
||
restarted.teamStripeEventEntity =
|
||
createMockStripeEventRepo() as unknown as TeamStripeService['teamStripeEventEntity'];
|
||
restarted.webhookSecret = testWebhookSecret;
|
||
expect(restarted.processedEventIds.size).toBe(0);
|
||
|
||
const secondRes = await restarted.handleWebhookEvent(event);
|
||
expect(secondRes.action).toBe('idempotent_ignored');
|
||
expect(subscriptions.length).toBe(1);
|
||
expect(
|
||
(restarted.teamSubscriptionEntity.save as unknown as jest.Mock).mock
|
||
.calls.length
|
||
).toBe(0);
|
||
});
|
||
|
||
it('5.4 无事件表 repository 时仍可用内存 Set 兜底幂等', async () => {
|
||
const memoryOnly = new TeamStripeService();
|
||
memoryOnly.teamSubscriptionEntity =
|
||
createMockSubscriptionRepo() as unknown as TeamStripeService['teamSubscriptionEntity'];
|
||
memoryOnly.teamStripeEventEntity = undefined as unknown as TeamStripeService['teamStripeEventEntity'];
|
||
memoryOnly.webhookSecret = testWebhookSecret;
|
||
|
||
const event: StripeWebhookEvent = {
|
||
id: 'evt_memory_only_55',
|
||
type: 'checkout.session.completed',
|
||
created: 1700000000,
|
||
data: {
|
||
object: {
|
||
client_reference_id: '55',
|
||
metadata: { tenantId: '55', plan: 'pro' },
|
||
},
|
||
},
|
||
};
|
||
|
||
const firstRes = await memoryOnly.handleWebhookEvent(event);
|
||
expect(firstRes.action).toBe('upgraded');
|
||
expect(subscriptions.length).toBe(1);
|
||
|
||
const secondRes = await memoryOnly.handleWebhookEvent(event);
|
||
expect(secondRes.action).toBe('idempotent_ignored');
|
||
expect(subscriptions.length).toBe(1);
|
||
});
|
||
});
|
||
});
|