chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,405 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { instantlyHandler } from '@/lib/webhooks/providers/instantly'
|
||||
|
||||
function reqWithHeaders(headers: Record<string, string>): NextRequest {
|
||||
return new NextRequest('http://localhost/test', { headers })
|
||||
}
|
||||
|
||||
describe('Instantly webhook provider', () => {
|
||||
it('verifyAuth rejects when secretToken is missing (fail-closed)', async () => {
|
||||
const res = await instantlyHandler.verifyAuth!({
|
||||
request: reqWithHeaders({}),
|
||||
rawBody: '{}',
|
||||
requestId: 't1',
|
||||
providerConfig: {},
|
||||
webhook: {},
|
||||
workflow: {},
|
||||
})
|
||||
expect(res?.status).toBe(401)
|
||||
})
|
||||
|
||||
it('verifyAuth rejects when the token header is missing', async () => {
|
||||
const res = await instantlyHandler.verifyAuth!({
|
||||
request: reqWithHeaders({}),
|
||||
rawBody: '{}',
|
||||
requestId: 't2',
|
||||
providerConfig: { secretToken: 'expected-token' },
|
||||
webhook: {},
|
||||
workflow: {},
|
||||
})
|
||||
expect(res?.status).toBe(401)
|
||||
})
|
||||
|
||||
it('verifyAuth rejects an incorrect token', async () => {
|
||||
const res = await instantlyHandler.verifyAuth!({
|
||||
request: reqWithHeaders({ 'x-sim-webhook-token': 'wrong-token' }),
|
||||
rawBody: '{}',
|
||||
requestId: 't3',
|
||||
providerConfig: { secretToken: 'expected-token' },
|
||||
webhook: {},
|
||||
workflow: {},
|
||||
})
|
||||
expect(res?.status).toBe(401)
|
||||
})
|
||||
|
||||
it('verifyAuth accepts a matching token', async () => {
|
||||
const res = await instantlyHandler.verifyAuth!({
|
||||
request: reqWithHeaders({ 'x-sim-webhook-token': 'expected-token' }),
|
||||
rawBody: '{}',
|
||||
requestId: 't4',
|
||||
providerConfig: { secretToken: 'expected-token' },
|
||||
webhook: {},
|
||||
workflow: {},
|
||||
})
|
||||
expect(res).toBeNull()
|
||||
})
|
||||
|
||||
it('matchEvent passes all events through for the generic webhook trigger', async () => {
|
||||
const matched = await instantlyHandler.matchEvent!({
|
||||
body: { event_type: 'lead_interested' },
|
||||
requestId: 't5',
|
||||
providerConfig: { triggerId: 'instantly_webhook' },
|
||||
webhook: {},
|
||||
workflow: {},
|
||||
request: new NextRequest('http://localhost/test'),
|
||||
})
|
||||
expect(matched).toBe(true)
|
||||
})
|
||||
|
||||
it('matchEvent filters events that do not match the configured trigger', async () => {
|
||||
const matched = await instantlyHandler.matchEvent!({
|
||||
body: { event_type: 'email_opened' },
|
||||
requestId: 't6',
|
||||
providerConfig: { triggerId: 'instantly_email_sent' },
|
||||
webhook: {},
|
||||
workflow: {},
|
||||
request: new NextRequest('http://localhost/test'),
|
||||
})
|
||||
expect(matched).toBe(false)
|
||||
})
|
||||
|
||||
it('matchEvent matches events for the configured trigger', async () => {
|
||||
const matched = await instantlyHandler.matchEvent!({
|
||||
body: { event_type: 'email_sent' },
|
||||
requestId: 't7',
|
||||
providerConfig: { triggerId: 'instantly_email_sent' },
|
||||
webhook: {},
|
||||
workflow: {},
|
||||
request: new NextRequest('http://localhost/test'),
|
||||
})
|
||||
expect(matched).toBe(true)
|
||||
})
|
||||
|
||||
it('matchEvent accepts both link-click event type spellings', async () => {
|
||||
const matchedA = await instantlyHandler.matchEvent!({
|
||||
body: { event_type: 'link_clicked' },
|
||||
requestId: 't8a',
|
||||
providerConfig: { triggerId: 'instantly_link_clicked' },
|
||||
webhook: {},
|
||||
workflow: {},
|
||||
request: new NextRequest('http://localhost/test'),
|
||||
})
|
||||
const matchedB = await instantlyHandler.matchEvent!({
|
||||
body: { event_type: 'email_link_clicked' },
|
||||
requestId: 't8b',
|
||||
providerConfig: { triggerId: 'instantly_link_clicked' },
|
||||
webhook: {},
|
||||
workflow: {},
|
||||
request: new NextRequest('http://localhost/test'),
|
||||
})
|
||||
expect(matchedA).toBe(true)
|
||||
expect(matchedB).toBe(true)
|
||||
})
|
||||
|
||||
it('formats input with keys matching the trigger outputs', async () => {
|
||||
const body = {
|
||||
timestamp: '2026-07-08T12:00:00.000Z',
|
||||
event_type: 'reply_received',
|
||||
workspace: 'ws-1',
|
||||
campaign_id: 'camp-1',
|
||||
campaign_name: 'Q3 Outreach',
|
||||
lead_email: 'lead@example.com',
|
||||
email_account: 'sender@example.com',
|
||||
unibox_url: 'https://app.instantly.ai/unibox/1',
|
||||
step: 2,
|
||||
variant: 1,
|
||||
is_first: true,
|
||||
email_id: 'email-1',
|
||||
reply_text_snippet: 'Sounds good',
|
||||
reply_subject: 'Re: Q3 Outreach',
|
||||
reply_text: 'Sounds good, thanks!',
|
||||
reply_html: '<p>Sounds good, thanks!</p>',
|
||||
}
|
||||
|
||||
const result = await instantlyHandler.formatInput!({
|
||||
body,
|
||||
webhook: {},
|
||||
workflow: { id: 'wf-1', userId: 'user-1' },
|
||||
headers: {},
|
||||
requestId: 't9',
|
||||
})
|
||||
|
||||
expect(result.input).toEqual({
|
||||
timestamp: '2026-07-08T12:00:00.000Z',
|
||||
eventType: 'reply_received',
|
||||
workspace: 'ws-1',
|
||||
campaignId: 'camp-1',
|
||||
campaignName: 'Q3 Outreach',
|
||||
leadEmail: 'lead@example.com',
|
||||
emailAccount: 'sender@example.com',
|
||||
uniboxUrl: 'https://app.instantly.ai/unibox/1',
|
||||
step: 2,
|
||||
variant: 1,
|
||||
isFirst: true,
|
||||
emailId: 'email-1',
|
||||
emailSubject: null,
|
||||
emailText: null,
|
||||
emailHtml: null,
|
||||
replyTextSnippet: 'Sounds good',
|
||||
replySubject: 'Re: Q3 Outreach',
|
||||
replyText: 'Sounds good, thanks!',
|
||||
replyHtml: '<p>Sounds good, thanks!</p>',
|
||||
payload: body,
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractIdempotencyId', () => {
|
||||
it('prefers the email_id when present, qualified by timestamp', () => {
|
||||
const id = instantlyHandler.extractIdempotencyId!({
|
||||
event_type: 'email_sent',
|
||||
email_id: 'email-123',
|
||||
campaign_id: 'camp-1',
|
||||
lead_email: 'lead@example.com',
|
||||
timestamp: '2026-07-08T12:00:00.000Z',
|
||||
})
|
||||
expect(id).toBe('instantly:email_sent:email-123:2026-07-08T12:00:00.000Z')
|
||||
})
|
||||
|
||||
it('returns null when email_id is present but timestamp is missing, rather than risk a false collision', () => {
|
||||
const id = instantlyHandler.extractIdempotencyId!({
|
||||
event_type: 'email_sent',
|
||||
email_id: 'email-123',
|
||||
})
|
||||
expect(id).toBeNull()
|
||||
})
|
||||
|
||||
it('falls back to a content-based key without an email_id', () => {
|
||||
const id = instantlyHandler.extractIdempotencyId!({
|
||||
event_type: 'lead_interested',
|
||||
campaign_id: 'camp-1',
|
||||
lead_email: 'lead@example.com',
|
||||
timestamp: '2026-07-08T12:00:00.000Z',
|
||||
})
|
||||
expect(id).toBe('instantly:lead_interested:camp-1:lead@example.com:2026-07-08T12:00:00.000Z')
|
||||
})
|
||||
|
||||
it('is stable across retries of the same delivery', () => {
|
||||
const body = {
|
||||
event_type: 'lead_interested',
|
||||
campaign_id: 'camp-1',
|
||||
lead_email: 'lead@example.com',
|
||||
timestamp: '2026-07-08T12:00:00.000Z',
|
||||
}
|
||||
const first = instantlyHandler.extractIdempotencyId!(body)
|
||||
const second = instantlyHandler.extractIdempotencyId!({ ...body })
|
||||
expect(first).toBe(second)
|
||||
})
|
||||
|
||||
it('is stable across retries of the same email_id-keyed delivery', () => {
|
||||
const body = {
|
||||
event_type: 'email_opened',
|
||||
email_id: 'email-123',
|
||||
timestamp: '2026-07-08T12:00:00.000Z',
|
||||
}
|
||||
const first = instantlyHandler.extractIdempotencyId!(body)
|
||||
const second = instantlyHandler.extractIdempotencyId!({ ...body })
|
||||
expect(first).toBe(second)
|
||||
})
|
||||
|
||||
it('does not collide across distinct occurrences of the same email_id (e.g. repeat opens/clicks/replies)', () => {
|
||||
const firstOpen = instantlyHandler.extractIdempotencyId!({
|
||||
event_type: 'email_opened',
|
||||
email_id: 'email-123',
|
||||
timestamp: '2026-07-08T12:00:00.000Z',
|
||||
})
|
||||
const secondOpen = instantlyHandler.extractIdempotencyId!({
|
||||
event_type: 'email_opened',
|
||||
email_id: 'email-123',
|
||||
timestamp: '2026-07-08T13:30:00.000Z',
|
||||
})
|
||||
expect(firstOpen).not.toBe(secondOpen)
|
||||
})
|
||||
|
||||
it('returns null when there is not enough data to build a stable key', () => {
|
||||
expect(instantlyHandler.extractIdempotencyId!({ event_type: 'account_error' })).toBeNull()
|
||||
expect(instantlyHandler.extractIdempotencyId!({})).toBeNull()
|
||||
expect(instantlyHandler.extractIdempotencyId!('not-an-object')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('createSubscription', () => {
|
||||
const fetchMock = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://app.test')
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
fetchMock.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it('creates an Instantly webhook with the mapped event type', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ id: 'wh-123' }),
|
||||
})
|
||||
|
||||
const result = await instantlyHandler.createSubscription!({
|
||||
webhook: {
|
||||
id: 'webhook-1',
|
||||
path: 'abc-path',
|
||||
providerConfig: { triggerApiKey: 'instantly-key', triggerId: 'instantly_email_sent' },
|
||||
},
|
||||
workflow: {},
|
||||
userId: 'user-1',
|
||||
requestId: 'req-create-1',
|
||||
request: new NextRequest('http://localhost/test'),
|
||||
})
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
const [url, init] = fetchMock.mock.calls[0]
|
||||
expect(url).toBe('https://api.instantly.ai/api/v2/webhooks')
|
||||
expect(init.method).toBe('POST')
|
||||
expect(init.headers.Authorization).toBe('Bearer instantly-key')
|
||||
const sent = JSON.parse(init.body)
|
||||
expect(sent.event_type).toBe('email_sent')
|
||||
expect(sent.target_hook_url).toContain('/api/webhooks/trigger/abc-path')
|
||||
expect(typeof sent.headers['X-Sim-Webhook-Token']).toBe('string')
|
||||
|
||||
expect(result?.providerConfigUpdates?.externalId).toBe('wh-123')
|
||||
})
|
||||
|
||||
it('maps the link-clicked trigger to the email_link_clicked subscription event', async () => {
|
||||
fetchMock.mockResolvedValue({ ok: true, status: 200, json: async () => ({ id: 'wh-456' }) })
|
||||
|
||||
await instantlyHandler.createSubscription!({
|
||||
webhook: {
|
||||
id: 'webhook-2',
|
||||
path: 'p2',
|
||||
providerConfig: { triggerApiKey: 'instantly-key', triggerId: 'instantly_link_clicked' },
|
||||
},
|
||||
workflow: {},
|
||||
userId: 'user-1',
|
||||
requestId: 'req-create-2',
|
||||
request: new NextRequest('http://localhost/test'),
|
||||
})
|
||||
|
||||
const sent = JSON.parse(fetchMock.mock.calls[0][1].body)
|
||||
expect(sent.event_type).toBe('email_link_clicked')
|
||||
})
|
||||
|
||||
it('throws a friendly error on 401', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: async () => ({ message: 'unauthorized' }),
|
||||
})
|
||||
|
||||
await expect(
|
||||
instantlyHandler.createSubscription!({
|
||||
webhook: {
|
||||
id: 'webhook-3',
|
||||
path: 'p3',
|
||||
providerConfig: { triggerApiKey: 'bad-key', triggerId: 'instantly_email_sent' },
|
||||
},
|
||||
workflow: {},
|
||||
userId: 'user-1',
|
||||
requestId: 'req-create-3',
|
||||
request: new NextRequest('http://localhost/test'),
|
||||
})
|
||||
).rejects.toThrow(/Invalid Instantly API Key/)
|
||||
})
|
||||
|
||||
it('throws when the API key is missing', async () => {
|
||||
await expect(
|
||||
instantlyHandler.createSubscription!({
|
||||
webhook: {
|
||||
id: 'webhook-4',
|
||||
path: 'p4',
|
||||
providerConfig: { triggerId: 'instantly_email_sent' },
|
||||
},
|
||||
workflow: {},
|
||||
userId: 'user-1',
|
||||
requestId: 'req-create-4',
|
||||
request: new NextRequest('http://localhost/test'),
|
||||
})
|
||||
).rejects.toThrow(/Instantly API Key is required/)
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteSubscription', () => {
|
||||
const fetchMock = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
fetchMock.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('deletes the webhook by externalId', async () => {
|
||||
fetchMock.mockResolvedValue({ ok: true, status: 200, body: null })
|
||||
|
||||
await instantlyHandler.deleteSubscription!({
|
||||
webhook: {
|
||||
id: 'webhook-1',
|
||||
providerConfig: { triggerApiKey: 'instantly-key', externalId: 'wh-123' },
|
||||
},
|
||||
workflow: {},
|
||||
requestId: 'req-del-1',
|
||||
})
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
const [url, init] = fetchMock.mock.calls[0]
|
||||
expect(url).toBe('https://api.instantly.ai/api/v2/webhooks/wh-123')
|
||||
expect(init.method).toBe('DELETE')
|
||||
})
|
||||
|
||||
it('skips when apiKey or externalId is missing and does not throw', async () => {
|
||||
await instantlyHandler.deleteSubscription!({
|
||||
webhook: { id: 'webhook-2', providerConfig: { externalId: 'wh-123' } },
|
||||
workflow: {},
|
||||
requestId: 'req-del-2',
|
||||
})
|
||||
await instantlyHandler.deleteSubscription!({
|
||||
webhook: { id: 'webhook-3', providerConfig: { triggerApiKey: 'instantly-key' } },
|
||||
workflow: {},
|
||||
requestId: 'req-del-3',
|
||||
})
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not throw on a non-ok response in non-strict mode', async () => {
|
||||
fetchMock.mockResolvedValue({ ok: false, status: 500, json: async () => ({}) })
|
||||
|
||||
await expect(
|
||||
instantlyHandler.deleteSubscription!({
|
||||
webhook: {
|
||||
id: 'webhook-4',
|
||||
providerConfig: { triggerApiKey: 'instantly-key', externalId: 'wh-9' },
|
||||
},
|
||||
workflow: {},
|
||||
requestId: 'req-del-4',
|
||||
})
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user