Files
fotospiel-app/resources/js/guest-v2/__tests__/GalleryScreen.test.tsx
Codex Agent ddbfa38db1
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
Improve guest photo downloads with preview/original variants
2026-02-07 14:31:48 +01:00

239 lines
7.8 KiB
TypeScript

import React from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
const setSearchParamsMock = vi.fn();
const pushGuestToastMock = vi.fn();
const mockEventData = {
token: 'demo',
event: { name: 'Demo Event', capabilities: { ai_styling: false } },
};
vi.mock('react-router-dom', () => ({
useNavigate: () => vi.fn(),
useSearchParams: () => [new URLSearchParams('photo=123'), setSearchParamsMock],
}));
vi.mock('../context/EventDataContext', () => ({
useEventData: () => mockEventData,
}));
vi.mock('../hooks/usePollGalleryDelta', () => ({
usePollGalleryDelta: () => ({ data: { photos: [] } }),
}));
vi.mock('../hooks/usePollStats', () => ({
usePollStats: () => ({ stats: { onlineGuests: 0, guestCount: 0, likesCount: 0 } }),
}));
vi.mock('@/shared/guest/i18n/useTranslation', () => ({
useTranslation: () => ({
t: (key: string, options?: unknown, fallback?: string) => {
if (typeof fallback === 'string') return fallback;
if (typeof options === 'string') return options;
return key;
},
}),
}));
vi.mock('@/shared/guest/i18n/LocaleContext', () => ({
useLocale: () => ({ locale: 'de' }),
}));
vi.mock('../lib/guestTheme', () => ({
useGuestThemeVariant: () => ({ isDark: false }),
}));
vi.mock('../lib/bento', () => ({
getBentoSurfaceTokens: () => ({
backgroundColor: '#fff',
borderColor: '#eee',
borderBottomColor: '#ddd',
shadow: 'none',
}),
}));
const fetchGalleryMock = vi.fn().mockResolvedValue({ data: [] });
const fetchPhotoMock = vi.fn().mockRejectedValue(Object.assign(new Error('not found'), { status: 404 }));
vi.mock('../services/photosApi', () => ({
fetchGallery: (...args: unknown[]) => fetchGalleryMock(...args),
fetchPhoto: (...args: unknown[]) => fetchPhotoMock(...args),
likePhoto: vi.fn(),
unlikePhoto: vi.fn(),
createPhotoShareLink: vi.fn(),
deletePhoto: vi.fn(),
}));
vi.mock('../components/AppShell', () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('../components/PhotoFrameTile', () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('../components/ShareSheet', () => ({
default: () => null,
}));
vi.mock('../components/AiMagicEditSheet', () => ({
default: () => null,
}));
vi.mock('../lib/toast', () => ({
pushGuestToast: (...args: unknown[]) => pushGuestToastMock(...args),
}));
vi.mock('@tamagui/stacks', () => ({
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/text', () => ({
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
vi.mock('@tamagui/button', () => ({
Button: ({ children, onPress, ...rest }: { children: React.ReactNode; onPress?: () => void }) => (
<button type="button" onClick={onPress} {...rest}>
{children}
</button>
),
}));
vi.mock('lucide-react', () => ({
Camera: () => <span>camera</span>,
Sparkles: () => <span>sparkles</span>,
Heart: () => <span>heart</span>,
Share2: () => <span>share</span>,
ChevronLeft: () => <span>left</span>,
ChevronRight: () => <span>right</span>,
Loader2: () => <span>loader</span>,
Download: () => <span>download</span>,
X: () => <span>x</span>,
Trash2: () => <span>trash</span>,
}));
import GalleryScreen from '../screens/GalleryScreen';
describe('GalleryScreen', () => {
beforeEach(() => {
setSearchParamsMock.mockClear();
pushGuestToastMock.mockClear();
fetchGalleryMock.mockReset();
fetchPhotoMock.mockReset();
mockEventData.token = 'demo';
mockEventData.event = { name: 'Demo Event', capabilities: { ai_styling: false } };
});
afterEach(() => {
vi.useRealTimers();
});
it('clears the photo param and shows a warning when lightbox fails to load', async () => {
vi.useFakeTimers();
fetchGalleryMock.mockResolvedValue({ data: [] });
fetchPhotoMock.mockRejectedValue(Object.assign(new Error('not found'), { status: 404 }));
render(<GalleryScreen />);
for (let i = 0; i < 7; i += 1) {
await vi.advanceTimersByTimeAsync(1500);
await Promise.resolve();
}
await vi.advanceTimersByTimeAsync(1000);
await vi.runAllTimersAsync();
await Promise.resolve();
expect(pushGuestToastMock).toHaveBeenCalled();
expect(setSearchParamsMock).toHaveBeenCalled();
const [params] = setSearchParamsMock.mock.calls.at(-1) ?? [];
const search = params instanceof URLSearchParams ? params : new URLSearchParams(params);
expect(search.get('photo')).toBeNull();
});
it('keeps lightbox open when a seeded photo exists but fetch fails', async () => {
vi.useFakeTimers();
fetchGalleryMock.mockResolvedValue({
data: [{ id: 123, thumbnail_url: '/storage/demo.jpg', likes_count: 2 }],
});
fetchPhotoMock.mockRejectedValue(Object.assign(new Error('not found'), { status: 404 }));
render(<GalleryScreen />);
await Promise.resolve();
await vi.runAllTimersAsync();
expect(fetchGalleryMock).toHaveBeenCalled();
expect(fetchPhotoMock).toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1000);
expect(setSearchParamsMock).not.toHaveBeenCalled();
expect(pushGuestToastMock).not.toHaveBeenCalled();
});
it('does not show ai magic edit action when ai styling is not entitled', async () => {
fetchGalleryMock.mockResolvedValue({
data: [{ id: 123, thumbnail_url: '/storage/demo.jpg', likes_count: 2 }],
});
fetchPhotoMock.mockRejectedValue(Object.assign(new Error('not found'), { status: 404 }));
render(<GalleryScreen />);
await waitFor(() => expect(fetchGalleryMock).toHaveBeenCalled());
await waitFor(() =>
expect(screen.queryByLabelText('AI Magic Edit')).not.toBeInTheDocument()
);
});
it('keeps ai magic edit action hidden while rollout flag is disabled', async () => {
mockEventData.event = { name: 'Demo Event', capabilities: { ai_styling: true } };
fetchGalleryMock.mockResolvedValue({
data: [{ id: 123, thumbnail_url: '/storage/demo.jpg', likes_count: 2 }],
});
fetchPhotoMock.mockRejectedValue(Object.assign(new Error('not found'), { status: 404 }));
render(<GalleryScreen />);
await waitFor(() => expect(fetchGalleryMock).toHaveBeenCalled());
await waitFor(() =>
expect(screen.queryByLabelText('AI Magic Edit')).not.toBeInTheDocument()
);
});
it('uses download_url when downloading from lightbox', async () => {
fetchGalleryMock.mockResolvedValue({
data: [{
id: 123,
thumbnail_url: '/storage/demo-thumb.jpg',
download_url: '/api/v1/gallery/demo/photos/123/download?signature=abc',
likes_count: 2,
}],
});
fetchPhotoMock.mockRejectedValue(Object.assign(new Error('not found'), { status: 404 }));
const originalCreateElement = document.createElement.bind(document);
const clickSpy = vi.fn();
let createdLink: HTMLAnchorElement | null = null;
const createElementSpy = vi.spyOn(document, 'createElement').mockImplementation((tagName: string) => {
const element = originalCreateElement(tagName) as HTMLElement;
if (tagName.toLowerCase() === 'a') {
createdLink = element as HTMLAnchorElement;
(createdLink as any).click = clickSpy;
}
return element;
});
render(<GalleryScreen />);
await waitFor(() => expect(fetchGalleryMock).toHaveBeenCalled());
const downloadButton = await screen.findByRole('button', { name: /download/i });
fireEvent.click(downloadButton);
expect(clickSpy).toHaveBeenCalled();
expect(createdLink?.getAttribute('href')).toBe('/api/v1/gallery/demo/photos/123/download?signature=abc');
createElementSpy.mockRestore();
});
});