Skip to content
← Public packages

@kentcdodds/cal-com

Cal.com helpers for booking pages, event types, slots, and webhooks.

test/helpers.test.js

337 lines · 11.9 KB · JavaScript
import assert from 'node:assert/strict'
import test from 'node:test'
import {
  applyEventTypeIdToSlotsQuery,
  canonicalizeWebhookTrigger,
  eventTypeSelectorFromInput,
  firstCatalogEventType,
  flattenEventTypes,
  formatCalErrorDetail,
  formatEventTypeCatalog,
  hasEventTypeLookupSelector,
  hasEventTypeSelector,
  isNumericEventTypeId,
  isSmokeInput,
  normalizeCreateBookingBody,
  normalizeCreateWebhookBody,
  planAvailableSlotsQuery,
  resolveEventTypeFromCatalog,
  utcDateOnly,
  WEBHOOK_TRIGGERS,
} from '../src/helpers.js'

test('formatCalErrorDetail prefers the Cal.com error message', () => {
  const detail = formatCalErrorDetail({
    status: 'error',
    error: {
      code: 'BadRequestException',
      message: 'attendee property is wrong, timeZone must be a valid IANA time-zone',
    },
  })
  assert.equal(detail, 'attendee property is wrong, timeZone must be a valid IANA time-zone')
})

test('formatCalErrorDetail flattens webhook constraint errors', () => {
  const detail = formatCalErrorDetail({
    error: {
      message: 'Bad Request Exception',
      details: {
        errors: [
          { property: 'active', constraints: { isBoolean: 'active must be a boolean value' } },
          { property: 'subscriberUrl', constraints: { isString: 'subscriberUrl must be a string' } },
        ],
      },
    },
  })
  assert.equal(detail, 'active must be a boolean value; subscriberUrl must be a string')
})

test('normalizeCreateBookingBody defaults attendee.timeZone to UTC', () => {
  const body = normalizeCreateBookingBody({
    eventTypeId: 123,
    start: '2026-06-01T16:00:00Z',
    attendee: { name: 'Ada', email: 'ada@example.com' },
  })
  assert.equal(body.attendee.timeZone, 'UTC')
  assert.equal(body.eventTypeId, 123)
})

test('normalizeCreateBookingBody lifts top-level timeZone onto attendee', () => {
  const body = normalizeCreateBookingBody({
    eventTypeId: 123,
    start: '2026-06-01T16:00:00Z',
    timeZone: 'America/Denver',
    attendee: { name: 'Ada', email: 'ada@example.com' },
  })
  assert.equal(body.attendee.timeZone, 'America/Denver')
  assert.equal('timeZone' in body, false)
})

test('normalizeCreateBookingBody rejects missing required fields', () => {
  assert.throws(() => normalizeCreateBookingBody({}), /attendee/)
  assert.throws(
    () =>
      normalizeCreateBookingBody({
        start: '2026-06-01T16:00:00Z',
        attendee: { name: 'Ada', email: 'ada@example.com' },
      }),
    /eventTypeId/,
  )
})

test('normalizeCreateWebhookBody defaults active to true', () => {
  const body = normalizeCreateWebhookBody({
    subscriberUrl: 'https://example.com/hooks/cal',
    triggers: ['BOOKING_CREATED'],
  })
  assert.equal(body.active, true)
  assert.deepEqual(body.triggers, ['BOOKING_CREATED'])
})

test('normalizeCreateWebhookBody rejects missing subscriberUrl and triggers', () => {
  assert.throws(() => normalizeCreateWebhookBody({}), /subscriberUrl/)
  assert.throws(
    () => normalizeCreateWebhookBody({ subscriberUrl: 'https://example.com/hooks/cal' }),
    /triggers/,
  )
})

test('normalizeCreateWebhookBody canonicalizes trigger aliases and accepts a string', () => {
  const body = normalizeCreateWebhookBody({
    subscriberUrl: 'https://example.com/hooks/cal',
    triggers: ['booking-created', 'BOOKING.CANCELLED', 'booking created', 'BOOKING_CREATED'],
  })
  assert.deepEqual(body.triggers, ['BOOKING_CREATED', 'BOOKING_CANCELLED'])

  const fromString = normalizeCreateWebhookBody({
    subscriberUrl: 'https://example.com/hooks/cal',
    triggers: 'meeting-started',
  })
  assert.deepEqual(fromString.triggers, ['MEETING_STARTED'])
})

test('normalizeCreateWebhookBody rejects unofficial trigger names locally', () => {
  assert.throws(
    () =>
      normalizeCreateWebhookBody({
        subscriberUrl: 'https://example.com/hooks/cal',
        triggers: ['BOOKING_CONFIRMED'],
      }),
    (error) => {
      assert.match(error.message, /BOOKING_CONFIRMED/)
      assert.match(error.message, /Allowed:[\s\S]*BOOKING_CREATED/)
      assert.doesNotMatch(error.message, /Cal\.com API request failed/)
      return true
    },
  )
})

test('canonicalizeWebhookTrigger maps kebab, dotted, and spaced names', () => {
  assert.equal(canonicalizeWebhookTrigger('booking-created'), 'BOOKING_CREATED')
  assert.equal(canonicalizeWebhookTrigger('booking.created'), 'BOOKING_CREATED')
  assert.equal(canonicalizeWebhookTrigger(' booking created '), 'BOOKING_CREATED')
  assert.ok(WEBHOOK_TRIGGERS.includes('RECORDING_TRANSCRIPTION_GENERATED'))
  assert.equal(WEBHOOK_TRIGGERS.includes('BOOKING_CONFIRMED'), false)
})

test('flattenEventTypes accepts arrays, eventTypes, and eventTypeGroups', () => {
  const row = { id: 101, title: '30 Min Meeting', slug: '30mins' }
  assert.deepEqual(flattenEventTypes([row]), [row])
  assert.deepEqual(flattenEventTypes({ eventTypes: [row] }), [row])
  assert.deepEqual(flattenEventTypes({ eventTypeGroups: [{ eventTypes: [row] }] }), [row])
  assert.deepEqual(flattenEventTypes({}), [])
})

test('formatEventTypeCatalog lists ids, titles, and slugs', () => {
  const catalog = formatEventTypeCatalog(
    [
      { id: 101, title: '30 Min Meeting', slug: '30mins' },
      { id: 102, name: '60 Min Meeting', slug: '60mins' },
    ],
    { limit: 8 },
  )
  assert.match(catalog, /101 "30 Min Meeting" \(30mins\)/)
  assert.match(catalog, /102 "60 Min Meeting" \(60mins\)/)
  assert.match(catalog, /list-event-types/)
})

test('formatEventTypeCatalog truncates and reports leftovers', () => {
  const types = Array.from({ length: 10 }, (_, index) => ({
    id: index + 1,
    title: `Type ${index + 1}`,
  }))
  const catalog = formatEventTypeCatalog(types, { limit: 3 })
  assert.match(catalog, /1 "Type 1"/)
  assert.match(catalog, /\+7 more/)
  assert.equal(catalog.includes('Type 4'), false)
})

test('formatEventTypeCatalog explains an empty account', () => {
  assert.match(formatEventTypeCatalog([]), /No event types found/)
})

test('hasEventTypeSelector accepts id or slug plus username/teamSlug', () => {
  assert.equal(hasEventTypeSelector({ eventTypeId: 101 }), true)
  assert.equal(hasEventTypeSelector({ eventTypeSlug: '30mins', username: 'kent' }), true)
  assert.equal(hasEventTypeSelector({ eventTypeSlug: '30mins', teamSlug: 'team' }), true)
  assert.equal(hasEventTypeSelector({ eventTypeSlug: '30mins' }), false)
  assert.equal(hasEventTypeSelector({}), false)
})

test('isNumericEventTypeId accepts positive integer ids', () => {
  assert.equal(isNumericEventTypeId(587527), true)
  assert.equal(isNumericEventTypeId('587527'), true)
  assert.equal(isNumericEventTypeId(' 587527 '), true)
  assert.equal(isNumericEventTypeId('30mins'), false)
  assert.equal(isNumericEventTypeId('30 Min Meeting'), false)
  assert.equal(isNumericEventTypeId(0), false)
  assert.equal(isNumericEventTypeId(-1), false)
})

test('eventTypeSelectorFromInput reads id aliases and params.id', () => {
  assert.deepEqual(eventTypeSelectorFromInput({ id: 101 }), {
    id: 101,
    slug: '',
    title: '',
  })
  assert.deepEqual(eventTypeSelectorFromInput({ eventTypeId: '102' }), {
    id: '102',
    slug: '',
    title: '',
  })
  assert.deepEqual(eventTypeSelectorFromInput({ params: { id: 103 } }), {
    id: 103,
    slug: '',
    title: '',
  })
})

test('eventTypeSelectorFromInput treats non-numeric id as slug or title', () => {
  assert.deepEqual(eventTypeSelectorFromInput({ id: '30mins' }), {
    id: undefined,
    slug: '30mins',
    title: '30mins',
  })
  assert.deepEqual(eventTypeSelectorFromInput({ eventTypeSlug: '30mins' }), {
    id: undefined,
    slug: '30mins',
    title: '',
  })
  assert.deepEqual(eventTypeSelectorFromInput({ title: '30 Min Meeting' }), {
    id: undefined,
    slug: '',
    title: '30 Min Meeting',
  })
  assert.equal(hasEventTypeLookupSelector(eventTypeSelectorFromInput({})), false)
  assert.equal(hasEventTypeLookupSelector(eventTypeSelectorFromInput({ slug: '30mins' })), true)
})

test('resolveEventTypeFromCatalog matches id, slug, or unique title', () => {
  const types = [
    { id: 101, title: '30 Min Meeting', slug: '30mins' },
    { id: 102, name: '60 Min Meeting', slug: '60mins' },
  ]
  assert.equal(resolveEventTypeFromCatalog(types, { id: 101 })?.id, 101)
  assert.equal(resolveEventTypeFromCatalog(types, { slug: '60MINS' })?.id, 102)
  assert.equal(resolveEventTypeFromCatalog(types, { title: '30 min meeting' })?.id, 101)
  assert.equal(resolveEventTypeFromCatalog(types, { slug: 'missing' }), null)
  assert.equal(resolveEventTypeFromCatalog(types, {}), null)
})

test('resolveEventTypeFromCatalog ignores ambiguous titles', () => {
  const types = [
    { id: 1, title: 'Meet', slug: 'meet-a' },
    { id: 2, title: 'Meet', slug: 'meet-b' },
  ]
  assert.equal(resolveEventTypeFromCatalog(types, { title: 'Meet' }), null)
  assert.equal(resolveEventTypeFromCatalog(types, { slug: 'meet-b' })?.id, 2)
})

test('planAvailableSlotsQuery lists when no event-type selector is given', () => {
  assert.deepEqual(planAvailableSlotsQuery({}), { kind: 'list' })
  assert.deepEqual(
    planAvailableSlotsQuery({ start: '2026-06-01', end: '2026-06-08' }),
    { kind: 'list' },
  )
})

test('planAvailableSlotsQuery keeps official Cal.com slots selectors', () => {
  assert.deepEqual(planAvailableSlotsQuery({ eventTypeId: 101, start: '2026-06-01' }), {
    kind: 'ready',
    query: { eventTypeId: 101, start: '2026-06-01' },
  })
  assert.deepEqual(
    planAvailableSlotsQuery({ eventTypeSlug: '30mins', username: 'kent', start: '2026-06-01' }),
    {
      kind: 'ready',
      query: { eventTypeSlug: '30mins', username: 'kent', start: '2026-06-01' },
    },
  )
})

test('planAvailableSlotsQuery maps numeric id aliases onto eventTypeId', () => {
  assert.deepEqual(planAvailableSlotsQuery({ id: 101, start: '2026-06-01' }), {
    kind: 'ready',
    query: { eventTypeId: 101, start: '2026-06-01' },
  })
  assert.deepEqual(planAvailableSlotsQuery({ params: { eventTypeId: '102' }, end: '2026-06-08' }), {
    kind: 'ready',
    query: { eventTypeId: '102', end: '2026-06-08' },
  })
})

test('planAvailableSlotsQuery resolves slug or title aliases via the catalog', () => {
  assert.deepEqual(planAvailableSlotsQuery({ slug: '30mins', start: '2026-06-01' }), {
    kind: 'resolve',
    query: { slug: '30mins', start: '2026-06-01' },
    selector: { id: undefined, slug: '30mins', title: '' },
  })
  assert.deepEqual(planAvailableSlotsQuery({ eventTypeSlug: '30mins', start: '2026-06-01' }), {
    kind: 'resolve',
    query: { eventTypeSlug: '30mins', start: '2026-06-01' },
    selector: { id: undefined, slug: '30mins', title: '' },
  })
  assert.deepEqual(planAvailableSlotsQuery({ title: '30 Min Meeting' }), {
    kind: 'resolve',
    query: { title: '30 Min Meeting' },
    selector: { id: undefined, slug: '', title: '30 Min Meeting' },
  })
})

test('isSmokeInput is only true for smoke: true', () => {
  assert.equal(isSmokeInput({ smoke: true }), true)
  assert.equal(isSmokeInput({ smoke: true, eventTypeId: 999999999 }), true)
  assert.equal(isSmokeInput({ smoke: false }), false)
  assert.equal(isSmokeInput({ eventTypeId: 101 }), false)
  assert.equal(isSmokeInput({}), false)
})

test('firstCatalogEventType picks the first row with an id', () => {
  assert.equal(
    firstCatalogEventType([
      { title: 'no id' },
      { id: 101, title: '30 Min Meeting' },
      { id: 102, title: '60 Min Meeting' },
    ])?.id,
    101,
  )
  assert.equal(firstCatalogEventType([]), null)
  assert.equal(firstCatalogEventType({ eventTypes: [{ id: 587527, slug: '30mins' }] })?.id, 587527)
})

test('utcDateOnly offsets from a fixed instant', () => {
  const now = new Date('2026-08-23T04:00:00.000Z')
  assert.equal(utcDateOnly(0, now), '2026-08-23')
  assert.equal(utcDateOnly(7, now), '2026-08-30')
})

test('planAvailableSlotsQuery treats smoke-only input as list', () => {
  assert.deepEqual(planAvailableSlotsQuery({ smoke: true }), { kind: 'list' })
})

test('applyEventTypeIdToSlotsQuery strips id/slug/title aliases', () => {
  assert.deepEqual(
    applyEventTypeIdToSlotsQuery({ slug: '30mins', title: '30 Min Meeting', start: '2026-06-01' }, 101),
    { eventTypeId: 101, start: '2026-06-01' },
  )
})