135 lines
3.7 KiB
TypeScript
135 lines
3.7 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import type { StripeEvent } from '../../src/types/payments';
|
|
|
|
const { mockPool } = vi.hoisted(() => ({
|
|
mockPool: {
|
|
query: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
vi.mock('../../src/infra/database/database.manager', () => ({
|
|
DatabaseManager: {
|
|
getInstance: () => ({
|
|
getPool: () => mockPool,
|
|
}),
|
|
},
|
|
}));
|
|
|
|
import { StripeWebhookService } from '../../src/services/payments/stripe/webhook.service';
|
|
|
|
describe('StripeWebhookService', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockPool.query.mockReset();
|
|
});
|
|
|
|
it('does not reprocess duplicate webhook events that are still pending', async () => {
|
|
const freshPendingUpdatedAt = new Date();
|
|
|
|
mockPool.query
|
|
.mockResolvedValueOnce({ rows: [] })
|
|
.mockResolvedValueOnce({ rows: [] })
|
|
.mockResolvedValueOnce({
|
|
rows: [
|
|
{
|
|
environment: 'test',
|
|
eventId: 'evt_123',
|
|
eventType: 'checkout.session.completed',
|
|
livemode: false,
|
|
accountId: null,
|
|
objectType: 'checkout.session',
|
|
objectId: 'cs_test_123',
|
|
processingStatus: 'pending',
|
|
attemptCount: 1,
|
|
lastError: null,
|
|
receivedAt: new Date('2026-04-30T00:00:00.000Z'),
|
|
processedAt: null,
|
|
createdAt: new Date('2026-04-30T00:00:00.000Z'),
|
|
updatedAt: freshPendingUpdatedAt,
|
|
},
|
|
],
|
|
});
|
|
|
|
const result = await StripeWebhookService.getInstance().recordWebhookEventStart('test', {
|
|
id: 'evt_123',
|
|
type: 'checkout.session.completed',
|
|
livemode: false,
|
|
account: null,
|
|
data: {
|
|
object: {
|
|
id: 'cs_test_123',
|
|
object: 'checkout.session',
|
|
},
|
|
},
|
|
} as StripeEvent);
|
|
|
|
expect(result).toMatchObject({
|
|
shouldProcess: false,
|
|
row: {
|
|
eventId: 'evt_123',
|
|
processingStatus: 'pending',
|
|
},
|
|
});
|
|
expect(
|
|
mockPool.query.mock.calls.some(([sql]) =>
|
|
/UPDATE payments\.webhook_events/i.test(String(sql))
|
|
)
|
|
).toBe(true);
|
|
expect(mockPool.query).toHaveBeenNthCalledWith(
|
|
2,
|
|
expect.stringMatching(
|
|
/UPDATE payments\.webhook_events[\s\S]*processing_status = 'failed'[\s\S]*OR \(processing_status = 'pending' AND updated_at < \$5\)/i
|
|
),
|
|
['stripe', 'test', 'evt_123', expect.any(Object), expect.any(Date)]
|
|
);
|
|
});
|
|
|
|
it('reclaims stale pending webhook events for retry', async () => {
|
|
const reclaimedUpdatedAt = new Date();
|
|
|
|
mockPool.query.mockResolvedValueOnce({ rows: [] }).mockResolvedValueOnce({
|
|
rows: [
|
|
{
|
|
environment: 'test',
|
|
eventId: 'evt_123',
|
|
eventType: 'checkout.session.completed',
|
|
livemode: false,
|
|
accountId: null,
|
|
objectType: 'checkout.session',
|
|
objectId: 'cs_test_123',
|
|
processingStatus: 'pending',
|
|
attemptCount: 2,
|
|
lastError: null,
|
|
receivedAt: new Date('2026-04-30T00:00:00.000Z'),
|
|
processedAt: null,
|
|
createdAt: new Date('2026-04-30T00:00:00.000Z'),
|
|
updatedAt: reclaimedUpdatedAt,
|
|
},
|
|
],
|
|
});
|
|
|
|
const result = await StripeWebhookService.getInstance().recordWebhookEventStart('test', {
|
|
id: 'evt_123',
|
|
type: 'checkout.session.completed',
|
|
livemode: false,
|
|
account: null,
|
|
data: {
|
|
object: {
|
|
id: 'cs_test_123',
|
|
object: 'checkout.session',
|
|
},
|
|
},
|
|
} as StripeEvent);
|
|
|
|
expect(result).toMatchObject({
|
|
shouldProcess: true,
|
|
row: {
|
|
eventId: 'evt_123',
|
|
processingStatus: 'pending',
|
|
attemptCount: 2,
|
|
},
|
|
});
|
|
expect(mockPool.query).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|