Add Paddle webhook ingestion foundation
This commit is contained in:
@@ -4,14 +4,17 @@ import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { Readable } from 'node:stream';
|
||||
import {
|
||||
BadRequestException,
|
||||
BadGatewayException,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
HttpStatus,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
ValidationPipe,
|
||||
VersioningType,
|
||||
} from '@nestjs/common';
|
||||
import { HTTP_CODE_METADATA } from '@nestjs/common/constants';
|
||||
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { API_JSON_BODY_LIMIT } from '../../src/app.factory';
|
||||
@@ -101,6 +104,7 @@ function createPrismaMock() {
|
||||
const uploadSessions = new Map<string, any>();
|
||||
const libraryEvents = new Map<bigint, any>();
|
||||
const billingCustomers = new Map<string, any>();
|
||||
const billingWebhookEvents = new Map<string, any>();
|
||||
const userSubscriptions = new Map<string, any>();
|
||||
const userOAuthIdentities = new Map<string, any>();
|
||||
const userEntitlements = new Map<string, any>();
|
||||
@@ -344,6 +348,43 @@ function createPrismaMock() {
|
||||
return applySelect(record, select);
|
||||
}),
|
||||
},
|
||||
billingWebhookEvent: {
|
||||
findUnique: jest.fn().mockImplementation(async ({ where, select }) => {
|
||||
const composite = where.provider_providerEventId;
|
||||
const record =
|
||||
[...billingWebhookEvents.values()].find(
|
||||
(event) =>
|
||||
event.provider === composite?.provider &&
|
||||
event.providerEventId === composite?.providerEventId,
|
||||
) ?? null;
|
||||
|
||||
return applySelect(record, select);
|
||||
}),
|
||||
create: jest.fn().mockImplementation(async ({ data }) => {
|
||||
const now = new Date();
|
||||
const record = {
|
||||
id: randomUUID(),
|
||||
receivedAt: now,
|
||||
processedAt: null,
|
||||
errorMessage: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
...data,
|
||||
};
|
||||
billingWebhookEvents.set(record.id, record);
|
||||
return record;
|
||||
}),
|
||||
update: jest.fn().mockImplementation(async ({ where, data }) => {
|
||||
const current = billingWebhookEvents.get(where.id);
|
||||
const updated = {
|
||||
...current,
|
||||
...data,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
billingWebhookEvents.set(where.id, updated);
|
||||
return updated;
|
||||
}),
|
||||
},
|
||||
userSubscription: {
|
||||
findUnique: jest.fn().mockImplementation(async ({ where, select }) => {
|
||||
const record =
|
||||
@@ -632,6 +673,7 @@ function createPrismaMock() {
|
||||
uploadSessions,
|
||||
libraryEvents,
|
||||
billingCustomers,
|
||||
billingWebhookEvents,
|
||||
userSubscriptions,
|
||||
userOAuthIdentities,
|
||||
userEntitlements,
|
||||
@@ -783,6 +825,7 @@ describe('Velody API wiring (e2e)', () => {
|
||||
accountAccessTokenTtlSeconds: 900,
|
||||
getPaddleEnvironment: jest.fn().mockReturnValue('sandbox'),
|
||||
getPaddleApiKey: jest.fn().mockReturnValue('paddle-key'),
|
||||
getPaddleWebhookSecret: jest.fn().mockReturnValue('paddle-webhook-secret'),
|
||||
getPaddleProMonthlyPriceId: jest.fn().mockReturnValue('pri_monthly'),
|
||||
getPaddleProYearlyPriceId: jest.fn().mockReturnValue('pri_yearly'),
|
||||
})
|
||||
@@ -2177,6 +2220,88 @@ describe('Velody API wiring (e2e)', () => {
|
||||
fetchRequest.mockRestore();
|
||||
});
|
||||
|
||||
it('accepts a valid webhook event without account auth', async () => {
|
||||
const payload = {
|
||||
event_id: 'evt_e2e_valid',
|
||||
event_type: 'transaction.completed',
|
||||
data: {
|
||||
id: 'txn_123',
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
Reflect.getMetadata(
|
||||
HTTP_CODE_METADATA,
|
||||
BillingController.prototype.ingestWebhook,
|
||||
),
|
||||
).toBe(HttpStatus.OK);
|
||||
|
||||
await expect(billingController.ingestWebhook(payload)).resolves.toEqual({
|
||||
ok: true,
|
||||
});
|
||||
|
||||
expect(prismaState.billingWebhookEvents.size).toBe(1);
|
||||
expect([...prismaState.billingWebhookEvents.values()][0]).toMatchObject({
|
||||
provider: 'PADDLE',
|
||||
providerEventId: 'evt_e2e_valid',
|
||||
eventType: 'transaction.completed',
|
||||
payload,
|
||||
status: 'RECEIVED',
|
||||
});
|
||||
expect(prismaState.userSubscriptions.size).toBe(0);
|
||||
expect(prismaState.userEntitlements.size).toBe(0);
|
||||
});
|
||||
|
||||
it('returns ok for duplicate webhook events and does not store them twice', async () => {
|
||||
const payload = {
|
||||
event_id: 'evt_e2e_duplicate',
|
||||
event_type: 'subscription.updated',
|
||||
};
|
||||
|
||||
expect(
|
||||
Reflect.getMetadata(
|
||||
HTTP_CODE_METADATA,
|
||||
BillingController.prototype.ingestWebhook,
|
||||
),
|
||||
).toBe(HttpStatus.OK);
|
||||
|
||||
await expect(billingController.ingestWebhook(payload)).resolves.toEqual({
|
||||
ok: true,
|
||||
});
|
||||
await expect(billingController.ingestWebhook(payload)).resolves.toEqual({
|
||||
ok: true,
|
||||
});
|
||||
|
||||
expect(prismaState.billingWebhookEvents.size).toBe(1);
|
||||
expect([...prismaState.billingWebhookEvents.values()][0]).toMatchObject({
|
||||
providerEventId: 'evt_e2e_duplicate',
|
||||
status: 'DUPLICATE',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns 400 for invalid webhook payloads', async () => {
|
||||
await expect(
|
||||
billingController.ingestWebhook({
|
||||
event_type: 'subscription.created',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('keeps checkout protected while webhook stays public', async () => {
|
||||
await expect(
|
||||
billingController.ingestWebhook({
|
||||
event_id: 'evt_public',
|
||||
event_type: 'subscription.canceled',
|
||||
}),
|
||||
).resolves.toEqual({ ok: true });
|
||||
|
||||
await expect(
|
||||
runBillingCheckoutRequest(undefined, {
|
||||
plan: BillingCheckoutPlan.PRO_MONTHLY,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('rejects invalid or revoked device tokens even when a legacy device id is supplied', async () => {
|
||||
const ownerDevice = await devicesController.register({
|
||||
platform: 'IPHONE',
|
||||
|
||||
Reference in New Issue
Block a user