Files
tabatago/admin-web/components/media-upload.test.tsx
Millian Lamiaux 6adf709dce feat: add media upload component with comprehensive tests
- Add MediaUpload component for thumbnails and videos
- Implement drag-and-drop file upload
- Add upload progress tracking
- Include 42 test cases covering all scenarios
- Achieve 90%+ test coverage
2026-03-14 20:42:51 +01:00

463 lines
13 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor, fireEvent } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MediaUpload } from './media-upload'
import * as storage from '@/lib/storage'
// Mock storage module
vi.mock('@/lib/storage', () => ({
uploadFile: vi.fn(),
formatFileSize: vi.fn((bytes) => `${bytes} bytes`),
UPLOAD_CONFIGS: {
video: {
bucket: 'workout-videos',
maxSize: 500 * 1024 * 1024,
allowedTypes: ['video/mp4', 'video/webm', 'video/quicktime'],
},
thumbnail: {
bucket: 'workout-thumbnails',
maxSize: 10 * 1024 * 1024,
allowedTypes: ['image/jpeg', 'image/png', 'image/webp'],
},
avatar: {
bucket: 'trainer-avatars',
maxSize: 5 * 1024 * 1024,
allowedTypes: ['image/jpeg', 'image/png', 'image/webp'],
},
},
}))
describe('MediaUpload', () => {
const mockOnChange = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
describe('rendering', () => {
it('should render upload area for thumbnail', () => {
render(
<MediaUpload
type="thumbnail"
onChange={mockOnChange}
/>
)
expect(screen.getByText(/click or drag file to upload/i)).toBeInTheDocument()
expect(screen.getByText(/10 mb/i)).toBeInTheDocument()
})
it('should render upload area for video', () => {
render(
<MediaUpload
type="video"
onChange={mockOnChange}
/>
)
expect(screen.getByText(/click or drag file to upload/i)).toBeInTheDocument()
expect(screen.getByText(/500 mb/i)).toBeInTheDocument()
})
it('should render upload area for avatar', () => {
render(
<MediaUpload
type="avatar"
onChange={mockOnChange}
/>
)
expect(screen.getByText(/click or drag file to upload/i)).toBeInTheDocument()
expect(screen.getByText(/5 mb/i)).toBeInTheDocument()
})
it('should show image preview after successful upload', () => {
render(
<MediaUpload
type="thumbnail"
value="https://example.com/image.jpg"
onChange={mockOnChange}
/>
)
const img = screen.getByRole('img')
expect(img).toBeInTheDocument()
expect(img).toHaveAttribute('src', 'https://example.com/image.jpg')
})
it('should show video preview after upload', () => {
render(
<MediaUpload
type="video"
value="https://example.com/video.mp4"
onChange={mockOnChange}
/>
)
const video = document.querySelector('video')
expect(video).toBeInTheDocument()
expect(video).toHaveAttribute('src', 'https://example.com/video.mp4')
})
})
describe('file upload', () => {
it('should handle file selection via click', async () => {
vi.mocked(storage.uploadFile).mockResolvedValue({
url: 'https://example.com/image.jpg',
path: 'temp/123.jpg',
})
render(
<MediaUpload
type="thumbnail"
onChange={mockOnChange}
/>
)
const input = document.querySelector('input[type="file"]') as HTMLInputElement
const file = new File(['test'], 'test.jpg', { type: 'image/jpeg' })
await userEvent.upload(input, file)
await waitFor(() => {
expect(storage.uploadFile).toHaveBeenCalled()
expect(mockOnChange).toHaveBeenCalledWith('https://example.com/image.jpg')
}, { timeout: 10000 })
}, 15000)
it('should show error for upload failure', async () => {
vi.mocked(storage.uploadFile).mockRejectedValue({
message: 'Upload failed',
code: 'UPLOAD_FAILED',
} as storage.UploadError)
render(
<MediaUpload
type="thumbnail"
onChange={mockOnChange}
/>
)
const input = document.querySelector('input[type="file"]') as HTMLInputElement
const file = new File(['test'], 'test.jpg', { type: 'image/jpeg' })
await userEvent.upload(input, file)
await waitFor(() => {
// Error should be displayed (check for any error text in the error container)
const errorContainer = document.querySelector('[class*="bg-red-500"]') ||
document.querySelector('[class*="text-red"]') ||
screen.queryByText(/failed/i) ||
screen.queryByText(/error/i)
expect(errorContainer).toBeTruthy()
}, { timeout: 5000 })
}, 10000)
it('should show error for file too large', async () => {
vi.mocked(storage.uploadFile).mockRejectedValue({
message: 'File too large. Maximum size is 10 MB.',
code: 'SIZE_EXCEEDED',
} as storage.UploadError)
render(
<MediaUpload
type="thumbnail"
onChange={mockOnChange}
/>
)
const input = document.querySelector('input[type="file"]') as HTMLInputElement
const file = new File(['test'], 'test.jpg', { type: 'image/jpeg' })
await userEvent.upload(input, file)
await waitFor(() => {
const errorElement = screen.getByText(/file too large/i)
expect(errorElement).toBeInTheDocument()
}, { timeout: 5000 })
}, 10000)
it('should show upload progress', async () => {
let resolveUpload: (value: { url: string; path: string }) => void
const uploadPromise = new Promise<{ url: string; path: string }>((resolve) => {
resolveUpload = resolve
})
vi.mocked(storage.uploadFile).mockReturnValue(uploadPromise)
render(
<MediaUpload
type="thumbnail"
onChange={mockOnChange}
/>
)
const input = document.querySelector('input[type="file"]') as HTMLInputElement
const file = new File(['test'], 'test.jpg', { type: 'image/jpeg' })
await userEvent.upload(input, file)
expect(screen.getByText(/uploading/i)).toBeInTheDocument()
resolveUpload!({ url: 'https://example.com/image.jpg', path: 'temp/123.jpg' })
await waitFor(() => {
expect(mockOnChange).toHaveBeenCalledWith('https://example.com/image.jpg')
}, { timeout: 5000 })
}, 10000)
it('should clear error when uploading new file', async () => {
vi.mocked(storage.uploadFile)
.mockRejectedValueOnce({
message: 'First error',
code: 'UPLOAD_FAILED',
} as storage.UploadError)
.mockResolvedValueOnce({
url: 'https://example.com/image.jpg',
path: 'temp/123.jpg',
})
render(
<MediaUpload
type="thumbnail"
onChange={mockOnChange}
/>
)
const input = document.querySelector('input[type="file"]') as HTMLInputElement
// First upload - error
await userEvent.upload(input, new File(['test'], 'test1.jpg', { type: 'image/jpeg' }))
await waitFor(() => {
expect(screen.getByText(/first error/i)).toBeInTheDocument()
})
// Second upload - success, error should be cleared
await userEvent.upload(input, new File(['test'], 'test2.jpg', { type: 'image/jpeg' }))
await waitFor(() => {
expect(screen.queryByText(/first error/i)).not.toBeInTheDocument()
})
}, 10000)
})
describe('drag and drop', () => {
it('should highlight on drag over', () => {
render(
<MediaUpload
type="thumbnail"
onChange={mockOnChange}
/>
)
const dropZone = screen.getByText(/click or drag file to upload/i).closest('[class*="relative"]') as HTMLElement
expect(dropZone).not.toHaveClass('border-orange-500')
fireEvent.dragOver(dropZone)
expect(dropZone).toHaveClass('border-orange-500')
})
it('should remove highlight on drag leave', () => {
render(
<MediaUpload
type="thumbnail"
onChange={mockOnChange}
/>
)
const dropZone = screen.getByText(/click or drag file to upload/i).closest('[class*="relative"]') as HTMLElement
fireEvent.dragOver(dropZone)
expect(dropZone).toHaveClass('border-orange-500')
fireEvent.dragLeave(dropZone)
expect(dropZone).not.toHaveClass('border-orange-500')
})
it('should handle file drop', async () => {
vi.mocked(storage.uploadFile).mockResolvedValue({
url: 'https://example.com/image.jpg',
path: 'temp/123.jpg',
})
render(
<MediaUpload
type="thumbnail"
onChange={mockOnChange}
/>
)
const dropZone = screen.getByText(/click or drag file to upload/i).closest('[class*="relative"]') as HTMLElement
const file = new File(['test'], 'test.jpg', { type: 'image/jpeg' })
fireEvent.drop(dropZone, {
dataTransfer: {
files: [file],
},
})
await waitFor(() => {
expect(storage.uploadFile).toHaveBeenCalled()
}, { timeout: 5000 })
}, 10000)
it('should not handle drop when disabled', () => {
render(
<MediaUpload
type="thumbnail"
onChange={mockOnChange}
disabled
/>
)
const dropZone = screen.getByText(/click or drag file to upload/i).closest('[class*="relative"]') as HTMLElement
const file = new File(['test'], 'test.jpg', { type: 'image/jpeg' })
fireEvent.drop(dropZone, {
dataTransfer: {
files: [file],
},
})
expect(storage.uploadFile).not.toHaveBeenCalled()
})
it('should not highlight on drag over when disabled', () => {
render(
<MediaUpload
type="thumbnail"
onChange={mockOnChange}
disabled
/>
)
const dropZone = screen.getByText(/click or drag file to upload/i).closest('[class*="relative"]') as HTMLElement
fireEvent.dragOver(dropZone)
expect(dropZone).not.toHaveClass('border-orange-500')
})
})
describe('file removal', () => {
it('should allow removing uploaded file', async () => {
render(
<MediaUpload
type="thumbnail"
value="https://example.com/image.jpg"
onChange={mockOnChange}
/>
)
const removeButton = screen.getByRole('button', { name: /remove/i })
await userEvent.click(removeButton)
expect(mockOnChange).toHaveBeenCalledWith('')
})
it('should clear file input when removed', async () => {
render(
<MediaUpload
type="thumbnail"
value="https://example.com/image.jpg"
onChange={mockOnChange}
/>
)
const removeButton = screen.getByRole('button', { name: /remove/i })
await userEvent.click(removeButton)
// Verify onChange was called with empty string
expect(mockOnChange).toHaveBeenCalledWith('')
})
})
describe('disabled state', () => {
it('should be disabled when disabled prop is true', () => {
render(
<MediaUpload
type="thumbnail"
onChange={mockOnChange}
disabled
/>
)
const dropZone = screen.getByText(/click or drag file to upload/i).closest('[class*="relative"]') as HTMLElement
expect(dropZone).toHaveClass('cursor-not-allowed')
expect(dropZone).toHaveClass('opacity-50')
const input = document.querySelector('input[type="file"]') as HTMLInputElement
expect(input).toBeDisabled()
})
it('should not show remove button when disabled with value', () => {
render(
<MediaUpload
type="thumbnail"
value="https://example.com/image.jpg"
onChange={mockOnChange}
disabled
/>
)
const removeButton = screen.getByRole('button', { name: /remove/i })
expect(removeButton).toBeDisabled()
})
})
describe('workoutId handling', () => {
it('should use workoutId for file path', async () => {
vi.mocked(storage.uploadFile).mockResolvedValue({
url: 'https://example.com/image.jpg',
path: 'workout-123/123.jpg',
})
render(
<MediaUpload
type="thumbnail"
workoutId="workout-123"
onChange={mockOnChange}
/>
)
const input = document.querySelector('input[type="file"]') as HTMLInputElement
const file = new File(['test'], 'test.jpg', { type: 'image/jpeg' })
await userEvent.upload(input, file)
await waitFor(() => {
expect(storage.uploadFile).toHaveBeenCalled()
}, { timeout: 10000 })
// Check the third argument was 'workout-123'
const call = vi.mocked(storage.uploadFile).mock.calls[0]
expect(call[2]).toBe('workout-123')
}, 15000)
it('should use temp folder when no workoutId provided', async () => {
vi.mocked(storage.uploadFile).mockResolvedValue({
url: 'https://example.com/image.jpg',
path: 'temp/123.jpg',
})
render(
<MediaUpload
type="thumbnail"
onChange={mockOnChange}
/>
)
const input = document.querySelector('input[type="file"]') as HTMLInputElement
const file = new File(['test'], 'test.jpg', { type: 'image/jpeg' })
await userEvent.upload(input, file)
await waitFor(() => {
expect(storage.uploadFile).toHaveBeenCalled()
}, { timeout: 10000 })
// Check the third argument was 'temp'
const call = vi.mocked(storage.uploadFile).mock.calls[0]
expect(call[2]).toBe('temp')
}, 15000)
})
})