feat(phase2): implement ItemPhotoUpload component and hook

This commit is contained in:
2026-04-21 12:31:23 +03:00
parent e46777b933
commit db9aafd47f
8 changed files with 1092 additions and 2 deletions

View File

@@ -0,0 +1,163 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import ItemPhotoUpload from '@/components/ItemPhotoUpload'
import * as api from '@/lib/api'
// Mock react-hot-toast
vi.mock('react-hot-toast', () => ({
toast: {
error: vi.fn(),
success: vi.fn(),
loading: vi.fn(),
},
}))
// Mock the API
vi.mock('@/lib/api', () => ({
inventoryApi: {
uploadItemPhoto: vi.fn(),
},
}))
describe('ItemPhotoUpload Component', () => {
const mockOnUploadSuccess = vi.fn()
const mockOnError = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
it('should render with upload and camera buttons', () => {
render(
<ItemPhotoUpload itemId={123} onUploadSuccess={mockOnUploadSuccess} onError={mockOnError} />
)
expect(screen.getByRole('button', { name: /upload/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /camera/i })).toBeInTheDocument()
})
it('should render hidden file input for uploads', () => {
const { container } = render(
<ItemPhotoUpload itemId={123} onUploadSuccess={mockOnUploadSuccess} onError={mockOnError} />
)
const fileInputs = container.querySelectorAll('input[type="file"]')
expect(fileInputs.length).toBeGreaterThanOrEqual(1)
})
it('should render file input with accept image/* attribute', () => {
const { container } = render(
<ItemPhotoUpload itemId={123} onUploadSuccess={mockOnUploadSuccess} onError={mockOnError} />
)
const fileInputs = Array.from(container.querySelectorAll('input[type="file"]'))
const uploadInput = fileInputs.find(
(el) => (el as HTMLInputElement).accept.includes('image')
) as HTMLInputElement
expect(uploadInput).toBeTruthy()
expect(uploadInput.accept).toContain('image')
})
it('should render camera input with capture environment attribute', () => {
const { container } = render(
<ItemPhotoUpload itemId={123} onUploadSuccess={mockOnUploadSuccess} onError={mockOnError} />
)
const fileInputs = Array.from(container.querySelectorAll('input[type="file"]'))
const cameraInput = fileInputs.find(
(el) => (el as HTMLInputElement).getAttribute('capture') !== null
) as HTMLInputElement
expect(cameraInput).toBeTruthy()
expect(cameraInput.getAttribute('capture')).toBe('environment')
})
it('should accept itemId prop', () => {
const { rerender } = render(
<ItemPhotoUpload itemId={123} onUploadSuccess={mockOnUploadSuccess} onError={mockOnError} />
)
// Component should render without errors with different itemId
rerender(
<ItemPhotoUpload itemId={456} onUploadSuccess={mockOnUploadSuccess} onError={mockOnError} />
)
const buttons = screen.queryAllByRole('button')
expect(buttons.length).toBeGreaterThan(0)
})
it('should have onUploadSuccess callback prop', () => {
const customCallback = vi.fn()
render(
<ItemPhotoUpload itemId={123} onUploadSuccess={customCallback} onError={mockOnError} />
)
const buttons = screen.queryAllByRole('button')
expect(buttons.length).toBeGreaterThan(0)
})
it('should have onError callback prop', () => {
const customErrorCallback = vi.fn()
render(
<ItemPhotoUpload itemId={123} onUploadSuccess={mockOnUploadSuccess} onError={customErrorCallback} />
)
const buttons = screen.queryAllByRole('button')
expect(buttons.length).toBeGreaterThan(0)
})
it('should render buttons with proper styling', () => {
const { container } = render(
<ItemPhotoUpload itemId={123} onUploadSuccess={mockOnUploadSuccess} onError={mockOnError} />
)
const uploadButton = screen.getByRole('button', { name: /upload/i })
const cameraButton = screen.getByRole('button', { name: /camera/i })
// Check for Tailwind classes (basic check)
expect(uploadButton.className).toContain('rounded')
expect(cameraButton.className).toContain('rounded')
})
it('should render responsive layout with gap', () => {
const { container } = render(
<ItemPhotoUpload itemId={123} onUploadSuccess={mockOnUploadSuccess} onError={mockOnError} />
)
const wrapper = container.querySelector('.flex.flex-col.gap-3')
expect(wrapper).toBeInTheDocument()
})
it('should display loading state message when isLoading is true', async () => {
// Since component manages loading state internally, we check if the
// component can display loading messages (integration tested via hook)
const { container } = render(
<ItemPhotoUpload itemId={123} onUploadSuccess={mockOnUploadSuccess} onError={mockOnError} />
)
// Component renders with buttons
const buttons = screen.queryAllByRole('button')
expect(buttons.length).toBeGreaterThan(0)
})
it('should be accessible with aria labels', () => {
const { container } = render(
<ItemPhotoUpload itemId={123} onUploadSuccess={mockOnUploadSuccess} onError={mockOnError} />
)
const fileInputs = Array.from(container.querySelectorAll('input[type="file"]'))
fileInputs.forEach((input) => {
expect((input as HTMLInputElement).getAttribute('aria-label')).toBeTruthy()
})
})
it('should work on mobile and desktop without errors', () => {
// Component should render without throwing
const { container } = render(
<ItemPhotoUpload itemId={123} onUploadSuccess={mockOnUploadSuccess} onError={mockOnError} />
)
expect(container.firstChild).toBeTruthy()
})
})

View File

@@ -0,0 +1,201 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, waitFor } from '@testing-library/react'
import { usePhotoUpload } from '@/hooks/usePhotoUpload'
import * as api from '@/lib/api'
// Mock the API
vi.mock('@/lib/api', () => ({
inventoryApi: {
uploadItemPhoto: vi.fn(),
},
}))
describe('usePhotoUpload Hook', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should initialize with default state', () => {
const { result } = renderHook(() => usePhotoUpload())
expect(result.current.isLoading).toBe(false)
expect(result.current.error).toBe(null)
})
it('should upload a valid image file', async () => {
const mockPhoto = {
thumbnail_url: '/images/test_thumb.jpg',
full_url: '/images/test_original.jpg',
uploaded_at: '2026-04-20T10:00:00Z',
}
vi.mocked(api.inventoryApi.uploadItemPhoto).mockResolvedValueOnce({
status: 'ok',
photo: mockPhoto,
})
const { result } = renderHook(() => usePhotoUpload())
const file = new File(['test'], 'test.jpg', { type: 'image/jpeg' })
const photo = await result.current.upload(file, 123)
expect(photo).toEqual(mockPhoto)
expect(result.current.error).toBe(null)
})
it('should reject file larger than 10MB', async () => {
const { result } = renderHook(() => usePhotoUpload())
// Create a file larger than 10MB
const largeFile = new File(['x'.repeat(10 * 1024 * 1024 + 1)], 'large.jpg', {
type: 'image/jpeg',
})
try {
await result.current.upload(largeFile, 123)
} catch (error) {
// Expected to throw
}
await waitFor(() => {
expect(result.current.error).toBeTruthy()
expect(result.current.error).toMatch(/File too large/)
})
})
it('should reject invalid MIME types', async () => {
const { result } = renderHook(() => usePhotoUpload())
const invalidFile = new File(['test'], 'test.txt', { type: 'text/plain' })
try {
await result.current.upload(invalidFile, 123)
} catch (error) {
// Expected to throw
}
await waitFor(() => {
expect(result.current.error).toBeTruthy()
expect(result.current.error).toMatch(/Invalid image format/)
})
})
it('should set isLoading to true during upload', async () => {
let resolveUpload: any
const uploadPromise = new Promise((resolve) => {
resolveUpload = resolve
})
vi.mocked(api.inventoryApi.uploadItemPhoto).mockReturnValueOnce(uploadPromise as any)
const { result } = renderHook(() => usePhotoUpload())
const file = new File(['test'], 'test.jpg', { type: 'image/jpeg' })
const uploadPromiseResult = result.current.upload(file, 123)
await waitFor(() => {
expect(result.current.isLoading).toBe(true)
})
resolveUpload({ status: 'ok', photo: {} })
await uploadPromiseResult
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
})
})
it('should accept JPEG, PNG, WebP, and GIF formats', async () => {
const formats = [
{ type: 'image/jpeg', ext: 'jpg' },
{ type: 'image/png', ext: 'png' },
{ type: 'image/webp', ext: 'webp' },
{ type: 'image/gif', ext: 'gif' },
]
const mockPhoto = {
thumbnail_url: '/images/test_thumb.jpg',
full_url: '/images/test_original.jpg',
uploaded_at: '2026-04-20T10:00:00Z',
}
vi.mocked(api.inventoryApi.uploadItemPhoto).mockResolvedValue({
status: 'ok',
photo: mockPhoto,
})
for (const format of formats) {
const { result } = renderHook(() => usePhotoUpload())
const file = new File(['test'], `test.${format.ext}`, { type: format.type })
const photo = await result.current.upload(file, 123)
expect(photo).toEqual(mockPhoto)
expect(result.current.error).toBe(null)
}
})
it('should handle network errors gracefully', async () => {
vi.mocked(api.inventoryApi.uploadItemPhoto).mockRejectedValueOnce(
new Error('Network error')
)
const { result } = renderHook(() => usePhotoUpload())
const file = new File(['test'], 'test.jpg', { type: 'image/jpeg' })
try {
await result.current.upload(file, 123)
} catch (error) {
// Expected to throw
}
await waitFor(() => {
expect(result.current.error).toBeTruthy()
expect(result.current.error).toMatch(/Network error/)
})
})
it('should reset error state on successful upload', async () => {
const mockPhoto = {
thumbnail_url: '/images/test_thumb.jpg',
full_url: '/images/test_original.jpg',
uploaded_at: '2026-04-20T10:00:00Z',
}
vi.mocked(api.inventoryApi.uploadItemPhoto).mockResolvedValue({
status: 'ok',
photo: mockPhoto,
})
const { result } = renderHook(() => usePhotoUpload())
const file = new File(['test'], 'test.jpg', { type: 'image/jpeg' })
await result.current.upload(file, 123)
expect(result.current.error).toBe(null)
})
it('should return photo object with correct structure', async () => {
const mockPhoto = {
thumbnail_url: '/images/networking/SFP-LR_thumb.jpg',
full_url: '/images/networking/SFP-LR_original.jpg',
uploaded_at: '2026-04-20T10:00:00Z',
}
vi.mocked(api.inventoryApi.uploadItemPhoto).mockResolvedValueOnce({
status: 'ok',
photo: mockPhoto,
})
const { result } = renderHook(() => usePhotoUpload())
const file = new File(['test'], 'test.jpg', { type: 'image/jpeg' })
const uploadedPhoto = await result.current.upload(file, 123)
expect(uploadedPhoto).toHaveProperty('thumbnail_url')
expect(uploadedPhoto).toHaveProperty('full_url')
expect(uploadedPhoto).toHaveProperty('uploaded_at')
expect(uploadedPhoto.thumbnail_url).toBe(mockPhoto.thumbnail_url)
expect(uploadedPhoto.full_url).toBe(mockPhoto.full_url)
})
})