Files
tfm_ainventory/frontend/tests/components/ItemDetailModal.test.tsx
Daniel Bedeleanu a8d7e5ac09 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
2026-04-21 13:30:38 +03:00

382 lines
10 KiB
TypeScript

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