feat(phase2): integrate photo upload into item creation
- Create frontend/app/items/create.tsx with multi-step item creation workflow (Details → Photo Upload → Preview → Confirm) - Create frontend/hooks/useItemCreate.ts custom hook managing form state, step navigation, and photo upload - Add integration tests for item creation workflow with photo upload support - Photo upload step supports manual crop UI with crop bounds submission - ManualCropUI visible by default with toggle to use full photo - Photo uploaded before item confirmation, ensuring photo is attached - Works with mobile camera capture via ItemPhotoUpload component - All 374 tests passing
This commit is contained in:
277
frontend/tests/integration/item-creation.test.tsx
Normal file
277
frontend/tests/integration/item-creation.test.tsx
Normal file
@@ -0,0 +1,277 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import { useItemCreate } from '@/hooks/useItemCreate';
|
||||
import * as api from '@/lib/api';
|
||||
|
||||
// Mock api module
|
||||
vi.mock('@/lib/api', () => ({
|
||||
inventoryApi: {
|
||||
createItem: vi.fn(),
|
||||
uploadItemPhoto: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('useItemCreate Hook - Item Creation Flow Integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should initialize with details step and empty form', () => {
|
||||
const { result } = renderHook(() => useItemCreate());
|
||||
|
||||
expect(result.current.step).toBe('details');
|
||||
expect(result.current.formData.name).toBe('');
|
||||
expect(result.current.formData.category).toBe('');
|
||||
expect(result.current.formData.item_type).toBe('');
|
||||
expect(result.current.formData.quantity).toBe(1);
|
||||
expect(result.current.uploadedPhoto).toBeNull();
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it('should update form data', () => {
|
||||
const { result } = renderHook(() => useItemCreate());
|
||||
|
||||
act(() => {
|
||||
result.current.setFormData({ name: 'Test Item' });
|
||||
result.current.setFormData({ category: 'Electronics' });
|
||||
result.current.setFormData({ item_type: 'Component' });
|
||||
});
|
||||
|
||||
expect(result.current.formData.name).toBe('Test Item');
|
||||
expect(result.current.formData.category).toBe('Electronics');
|
||||
expect(result.current.formData.item_type).toBe('Component');
|
||||
});
|
||||
|
||||
it('should navigate between steps', async () => {
|
||||
const { result } = renderHook(() => useItemCreate());
|
||||
|
||||
expect(result.current.step).toBe('details');
|
||||
|
||||
act(() => {
|
||||
result.current.goToStep('photo');
|
||||
});
|
||||
|
||||
expect(result.current.step).toBe('photo');
|
||||
|
||||
act(() => {
|
||||
result.current.nextStep();
|
||||
});
|
||||
|
||||
expect(result.current.step).toBe('preview');
|
||||
|
||||
act(() => {
|
||||
result.current.prevStep();
|
||||
});
|
||||
|
||||
expect(result.current.step).toBe('photo');
|
||||
});
|
||||
|
||||
it('should create item and return response', async () => {
|
||||
const mockCreateItem = vi.mocked(api.inventoryApi.createItem);
|
||||
mockCreateItem.mockResolvedValueOnce({ id: 123, name: 'Test Item' });
|
||||
|
||||
const { result } = renderHook(() => useItemCreate());
|
||||
|
||||
act(() => {
|
||||
result.current.setFormData({ name: 'Test Item' });
|
||||
result.current.setFormData({ category: 'Electronics' });
|
||||
result.current.setFormData({ item_type: 'Component' });
|
||||
});
|
||||
|
||||
let createdItem;
|
||||
await act(async () => {
|
||||
createdItem = await result.current.submitItem(1);
|
||||
});
|
||||
|
||||
expect(createdItem?.id).toBe(123);
|
||||
expect(mockCreateItem).toHaveBeenCalledWith(1, expect.objectContaining({
|
||||
name: 'Test Item',
|
||||
category: 'Electronics',
|
||||
item_type: 'Component',
|
||||
}));
|
||||
});
|
||||
|
||||
it('should validate required fields on submission', async () => {
|
||||
const mockCreateItem = vi.mocked(api.inventoryApi.createItem);
|
||||
|
||||
const { result } = renderHook(() => useItemCreate());
|
||||
|
||||
// Try submitting without required fields (empty name)
|
||||
let submitResult;
|
||||
await act(async () => {
|
||||
submitResult = await result.current.submitItem(1);
|
||||
});
|
||||
|
||||
// Should return undefined and set error
|
||||
expect(submitResult).toBeUndefined();
|
||||
expect(result.current.error).toBe('Item name is required');
|
||||
expect(mockCreateItem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle crop bounds independently', () => {
|
||||
const { result } = renderHook(() => useItemCreate());
|
||||
|
||||
expect(result.current.cropBounds).toBeNull();
|
||||
|
||||
act(() => {
|
||||
result.current.setCropBounds({ x: 10, y: 10, width: 100, height: 100 });
|
||||
});
|
||||
|
||||
expect(result.current.cropBounds).toEqual({ x: 10, y: 10, width: 100, height: 100 });
|
||||
|
||||
act(() => {
|
||||
result.current.setCropBounds(null);
|
||||
});
|
||||
|
||||
expect(result.current.cropBounds).toBeNull();
|
||||
});
|
||||
|
||||
it('should reset all state', async () => {
|
||||
const mockCreateItem = vi.mocked(api.inventoryApi.createItem);
|
||||
mockCreateItem.mockResolvedValueOnce({ id: 123 });
|
||||
|
||||
const { result } = renderHook(() => useItemCreate());
|
||||
|
||||
// Populate form and navigate
|
||||
await act(async () => {
|
||||
result.current.setFormData({ name: 'Test Item' });
|
||||
result.current.setFormData({ category: 'Electronics' });
|
||||
result.current.setFormData({ item_type: 'Component' });
|
||||
result.current.goToStep('photo');
|
||||
await result.current.submitItem(1);
|
||||
});
|
||||
|
||||
// Verify populated state
|
||||
expect(result.current.formData.name).toBe('Test Item');
|
||||
expect(result.current.formData.category).toBe('Electronics');
|
||||
|
||||
// Reset
|
||||
act(() => {
|
||||
result.current.reset();
|
||||
});
|
||||
|
||||
expect(result.current.step).toBe('details');
|
||||
expect(result.current.formData.name).toBe('');
|
||||
expect(result.current.formData.category).toBe('');
|
||||
expect(result.current.uploadedPhoto).toBeNull();
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(result.current.photoError).toBeNull();
|
||||
});
|
||||
|
||||
it('should complete full workflow: details -> photo -> preview -> confirm', async () => {
|
||||
const mockCreateItem = vi.mocked(api.inventoryApi.createItem);
|
||||
const mockUploadPhoto = vi.mocked(api.inventoryApi.uploadItemPhoto);
|
||||
|
||||
mockCreateItem.mockResolvedValueOnce({ id: 456 });
|
||||
mockUploadPhoto.mockResolvedValueOnce({
|
||||
status: 'ok',
|
||||
photo: {
|
||||
thumbnail_url: 'http://example.com/thumb.jpg',
|
||||
full_url: 'http://example.com/full.jpg',
|
||||
uploaded_at: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useItemCreate());
|
||||
|
||||
// Step 1: Details
|
||||
expect(result.current.step).toBe('details');
|
||||
|
||||
act(() => {
|
||||
result.current.setFormData({
|
||||
name: 'Workflow Item',
|
||||
category: 'Electronics',
|
||||
item_type: 'Component',
|
||||
quantity: 5,
|
||||
});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.submitItem(1);
|
||||
});
|
||||
|
||||
// Step 2: Photo
|
||||
act(() => {
|
||||
result.current.goToStep('photo');
|
||||
});
|
||||
expect(result.current.step).toBe('photo');
|
||||
|
||||
// Step 3: Preview
|
||||
act(() => {
|
||||
result.current.goToStep('preview');
|
||||
result.current.setCropBounds({ x: 0, y: 0, width: 100, height: 100 });
|
||||
});
|
||||
expect(result.current.step).toBe('preview');
|
||||
expect(result.current.cropBounds).not.toBeNull();
|
||||
|
||||
// Step 4: Confirm
|
||||
act(() => {
|
||||
result.current.goToStep('confirm');
|
||||
});
|
||||
expect(result.current.step).toBe('confirm');
|
||||
|
||||
// Reset (simulating completion)
|
||||
act(() => {
|
||||
result.current.reset();
|
||||
});
|
||||
|
||||
expect(result.current.step).toBe('details');
|
||||
expect(result.current.formData.name).toBe('');
|
||||
});
|
||||
|
||||
it('should call API with correct item data structure', async () => {
|
||||
const mockCreateItem = vi.mocked(api.inventoryApi.createItem);
|
||||
mockCreateItem.mockResolvedValueOnce({ id: 999 });
|
||||
|
||||
const { result } = renderHook(() => useItemCreate());
|
||||
|
||||
act(() => {
|
||||
result.current.setFormData({
|
||||
name: 'Precise Item',
|
||||
category: 'Tools',
|
||||
item_type: 'Power Tool',
|
||||
quantity: 10,
|
||||
barcode: 'BAR123',
|
||||
part_number: 'PT-001',
|
||||
});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.submitItem(5);
|
||||
});
|
||||
|
||||
expect(mockCreateItem).toHaveBeenCalledWith(5, expect.objectContaining({
|
||||
name: 'Precise Item',
|
||||
category: 'Tools',
|
||||
item_type: 'Power Tool',
|
||||
quantity: 10,
|
||||
barcode: 'BAR123',
|
||||
part_number: 'PT-001',
|
||||
}));
|
||||
});
|
||||
|
||||
it('should handle API errors gracefully', async () => {
|
||||
const mockCreateItem = vi.mocked(api.inventoryApi.createItem);
|
||||
mockCreateItem.mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
const { result } = renderHook(() => useItemCreate());
|
||||
|
||||
act(() => {
|
||||
result.current.setFormData({ name: 'Test Item' });
|
||||
result.current.setFormData({ category: 'Electronics' });
|
||||
result.current.setFormData({ item_type: 'Component' });
|
||||
});
|
||||
|
||||
let submitResult;
|
||||
await act(async () => {
|
||||
submitResult = await result.current.submitItem(1);
|
||||
});
|
||||
|
||||
// Should return undefined on error
|
||||
expect(submitResult).toBeUndefined();
|
||||
// Error should be set (may need to wait for state update)
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user