feat: auto-upload photo after item creation if image_processing provided

This commit is contained in:
2026-04-21 19:16:42 +03:00
parent 68f52ccb03
commit fab1e81cf6
3 changed files with 473 additions and 2 deletions

View File

@@ -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 *)"
]
}
}

View File

@@ -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;

View 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);
});
});
});