PlatformWebhooksEndpointSheet.test.tsx 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272
  1. import { fireEvent, screen, waitFor } from '@testing-library/react'
  2. import userEvent from '@testing-library/user-event'
  3. import type { ComponentProps } from 'react'
  4. import { afterEach, describe, expect, it, vi } from 'vitest'
  5. import type { WebhookEndpoint } from './PlatformWebhooks.types'
  6. import { PlatformWebhooksEndpointSheet, toEndpointPayload } from './PlatformWebhooksEndpointSheet'
  7. import { customRender } from '@/tests/lib/custom-render'
  8. const { generateWebhookEndpointNameMock } = vi.hoisted(() => ({
  9. generateWebhookEndpointNameMock: vi.fn(() => 'winged-envelope'),
  10. }))
  11. vi.mock(import('./PlatformWebhooks.utils'), async (importOriginal) => {
  12. const actual = await importOriginal()
  13. return {
  14. ...actual,
  15. generateWebhookEndpointName: generateWebhookEndpointNameMock,
  16. }
  17. })
  18. const PROJECT_EVENT_TYPES = ['project.updated', 'project.paused']
  19. const createEndpoint = (overrides?: Partial<WebhookEndpoint>): WebhookEndpoint => ({
  20. id: '3c9b7e21-8d54-4f63-b2a1-6e7d8c9f0a12',
  21. name: 'Billing events',
  22. url: 'https://hooks.example.com/billing',
  23. description: 'Invoices and receipts',
  24. enabled: true,
  25. eventTypes: ['project.updated'],
  26. customHeaders: [],
  27. createdBy: 'user@supabase.io',
  28. createdAt: '2026-03-16T00:00:00.000Z',
  29. ...overrides,
  30. })
  31. const renderEndpointSheet = (
  32. props?: Partial<ComponentProps<typeof PlatformWebhooksEndpointSheet>>
  33. ) => {
  34. const onClose = vi.fn()
  35. const onSubmit = vi.fn()
  36. customRender(
  37. <PlatformWebhooksEndpointSheet
  38. visible
  39. mode="create"
  40. scope="project"
  41. eventTypes={PROJECT_EVENT_TYPES}
  42. onClose={onClose}
  43. onSubmit={onSubmit}
  44. {...props}
  45. />
  46. )
  47. return { onClose, onSubmit }
  48. }
  49. const submitForm = () =>
  50. fireEvent.submit(document.getElementById('platform-webhook-endpoint-form')!)
  51. const getUrlInput = () => screen.getByPlaceholderText('https://api.example.com/webhooks/briven')
  52. const findEventTypeCheckbox = (eventType: string) =>
  53. screen.findByRole('checkbox', {
  54. name: new RegExp(eventType.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')),
  55. })
  56. describe('PlatformWebhooksEndpointSheet', () => {
  57. afterEach(() => {
  58. vi.clearAllMocks()
  59. })
  60. it('prefills the name field when creating an endpoint', async () => {
  61. renderEndpointSheet()
  62. expect(await screen.findByDisplayValue('winged-envelope')).toBeInTheDocument()
  63. })
  64. it('loads the existing name and description in edit mode', async () => {
  65. renderEndpointSheet({
  66. mode: 'edit',
  67. endpoint: createEndpoint(),
  68. })
  69. expect(await screen.findByDisplayValue('Billing events')).toBeInTheDocument()
  70. expect(screen.getByDisplayValue('Invoices and receipts')).toBeInTheDocument()
  71. })
  72. it('submits an empty name when an existing name is cleared', async () => {
  73. const user = userEvent.setup()
  74. const { onSubmit } = renderEndpointSheet({
  75. mode: 'edit',
  76. endpoint: createEndpoint(),
  77. })
  78. const nameInput = await screen.findByDisplayValue('Billing events')
  79. await user.clear(nameInput)
  80. submitForm()
  81. await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
  82. expect(onSubmit).toHaveBeenCalledWith(
  83. expect.objectContaining({
  84. name: '',
  85. url: 'https://hooks.example.com/billing',
  86. description: 'Invoices and receipts',
  87. }),
  88. expect.anything()
  89. )
  90. })
  91. it('blocks submit when the endpoint URL is empty', async () => {
  92. const user = userEvent.setup()
  93. const { onSubmit } = renderEndpointSheet()
  94. await user.click(await findEventTypeCheckbox('project.updated'))
  95. submitForm()
  96. expect(await screen.findByText('Please provide a URL')).toBeInTheDocument()
  97. expect(onSubmit).not.toHaveBeenCalled()
  98. })
  99. it('blocks submit when the endpoint URL is malformed', async () => {
  100. const user = userEvent.setup()
  101. const { onSubmit } = renderEndpointSheet()
  102. await user.type(getUrlInput(), 'https://not a url')
  103. await user.click(await findEventTypeCheckbox('project.updated'))
  104. submitForm()
  105. expect(await screen.findByText('Please provide a valid URL')).toBeInTheDocument()
  106. expect(onSubmit).not.toHaveBeenCalled()
  107. })
  108. it('blocks submit when the endpoint URL uses an incomplete hostname', async () => {
  109. const user = userEvent.setup()
  110. const { onSubmit } = renderEndpointSheet()
  111. await user.type(getUrlInput(), 'https://webhook')
  112. await user.click(await findEventTypeCheckbox('project.updated'))
  113. submitForm()
  114. expect(await screen.findByText('Please provide a valid URL')).toBeInTheDocument()
  115. expect(onSubmit).not.toHaveBeenCalled()
  116. })
  117. it('blocks submit when the endpoint URL does not include a protocol', async () => {
  118. const user = userEvent.setup()
  119. const { onSubmit } = renderEndpointSheet()
  120. await user.type(getUrlInput(), 'hooks.example.com/billing')
  121. await user.click(await findEventTypeCheckbox('project.updated'))
  122. submitForm()
  123. expect(
  124. await screen.findByText('Please prefix your URL with http:// or https://')
  125. ).toBeInTheDocument()
  126. expect(onSubmit).not.toHaveBeenCalled()
  127. })
  128. it('shows an error when no event types are selected', async () => {
  129. const user = userEvent.setup()
  130. const { onSubmit } = renderEndpointSheet()
  131. await user.type(getUrlInput(), 'https://hooks.example.com/billing')
  132. submitForm()
  133. expect(await screen.findByText('Select at least one event type')).toBeInTheDocument()
  134. expect(onSubmit).not.toHaveBeenCalled()
  135. })
  136. it('clears the event type error after selecting an event and allows submit', async () => {
  137. const user = userEvent.setup()
  138. const { onSubmit } = renderEndpointSheet()
  139. await user.type(getUrlInput(), 'https://hooks.example.com/billing')
  140. submitForm()
  141. expect(await screen.findByText('Select at least one event type')).toBeInTheDocument()
  142. await user.click(await findEventTypeCheckbox('project.updated'))
  143. await waitFor(() => {
  144. expect(screen.queryByText('Select at least one event type')).not.toBeInTheDocument()
  145. })
  146. submitForm()
  147. await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
  148. expect(onSubmit).toHaveBeenCalledWith(
  149. expect.objectContaining({
  150. url: 'https://hooks.example.com/billing',
  151. eventTypes: ['project.updated'],
  152. }),
  153. expect.anything()
  154. )
  155. })
  156. it('allows submit when subscribe all is enabled', async () => {
  157. const user = userEvent.setup()
  158. const { onSubmit } = renderEndpointSheet()
  159. await user.type(getUrlInput(), 'https://hooks.example.com/billing')
  160. await user.click(screen.getByRole('checkbox', { name: /subscribe to all events/i }))
  161. submitForm()
  162. await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
  163. expect(onSubmit).toHaveBeenCalledWith(
  164. expect.objectContaining({
  165. subscribeAll: true,
  166. eventTypes: PROJECT_EVENT_TYPES,
  167. url: 'https://hooks.example.com/billing',
  168. }),
  169. expect.anything()
  170. )
  171. })
  172. it('submits custom headers added through the shared header editor', async () => {
  173. const user = userEvent.setup()
  174. const { onSubmit } = renderEndpointSheet({
  175. mode: 'edit',
  176. endpoint: createEndpoint(),
  177. })
  178. await user.click(screen.getByRole('button', { name: 'Add header' }))
  179. await user.type(screen.getByPlaceholderText('Header name'), 'X-Webhook-Secret')
  180. await user.type(screen.getByPlaceholderText('Header value'), 'super-secret')
  181. submitForm()
  182. await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
  183. expect(onSubmit).toHaveBeenCalledWith(
  184. expect.objectContaining({
  185. customHeaders: [{ key: 'X-Webhook-Secret', value: 'super-secret' }],
  186. }),
  187. expect.anything()
  188. )
  189. })
  190. it('blocks submit when a custom header is missing its value', async () => {
  191. const user = userEvent.setup()
  192. const { onSubmit } = renderEndpointSheet({
  193. mode: 'edit',
  194. endpoint: createEndpoint(),
  195. })
  196. await user.click(screen.getByRole('button', { name: 'Add header' }))
  197. await user.type(screen.getByPlaceholderText('Header name'), 'X-Webhook-Secret')
  198. submitForm()
  199. expect(await screen.findByText('Header value is required')).toBeInTheDocument()
  200. expect(onSubmit).not.toHaveBeenCalled()
  201. })
  202. it('strips fully empty custom header rows from the payload', () => {
  203. expect(
  204. toEndpointPayload({
  205. name: 'Billing events',
  206. url: 'https://hooks.example.com/billing',
  207. description: '',
  208. enabled: true,
  209. subscribeAll: false,
  210. eventTypes: ['project.updated'],
  211. customHeaders: [
  212. { key: 'X-Webhook-Secret', value: 'super-secret' },
  213. { key: '', value: '' },
  214. ],
  215. })
  216. ).toEqual(
  217. expect.objectContaining({
  218. customHeaders: [{ key: 'X-Webhook-Secret', value: 'super-secret' }],
  219. })
  220. )
  221. })
  222. })