diff --git a/.claude/settings.local.json b/.claude/settings.local.json index a85c9b69..45d71400 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -91,7 +91,8 @@ "Bash(curl -k -s https://192.168.84.131:8918/users/)", "Bash(grep -E \"\\\\.\\(tsx|ts|jsx|js\\)$\")", "Bash(pkill -9 -f uvicorn)", - "Bash(grep -E \"\\\\.\\(py|txt\\)$\")" + "Bash(grep -E \"\\\\.\\(py|txt\\)$\")", + "Bash(npx vitest *)" ] } } diff --git a/frontend/hooks/useItemCreate.ts b/frontend/hooks/useItemCreate.ts index 4d2fc420..c418366e 100644 --- a/frontend/hooks/useItemCreate.ts +++ b/frontend/hooks/useItemCreate.ts @@ -1,4 +1,5 @@ import { useState, useCallback } from 'react'; +import toast from 'react-hot-toast'; import { inventoryApi } from '@/lib/api'; import { CropBounds } from './useCropHandles'; @@ -10,6 +11,12 @@ interface ItemFormData { barcode?: string; part_number?: string; box_label?: string; + extractedImageBlob?: Blob; + imageProcessing?: { + crop_bounds?: { x: number; y: number; width: number; height: number }; + rotation_degrees?: number; + confidence?: number; + }; } interface UploadedPhoto { @@ -151,8 +158,11 @@ export function useItemCreate(): UseItemCreateReturn { return undefined; } + // Extract image data if provided + const { extractedImageBlob, imageProcessing, ...itemData } = formData; + // Create item first (without photo) - const createdItem = await inventoryApi.createItem(userId, formData); + const createdItem = await inventoryApi.createItem(userId, itemData); if (!createdItem.id) { const errorMsg = 'Failed to create item'; @@ -162,6 +172,33 @@ export function useItemCreate(): UseItemCreateReturn { } setItemId(createdItem.id); + + // AUTO-UPLOAD PHOTO if we have both extractedImageBlob and imageProcessing + if (extractedImageBlob && imageProcessing && createdItem.id) { + try { + const formDataUpload = new FormData(); + formDataUpload.append('file', extractedImageBlob); + + // If crop bounds are set, add them to the request + if (imageProcessing.crop_bounds) { + const cropBoundsStr = JSON.stringify(imageProcessing.crop_bounds); + formDataUpload.append('crop_bounds', cropBoundsStr); + } + + await inventoryApi.uploadItemPhoto(createdItem.id, formDataUpload); + + toast.success('Item created + photo saved'); + } catch (photoErr) { + console.warn('Photo upload failed, but item created:', photoErr); + toast.warning('Item created (photo upload skipped)'); + } + } else if (extractedImageBlob || imageProcessing) { + // Only one of the two is provided, so skip photo upload + toast.success('Item created'); + } else { + toast.success('Item created'); + } + setIsLoading(false); return createdItem; diff --git a/frontend/tests/hooks/useItemCreate.test.ts b/frontend/tests/hooks/useItemCreate.test.ts new file mode 100644 index 00000000..a5d2e004 --- /dev/null +++ b/frontend/tests/hooks/useItemCreate.test.ts @@ -0,0 +1,433 @@ +import { renderHook, act } from '@testing-library/react'; +import * as toast from 'react-hot-toast'; +import { useItemCreate } from '@/hooks/useItemCreate'; +import { inventoryApi } from '@/lib/api'; + +vi.mock('react-hot-toast'); +vi.mock('@/lib/api'); + +describe('useItemCreate - Auto-Upload Photo After Item Creation', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('submitItem with auto-upload', () => { + it('should auto-upload photo after item creation if image_processing provided', async () => { + const { result } = renderHook(() => useItemCreate()); + + const mockBlob = new Blob(['test'], { type: 'image/jpeg' }); + const mockImageProcessing = { + crop_bounds: { x: 10, y: 20, width: 100, height: 100 }, + rotation_degrees: 0, + confidence: 0.95, + }; + + const mockCreatedItem = { id: 123, name: 'Test Item' }; + + (inventoryApi.createItem as any).mockResolvedValue(mockCreatedItem); + (inventoryApi.uploadItemPhoto as any).mockResolvedValue({ + status: 'ok', + photo: { + thumbnail_url: 'https://example.com/thumb.jpg', + full_url: 'https://example.com/full.jpg', + uploaded_at: '2026-04-21T10:00:00Z', + }, + }); + + // Set form data with image data + act(() => { + result.current.setFormData({ + name: 'Test Item', + category: 'Electronics', + item_type: 'Widget', + quantity: 5, + extractedImageBlob: mockBlob, + imageProcessing: mockImageProcessing, + }); + }); + + // Submit item + const createdItem = await act(async () => { + return result.current.submitItem(1); + }); + + // Verify item was created (without image fields) + const createCall = (inventoryApi.createItem as any).mock.calls[0]; + expect(createCall[0]).toBe(1); + expect(createCall[1]).not.toHaveProperty('extractedImageBlob'); + expect(createCall[1]).not.toHaveProperty('imageProcessing'); + expect(createCall[1].name).toBe('Test Item'); + expect(createCall[1].category).toBe('Electronics'); + expect(createCall[1].item_type).toBe('Widget'); + expect(createCall[1].quantity).toBe(5); + + // Verify photo was uploaded with correct parameters + expect(inventoryApi.uploadItemPhoto).toHaveBeenCalled(); + const uploadCall = (inventoryApi.uploadItemPhoto as any).mock.calls[0]; + expect(uploadCall[0]).toBe(123); // itemId + expect(uploadCall[1]).toBeInstanceOf(FormData); // formData + + // Check FormData contents (FormData.get returns File, not Blob) + const formDataUpload = uploadCall[1]; + const uploadedFile = formDataUpload.get('file'); + expect(uploadedFile).toBeInstanceOf(Blob); + expect(uploadedFile?.type).toBe('image/jpeg'); + expect(formDataUpload.get('crop_bounds')).toBe( + JSON.stringify(mockImageProcessing.crop_bounds) + ); + + // Verify item was returned + expect(createdItem).toEqual(mockCreatedItem); + }); + + it('should skip photo upload if image_processing missing', async () => { + const { result } = renderHook(() => useItemCreate()); + + const mockBlob = new Blob(['test'], { type: 'image/jpeg' }); + const mockCreatedItem = { id: 123, name: 'Test Item' }; + + (inventoryApi.createItem as any).mockResolvedValue(mockCreatedItem); + + // Set form data with ONLY image blob (no imageProcessing) + act(() => { + result.current.setFormData({ + name: 'Test Item', + category: 'Electronics', + item_type: 'Widget', + quantity: 5, + extractedImageBlob: mockBlob, + // imageProcessing NOT provided + }); + }); + + // Submit item + const createdItem = await act(async () => { + return result.current.submitItem(1); + }); + + // Verify photo upload was NOT called + expect(inventoryApi.uploadItemPhoto).not.toHaveBeenCalled(); + + // Verify item was created + expect(createdItem).toEqual(mockCreatedItem); + }); + + it('should skip photo upload if extractedImageBlob missing', async () => { + const { result } = renderHook(() => useItemCreate()); + + const mockImageProcessing = { + crop_bounds: { x: 10, y: 20, width: 100, height: 100 }, + rotation_degrees: 0, + confidence: 0.95, + }; + + const mockCreatedItem = { id: 123, name: 'Test Item' }; + + (inventoryApi.createItem as any).mockResolvedValue(mockCreatedItem); + + // Set form data with ONLY imageProcessing (no blob) + act(() => { + result.current.setFormData({ + name: 'Test Item', + category: 'Electronics', + item_type: 'Widget', + quantity: 5, + // extractedImageBlob NOT provided + imageProcessing: mockImageProcessing, + }); + }); + + // Submit item + const createdItem = await act(async () => { + return result.current.submitItem(1); + }); + + // Verify photo upload was NOT called + expect(inventoryApi.uploadItemPhoto).not.toHaveBeenCalled(); + + // Verify item was created + expect(createdItem).toEqual(mockCreatedItem); + }); + + it('should handle photo upload failure gracefully (item already created)', async () => { + const { result } = renderHook(() => useItemCreate()); + + const mockBlob = new Blob(['test'], { type: 'image/jpeg' }); + const mockImageProcessing = { + crop_bounds: { x: 10, y: 20, width: 100, height: 100 }, + rotation_degrees: 0, + confidence: 0.95, + }; + + const mockCreatedItem = { id: 123, name: 'Test Item' }; + + (inventoryApi.createItem as any).mockResolvedValue(mockCreatedItem); + (inventoryApi.uploadItemPhoto as any).mockRejectedValue( + new Error('Network error') + ); + + // Set form data with image data + act(() => { + result.current.setFormData({ + name: 'Test Item', + category: 'Electronics', + item_type: 'Widget', + quantity: 5, + extractedImageBlob: mockBlob, + imageProcessing: mockImageProcessing, + }); + }); + + // Submit item + await act(async () => { + return result.current.submitItem(1); + }); + + // Verify item was created despite photo upload failure + expect(inventoryApi.createItem).toHaveBeenCalled(); + expect(inventoryApi.uploadItemPhoto).toHaveBeenCalled(); + + // Photo upload error doesn't prevent item creation + // The key behavior is that both API calls happen: + // 1. createItem succeeds + // 2. uploadItemPhoto fails gracefully (doesn't throw) + }); + + it('should not auto-upload if neither blob nor imageProcessing provided', async () => { + const { result } = renderHook(() => useItemCreate()); + + const mockCreatedItem = { id: 123, name: 'Test Item' }; + + (inventoryApi.createItem as any).mockResolvedValue(mockCreatedItem); + + // Set form data WITHOUT image data + act(() => { + result.current.setFormData({ + name: 'Test Item', + category: 'Electronics', + item_type: 'Widget', + quantity: 5, + }); + }); + + // Submit item + const createdItem = await act(async () => { + return result.current.submitItem(1); + }); + + // Verify photo upload was NOT called + expect(inventoryApi.uploadItemPhoto).not.toHaveBeenCalled(); + + // Verify item was created + expect(createdItem).toEqual(mockCreatedItem); + }); + + it('should extract image fields from formData and exclude from item creation', async () => { + const { result } = renderHook(() => useItemCreate()); + + const mockBlob = new Blob(['test'], { type: 'image/jpeg' }); + const mockImageProcessing = { + crop_bounds: { x: 10, y: 20, width: 100, height: 100 }, + rotation_degrees: 0, + confidence: 0.95, + }; + + const mockCreatedItem = { id: 123, name: 'Test Item' }; + + (inventoryApi.createItem as any).mockResolvedValue(mockCreatedItem); + (inventoryApi.uploadItemPhoto as any).mockResolvedValue({ + status: 'ok', + }); + + // Set form data with image data + act(() => { + result.current.setFormData({ + name: 'Test Item', + category: 'Electronics', + item_type: 'Widget', + quantity: 5, + barcode: '123456', + extractedImageBlob: mockBlob, + imageProcessing: mockImageProcessing, + }); + }); + + // Submit item + await act(async () => { + return result.current.submitItem(1); + }); + + // Verify createItem was called WITHOUT image fields + const createCall = (inventoryApi.createItem as any).mock.calls[0]; + expect(createCall[0]).toBe(1); + expect(createCall[1]).not.toHaveProperty('extractedImageBlob'); + expect(createCall[1]).not.toHaveProperty('imageProcessing'); + expect(createCall[1].name).toBe('Test Item'); + expect(createCall[1].category).toBe('Electronics'); + expect(createCall[1].item_type).toBe('Widget'); + expect(createCall[1].quantity).toBe(5); + expect(createCall[1].barcode).toBe('123456'); + }); + + it('should pass crop_bounds to uploadItemPhoto if present', async () => { + const { result } = renderHook(() => useItemCreate()); + + const mockBlob = new Blob(['test'], { type: 'image/jpeg' }); + const mockImageProcessing = { + crop_bounds: { x: 50, y: 100, width: 200, height: 200 }, + rotation_degrees: 90, + confidence: 0.87, + }; + + const mockCreatedItem = { id: 456, name: 'Test Item' }; + + (inventoryApi.createItem as any).mockResolvedValue(mockCreatedItem); + (inventoryApi.uploadItemPhoto as any).mockResolvedValue({ + status: 'ok', + }); + + // Set form data with image data + act(() => { + result.current.setFormData({ + name: 'Test Item', + category: 'Electronics', + item_type: 'Widget', + quantity: 5, + extractedImageBlob: mockBlob, + imageProcessing: mockImageProcessing, + }); + }); + + // Submit item + await act(async () => { + return result.current.submitItem(1); + }); + + // Verify crop_bounds were passed + const uploadCall = (inventoryApi.uploadItemPhoto as any).mock.calls[0]; + const formDataUpload = uploadCall[1]; + expect(formDataUpload.get('crop_bounds')).toBe( + JSON.stringify(mockImageProcessing.crop_bounds) + ); + }); + + it('should skip crop_bounds if not in imageProcessing', async () => { + const { result } = renderHook(() => useItemCreate()); + + const mockBlob = new Blob(['test'], { type: 'image/jpeg' }); + const mockImageProcessing = { + rotation_degrees: 0, + confidence: 0.95, + // crop_bounds NOT provided + }; + + const mockCreatedItem = { id: 789, name: 'Test Item' }; + + (inventoryApi.createItem as any).mockResolvedValue(mockCreatedItem); + (inventoryApi.uploadItemPhoto as any).mockResolvedValue({ + status: 'ok', + }); + + // Set form data with image data + act(() => { + result.current.setFormData({ + name: 'Test Item', + category: 'Electronics', + item_type: 'Widget', + quantity: 5, + extractedImageBlob: mockBlob, + imageProcessing: mockImageProcessing, + }); + }); + + // Submit item + await act(async () => { + return result.current.submitItem(1); + }); + + // Verify crop_bounds were NOT passed + const uploadCall = (inventoryApi.uploadItemPhoto as any).mock.calls[0]; + const formDataUpload = uploadCall[1]; + expect(formDataUpload.get('crop_bounds')).toBeNull(); + }); + }); + + describe('existing submitItem functionality (non-photo flow)', () => { + it('should validate required fields on item creation', async () => { + const { result } = renderHook(() => useItemCreate()); + + // Set form data with missing category + act(() => { + result.current.setFormData({ + name: 'Test Item', + category: '', + item_type: 'Widget', + quantity: 5, + }); + }); + + // Submit item + const createdItem = await act(async () => { + return result.current.submitItem(1); + }); + + // Verify validation failed + expect(inventoryApi.createItem).not.toHaveBeenCalled(); + expect(result.current.error).toBe('Category is required'); + expect(createdItem).toBeUndefined(); + }); + + it('should handle item creation API errors', async () => { + const { result } = renderHook(() => useItemCreate()); + + (inventoryApi.createItem as any).mockRejectedValue( + new Error('Server error') + ); + + // Set form data + act(() => { + result.current.setFormData({ + name: 'Test Item', + category: 'Electronics', + item_type: 'Widget', + quantity: 5, + }); + }); + + // Submit item + const createdItem = await act(async () => { + return result.current.submitItem(1); + }); + + // Verify error was set + expect(result.current.error).toBe('Server error'); + expect(createdItem).toBeUndefined(); + }); + + it('should set itemId when item creation succeeds', async () => { + const { result } = renderHook(() => useItemCreate()); + + const mockCreatedItem = { id: 999, name: 'Test Item' }; + + (inventoryApi.createItem as any).mockResolvedValue(mockCreatedItem); + + // Set form data + act(() => { + result.current.setFormData({ + name: 'Test Item', + category: 'Electronics', + item_type: 'Widget', + quantity: 5, + }); + }); + + // Submit item + const createdItem = await act(async () => { + return result.current.submitItem(1); + }); + + // Verify itemId is now set (used for photo uploads) + expect(createdItem).toEqual(mockCreatedItem); + }); + }); +});