All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 10m14s
217 lines
8.8 KiB
TypeScript
217 lines
8.8 KiB
TypeScript
// src/features/flyer/BulkImporter.test.tsx
|
|
import React from 'react';
|
|
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { BulkImporter } from './BulkImporter';
|
|
|
|
describe('BulkImporter', () => {
|
|
const mockOnFilesChange = vi.fn();
|
|
const mockCreateObjectURL = vi.fn();
|
|
const mockRevokeObjectURL = vi.fn();
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
// Mock the global URL object methods
|
|
global.URL.createObjectURL = mockCreateObjectURL;
|
|
global.URL.revokeObjectURL = mockRevokeObjectURL;
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('should render the initial state correctly', () => {
|
|
render(<BulkImporter onFilesChange={mockOnFilesChange} isProcessing={false} />);
|
|
expect(screen.getByText(/click to upload/i)).toBeInTheDocument();
|
|
expect(screen.getByText(/drag and drop/i)).toBeInTheDocument();
|
|
expect(screen.getByText(/png, jpg, webp, or pdf/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('should render the processing state', () => {
|
|
render(<BulkImporter onFilesChange={mockOnFilesChange} isProcessing={true} />);
|
|
expect(screen.getByText(/processing, please wait.../i)).toBeInTheDocument();
|
|
const label = screen.getByText(/processing, please wait.../i).closest('label');
|
|
expect(label).toHaveClass('cursor-not-allowed');
|
|
expect(label).toHaveClass('opacity-60');
|
|
});
|
|
|
|
it('should call onFilesChange when files are selected via input', async () => {
|
|
render(<BulkImporter onFilesChange={mockOnFilesChange} isProcessing={false} />);
|
|
const file = new File(['content'], 'flyer.pdf', { type: 'application/pdf' });
|
|
const input = screen.getByLabelText(/click to upload/i);
|
|
|
|
await waitFor(() =>
|
|
fireEvent.change(input, {
|
|
target: { files: [file] },
|
|
}),
|
|
);
|
|
|
|
expect(mockOnFilesChange).toHaveBeenCalledTimes(1);
|
|
expect(mockOnFilesChange.mock.calls[0][0][0]).toBe(file);
|
|
});
|
|
|
|
it('should handle drag and drop events', () => {
|
|
render(<BulkImporter onFilesChange={mockOnFilesChange} isProcessing={false} />);
|
|
// `getByLabelText` finds the input associated with the label.
|
|
// We need to test the label itself, as it contains the drag-and-drop handlers and styles.
|
|
// We can find it by its text content.
|
|
const dropzone = screen.getByText(/click to upload/i).closest('label');
|
|
// Assert that the dropzone element was found before using it.
|
|
// This satisfies TypeScript's null-check and makes the test more robust.
|
|
expect(dropzone).not.toBeNull();
|
|
|
|
// Test drag enter
|
|
// We need to assert that dropzone is not null before passing it to fireEvent.
|
|
if (!dropzone) throw new Error('Dropzone not found');
|
|
fireEvent.dragEnter(dropzone, { dataTransfer: { files: [] } });
|
|
expect(dropzone).toHaveClass('border-brand-primary');
|
|
|
|
// Test drag leave
|
|
// We need to assert that dropzone is not null before passing it to fireEvent.
|
|
if (!dropzone) throw new Error('Dropzone not found');
|
|
fireEvent.dragLeave(dropzone);
|
|
expect(dropzone).not.toHaveClass('border-brand-primary');
|
|
});
|
|
|
|
it('should call onFilesChange when files are dropped', () => {
|
|
render(<BulkImporter onFilesChange={mockOnFilesChange} isProcessing={false} />);
|
|
// The drop event handlers are on the <label>, not the <input>
|
|
const dropzone = screen.getByText(/click to upload/i).closest('label')!;
|
|
const file = new File(['content'], 'flyer.pdf', { type: 'application/pdf' });
|
|
|
|
fireEvent.drop(dropzone, {
|
|
dataTransfer: {
|
|
files: [file],
|
|
items: [file], // Also mock the 'items' property for full DataTransfer simulation
|
|
},
|
|
});
|
|
|
|
expect(mockOnFilesChange).toHaveBeenCalledTimes(1);
|
|
expect(mockOnFilesChange.mock.calls[0][0][0]).toBe(file);
|
|
});
|
|
|
|
it('should not call onFilesChange if no files are dropped', () => {
|
|
render(<BulkImporter onFilesChange={mockOnFilesChange} isProcessing={false} />);
|
|
// The drop event handlers are on the <label>, not the <input>
|
|
const dropzone = screen.getByText(/click to upload/i).closest('label')!;
|
|
|
|
fireEvent.drop(dropzone, {
|
|
dataTransfer: {
|
|
files: [],
|
|
},
|
|
});
|
|
|
|
expect(mockOnFilesChange).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should not respond to interactions when isProcessing is true', () => {
|
|
render(<BulkImporter onFilesChange={mockOnFilesChange} isProcessing={true} />);
|
|
const dropzone = screen.getByLabelText(/processing, please wait.../i);
|
|
|
|
fireEvent.dragEnter(dropzone, { dataTransfer: { files: [] } });
|
|
expect(dropzone).not.toHaveClass('border-brand-primary');
|
|
});
|
|
|
|
describe('when files are selected', () => {
|
|
const imageFile = new File(['image-content'], 'flyer.jpg', { type: 'image/jpeg' });
|
|
const pdfFile = new File(['pdf-content'], 'document.pdf', { type: 'application/pdf' });
|
|
|
|
it('should display file previews and distinguish between images and other file types', async () => {
|
|
mockCreateObjectURL
|
|
.mockReturnValueOnce('blob:http://localhost/image-guid') // For the image file
|
|
.mockReturnValueOnce(''); // For the PDF file
|
|
|
|
render(<BulkImporter onFilesChange={mockOnFilesChange} isProcessing={false} />);
|
|
const input = screen.getByLabelText(/click to upload/i);
|
|
|
|
await act(async () => {
|
|
fireEvent.change(input, { target: { files: [imageFile, pdfFile] } });
|
|
});
|
|
|
|
// Wait for previews to render
|
|
await waitFor(() => {
|
|
expect(screen.getByText('flyer.jpg')).toBeInTheDocument();
|
|
expect(screen.getByText('document.pdf')).toBeInTheDocument();
|
|
});
|
|
|
|
// Check that an image preview is rendered for the image file
|
|
const imagePreview = screen.getByAltText('flyer.jpg');
|
|
expect(imagePreview).toBeInTheDocument();
|
|
expect(imagePreview).toHaveAttribute('src', 'blob:http://localhost/image-guid');
|
|
|
|
// Check that a generic document icon is rendered for the PDF
|
|
const pdfPreviewContainer = screen
|
|
.getByText('document.pdf')
|
|
.closest('div.relative') as HTMLElement;
|
|
// The icon itself doesn't have a test-id, but we can find it by its role and class.
|
|
expect(pdfPreviewContainer.querySelector('svg.w-12.h-12')).toBeInTheDocument();
|
|
});
|
|
|
|
it('should allow removing a file from the preview list', async () => {
|
|
render(<BulkImporter onFilesChange={mockOnFilesChange} isProcessing={false} />);
|
|
const input = screen.getByLabelText(/click to upload/i);
|
|
|
|
await act(async () => {
|
|
fireEvent.change(input, { target: { files: [imageFile, pdfFile] } });
|
|
});
|
|
|
|
// Wait for previews to appear and check initial state
|
|
await screen.findByText('flyer.jpg');
|
|
expect(screen.getByText('document.pdf')).toBeInTheDocument();
|
|
expect(mockOnFilesChange).toHaveBeenLastCalledWith([imageFile, pdfFile]);
|
|
|
|
// Find and click the remove button for the first file ('flyer.jpg')
|
|
const removeButton = screen.getByLabelText(`Remove ${imageFile.name}`);
|
|
await act(async () => {
|
|
fireEvent.click(removeButton);
|
|
});
|
|
|
|
// Assert that the file was removed from the display and the parent was notified
|
|
expect(screen.queryByText('flyer.jpg')).not.toBeInTheDocument();
|
|
expect(screen.getByText('document.pdf')).toBeInTheDocument(); // The other file should remain
|
|
expect(mockOnFilesChange).toHaveBeenLastCalledWith([pdfFile]);
|
|
});
|
|
|
|
it('should prevent adding duplicate files', async () => {
|
|
render(<BulkImporter onFilesChange={mockOnFilesChange} isProcessing={false} />);
|
|
const input = screen.getByLabelText(/click to upload/i);
|
|
|
|
// Add the file for the first time
|
|
await act(async () => {
|
|
fireEvent.change(input, { target: { files: [imageFile] } });
|
|
});
|
|
expect(mockOnFilesChange).toHaveBeenCalledTimes(1);
|
|
expect(mockOnFilesChange).toHaveBeenLastCalledWith([imageFile]);
|
|
|
|
// Attempt to add the exact same file again
|
|
await act(async () => {
|
|
fireEvent.change(input, { target: { files: [imageFile] } });
|
|
});
|
|
// The number of calls should NOT increase because the file is a duplicate
|
|
expect(mockOnFilesChange).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('should revoke object URLs on cleanup', async () => {
|
|
mockCreateObjectURL.mockReturnValue('blob:http://localhost/test-url');
|
|
const { unmount } = render(
|
|
<BulkImporter onFilesChange={mockOnFilesChange} isProcessing={false} />,
|
|
);
|
|
const input = screen.getByLabelText(/click to upload/i);
|
|
|
|
await act(async () => {
|
|
fireEvent.change(input, { target: { files: [imageFile] } });
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(mockCreateObjectURL).toHaveBeenCalledWith(imageFile);
|
|
});
|
|
|
|
// Unmount the component to trigger the cleanup effect
|
|
unmount();
|
|
|
|
expect(mockRevokeObjectURL).toHaveBeenCalledTimes(1);
|
|
expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:http://localhost/test-url');
|
|
});
|
|
});
|
|
});
|