feat(phase2): add admin photo replacement button with ItemDetailModal
- Add ItemDetailModal component for viewing item details and replacing photos
- Add photo replacement/deletion endpoints to API layer (PUT/DELETE /items/{id}/photo)
- Update InventoryTable to open detail modal on item click
- Show current photo thumbnail with Replace/Delete buttons
- Support uploading new photo with ItemPhotoUpload component
- Delete old photo on backend when replacing (no orphaned files)
- Full test coverage: 18 tests for ItemDetailModal component
- All 393 tests passing, zero TypeScript errors
- Build verified successfully
This commit is contained in:
381
frontend/tests/components/ItemDetailModal.test.tsx
Normal file
381
frontend/tests/components/ItemDetailModal.test.tsx
Normal file
@@ -0,0 +1,381 @@
|
||||
import { render, screen, fireEvent, waitFor, within } from '@testing-library/react';
|
||||
import { vi } from 'vitest';
|
||||
import ItemDetailModal from '@/components/ItemDetailModal';
|
||||
import { Item } from '@/lib/db';
|
||||
import * as api from '@/lib/api';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
vi.mock('react-hot-toast');
|
||||
vi.mock('@/lib/api');
|
||||
vi.mock('@/components/ItemPhotoUpload', () => ({
|
||||
default: ({ itemId, onUploadSuccess, onError }: any) => (
|
||||
<div data-testid="item-photo-upload">
|
||||
<button
|
||||
data-testid="mock-upload-success"
|
||||
onClick={() =>
|
||||
onUploadSuccess({
|
||||
thumbnail_url: 'http://test.com/thumb.jpg',
|
||||
full_url: 'http://test.com/full.jpg',
|
||||
uploaded_at: '2026-04-21T10:00:00Z',
|
||||
})
|
||||
}
|
||||
>
|
||||
Upload Success
|
||||
</button>
|
||||
<button
|
||||
data-testid="mock-upload-error"
|
||||
onClick={() => onError('Upload failed')}
|
||||
>
|
||||
Upload Error
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe('ItemDetailModal', () => {
|
||||
const mockItem: Item = {
|
||||
id: 1,
|
||||
name: 'Test Component',
|
||||
category: 'Electronics',
|
||||
quantity: 10,
|
||||
part_number: 'PN-001',
|
||||
barcode: 'BAR-001',
|
||||
type: 'IC',
|
||||
specs: 'Test specs',
|
||||
min_quantity: 5,
|
||||
image_url: 'http://test.com/photo.jpg',
|
||||
};
|
||||
|
||||
const mockItemNoPhoto: Item = {
|
||||
id: 2,
|
||||
name: 'No Photo Item',
|
||||
category: 'Electronics',
|
||||
quantity: 5,
|
||||
part_number: 'PN-002',
|
||||
barcode: 'BAR-002',
|
||||
type: 'Resistor',
|
||||
specs: 'No photo specs',
|
||||
min_quantity: 2,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render modal with item details', () => {
|
||||
render(
|
||||
<ItemDetailModal
|
||||
item={mockItem}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('Test Component')).toBeInTheDocument();
|
||||
expect(screen.getByText('Electronics')).toBeInTheDocument();
|
||||
expect(screen.getByText('IC')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display item photo when image_url exists', () => {
|
||||
render(
|
||||
<ItemDetailModal
|
||||
item={mockItem}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
const photoImg = screen.getByAltText('Test Component') as HTMLImageElement;
|
||||
expect(photoImg).toBeInTheDocument();
|
||||
expect(photoImg.src).toContain('photo.jpg');
|
||||
});
|
||||
|
||||
it('should show "No photo uploaded" when image_url is missing', () => {
|
||||
render(
|
||||
<ItemDetailModal
|
||||
item={mockItemNoPhoto}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('No photo uploaded')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render item specifications in detail grid', () => {
|
||||
render(
|
||||
<ItemDetailModal
|
||||
item={mockItem}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('PN-001')).toBeInTheDocument();
|
||||
expect(screen.getByText('BAR-001')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Photo Replacement Button', () => {
|
||||
it('should show "Replace Photo" button when photo exists', () => {
|
||||
render(
|
||||
<ItemDetailModal
|
||||
item={mockItem}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('Replace Photo')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show "Upload Photo" button when no photo exists', () => {
|
||||
render(
|
||||
<ItemDetailModal
|
||||
item={mockItemNoPhoto}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('Upload Photo')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should toggle photo upload UI on "Replace Photo" click', () => {
|
||||
render(
|
||||
<ItemDetailModal
|
||||
item={mockItem}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
const replaceButton = screen.getByText('Replace Photo');
|
||||
fireEvent.click(replaceButton);
|
||||
expect(screen.getByTestId('item-photo-upload')).toBeInTheDocument();
|
||||
expect(screen.getByText('Upload New Photo')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should hide photo upload UI on cancel', () => {
|
||||
render(
|
||||
<ItemDetailModal
|
||||
item={mockItem}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByText('Replace Photo'));
|
||||
expect(screen.getByTestId('item-photo-upload')).toBeInTheDocument();
|
||||
|
||||
const closeButton = screen.getByLabelText('Cancel upload');
|
||||
fireEvent.click(closeButton);
|
||||
expect(screen.queryByTestId('item-photo-upload')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Photo Upload Success', () => {
|
||||
it('should update photo and close upload UI on success', async () => {
|
||||
const onPhotoUpdated = vi.fn();
|
||||
render(
|
||||
<ItemDetailModal
|
||||
item={mockItem}
|
||||
onClose={vi.fn()}
|
||||
onPhotoUpdated={onPhotoUpdated}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('Replace Photo'));
|
||||
fireEvent.click(screen.getByTestId('mock-upload-success'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onPhotoUpdated).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
thumbnail_url: 'http://test.com/thumb.jpg',
|
||||
})
|
||||
);
|
||||
}, { timeout: 1000 });
|
||||
|
||||
expect(screen.queryByTestId('item-photo-upload')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call onItemRefresh callback on upload success', async () => {
|
||||
const onItemRefresh = vi.fn();
|
||||
render(
|
||||
<ItemDetailModal
|
||||
item={mockItem}
|
||||
onClose={vi.fn()}
|
||||
onItemRefresh={onItemRefresh}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('Replace Photo'));
|
||||
fireEvent.click(screen.getByTestId('mock-upload-success'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onItemRefresh).toHaveBeenCalled();
|
||||
}, { timeout: 1000 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Photo Upload Error', () => {
|
||||
it('should keep upload UI open on error', async () => {
|
||||
render(
|
||||
<ItemDetailModal
|
||||
item={mockItem}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('Replace Photo'));
|
||||
fireEvent.click(screen.getByTestId('mock-upload-error'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('item-photo-upload')).toBeInTheDocument();
|
||||
}, { timeout: 1000 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Photo Deletion', () => {
|
||||
it('should show delete button when photo exists', () => {
|
||||
render(
|
||||
<ItemDetailModal
|
||||
item={mockItem}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const hasDeleteButton = buttons.some(b => b.className?.includes('rose-500'));
|
||||
expect(hasDeleteButton).toBe(true);
|
||||
});
|
||||
|
||||
it('should call deleteItemPhoto API on delete confirmation', async () => {
|
||||
vi.mocked(api.inventoryApi.deleteItemPhoto).mockResolvedValue({ status: 'ok' });
|
||||
window.confirm = vi.fn(() => true);
|
||||
|
||||
render(
|
||||
<ItemDetailModal
|
||||
item={mockItem}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const deleteButton = buttons.find(b => b.className?.includes('rose-500'));
|
||||
|
||||
if (deleteButton) {
|
||||
fireEvent.click(deleteButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.inventoryApi.deleteItemPhoto).toHaveBeenCalledWith(mockItem.id);
|
||||
}, { timeout: 1000 });
|
||||
}
|
||||
});
|
||||
|
||||
it('should show success toast on delete', async () => {
|
||||
vi.mocked(api.inventoryApi.deleteItemPhoto).mockResolvedValue({ status: 'ok' });
|
||||
window.confirm = vi.fn(() => true);
|
||||
|
||||
render(
|
||||
<ItemDetailModal
|
||||
item={mockItem}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const deleteButton = buttons.find(b => b.className?.includes('rose-500'));
|
||||
|
||||
if (deleteButton) {
|
||||
fireEvent.click(deleteButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.success).toHaveBeenCalledWith('Photo deleted successfully');
|
||||
}, { timeout: 1000 });
|
||||
}
|
||||
});
|
||||
|
||||
it('should not delete without confirmation', async () => {
|
||||
window.confirm = vi.fn(() => false);
|
||||
|
||||
render(
|
||||
<ItemDetailModal
|
||||
item={mockItem}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const deleteButton = buttons.find(b => b.className?.includes('rose-500'));
|
||||
|
||||
if (deleteButton) {
|
||||
fireEvent.click(deleteButton);
|
||||
expect(api.inventoryApi.deleteItemPhoto).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('should call onItemRefresh on delete success', async () => {
|
||||
vi.mocked(api.inventoryApi.deleteItemPhoto).mockResolvedValue({ status: 'ok' });
|
||||
window.confirm = vi.fn(() => true);
|
||||
const onItemRefresh = vi.fn();
|
||||
|
||||
render(
|
||||
<ItemDetailModal
|
||||
item={mockItem}
|
||||
onClose={vi.fn()}
|
||||
onItemRefresh={onItemRefresh}
|
||||
/>
|
||||
);
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const deleteButton = buttons.find(b => b.className?.includes('rose-500'));
|
||||
|
||||
if (deleteButton) {
|
||||
fireEvent.click(deleteButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onItemRefresh).toHaveBeenCalled();
|
||||
}, { timeout: 1000 });
|
||||
}
|
||||
});
|
||||
|
||||
it('should show error toast on delete failure', async () => {
|
||||
vi.mocked(api.inventoryApi.deleteItemPhoto).mockRejectedValue(
|
||||
new Error('Delete failed')
|
||||
);
|
||||
window.confirm = vi.fn(() => true);
|
||||
|
||||
render(
|
||||
<ItemDetailModal
|
||||
item={mockItem}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const deleteButton = buttons.find(b => b.className?.includes('rose-500'));
|
||||
|
||||
if (deleteButton) {
|
||||
fireEvent.click(deleteButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith('Delete failed');
|
||||
}, { timeout: 1000 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Modal Controls', () => {
|
||||
it('should call onClose when close button is clicked', () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<ItemDetailModal
|
||||
item={mockItem}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
|
||||
const closeButton = screen.getByLabelText('Close modal');
|
||||
fireEvent.click(closeButton);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should be scrollable when content exceeds viewport', () => {
|
||||
render(
|
||||
<ItemDetailModal
|
||||
item={mockItem}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const modalContent = screen.getByText('Test Component').closest('div');
|
||||
expect(modalContent?.parentElement?.className).toContain('max-h-[90vh]');
|
||||
expect(modalContent?.parentElement?.className).toContain('overflow-y-auto');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user