import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { Upload, type UploadRequestOption } from './upload'; const fileOf = (name: string, type = 'text/plain') => new File(['hello'], name, { type }); const inputOf = (container: HTMLElement) => container.querySelector('input[type="file"]') as HTMLInputElement; /** Minimal XHR stand-in — jsdom has no real network. */ class FakeXhr { static instances: FakeXhr[] = []; upload = { onprogress: null as ((event: ProgressEvent) => void) | null }; onload: (() => void) | null = null; onerror: ((event: ProgressEvent) => void) | null = null; status = 200; responseText = '{"ok":true}'; response = '{"ok":true}'; withCredentials = false; sent: FormData | null = null; opened: [string, string] | null = null; headers: Record = {}; aborted = false; constructor() { FakeXhr.instances.push(this); } open(method: string, url: string) { this.opened = [method, url]; } setRequestHeader(key: string, value: string) { this.headers[key] = value; } send(body: FormData) { this.sent = body; } abort() { this.aborted = true; } } describe('Upload', () => { beforeEach(() => { FakeXhr.instances = []; vi.stubGlobal('XMLHttpRequest', FakeXhr); }); afterEach(() => { vi.unstubAllGlobals(); }); describe('accessibility', () => { it('exposes a real file input, named by the dropzone text', () => { render(); const input = screen.getByLabelText(/drop files here/i); expect(input).toHaveAttribute('type', 'file'); }); it('keeps the input in the tab order rather than display:none', async () => { const user = userEvent.setup(); const { container } = render(); await user.tab(); expect(inputOf(container)).toHaveFocus(); }); it('forwards accept, multiple and disabled to the input', () => { const { container } = render(); const input = inputOf(container); expect(input).toHaveAttribute('accept', 'image/*'); expect(input).toHaveAttribute('multiple'); expect(input).toBeDisabled(); }); it('sets the directory attributes only when asked', () => { const { container: plain } = render(); expect(inputOf(plain)).not.toHaveAttribute('webkitdirectory'); const { container: folders } = render(); expect(inputOf(folders)).toHaveAttribute('webkitdirectory'); }); }); describe('picking files', () => { it('starts an upload for the picked file', async () => { const user = userEvent.setup(); const onStart = vi.fn(); const { container } = render(); await user.upload(inputOf(container), fileOf('report.txt')); await waitFor(() => expect(onStart).toHaveBeenCalledTimes(1)); expect(onStart.mock.calls[0][0].name).toBe('report.txt'); }); it('sends to the configured endpoint under the configured field name', async () => { const user = userEvent.setup(); const { container } = render(); await user.upload(inputOf(container), fileOf('report.txt')); await waitFor(() => expect(FakeXhr.instances).toHaveLength(1)); const xhr = FakeXhr.instances[0]; expect(xhr.opened).toEqual(['POST', '/api/uploads']); expect(xhr.sent?.get('document')).toBeInstanceOf(File); }); it('resolves a per-file action for signed URLs', async () => { const user = userEvent.setup(); const { container } = render( `/api/uploads/${file.name}`} /> ); await user.upload(inputOf(container), fileOf('report.txt')); await waitFor(() => expect(FakeXhr.instances).toHaveLength(1)); expect(FakeXhr.instances[0].opened?.[1]).toBe('/api/uploads/report.txt'); }); it('appends extra form fields and headers', async () => { const user = userEvent.setup(); const { container } = render( ); await user.upload(inputOf(container), fileOf('report.txt')); await waitFor(() => expect(FakeXhr.instances).toHaveLength(1)); expect(FakeXhr.instances[0].sent?.get('folder')).toBe('invoices'); expect(FakeXhr.instances[0].headers['X-Token']).toBe('abc'); }); it('only takes the first file when not multiple', async () => { const user = userEvent.setup(); const onStart = vi.fn(); const { container } = render(); await user.upload(inputOf(container), [fileOf('a.txt'), fileOf('b.txt')]); await waitFor(() => expect(onStart).toHaveBeenCalledTimes(1)); expect(onStart.mock.calls[0][0].name).toBe('a.txt'); }); it('takes every file when multiple', async () => { const user = userEvent.setup(); const onStart = vi.fn(); const { container } = render(); await user.upload(inputOf(container), [fileOf('a.txt'), fileOf('b.txt')]); await waitFor(() => expect(onStart).toHaveBeenCalledTimes(2)); }); it('clears the input so the same file can be picked twice', async () => { const user = userEvent.setup(); const onStart = vi.fn(); const { container } = render(); const input = inputOf(container); await user.upload(input, fileOf('report.txt')); await waitFor(() => expect(onStart).toHaveBeenCalledTimes(1)); expect(input.value).toBe(''); await user.upload(input, fileOf('report.txt')); await waitFor(() => expect(onStart).toHaveBeenCalledTimes(2)); }); }); describe('beforeUpload', () => { it('cancels the request when it returns false', async () => { const user = userEvent.setup(); const beforeUpload = vi.fn(() => false); const onStart = vi.fn(); const { container } = render( ); await user.upload(inputOf(container), fileOf('report.txt')); await waitFor(() => expect(beforeUpload).toHaveBeenCalledTimes(1)); expect(FakeXhr.instances).toHaveLength(0); /* `onStart` means "the upload started" — a cancelled file never does. */ expect(onStart).not.toHaveBeenCalled(); }); it('sends a replacement Blob but reports the original file', async () => { const user = userEvent.setup(); const onStart = vi.fn(); const replacement = new Blob(['resized'], { type: 'image/png' }); const { container } = render( replacement} onStart={onStart} /> ); await user.upload(inputOf(container), fileOf('photo.png', 'image/png')); await waitFor(() => expect(FakeXhr.instances).toHaveLength(1)); /* The caller still hears about the file they picked… */ expect(onStart.mock.calls[0][0].name).toBe('photo.png'); /* …but the wire carries the replacement. FormData names a bare Blob "blob", which is what distinguishes it from the original here. */ expect((FakeXhr.instances[0].sent?.get('file') as File).name).toBe('blob'); }); it('awaits an async decision', async () => { const user = userEvent.setup(); const { container } = render( Promise.resolve(false)} /> ); await user.upload(inputOf(container), fileOf('report.txt')); await waitFor(() => expect(FakeXhr.instances).toHaveLength(0)); }); }); describe('customRequest', () => { it('takes over the transfer entirely', async () => { const user = userEvent.setup(); const customRequest = vi.fn<(option: UploadRequestOption) => void>(); const { container } = render(); await user.upload(inputOf(container), fileOf('report.txt')); await waitFor(() => expect(customRequest).toHaveBeenCalledTimes(1)); expect(FakeXhr.instances).toHaveLength(0); expect(customRequest.mock.calls[0][0].action).toBe('/api'); }); it('is handed the default request to fall back on', async () => { const user = userEvent.setup(); const customRequest = vi.fn((option, info) => info.defaultRequest(option)); const { container } = render(); await user.upload(inputOf(container), fileOf('report.txt')); await waitFor(() => expect(FakeXhr.instances).toHaveLength(1)); }); }); describe('request lifecycle', () => { it('reports success with the parsed body', async () => { const user = userEvent.setup(); const onSuccess = vi.fn(); const { container } = render(); await user.upload(inputOf(container), fileOf('report.txt')); await waitFor(() => expect(FakeXhr.instances).toHaveLength(1)); FakeXhr.instances[0].onload?.(); expect(onSuccess).toHaveBeenCalledTimes(1); expect(onSuccess.mock.calls[0][0]).toEqual({ ok: true }); }); it('reports an error for a non-2xx status', async () => { const user = userEvent.setup(); const onError = vi.fn(); const { container } = render(); await user.upload(inputOf(container), fileOf('report.txt')); await waitFor(() => expect(FakeXhr.instances).toHaveLength(1)); const xhr = FakeXhr.instances[0]; xhr.status = 500; xhr.onload?.(); expect(onError).toHaveBeenCalledTimes(1); expect(onError.mock.calls[0][0]).toMatchObject({ status: 500, url: '/api' }); }); it('reports progress as a percentage', async () => { const user = userEvent.setup(); const onProgress = vi.fn(); const { container } = render(); await user.upload(inputOf(container), fileOf('report.txt')); await waitFor(() => expect(FakeXhr.instances).toHaveLength(1)); FakeXhr.instances[0].upload.onprogress?.({ loaded: 30, total: 120, } as ProgressEvent); expect(onProgress.mock.calls[0][0].percent).toBe(25); }); it('aborts anything still in flight when unmounted', async () => { const user = userEvent.setup(); const { container, unmount } = render(); await user.upload(inputOf(container), fileOf('report.txt')); await waitFor(() => expect(FakeXhr.instances).toHaveLength(1)); unmount(); expect(FakeXhr.instances[0].aborted).toBe(true); }); }); describe('drag and drop', () => { it('uploads dropped files', async () => { const onStart = vi.fn(); const { container } = render(); const dropzone = container.querySelector('[data-slot="upload"]')!; fireEvent.drop(dropzone, { dataTransfer: { files: [fileOf('dropped.txt')] } }); await waitFor(() => expect(onStart).toHaveBeenCalledTimes(1)); expect(onStart.mock.calls[0][0].name).toBe('dropped.txt'); }); it('marks the target while a file hovers over it', () => { const { container } = render(); const dropzone = container.querySelector('[data-slot="upload"]')!; fireEvent.dragOver(dropzone, { dataTransfer: { files: [] } }); expect(dropzone).toHaveAttribute('data-dragging'); fireEvent.dragLeave(dropzone); expect(dropzone).not.toHaveAttribute('data-dragging'); }); it('ignores drops when disabled', async () => { const onStart = vi.fn(); const { container } = render(); const dropzone = container.querySelector('[data-slot="upload"]')!; fireEvent.drop(dropzone, { dataTransfer: { files: [fileOf('dropped.txt')] } }); await waitFor(() => expect(FakeXhr.instances).toHaveLength(0)); expect(onStart).not.toHaveBeenCalled(); }); }); describe('variant', () => { it('defaults to the drop target', () => { const { container } = render(); expect(container.querySelector('[data-slot="upload"]')).toHaveAttribute( 'data-variant', 'dropzone' ); expect(screen.getByText(/drop files here/i)).toBeInTheDocument(); }); it('renders a trigger instead of a drop target for variant="button"', () => { const { container } = render(); expect(container.querySelector('[data-slot="upload"]')).toHaveAttribute( 'data-variant', 'button' ); expect(screen.getByText('Upload file')).toBeInTheDocument(); expect(screen.queryByText(/drop files here/i)).not.toBeInTheDocument(); }); /* * A button is not somewhere a file can be dropped, so it must not light up * as one. The drop handler itself stays wired — the browser hands the files * over either way, and refusing them would be worse than accepting them. */ it('does not advertise the button variant as a drop target', () => { const { container } = render(); const trigger = container.querySelector('[data-slot="upload"]')!; fireEvent.dragOver(trigger, { dataTransfer: { files: [] } }); expect(trigger).not.toHaveAttribute('data-dragging'); }); }); it('renders custom children in place of the default dropzone', () => { render(
Drop images here
); expect(screen.getByText('Drop images here')).toBeInTheDocument(); expect(screen.queryByText(/drop files here/i)).not.toBeInTheDocument(); }); });