feat: auto-upload photo after item creation if image_processing provided
This commit is contained in:
@@ -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 *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
433
frontend/tests/hooks/useItemCreate.test.ts
Normal file
433
frontend/tests/hooks/useItemCreate.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user