Files
simstudioai--sim/apps/sim/lib/webhooks/providers/revenuecat.test.ts
T
wehub-resource-sync d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

306 lines
9.9 KiB
TypeScript

import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { revenueCatHandler } from '@/lib/webhooks/providers/revenuecat'
function requestWithAuth(authValue?: string): NextRequest {
return new NextRequest('http://localhost/test', {
headers: authValue ? { authorization: authValue } : {},
})
}
const sampleInitialPurchase = {
api_version: '1.0',
event: {
type: 'INITIAL_PURCHASE',
id: '12345678-1234-1234-1234-123456789012',
app_id: '1234567890',
event_timestamp_ms: 1658726378679,
app_user_id: '1234567890',
original_app_user_id: '$RCAnonymousID:abc',
aliases: ['$RCAnonymousID:abc'],
product_id: 'com.subscription.weekly',
period_type: 'NORMAL',
purchased_at_ms: 1658726374000,
expiration_at_ms: 1659331174000,
environment: 'PRODUCTION',
entitlement_id: null,
entitlement_ids: ['pro'],
presented_offering_id: null,
transaction_id: '123456789012345',
original_transaction_id: '123456789012345',
is_family_share: false,
country_code: 'US',
currency: 'USD',
price: 4.99,
price_in_purchased_currency: 4.99,
store: 'APP_STORE',
takehome_percentage: 0.7,
tax_percentage: 0.0,
commission_percentage: 0.3,
offer_code: null,
subscriber_attributes: { $email: { updated_at_ms: 1662955084635, value: 'a@b.com' } },
experiments: [],
},
}
describe('RevenueCat webhook provider', () => {
describe('verifyAuth', () => {
const secret = 'super-secret-header-value'
it('rejects requests when no secret is configured (fail-closed)', async () => {
const res = await revenueCatHandler.verifyAuth!({
request: requestWithAuth(),
rawBody: '{}',
requestId: 'rc-1',
providerConfig: {},
webhook: {},
workflow: {},
})
expect(res?.status).toBe(401)
})
it('rejects requests missing the Authorization header', async () => {
const res = await revenueCatHandler.verifyAuth!({
request: requestWithAuth(),
rawBody: '{}',
requestId: 'rc-2',
providerConfig: { authHeaderSecret: secret },
webhook: {},
workflow: {},
})
expect(res?.status).toBe(401)
})
it('rejects requests with a mismatched Authorization header', async () => {
const res = await revenueCatHandler.verifyAuth!({
request: requestWithAuth('wrong-value'),
rawBody: '{}',
requestId: 'rc-3',
providerConfig: { authHeaderSecret: secret },
webhook: {},
workflow: {},
})
expect(res?.status).toBe(401)
})
it('accepts requests with a matching Authorization header', async () => {
const res = await revenueCatHandler.verifyAuth!({
request: requestWithAuth(secret),
rawBody: '{}',
requestId: 'rc-4',
providerConfig: { authHeaderSecret: secret },
webhook: {},
workflow: {},
})
expect(res).toBeNull()
})
})
describe('matchEvent', () => {
it('matches the configured event type', async () => {
const res = await revenueCatHandler.matchEvent!({
body: sampleInitialPurchase,
request: requestWithAuth(),
requestId: 'rc-5',
providerConfig: { triggerId: 'revenuecat_initial_purchase' },
webhook: {},
workflow: {},
})
expect(res).toBe(true)
})
it('skips events whose type does not match the trigger', async () => {
const res = await revenueCatHandler.matchEvent!({
body: sampleInitialPurchase,
request: requestWithAuth(),
requestId: 'rc-6',
providerConfig: { triggerId: 'revenuecat_cancellation' },
webhook: {},
workflow: {},
})
expect(res).toBe(false)
})
})
describe('formatInput', () => {
it('flattens the event wrapper into the trigger output keys', async () => {
const { input } = await revenueCatHandler.formatInput!({
body: sampleInitialPurchase,
webhook: {},
workflow: { id: 'wf', userId: 'user' },
headers: {},
requestId: 'rc-7',
})
const data = input as Record<string, unknown>
expect(data.type).toBe('INITIAL_PURCHASE')
expect(data.app_user_id).toBe('1234567890')
expect(data.product_id).toBe('com.subscription.weekly')
expect(data.price).toBe(4.99)
expect(data.entitlement_ids).toEqual(['pro'])
expect(data.cancel_reason).toBeNull()
expect(data.new_product_id).toBeNull()
expect(data.api_version).toBe('1.0')
expect(data.event).toEqual(sampleInitialPurchase.event)
})
})
describe('createSubscription', () => {
const baseCtx = {
webhook: {
id: 'wh-1',
path: 'abc123',
providerConfig: {
apiKey: 'sk_test',
projectId: 'proj1ab2c3d4',
triggerId: 'revenuecat_initial_purchase',
environment: 'all',
},
},
workflow: {},
userId: 'user-1',
requestId: 'rc-create',
request: requestWithAuth(),
}
beforeEach(() => {
vi.restoreAllMocks()
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://sim.example.com')
})
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllEnvs()
})
it('creates the integration and returns externalId + generated authHeaderSecret', async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(new Response(JSON.stringify({ id: 'wh_remote_1' }), { status: 201 }))
vi.stubGlobal('fetch', fetchMock)
const result = await revenueCatHandler.createSubscription!(baseCtx)
expect(fetchMock).toHaveBeenCalledTimes(1)
const [url, init] = fetchMock.mock.calls[0]
expect(url).toBe('https://api.revenuecat.com/v2/projects/proj1ab2c3d4/integrations/webhooks')
expect(init.method).toBe('POST')
expect(init.headers.Authorization).toBe('Bearer sk_test')
const body = JSON.parse(init.body)
expect(body.event_types).toEqual(['initial_purchase'])
expect(body.url).toContain('/api/webhooks/trigger/abc123')
expect(typeof body.authorization_header).toBe('string')
expect(body.authorization_header.length).toBeGreaterThan(0)
expect(body.environment).toBeUndefined()
expect(result?.providerConfigUpdates?.externalId).toBe('wh_remote_1')
expect(result?.providerConfigUpdates?.authHeaderSecret).toBe(body.authorization_header)
})
it('forwards a concrete environment when set to production', async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(new Response(JSON.stringify({ id: 'wh_remote_2' }), { status: 201 }))
vi.stubGlobal('fetch', fetchMock)
await revenueCatHandler.createSubscription!({
...baseCtx,
webhook: {
...baseCtx.webhook,
providerConfig: { ...baseCtx.webhook.providerConfig, environment: 'production' },
},
})
const body = JSON.parse(fetchMock.mock.calls[0][1].body)
expect(body.environment).toBe('production')
})
it('throws when the API key is missing', async () => {
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
await expect(
revenueCatHandler.createSubscription!({
...baseCtx,
webhook: { ...baseCtx.webhook, providerConfig: { projectId: 'proj1ab2c3d4' } },
})
).rejects.toThrow(/Secret API key/)
expect(fetchMock).not.toHaveBeenCalled()
})
it('throws a friendly error on a 401 from RevenueCat', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('{}', { status: 401 })))
await expect(revenueCatHandler.createSubscription!(baseCtx)).rejects.toThrow(
/authentication failed/i
)
})
})
describe('deleteSubscription', () => {
const baseCtx = {
webhook: {
id: 'wh-1',
providerConfig: { apiKey: 'sk_test', projectId: 'proj1ab2c3d4', externalId: 'wh_remote_1' },
},
workflow: {},
requestId: 'rc-delete',
}
afterEach(() => {
vi.restoreAllMocks()
})
it('issues a DELETE to the integration endpoint', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response('{}', { status: 200 }))
vi.stubGlobal('fetch', fetchMock)
await revenueCatHandler.deleteSubscription!(baseCtx)
const [url, init] = fetchMock.mock.calls[0]
expect(url).toBe(
'https://api.revenuecat.com/v2/projects/proj1ab2c3d4/integrations/webhooks/wh_remote_1'
)
expect(init.method).toBe('DELETE')
expect(init.headers.Authorization).toBe('Bearer sk_test')
})
it('does not throw on a 404 (already gone)', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('{}', { status: 404 })))
await expect(revenueCatHandler.deleteSubscription!(baseCtx)).resolves.toBeUndefined()
})
it('skips silently when credentials are missing (non-strict)', async () => {
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
await expect(
revenueCatHandler.deleteSubscription!({
...baseCtx,
webhook: { id: 'wh-1', providerConfig: {} },
})
).resolves.toBeUndefined()
expect(fetchMock).not.toHaveBeenCalled()
})
it('throws on missing credentials when strict', async () => {
vi.stubGlobal('fetch', vi.fn())
await expect(
revenueCatHandler.deleteSubscription!({
...baseCtx,
webhook: { id: 'wh-1', providerConfig: {} },
strict: true,
})
).rejects.toThrow(/Missing RevenueCat credentials/)
})
})
describe('extractIdempotencyId', () => {
it('returns the event id', () => {
expect(revenueCatHandler.extractIdempotencyId!(sampleInitialPurchase)).toBe(
'12345678-1234-1234-1234-123456789012'
)
})
it('returns null when no event id is present', () => {
expect(revenueCatHandler.extractIdempotencyId!({ event: {} })).toBeNull()
})
})
})