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