feat(phase2): implement ItemPhotoUpload component and hook
This commit is contained in:
144
frontend/components/ItemPhotoUpload.tsx
Normal file
144
frontend/components/ItemPhotoUpload.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { Camera, Upload, Loader2 } from 'lucide-react';
|
||||
import { usePhotoUpload } from '@/hooks/usePhotoUpload';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
interface ItemPhotoUploadProps {
|
||||
itemId: number;
|
||||
onUploadSuccess: (photo: { thumbnail_url: string; full_url: string; uploaded_at: string }) => void;
|
||||
onError: (errorMessage: string) => void;
|
||||
}
|
||||
|
||||
export default function ItemPhotoUpload({
|
||||
itemId,
|
||||
onUploadSuccess,
|
||||
onError,
|
||||
}: ItemPhotoUploadProps) {
|
||||
const { upload, isLoading, error } = usePhotoUpload();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const cameraInputRef = useRef<HTMLInputElement>(null);
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
|
||||
// Update local error when hook error changes
|
||||
React.useEffect(() => {
|
||||
if (error) {
|
||||
setLocalError(error);
|
||||
onError(error);
|
||||
}
|
||||
}, [error, onError]);
|
||||
|
||||
const handleFileSelect = async (file: File) => {
|
||||
setLocalError(null);
|
||||
|
||||
if (!file) return;
|
||||
|
||||
const toastId = toast.loading('Uploading...');
|
||||
|
||||
try {
|
||||
const photo = await upload(file, itemId);
|
||||
toast.success('Photo uploaded successfully', { id: toastId });
|
||||
onUploadSuccess(photo);
|
||||
|
||||
// Reset file inputs
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
if (cameraInputRef.current) {
|
||||
cameraInputRef.current.value = '';
|
||||
}
|
||||
} catch (err: any) {
|
||||
const errorMsg = err.message || 'Upload failed';
|
||||
setLocalError(errorMsg);
|
||||
toast.error(errorMsg, { id: toastId });
|
||||
onError(errorMsg);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (files && files.length > 0) {
|
||||
handleFileSelect(files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCameraCapture = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (files && files.length > 0) {
|
||||
handleFileSelect(files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const triggerFileInput = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const triggerCameraInput = () => {
|
||||
cameraInputRef.current?.click();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Hidden file inputs */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFileInputChange}
|
||||
className="sr-only"
|
||||
aria-label="Upload photo from device"
|
||||
/>
|
||||
|
||||
<input
|
||||
ref={cameraInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
onChange={handleCameraCapture}
|
||||
className="sr-only"
|
||||
aria-label="Capture photo with camera"
|
||||
/>
|
||||
|
||||
{/* Button group */}
|
||||
<div className="flex gap-2">
|
||||
{/* File upload button */}
|
||||
<button
|
||||
onClick={triggerFileInput}
|
||||
disabled={isLoading}
|
||||
className="flex items-center justify-center gap-2 flex-1 px-4 py-2.5 bg-primary text-white rounded-lg font-normal text-base transition-colors hover:bg-primary/90 disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
aria-label="Upload photo"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<Upload className="w-5 h-5" />
|
||||
)}
|
||||
<span>Upload</span>
|
||||
</button>
|
||||
|
||||
{/* Camera button (mobile) */}
|
||||
<button
|
||||
onClick={triggerCameraInput}
|
||||
disabled={isLoading}
|
||||
className="flex items-center justify-center gap-2 flex-1 px-4 py-2.5 bg-slate-700 text-white rounded-lg font-normal text-base transition-colors hover:bg-slate-600 disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
aria-label="Capture photo with camera"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<Camera className="w-5 h-5" />
|
||||
)}
|
||||
<span>Camera</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Status messages */}
|
||||
{isLoading && (
|
||||
<div className="text-sm text-slate-400">Uploading...</div>
|
||||
)}
|
||||
|
||||
{localError && (
|
||||
<div className="text-sm text-rose-500">{localError}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
92
frontend/hooks/usePhotoUpload.ts
Normal file
92
frontend/hooks/usePhotoUpload.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { inventoryApi } from '@/lib/api';
|
||||
|
||||
interface Photo {
|
||||
thumbnail_url: string;
|
||||
full_url: string;
|
||||
uploaded_at: string;
|
||||
}
|
||||
|
||||
interface UsePhotoUploadReturn {
|
||||
upload: (file: File, itemId: number) => Promise<Photo>;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const ACCEPTED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
export function usePhotoUpload(): UsePhotoUploadReturn {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const validateFile = useCallback((file: File): { valid: boolean; error?: string } => {
|
||||
// Validate file size
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'File too large, max 10MB',
|
||||
};
|
||||
}
|
||||
|
||||
// Validate MIME type
|
||||
if (!ACCEPTED_MIME_TYPES.includes(file.type)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Invalid image format',
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}, []);
|
||||
|
||||
const upload = useCallback(
|
||||
async (file: File, itemId: number): Promise<Photo> => {
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// Validate file (synchronously)
|
||||
const validation = validateFile(file);
|
||||
if (!validation.valid) {
|
||||
const errorMsg = validation.error || 'File validation failed';
|
||||
setError(errorMsg);
|
||||
setIsLoading(false);
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
// Create FormData for multipart upload
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
// Upload to backend
|
||||
const response = await inventoryApi.uploadItemPhoto(itemId, formData);
|
||||
|
||||
// Handle response
|
||||
if (response.status === 'ok' && response.photo) {
|
||||
const photo: Photo = {
|
||||
thumbnail_url: response.photo.thumbnail_url,
|
||||
full_url: response.photo.full_url,
|
||||
uploaded_at: response.photo.uploaded_at,
|
||||
};
|
||||
setIsLoading(false);
|
||||
return photo;
|
||||
}
|
||||
|
||||
throw new Error('Invalid response from server');
|
||||
} catch (err: any) {
|
||||
const errorMsg = err.message || 'Upload failed';
|
||||
setError(errorMsg);
|
||||
setIsLoading(false);
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
},
|
||||
[validateFile]
|
||||
);
|
||||
|
||||
return {
|
||||
upload,
|
||||
isLoading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
@@ -263,5 +263,13 @@ export const inventoryApi = {
|
||||
testAiKey: async (provider: string, key: string) => {
|
||||
const res = await axiosInstance.post('/admin/ai/settings/test-key', { provider, key });
|
||||
return res.data;
|
||||
}
|
||||
},
|
||||
|
||||
// Photo Upload
|
||||
uploadItemPhoto: async (itemId: number, formData: FormData) => {
|
||||
const res = await axiosInstance.post(`/items/${itemId}/photo`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
return res.data;
|
||||
},
|
||||
};
|
||||
|
||||
163
frontend/tests/components/ItemPhotoUpload.test.tsx
Normal file
163
frontend/tests/components/ItemPhotoUpload.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
201
frontend/tests/hooks/usePhotoUpload.test.ts
Normal file
201
frontend/tests/hooks/usePhotoUpload.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user