import { render, screen, fireEvent, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { getCached, isLoading, fetchPhoto, onThumbReady } from '../../services/photoService';
// Mock photoService — PlaceAvatarUpload wraps PlaceAvatar, which pulls thumbnails.
vi.mock('../../services/photoService', () => ({
getCached: vi.fn(() => null),
isLoading: vi.fn(() => false),
fetchPhoto: vi.fn(),
onThumbReady: vi.fn(() => () => {}),
}));
// IntersectionObserver stub (PlaceAvatar observes visibility to lazy-load photos)
class MockIntersectionObserver {
observe = vi.fn();
disconnect = vi.fn();
unobserve = vi.fn();
}
beforeAll(() => {
(globalThis as any).IntersectionObserver = MockIntersectionObserver;
});
beforeEach(() => {
vi.mocked(getCached).mockReturnValue(null);
vi.mocked(isLoading).mockReturnValue(false);
vi.mocked(fetchPhoto).mockReset();
vi.mocked(onThumbReady).mockReturnValue(() => {});
});
import PlaceAvatarUpload from './PlaceAvatarUpload';
const placeNoImage = {
id: 1,
name: 'Eiffel Tower',
image_url: null,
google_place_id: null,
osm_id: null,
lat: 48.8584,
lng: 2.2945,
};
const placeWithImage = { ...placeNoImage, image_url: 'https://example.com/eiffel.jpg' };
describe('PlaceAvatarUpload', () => {
it('FE-COMP-PLACEAVUPLOAD-001: renders the underlying avatar image when image_url is set', () => {
render();
const img = screen.getByRole('img') as HTMLImageElement;
expect(img.src).toContain('eiffel.jpg');
});
it('FE-COMP-PLACEAVUPLOAD-002: clicking the avatar opens the hidden file picker', async () => {
const user = userEvent.setup();
const { container } = render();
const input = container.querySelector('input[type="file"]') as HTMLInputElement;
const clickSpy = vi.spyOn(input, 'click');
await user.click(screen.getByRole('button', { name: 'Upload image' }));
expect(clickSpy).toHaveBeenCalled();
});
it('FE-COMP-PLACEAVUPLOAD-003: choosing a file calls onUpload with the picked file', async () => {
const onUpload = vi.fn().mockResolvedValue(undefined);
const { container } = render();
const input = container.querySelector('input[type="file"]') as HTMLInputElement;
const file = new File(['x'], 'photo.jpg', { type: 'image/jpeg' });
fireEvent.change(input, { target: { files: [file] } });
await waitFor(() => expect(onUpload).toHaveBeenCalledWith(file));
});
it('FE-COMP-PLACEAVUPLOAD-004: the remove button only appears when a custom image is set', () => {
const { rerender } = render();
expect(screen.queryByRole('button', { name: 'Remove image' })).toBeNull();
rerender();
expect(screen.getByRole('button', { name: 'Remove image' })).toBeTruthy();
});
it('FE-COMP-PLACEAVUPLOAD-005: clicking remove calls onRemove', async () => {
const user = userEvent.setup();
const onRemove = vi.fn().mockResolvedValue(undefined);
render();
await user.click(screen.getByRole('button', { name: 'Remove image' }));
expect(onRemove).toHaveBeenCalled();
});
it('FE-COMP-PLACEAVUPLOAD-006: the avatar aria-label reflects upload vs change from the places.* keys', () => {
const { rerender } = render();
expect(screen.getByRole('button', { name: 'Upload image' })).toBeTruthy();
rerender();
expect(screen.getByRole('button', { name: 'Change image' })).toBeTruthy();
});
});