Skip to content

Commit cafc9aa

Browse files
authored
test(data-drains): cover the migrated detail and create surfaces (#5958)
Executes the two new views rather than reasoning about them, and locks the behaviours this migration could silently drop: - the detail carries every column the table used to show (source, destination, cadence, last run) and never renders credentials - run sizes report sub-kilobyte writes instead of flooring to '0 Bytes', and a multi-gigabyte run stays in GB - Run now stays disabled while a drain is disabled, as the row menu did - delete leaves only after the request resolves, and stays put on failure - create gates on name plus a complete destination, sends the right destination branch, and toasts on failure - every labelled field in all seven destination forms resolves to a real control id, and every select carries an accessible name The first run caught a defect: dropping toLowerCase from humanizeConfigKey left keys rendering Title Case ('Force Path Style') while the TSDoc still promised sentence case. Now sentence case with initialisms preserved — 'Access key ID', 'Service account JSON'.
1 parent 8a2ae25 commit cafc9aa

3 files changed

Lines changed: 687 additions & 3 deletions

File tree

Lines changed: 315 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,315 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { act, type ReactNode } from 'react'
5+
import { createRoot, type Root } from 'react-dom/client'
6+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7+
import { DESTINATION_TYPES } from '@/lib/data-drains/types'
8+
9+
const { mockToastError, mockToastSuccess, mockUseCreateDataDrain, mockGuardBack } = vi.hoisted(
10+
() => ({
11+
mockToastError: vi.fn(),
12+
mockToastSuccess: vi.fn(),
13+
mockUseCreateDataDrain: vi.fn(),
14+
mockGuardBack: vi.fn((onLeave: () => void) => onLeave()),
15+
})
16+
)
17+
18+
interface SelectProps {
19+
value?: string
20+
onChange?: (value: string) => void
21+
options?: { value: string; label: string }[]
22+
'aria-label'?: string
23+
}
24+
25+
vi.mock('@sim/emcn', () => ({
26+
// A real <label for> so the wiring assertions below mean something.
27+
Label: ({ htmlFor, children }: { htmlFor?: string; children?: ReactNode }) => (
28+
<label htmlFor={htmlFor}>{children}</label>
29+
),
30+
Info: ({ children }: { children?: ReactNode }) => <span>{children}</span>,
31+
ChipInput: ({
32+
id,
33+
value,
34+
onChange,
35+
}: {
36+
id?: string
37+
value?: string
38+
onChange?: (event: { target: { value: string } }) => void
39+
}) => (
40+
<input id={id} value={value ?? ''} onChange={(event) => onChange?.({ target: event.target })} />
41+
),
42+
ChipTextarea: ({
43+
id,
44+
value,
45+
onChange,
46+
}: {
47+
id?: string
48+
value?: string
49+
onChange?: (event: { target: { value: string } }) => void
50+
}) => (
51+
<textarea
52+
id={id}
53+
value={value ?? ''}
54+
onChange={(event) => onChange?.({ target: event.target })}
55+
/>
56+
),
57+
// Real SecretInput calls onChange with the plain value, not an event.
58+
SecretInput: ({
59+
id,
60+
value,
61+
onChange,
62+
}: {
63+
id?: string
64+
value?: string
65+
onChange?: (next: string) => void
66+
}) => (
67+
<input
68+
id={id}
69+
type='password'
70+
value={value ?? ''}
71+
onChange={(event) => onChange?.(event.target.value)}
72+
/>
73+
),
74+
ChipSelect: ({ value, onChange, options, ...rest }: SelectProps) => (
75+
<select
76+
aria-label={rest['aria-label']}
77+
value={value ?? ''}
78+
onChange={(event) => onChange?.(event.target.value)}
79+
>
80+
{(options ?? []).map((option) => (
81+
<option key={option.value} value={option.value}>
82+
{option.label}
83+
</option>
84+
))}
85+
</select>
86+
),
87+
Switch: ({ id }: { id?: string }) => <input id={id} type='checkbox' />,
88+
cn: (...values: unknown[]) => values.filter(Boolean).join(' '),
89+
toast: { error: mockToastError, success: mockToastSuccess },
90+
}))
91+
92+
vi.mock('@sim/emcn/icons', () => ({ ArrowLeft: () => <svg />, Database: () => <svg /> }))
93+
94+
interface SettingsAction {
95+
text: string
96+
disabled?: boolean
97+
onSelect?: () => void
98+
}
99+
100+
vi.mock('@/app/workspace/[workspaceId]/settings/components/settings-panel', () => ({
101+
SettingsPanel: ({ actions, children }: { actions?: SettingsAction[]; children?: ReactNode }) => (
102+
<div>
103+
{actions?.map((action) => (
104+
<button
105+
type='button'
106+
key={action.text}
107+
data-testid='primary-action'
108+
disabled={action.disabled}
109+
onClick={() => action.onSelect?.()}
110+
>
111+
{action.text}
112+
</button>
113+
))}
114+
{children}
115+
</div>
116+
),
117+
}))
118+
119+
vi.mock(
120+
'@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section',
121+
() => ({
122+
SettingsSection: ({ label, children }: { label: string; children?: ReactNode }) => (
123+
<section>
124+
<h2>{label}</h2>
125+
{children}
126+
</section>
127+
),
128+
})
129+
)
130+
131+
vi.mock('@/app/workspace/[workspaceId]/components', () => ({ ResourceTile: () => <div /> }))
132+
133+
vi.mock('@/app/workspace/[workspaceId]/components/credential-detail', () => ({
134+
CredentialDetailHeading: ({ title }: { title?: ReactNode }) => <h1>{title}</h1>,
135+
UnsavedChangesModal: () => null,
136+
}))
137+
138+
vi.mock('@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard', () => ({
139+
useSettingsUnsavedGuard: () => ({
140+
showUnsavedModal: false,
141+
setShowUnsavedModal: vi.fn(),
142+
guardBack: mockGuardBack,
143+
confirmDiscard: vi.fn(),
144+
}),
145+
}))
146+
147+
vi.mock('@/ee/data-drains/hooks/data-drains', () => ({
148+
useCreateDataDrain: mockUseCreateDataDrain,
149+
}))
150+
151+
import { DataDrainCreate } from '@/ee/data-drains/components/data-drain-create'
152+
import { DESTINATION_FORM_REGISTRY } from '@/ee/data-drains/destinations/registry'
153+
154+
let container: HTMLDivElement
155+
let root: Root
156+
const createMutate = vi.fn()
157+
158+
function render(node: ReactNode) {
159+
act(() => {
160+
root.render(node)
161+
})
162+
}
163+
164+
function primaryAction() {
165+
const el = container.querySelector<HTMLButtonElement>('[data-testid="primary-action"]')
166+
if (!el) throw new Error('missing primary action')
167+
return el
168+
}
169+
170+
function typeInto(selector: string, value: string) {
171+
const el = container.querySelector<HTMLInputElement | HTMLTextAreaElement>(selector)
172+
if (!el) throw new Error(`missing ${selector}`)
173+
act(() => {
174+
const setter = Object.getOwnPropertyDescriptor(
175+
el instanceof HTMLTextAreaElement
176+
? HTMLTextAreaElement.prototype
177+
: HTMLInputElement.prototype,
178+
'value'
179+
)?.set
180+
setter?.call(el, value)
181+
el.dispatchEvent(new Event('input', { bubbles: true }))
182+
})
183+
}
184+
185+
beforeEach(() => {
186+
vi.clearAllMocks()
187+
mockGuardBack.mockImplementation((onLeave: () => void) => onLeave())
188+
container = document.createElement('div')
189+
document.body.appendChild(container)
190+
root = createRoot(container)
191+
createMutate.mockResolvedValue({ id: 'drain-new' })
192+
mockUseCreateDataDrain.mockReturnValue({
193+
mutateAsync: createMutate,
194+
isPending: false,
195+
error: null,
196+
})
197+
})
198+
199+
afterEach(() => {
200+
act(() => {
201+
root.unmount()
202+
})
203+
container.remove()
204+
})
205+
206+
describe('DataDrainCreate', () => {
207+
it('keeps Create disabled until the name and destination are complete', () => {
208+
render(<DataDrainCreate organizationId='org-1' onBack={vi.fn()} onCreated={vi.fn()} />)
209+
expect(primaryAction().disabled).toBe(true)
210+
211+
typeInto('#data-drain-name', 'Workflow logs export')
212+
expect(primaryAction().disabled).toBe(true)
213+
214+
typeInto('#drain-s3-bucket', 'my-logs-bucket')
215+
typeInto('#drain-s3-access-key-id', 'AKIA')
216+
typeInto('#drain-s3-secret-access-key', 'secret')
217+
expect(primaryAction().disabled).toBe(false)
218+
})
219+
220+
it('submits the destination branch and hands the new id back', async () => {
221+
const onCreated = vi.fn()
222+
render(<DataDrainCreate organizationId='org-1' onBack={vi.fn()} onCreated={onCreated} />)
223+
224+
typeInto('#data-drain-name', ' Workflow logs export ')
225+
typeInto('#drain-s3-bucket', 'my-logs-bucket')
226+
typeInto('#drain-s3-access-key-id', 'AKIA')
227+
typeInto('#drain-s3-secret-access-key', 'secret')
228+
act(() => {
229+
primaryAction().click()
230+
})
231+
await act(async () => {})
232+
233+
expect(createMutate).toHaveBeenCalledTimes(1)
234+
const body = createMutate.mock.calls[0][0].body
235+
expect(body.name).toBe('Workflow logs export')
236+
expect(body.source).toBe('workflow_logs')
237+
expect(body.scheduleCadence).toBe('daily')
238+
expect(body.destinationType).toBe('s3')
239+
expect(body.destinationConfig.bucket).toBe('my-logs-bucket')
240+
expect(body.destinationCredentials.accessKeyId).toBe('AKIA')
241+
expect(mockToastSuccess).toHaveBeenCalledWith('Drain created')
242+
expect(onCreated).toHaveBeenCalledWith('drain-new')
243+
})
244+
245+
it('announces a failed create instead of only rendering it below the fold', async () => {
246+
createMutate.mockRejectedValue(new Error('bucket name is invalid'))
247+
const onCreated = vi.fn()
248+
render(<DataDrainCreate organizationId='org-1' onBack={vi.fn()} onCreated={onCreated} />)
249+
250+
typeInto('#data-drain-name', 'Workflow logs export')
251+
typeInto('#drain-s3-bucket', 'nope')
252+
typeInto('#drain-s3-access-key-id', 'AKIA')
253+
typeInto('#drain-s3-secret-access-key', 'secret')
254+
act(() => {
255+
primaryAction().click()
256+
})
257+
await act(async () => {})
258+
259+
expect(mockToastError).toHaveBeenCalledWith("Couldn't create drain", {
260+
description: 'bucket name is invalid',
261+
})
262+
expect(onCreated).not.toHaveBeenCalled()
263+
})
264+
265+
it('swaps the destination fields when the type changes', () => {
266+
render(<DataDrainCreate organizationId='org-1' onBack={vi.fn()} onCreated={vi.fn()} />)
267+
expect(container.querySelector('#drain-s3-bucket')).not.toBeNull()
268+
269+
const typeSelect = container.querySelector<HTMLSelectElement>(
270+
'select[aria-label="Destination type"]'
271+
)
272+
if (!typeSelect) throw new Error('missing destination select')
273+
act(() => {
274+
const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value')?.set as (
275+
v: string
276+
) => void
277+
setter.call(typeSelect, 'datadog')
278+
typeSelect.dispatchEvent(new Event('change', { bubbles: true }))
279+
})
280+
281+
expect(container.querySelector('#drain-s3-bucket')).toBeNull()
282+
expect(container.querySelector('#drain-datadog-api-key')).not.toBeNull()
283+
})
284+
285+
it('names every select for assistive tech', () => {
286+
render(<DataDrainCreate organizationId='org-1' onBack={vi.fn()} onCreated={vi.fn()} />)
287+
const labels = Array.from(container.querySelectorAll('select')).map((el) =>
288+
el.getAttribute('aria-label')
289+
)
290+
expect(labels).toEqual(['Source', 'Cadence', 'Destination type'])
291+
})
292+
})
293+
294+
describe('destination form registry', () => {
295+
it('wires every labelled field to its control across all destinations', () => {
296+
for (const destination of DESTINATION_TYPES) {
297+
const spec = DESTINATION_FORM_REGISTRY[destination]
298+
render(<spec.FormFields state={spec.initialState} setState={vi.fn()} />)
299+
300+
const labelled = Array.from(container.querySelectorAll('label[for]'))
301+
for (const label of labelled) {
302+
const id = label.getAttribute('for') as string
303+
expect(
304+
container.querySelector(`#${id}`),
305+
`${destination}: label "${label.textContent}" points at missing #${id}`
306+
).not.toBeNull()
307+
}
308+
309+
const controls = container.querySelectorAll('input, textarea')
310+
// Every text-ish control is reachable by its label, so none announces unnamed.
311+
expect(controls.length, `${destination} renders no controls`).toBeGreaterThan(0)
312+
expect(labelled.length, `${destination} has unlabelled controls`).toBe(controls.length)
313+
}
314+
})
315+
})

0 commit comments

Comments
 (0)