/**
* Tests for the FileUploadModal component.
* Tests file upload, drag-and-drop, ZIP/3MF/STL detection, and autoUpload mode.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { render } from '../utils';
import { FileUploadModal } from '../../components/FileUploadModal';
import { http, HttpResponse } from 'msw';
import { server } from '../mocks/server';
describe('FileUploadModal', () => {
const defaultProps = {
folderId: null as number | null,
onClose: vi.fn(),
onUploadComplete: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
server.use(
http.post('/api/v1/library/files', () => {
return HttpResponse.json({
id: 1,
filename: 'test.gcode.3mf',
file_type: '3mf',
file_size: 1048576,
thumbnail_path: null,
duplicate_of: null,
metadata: null,
});
}),
http.post('/api/v1/library/extract-zip', () => {
return HttpResponse.json({
extracted: 3,
errors: [],
});
})
);
});
describe('rendering', () => {
it('renders the modal with title', () => {
render();
expect(screen.getByText('Upload Files')).toBeInTheDocument();
});
it('renders drag and drop zone', () => {
render();
expect(screen.getByText(/Drag & drop/)).toBeInTheDocument();
});
it('renders click to browse text', () => {
render();
expect(screen.getByText(/click to browse/i)).toBeInTheDocument();
});
it('renders Cancel button', () => {
render();
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
});
it('renders Upload button disabled when no files', () => {
render();
const uploadButton = screen.getByRole('button', { name: /Upload/i });
expect(uploadButton).toBeDisabled();
});
it('shows all file types supported text', () => {
render();
expect(screen.getByText(/All file types supported/i)).toBeInTheDocument();
});
});
describe('file selection', () => {
it('shows added file in the list', async () => {
const user = userEvent.setup();
render();
const file = new File(['content'], 'model.gcode.3mf', { type: 'application/octet-stream' });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await user.upload(fileInput, file);
expect(screen.getByText('model.gcode.3mf')).toBeInTheDocument();
});
it('shows file size in MB', async () => {
const user = userEvent.setup();
render();
const file = new File(['x'.repeat(1048576)], 'model.3mf', { type: 'application/octet-stream' });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await user.upload(fileInput, file);
expect(screen.getByText('1.00 MB')).toBeInTheDocument();
});
it('enables Upload button when files are added', async () => {
const user = userEvent.setup();
render();
const file = new File(['content'], 'model.3mf', { type: 'application/octet-stream' });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await user.upload(fileInput, file);
const uploadButton = screen.getByRole('button', { name: /Upload \(1\)/i });
expect(uploadButton).not.toBeDisabled();
});
it('shows file count in Upload button', async () => {
const user = userEvent.setup();
render();
const files = [
new File(['a'], 'file1.3mf', { type: 'application/octet-stream' }),
new File(['b'], 'file2.stl', { type: 'application/octet-stream' }),
];
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await user.upload(fileInput, files);
expect(screen.getByRole('button', { name: /Upload \(2\)/i })).toBeInTheDocument();
});
it('accepts any file type (not restricted like UploadModal)', async () => {
const user = userEvent.setup();
render();
const file = new File(['content'], 'readme.txt', { type: 'text/plain' });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await user.upload(fileInput, file);
expect(screen.getByText('readme.txt')).toBeInTheDocument();
});
});
describe('file removal', () => {
it('removes a file when X button is clicked', async () => {
const user = userEvent.setup();
render();
const file = new File(['content'], 'model.3mf', { type: 'application/octet-stream' });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await user.upload(fileInput, file);
expect(screen.getByText('model.3mf')).toBeInTheDocument();
const fileRow = screen.getByText('model.3mf').closest('.flex');
const removeButton = fileRow?.querySelector('button');
if (removeButton) {
await user.click(removeButton);
}
await waitFor(() => {
expect(screen.queryByText('model.3mf')).not.toBeInTheDocument();
});
});
it('disables Upload button after removing all files', async () => {
const user = userEvent.setup();
render();
const file = new File(['content'], 'model.3mf', { type: 'application/octet-stream' });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await user.upload(fileInput, file);
const fileRow = screen.getByText('model.3mf').closest('.flex');
const removeButton = fileRow?.querySelector('button');
if (removeButton) {
await user.click(removeButton);
}
await waitFor(() => {
const uploadButton = screen.getByRole('button', { name: /Upload/i });
expect(uploadButton).toBeDisabled();
});
});
});
describe('file type detection', () => {
it('shows ZIP options when .zip file is added', async () => {
const user = userEvent.setup();
render();
const zipFile = new File(['pk'], 'models.zip', { type: 'application/zip' });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await user.upload(fileInput, zipFile);
await waitFor(() => {
expect(screen.getByText('ZIP files detected')).toBeInTheDocument();
expect(screen.getByText(/Preserve folder structure/)).toBeInTheDocument();
expect(screen.getByText(/Create folder from ZIP/)).toBeInTheDocument();
});
});
it('shows 3MF info when .3mf file is added', async () => {
const user = userEvent.setup();
render();
const threemfFile = new File(['content'], 'model.gcode.3mf', { type: 'application/octet-stream' });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await user.upload(fileInput, threemfFile);
await waitFor(() => {
expect(screen.getByText('3MF files detected')).toBeInTheDocument();
});
});
it('shows STL thumbnail option when .stl file is added', async () => {
const user = userEvent.setup();
render();
const stlFile = new File(['solid'], 'bracket.stl', { type: 'application/sla' });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await user.upload(fileInput, stlFile);
await waitFor(() => {
expect(screen.getByText('STL thumbnail generation')).toBeInTheDocument();
expect(screen.getByText(/Thumbnails can be generated/i)).toBeInTheDocument();
});
});
it('shows STL thumbnail option when ZIP file is added (may contain STLs)', async () => {
const user = userEvent.setup();
render();
const zipFile = new File(['pk'], 'models.zip', { type: 'application/zip' });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await user.upload(fileInput, zipFile);
await waitFor(() => {
expect(screen.getByText('STL thumbnail generation')).toBeInTheDocument();
expect(screen.getByText(/ZIP files may contain STL/i)).toBeInTheDocument();
});
});
});
describe('ZIP options', () => {
it('preserve structure checkbox is checked by default', async () => {
const user = userEvent.setup();
render();
const zipFile = new File(['pk'], 'models.zip', { type: 'application/zip' });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await user.upload(fileInput, zipFile);
await waitFor(() => {
const label = screen.getByText(/Preserve folder structure/).closest('label');
const checkbox = label?.querySelector('input[type="checkbox"]') as HTMLInputElement;
expect(checkbox).toBeChecked();
});
});
it('create folder checkbox is unchecked by default', async () => {
const user = userEvent.setup();
render();
const zipFile = new File(['pk'], 'models.zip', { type: 'application/zip' });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await user.upload(fileInput, zipFile);
await waitFor(() => {
const label = screen.getByText(/Create folder from ZIP/).closest('label');
const checkbox = label?.querySelector('input[type="checkbox"]') as HTMLInputElement;
expect(checkbox).not.toBeChecked();
});
});
it('can toggle ZIP options', async () => {
const user = userEvent.setup();
render();
const zipFile = new File(['pk'], 'models.zip', { type: 'application/zip' });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await user.upload(fileInput, zipFile);
await waitFor(() => {
expect(screen.getByText('ZIP files detected')).toBeInTheDocument();
});
const preserveLabel = screen.getByText(/Preserve folder structure/).closest('label');
const preserveCheckbox = preserveLabel?.querySelector('input[type="checkbox"]') as HTMLInputElement;
await user.click(preserveCheckbox);
expect(preserveCheckbox).not.toBeChecked();
const createFolderLabel = screen.getByText(/Create folder from ZIP/).closest('label');
const createFolderCheckbox = createFolderLabel?.querySelector('input[type="checkbox"]') as HTMLInputElement;
await user.click(createFolderCheckbox);
expect(createFolderCheckbox).toBeChecked();
});
});
describe('upload flow', () => {
it('calls onUploadComplete after successful upload', async () => {
const user = userEvent.setup();
render();
const file = new File(['content'], 'model.3mf', { type: 'application/octet-stream' });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await user.upload(fileInput, file);
const uploadButton = screen.getByRole('button', { name: /Upload \(1\)/i });
await user.click(uploadButton);
await waitFor(() => {
expect(defaultProps.onUploadComplete).toHaveBeenCalled();
});
});
it('calls onFileUploaded with response data for each file', async () => {
const onFileUploaded = vi.fn();
const user = userEvent.setup();
render();
const file = new File(['content'], 'model.3mf', { type: 'application/octet-stream' });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await user.upload(fileInput, file);
const uploadButton = screen.getByRole('button', { name: /Upload \(1\)/i });
await user.click(uploadButton);
await waitFor(() => {
expect(onFileUploaded).toHaveBeenCalledWith(
expect.objectContaining({
id: 1,
filename: 'test.gcode.3mf',
})
);
});
});
it('shows uploading state while uploading', async () => {
// Delay the response to observe uploading state
server.use(
http.post('/api/v1/library/files', async () => {
await new Promise((resolve) => setTimeout(resolve, 100));
return HttpResponse.json({
id: 1,
filename: 'model.3mf',
file_type: '3mf',
file_size: 1024,
thumbnail_path: null,
duplicate_of: null,
metadata: null,
});
})
);
const user = userEvent.setup();
render();
const file = new File(['content'], 'model.3mf', { type: 'application/octet-stream' });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await user.upload(fileInput, file);
const uploadButton = screen.getByRole('button', { name: /Upload \(1\)/i });
await user.click(uploadButton);
// Should show uploading state
await waitFor(() => {
expect(screen.getByText('Uploading...')).toBeInTheDocument();
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
});
});
it('shows error state on upload failure', async () => {
server.use(
http.post('/api/v1/library/files', () => {
return HttpResponse.json({ detail: 'File too large' }, { status: 413 });
})
);
const user = userEvent.setup();
render();
const file = new File(['content'], 'model.3mf', { type: 'application/octet-stream' });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await user.upload(fileInput, file);
const uploadButton = screen.getByRole('button', { name: /Upload \(1\)/i });
await user.click(uploadButton);
await waitFor(() => {
expect(defaultProps.onUploadComplete).toHaveBeenCalled();
});
});
it('closes modal after manual upload completes', async () => {
const user = userEvent.setup();
render();
const file = new File(['content'], 'model.3mf', { type: 'application/octet-stream' });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await user.upload(fileInput, file);
const uploadButton = screen.getByRole('button', { name: /Upload \(1\)/i });
await user.click(uploadButton);
await waitFor(() => {
expect(defaultProps.onUploadComplete).toHaveBeenCalled();
expect(defaultProps.onClose).toHaveBeenCalled();
});
});
});
describe('autoUpload mode', () => {
it('uploads immediately when file is added', async () => {
const onFileUploaded = vi.fn();
const user = userEvent.setup();
render(
);
const file = new File(['content'], 'model.gcode.3mf', { type: 'application/octet-stream' });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await user.upload(fileInput, file);
await waitFor(() => {
expect(onFileUploaded).toHaveBeenCalledWith(
expect.objectContaining({ id: 1 })
);
});
});
it('calls onClose after autoUpload completes', async () => {
const user = userEvent.setup();
render();
const file = new File(['content'], 'model.gcode.3mf', { type: 'application/octet-stream' });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await user.upload(fileInput, file);
await waitFor(() => {
expect(defaultProps.onClose).toHaveBeenCalled();
expect(defaultProps.onUploadComplete).toHaveBeenCalled();
});
});
});
describe('close behavior', () => {
it('calls onClose when Cancel button is clicked', async () => {
const user = userEvent.setup();
render();
await user.click(screen.getByRole('button', { name: 'Cancel' }));
expect(defaultProps.onClose).toHaveBeenCalled();
});
it('calls onClose when X button is clicked', async () => {
const user = userEvent.setup();
render();
// The X button is the one in the header (not file remove buttons)
const headerButtons = screen.getByText('Upload Files').parentElement?.querySelectorAll('button');
const closeButton = headerButtons?.[0];
if (closeButton) {
await user.click(closeButton);
expect(defaultProps.onClose).toHaveBeenCalled();
}
});
it('always shows Cancel button (modal auto-closes after upload)', () => {
render();
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
});
});
describe('drag and drop', () => {
it('highlights drop zone on drag over', () => {
render();
const dropZone = screen.getByText(/Drag & drop/).closest('div[class*="border-dashed"]');
if (dropZone) {
fireEvent.dragOver(dropZone, { dataTransfer: { files: [] } });
expect(dropZone.className).toContain('border-bambu-green');
}
});
it('removes highlight on drag leave', () => {
render();
const dropZone = screen.getByText(/Drag & drop/).closest('div[class*="border-dashed"]');
if (dropZone) {
fireEvent.dragOver(dropZone, { dataTransfer: { files: [] } });
fireEvent.dragLeave(dropZone, { dataTransfer: { files: [] } });
expect(dropZone.className).not.toContain('bg-bambu-green');
}
});
});
describe('folder context', () => {
it('accepts folderId prop for uploading to specific folder', () => {
render();
// Component should render without errors with a folder context
expect(screen.getByText('Upload Files')).toBeInTheDocument();
});
});
describe('validateFile prop', () => {
it('rejects files that fail validation and shows error', async () => {
const user = userEvent.setup();
render(
{
if (!file.name.endsWith('.gcode')) return 'Only .gcode files allowed';
}}
/>
);
const file = new File(['content'], 'model.stl', { type: 'application/octet-stream' });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await user.upload(fileInput, file);
// Error should be shown
expect(screen.getByText('Only .gcode files allowed')).toBeInTheDocument();
// File should NOT be added to the list
expect(screen.queryByText('model.stl')).not.toBeInTheDocument();
});
it('allows files that pass validation', async () => {
const user = userEvent.setup();
render(
{
if (!file.name.endsWith('.gcode')) return 'Only .gcode files allowed';
}}
/>
);
const file = new File(['content'], 'model.gcode', { type: 'application/octet-stream' });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await user.upload(fileInput, file);
expect(screen.getByText('model.gcode')).toBeInTheDocument();
expect(screen.queryByText('Only .gcode files allowed')).not.toBeInTheDocument();
});
it('clears validation error when a new file is added', async () => {
const user = userEvent.setup();
render(
{
if (!file.name.endsWith('.gcode')) return 'Only .gcode files allowed';
}}
/>
);
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
// First add an invalid file
const badFile = new File(['content'], 'model.stl', { type: 'application/octet-stream' });
await user.upload(fileInput, badFile);
expect(screen.getByText('Only .gcode files allowed')).toBeInTheDocument();
// Then add a valid file — error should clear
const goodFile = new File(['content'], 'model.gcode', { type: 'application/octet-stream' });
await user.upload(fileInput, goodFile);
expect(screen.queryByText('Only .gcode files allowed')).not.toBeInTheDocument();
});
});
describe('accept prop', () => {
it('sets accept attribute on file input', () => {
render();
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
expect(fileInput.accept).toBe('.gcode,.gcode.3mf');
});
it('does not set accept attribute when prop is omitted', () => {
render();
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
expect(fileInput.accept).toBe('');
});
});
describe('onFileUploaded error handling', () => {
it('shows error and keeps modal open when onFileUploaded returns a string', async () => {
const user = userEvent.setup();
render(
'This file was sliced for the wrong printer'}
/>
);
const file = new File(['content'], 'model.3mf', { type: 'application/octet-stream' });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await user.upload(fileInput, file);
const uploadButton = screen.getByRole('button', { name: /Upload \(1\)/i });
await user.click(uploadButton);
await waitFor(() => {
expect(screen.getByText('This file was sliced for the wrong printer')).toBeInTheDocument();
});
// Modal should NOT close
expect(defaultProps.onClose).not.toHaveBeenCalled();
});
it('clears file list when onFileUploaded returns an error', async () => {
const user = userEvent.setup();
render(
'Incompatible printer'}
/>
);
const file = new File(['content'], 'model.3mf', { type: 'application/octet-stream' });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await user.upload(fileInput, file);
const uploadButton = screen.getByRole('button', { name: /Upload \(1\)/i });
await user.click(uploadButton);
await waitFor(() => {
expect(screen.getByText('Incompatible printer')).toBeInTheDocument();
});
// File list should be cleared
expect(screen.queryByText('model.3mf')).not.toBeInTheDocument();
});
it('closes modal normally when onFileUploaded returns undefined', async () => {
const onFileUploaded = vi.fn();
const user = userEvent.setup();
render();
const file = new File(['content'], 'model.3mf', { type: 'application/octet-stream' });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await user.upload(fileInput, file);
const uploadButton = screen.getByRole('button', { name: /Upload \(1\)/i });
await user.click(uploadButton);
await waitFor(() => {
expect(defaultProps.onClose).toHaveBeenCalled();
});
});
});
});