tests/bento.unit.test.ts
Rama main · réplica de lectura del repositorio original.
import type { Context } from 'hono'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getBentoSubscriberEmailByUuid, parseBentoSubscriberEmail, syncBentoSubscriberTags, trackBentoEvent, trackBentoEvents, unsubscribeBento, updateBentoFields } from '../supabase/functions/_backend/utils/bento.ts'
const { cloudlogErrMock, getEnvMock } = vi.hoisted(() => ({
cloudlogErrMock: vi.fn(),
getEnvMock: vi.fn(),
}))
vi.mock('../supabase/functions/_backend/utils/logging.ts', () => ({
cloudlog: vi.fn(),
cloudlogErr: cloudlogErrMock,
serializeError: (error: unknown) => error,
}))
vi.mock('../supabase/functions/_backend/utils/utils.ts', () => ({
getEnv: getEnvMock,
}))
function configureBentoEnv() {
getEnvMock.mockImplementation((_context: unknown, key: string) => {
const values: Record<string, string> = {
BENTO_PUBLISHABLE_KEY: 'publishable-key-value',
BENTO_SECRET_KEY: 'secret-key-value',
BENTO_SITE_UUID: 'site-uuid-value',
}
return values[key] ?? ''
})
}
const fetchMock = vi.fn<(
input: string | URL | Request,
init?: RequestInit,
) => Promise<Response>>()
function createContext() {
return {
get: vi.fn(() => 'request-id'),
} as unknown as Context
}
function jsonResponse(body: unknown) {
return new Response(JSON.stringify(body), {
headers: { 'content-type': 'application/json' },
status: 200,
})
}
function queueAcknowledgement(body: unknown) {
fetchMock.mockResolvedValueOnce(jsonResponse(body))
}
const subscriberUpdates = [
{
deleteSegments: [],
email: 'first.user@example.com',
segments: ['onboarding:awaiting_first_org'],
},
{
deleteSegments: ['onboarding:awaiting_first_org'],
email: 'second.user@example.com',
segments: ['onboarding:first_org_recovery_suppressed'],
},
]
const events = [
{ event: 'cli:command_invoked', data: { occurrence_count: 1 } },
{ event: 'cli:login_successful', data: { occurrence_count: 2 } },
]
describe('bento response acceptance and configuration', () => {
beforeEach(() => {
cloudlogErrMock.mockReset()
fetchMock.mockReset()
getEnvMock.mockReset()
configureBentoEnv()
vi.stubGlobal('fetch', fetchMock)
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('syncBentoSubscriberTags', () => {
it('accepts an acknowledgement for the exact subscriber count', async () => {
queueAcknowledgement({ failed: 0, results: 2 })
await expect(syncBentoSubscriberTags(createContext(), subscriberUpdates)).resolves.toBe(true)
})
it.each([
['zero results', { failed: 0, results: 0 }],
['a missing result count', { failed: 0 }],
['a missing failure count', { results: 2 }],
['a short result count', { failed: 0, results: 1 }],
['a malformed result count', { failed: 0, results: '2' }],
['a malformed acknowledgement', null],
['a non-zero failure count', { failed: 1, results: 2 }],
])('rejects %s', async (_label, acknowledgement) => {
queueAcknowledgement(acknowledgement)
await expect(syncBentoSubscriberTags(createContext(), subscriberUpdates)).resolves.toBe(false)
})
it('logs only numeric acknowledgement counts for a rejected subscriber batch', async () => {
queueAcknowledgement({
failed: 1,
results: 2,
email: 'private.user@example.com',
observations: [{ token: 'private-observation-token' }],
token: 'private-provider-token',
})
await expect(syncBentoSubscriberTags(createContext(), subscriberUpdates)).resolves.toBe(false)
expect(cloudlogErrMock).toHaveBeenCalledWith({
requestId: 'request-id',
message: 'syncBentoSubscriberTags',
error: { failed: 1, results: 2 },
})
expect(JSON.stringify(cloudlogErrMock.mock.calls)).not.toContain('private.user@example.com')
expect(JSON.stringify(cloudlogErrMock.mock.calls)).not.toContain('private-observation-token')
expect(JSON.stringify(cloudlogErrMock.mock.calls)).not.toContain('private-provider-token')
})
})
describe('trackBentoEvent', () => {
it('accepts an acknowledgement for exactly one event', async () => {
queueAcknowledgement({ failed: 0, results: 1 })
await expect(trackBentoEvent(
createContext(),
'event.user@example.com',
{ source: 'unit-test' },
'user:created',
)).resolves.toBe(true)
})
it.each([
['zero results', { failed: 0, results: 0 }],
['a missing result count', { failed: 0 }],
['a missing failure count', { results: 1 }],
['a malformed result count', { failed: 0, results: '1' }],
['a malformed acknowledgement', null],
['a non-zero failure count', { failed: 1, results: 1 }],
])('rejects %s', async (_label, acknowledgement) => {
queueAcknowledgement(acknowledgement)
await expect(trackBentoEvent(
createContext(),
'event.user@example.com',
{ source: 'unit-test' },
'user:created',
)).resolves.toBe(false)
})
})
describe('trackBentoEvents', () => {
it('accepts an empty configured batch without a request', async () => {
await expect(trackBentoEvents(
createContext(),
'event.user@example.com',
[],
)).resolves.toBe(true)
expect(fetchMock).not.toHaveBeenCalled()
})
it('skips an unconfigured batch without a request', async () => {
getEnvMock.mockReturnValue('')
await expect(trackBentoEvents(
createContext(),
'event.user@example.com',
events,
)).resolves.toBeUndefined()
expect(fetchMock).not.toHaveBeenCalled()
})
it('accepts an acknowledgement for the exact event count', async () => {
queueAcknowledgement({ failed: 0, results: 2 })
await expect(trackBentoEvents(
createContext(),
'event.user@example.com',
events,
)).resolves.toBe(true)
})
it('sends all events in one batch request', async () => {
queueAcknowledgement({ failed: 0, results: 2 })
await trackBentoEvents(createContext(), 'event.user@example.com', events)
expect(fetchMock).toHaveBeenCalledOnce()
expect(JSON.parse(fetchMock.mock.calls[0]![1]?.body as string)).toEqual({
events: [
{ type: 'cli:command_invoked', email: 'event.user@example.com', details: { occurrence_count: 1 } },
{ type: 'cli:login_successful', email: 'event.user@example.com', details: { occurrence_count: 2 } },
],
})
})
it('logs and rejects a failed request', async () => {
const error = new Error('Bento request failed')
fetchMock.mockRejectedValueOnce(error)
await expect(trackBentoEvents(
createContext(),
'event.user@example.com',
events,
)).resolves.toBe(false)
expect(cloudlogErrMock).toHaveBeenCalledWith({
requestId: 'request-id',
message: 'trackBentoEvents error',
error,
})
})
it('logs a non-2xx status without reading provider response data', async () => {
const response = new Response(JSON.stringify({
email: 'private.user@example.com',
observations: [{ token: 'private-observation-token' }],
token: 'private-provider-token',
}), { status: 422 })
const cancel = vi.spyOn(response.body!, 'cancel')
fetchMock.mockResolvedValueOnce(response)
await expect(trackBentoEvents(
createContext(),
'event.user@example.com',
events,
)).resolves.toBe(false)
const loggedError = cloudlogErrMock.mock.calls[0]?.[0]?.error
expect(cancel).toHaveBeenCalledOnce()
expect(loggedError).toBeInstanceOf(Error)
expect((loggedError as Error).message).toBe('Bento API error: 422')
expect(JSON.stringify(cloudlogErrMock.mock.calls)).not.toContain('private.user@example.com')
expect(JSON.stringify(cloudlogErrMock.mock.calls)).not.toContain('private-observation-token')
expect(JSON.stringify(cloudlogErrMock.mock.calls)).not.toContain('private-provider-token')
})
it('keeps the status-only error when rejected response cancellation fails', async () => {
const response = new Response('private-provider-body', { status: 503 })
const cancel = vi.spyOn(response.body!, 'cancel')
.mockRejectedValueOnce(new Error('private-cancel-error'))
fetchMock.mockResolvedValueOnce(response)
await expect(trackBentoEvents(
createContext(),
'event.user@example.com',
events,
)).resolves.toBe(false)
const loggedError = cloudlogErrMock.mock.calls[0]?.[0]?.error
expect(cancel).toHaveBeenCalledOnce()
expect(loggedError).toBeInstanceOf(Error)
expect((loggedError as Error).message).toBe('Bento API error: 503')
expect(JSON.stringify(cloudlogErrMock.mock.calls)).not.toContain('private-provider-body')
expect(JSON.stringify(cloudlogErrMock.mock.calls)).not.toContain('private-cancel-error')
})
it('sanitizes invalid provider JSON without logging response data', async () => {
fetchMock.mockResolvedValueOnce(new Response(
'{"token":"private-provider-token", "observations": [',
{ status: 200 },
))
await expect(trackBentoEvents(
createContext(),
'event.user@example.com',
events,
)).resolves.toBe(false)
const loggedError = cloudlogErrMock.mock.calls[0]?.[0]?.error
expect(loggedError).toBeInstanceOf(Error)
expect((loggedError as Error).message).toBe('Bento API returned invalid JSON: 200')
expect(JSON.stringify(cloudlogErrMock.mock.calls)).not.toContain('private-provider-token')
expect(JSON.stringify(cloudlogErrMock.mock.calls)).not.toContain('observations')
})
it.each([
['typed counts', {
failed: 1,
results: 2,
email: 'private.user@example.com',
observations: [{ token: 'private-observation-token' }],
token: 'private-provider-token',
}, { failed: 1, results: 2 }],
['malformed counts', {
failed: '1',
results: '2',
email: 'private.user@example.com',
observations: [{ token: 'private-observation-token' }],
token: 'private-provider-token',
}, {}],
])('logs only an allowlisted summary for rejected batch %s', async (_label, acknowledgement, summary) => {
queueAcknowledgement(acknowledgement)
await expect(trackBentoEvents(
createContext(),
'event.user@example.com',
events,
)).resolves.toBe(false)
expect(cloudlogErrMock).toHaveBeenCalledWith({
requestId: 'request-id',
message: 'trackBentoEvents',
error: summary,
})
expect(JSON.stringify(cloudlogErrMock.mock.calls)).not.toContain('private.user@example.com')
expect(JSON.stringify(cloudlogErrMock.mock.calls)).not.toContain('private-observation-token')
expect(JSON.stringify(cloudlogErrMock.mock.calls)).not.toContain('private-provider-token')
})
it.each([
['a short result count', { failed: 0, results: 1 }],
['a non-zero failure count', { failed: 1, results: 2 }],
['a malformed acknowledgement', null],
])('rejects %s', async (_label, acknowledgement) => {
queueAcknowledgement(acknowledgement)
await expect(trackBentoEvents(
createContext(),
'event.user@example.com',
events,
)).resolves.toBe(false)
})
})
describe('unsubscribeBento', () => {
it('accepts an acknowledgement for exactly one command', async () => {
queueAcknowledgement({ results: 1 })
await expect(unsubscribeBento(createContext(), 'unsubscribed.user@example.com')).resolves.toBe(true)
})
it.each([
['zero results', { results: 0 }],
['a missing result count', {}],
['a malformed result count', { results: '1' }],
['a malformed acknowledgement', null],
])('rejects %s', async (_label, acknowledgement) => {
queueAcknowledgement(acknowledgement)
await expect(unsubscribeBento(createContext(), 'unsubscribed.user@example.com')).resolves.toBe(false)
})
})
describe('getBentoSubscriberEmailByUuid', () => {
const visitorUuid = '11111111-1111-4111-8111-111111111111'
it('returns the subscriber email from a GET lookup', async () => {
queueAcknowledgement({
data: {
attributes: { email: 'User@Example.com', uuid: visitorUuid },
id: '1',
type: 'visitors',
},
})
await expect(getBentoSubscriberEmailByUuid(createContext(), visitorUuid)).resolves.toBe('user@example.com')
const [url, init] = fetchMock.mock.calls[0]!
expect(String(url)).toContain('/api/v1/fetch/subscribers')
expect(String(url)).toContain(`uuid=${visitorUuid}`)
expect(init?.method).toBe('GET')
expect(init?.body).toBeUndefined()
})
it('returns null when Bento has no subscriber for the uuid', async () => {
fetchMock.mockResolvedValueOnce(new Response(null, { status: 404 }))
await expect(getBentoSubscriberEmailByUuid(createContext(), visitorUuid)).resolves.toBeNull()
})
it('returns undefined when Bento is not configured', async () => {
getEnvMock.mockReturnValue('')
await expect(getBentoSubscriberEmailByUuid(createContext(), visitorUuid)).resolves.toBeUndefined()
expect(fetchMock).not.toHaveBeenCalled()
})
})
describe('parseBentoSubscriberEmail', () => {
it('reads JSON:API subscriber attributes', () => {
expect(parseBentoSubscriberEmail({
data: { attributes: { email: ' User@Example.com ' } },
})).toBe('user@example.com')
})
it('returns null for missing or invalid payloads', () => {
expect(parseBentoSubscriberEmail(null)).toBeNull()
expect(parseBentoSubscriberEmail({ data: null })).toBeNull()
expect(parseBentoSubscriberEmail({ data: { attributes: { email: 1 } } })).toBeNull()
})
})
describe('abort signals', () => {
const update = subscriberUpdates[1]!
const eventsUrl = 'https://app.bentonow.com/api/v1/batch/events?site_uuid=site-uuid-value'
it.each([
['subscriber synchronization', { failed: 0, results: 1 }, (c: Context, s: AbortSignal) => syncBentoSubscriberTags(c, update, s), '/api/v1/batch/subscribers', true],
['unsubscribe', { failed: 0, results: 1 }, (c: Context, s: AbortSignal) => unsubscribeBento(c, update.email, s), '/api/v1/fetch/commands', true],
['event batch tracking', { failed: 0, results: 2 }, (c: Context, s: AbortSignal) => trackBentoEvents(c, 'event.user@example.com', events, s), eventsUrl, true],
['field update', { failed: 0, results: 1 }, (c: Context, s: AbortSignal) => updateBentoFields(c, 'event.user@example.com', { preorg_intent: 'builder' }, s), eventsUrl, true],
['subscriber uuid lookup', { data: { attributes: { email: 'looked.up@example.com' } } }, (c: Context, s: AbortSignal) => getBentoSubscriberEmailByUuid(c, '11111111-1111-4111-8111-111111111111', s), '/api/v1/fetch/subscribers', 'looked.up@example.com'],
] as const)('forwards the optional %s signal to the exact Bento fetch', async (_label, response, operation, url, expected) => {
queueAcknowledgement(response)
const controller = new AbortController()
await expect(operation(createContext(), controller.signal)).resolves.toBe(expected)
expect(fetchMock).toHaveBeenCalledOnce()
const [calledUrl, init] = fetchMock.mock.calls[0]!
expect(String(calledUrl)).toContain(url)
expect(init?.signal).toBe(controller.signal)
})
it('sends field updates through Bento update_fields', async () => {
queueAcknowledgement({ failed: 0, results: 1 })
await expect(updateBentoFields(createContext(), 'event.user@example.com', { preorg_intent: 'builder' })).resolves.toBe(true)
expect(JSON.parse(String(fetchMock.mock.calls[0]![1]?.body))).toEqual({
events: [{ email: 'event.user@example.com', fields: { preorg_intent: 'builder' }, type: '$update_fields' }],
})
})
it.each([
['event batch tracking', (c: Context, s: AbortSignal) => trackBentoEvents(c, update.email, [events[0]!], s)],
['subscriber synchronization', (c: Context, s: AbortSignal) => syncBentoSubscriberTags(c, update, s)],
['field update', (c: Context, s: AbortSignal) => updateBentoFields(c, update.email, { preorg_intent: 'builder' }, s)],
['unsubscribe', (c: Context, s: AbortSignal) => unsubscribeBento(c, update.email, s)],
])('settles %s as failed when its fetch is aborted', async (_label, operation) => {
fetchMock.mockImplementation((_input, init) => new Promise<Response>((_resolve, reject) => {
const signal = init?.signal
if (!signal) {
reject(new Error('missing abort signal'))
return
}
const rejectAbort = () => reject(new DOMException('The operation was aborted', 'AbortError'))
if (signal.aborted)
rejectAbort()
else
signal.addEventListener('abort', rejectAbort, { once: true })
}))
const controller = new AbortController()
const result = operation(createContext(), controller.signal)
expect(fetchMock).toHaveBeenCalledOnce()
controller.abort()
await expect(result).resolves.toBe(false)
})
})
})