diff --git a/frontend/src/components/admin/PhotoUpload.tsx b/frontend/src/components/admin/PhotoUpload.tsx index 8814ff45..d987503d 100644 --- a/frontend/src/components/admin/PhotoUpload.tsx +++ b/frontend/src/components/admin/PhotoUpload.tsx @@ -453,6 +453,17 @@ export const PhotoUpload: React.FC = ({ eventId, onUploadCompl t('upload.processingFailed', { count: processingAggregate.failed }) || `${processingAggregate.failed} photo(s) failed to process` ); + } else if (transferFailures.length > 0) { + // Processing was clean, but files were rejected or lost before they got + // there. A plain "Upload complete!" here would contradict the failure + // report right below it (QA P4-B.05 / 7.05) — report the real split. + toast.warning( + t('upload.partialComplete', '{{uploaded}} of {{total}} files uploaded — {{failed}} could not be uploaded.', { + uploaded: processingAggregate.complete, + total: processingAggregate.complete + transferFailures.length, + failed: transferFailures.length, + }) + ); } else { toast.success( t('upload.uploadComplete') || `Successfully uploaded ${processingAggregate.complete} photo(s)` diff --git a/frontend/src/components/admin/__tests__/PhotoUpload.outcomeToast.test.tsx b/frontend/src/components/admin/__tests__/PhotoUpload.outcomeToast.test.tsx new file mode 100644 index 00000000..7e2ed545 --- /dev/null +++ b/frontend/src/components/admin/__tests__/PhotoUpload.outcomeToast.test.tsx @@ -0,0 +1,189 @@ +/** + * The completion toast must describe what actually happened. + * + * QA P4-B.05 / 7.05: uploading past a photo cap (whole request 400s) and + * uploading a `.txt` renamed to `.jpg` (magic-byte rejection) both produced a + * generic "Upload completed successfully" toast *alongside* the rejection + * toast, with 0 of N files in the gallery. The success toast came from the + * host's `onUploadComplete` handler, which PhotoUpload fires purely as a + * "refresh the grid" signal — including on runs where nothing landed. + * + * These pin the outcome contract: + * - nothing landed -> no success toast (and the refresh still fires) + * - some landed -> an accurate partial message, not "complete!" + * - all landed -> success + * plus the host-side rule that the refresh callback never announces success. + */ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import fs from 'fs'; +import path from 'path'; +import type { ReactElement } from 'react'; + +import { PhotoUpload } from '../PhotoUpload'; + +// Interpolating t() — the partial message is only meaningful with its numbers +// substituted, so the mock has to do what i18next would. +vi.mock('react-i18next', async () => { + const actual = await vi.importActual('react-i18next'); + return { + ...actual, + useTranslation: () => ({ + t: (key: string, second?: any, third?: any) => { + const fallback = typeof second === 'string' ? second : undefined; + const vars = (typeof second === 'object' ? second : third) || {}; + let out = fallback ?? key; + for (const [k, v] of Object.entries(vars)) { + out = out.split(`{{${k}}}`).join(String(v)); + } + return out; + }, + }), + }; +}); + +const toastMock = vi.hoisted(() => ({ + warning: vi.fn(), info: vi.fn(), error: vi.fn(), success: vi.fn(), +})); +vi.mock('react-toastify', () => ({ toast: toastMock })); + +const postMock = vi.fn(); +vi.mock('../../../config/api', () => ({ api: { post: (...a: any[]) => postMock(...a), get: vi.fn() } })); + +const hoisted = vi.hoisted(() => ({ aggregate: null as any })); +const idle = { + total: 0, pending: 0, processing: 0, complete: 0, failed: 0, + failedPhotos: [] as { id: number; filename: string; error: string | null }[], + isComplete: false, isReady: true, +}; +vi.mock('../../../hooks/useUploadProgress', () => ({ + useUploadProgress: (ids: string[]) => ({ + snapshots: {}, + error: null, + aggregate: ids && ids.length > 0 ? hoisted.aggregate : idle, + }), +})); + +vi.mock('../../../services/categories.service', () => ({ + categoriesService: { getEventCategories: vi.fn().mockResolvedValue([]) }, +})); +vi.mock('../../../services/settings.service', () => ({ + settingsService: { getAllSettings: vi.fn().mockResolvedValue({}) }, +})); + +const renderWithClient = (ui: ReactElement) => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render({ui}); +}; + +const makeFile = (name: string) => + new File([new Uint8Array([1, 2, 3])], name, { type: 'image/png' }); + +async function uploadFiles(container: HTMLElement, user: ReturnType, names: string[]) { + const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement; + await user.upload(fileInput, names.map(makeFile)); + await user.click(screen.getByRole('button', { name: /common\.upload/ })); +} + +describe('PhotoUpload completion toast', () => { + beforeEach(() => { + postMock.mockReset(); + hoisted.aggregate = { ...idle }; + }); + afterEach(() => vi.clearAllMocks()); + + it('stays silent on success when the whole request was refused (photo cap)', async () => { + // Backend 400s the entire upload — every file is a transfer failure. + postMock.mockRejectedValue({ + response: { data: { error: 'Photo cap exceeded. This event allows a maximum of 3 photos.' } }, + }); + const onUploadComplete = vi.fn(); + const user = userEvent.setup(); + const { container } = renderWithClient( + + ); + + await uploadFiles(container, user, ['a.png', 'b.png']); + + await screen.findByTestId('upload-failure-report'); + expect(toastMock.success).not.toHaveBeenCalled(); + // The grid refresh still has to happen — it is a refresh signal, which is + // exactly why the host must not hang a success toast off it. + expect(onUploadComplete).toHaveBeenCalled(); + }); + + it('stays silent on success when every file was rejected per-file', async () => { + // 202, but count 0: nothing was queued (renamed .txt / magic-byte check). + postMock.mockResolvedValue({ + data: { + count: 0, + upload_id: 'u1', + errors: [{ filename: 'fake.jpg', error: 'File content does not match declared type' }], + }, + }); + const user = userEvent.setup(); + const { container } = renderWithClient(); + + await uploadFiles(container, user, ['fake.jpg']); + + await screen.findByTestId('upload-failure-report'); + expect(toastMock.success).not.toHaveBeenCalled(); + }); + + it('reports the real split when some files land and others do not', async () => { + postMock.mockResolvedValue({ + data: { + count: 1, + upload_id: 'u1', + errors: [{ filename: 'fake.jpg', error: 'File content does not match declared type' }], + }, + }); + hoisted.aggregate = { + total: 1, pending: 0, processing: 0, complete: 1, failed: 0, + failedPhotos: [], isComplete: true, isReady: true, + }; + const user = userEvent.setup(); + const { container } = renderWithClient(); + + await uploadFiles(container, user, ['good.png', 'fake.jpg']); + + await waitFor(() => + expect(toastMock.warning).toHaveBeenCalledWith( + '1 of 2 files uploaded — 1 could not be uploaded.' + ) + ); + expect(toastMock.success).not.toHaveBeenCalled(); + }); + + it('still congratulates a clean upload', async () => { + postMock.mockResolvedValue({ data: { count: 1, upload_id: 'u1', errors: [] } }); + hoisted.aggregate = { + total: 1, pending: 0, processing: 0, complete: 1, failed: 0, + failedPhotos: [], isComplete: true, isReady: true, + }; + const user = userEvent.setup(); + const { container } = renderWithClient(); + + await uploadFiles(container, user, ['good.png']); + + await waitFor(() => expect(toastMock.success).toHaveBeenCalled()); + expect(toastMock.warning).not.toHaveBeenCalled(); + }); +}); + +describe('host refresh callback', () => { + it('does not announce success from the Photos tab refresh handler', () => { + const source = fs.readFileSync( + path.join(__dirname, '..', '..', '..', 'pages', 'admin', 'event-details', 'PhotosTab.tsx'), + 'utf8' + ); + const handler = source.slice( + source.indexOf('onUploadComplete={'), + source.indexOf('{/* Photo Filters */}') + ); + expect(handler).toContain('refetchPhotos()'); + expect(handler).not.toContain('toast.success'); + }); +}); diff --git a/frontend/src/components/gallery/UserPhotoUpload.tsx b/frontend/src/components/gallery/UserPhotoUpload.tsx index 256f4ed1..bf9462ac 100644 --- a/frontend/src/components/gallery/UserPhotoUpload.tsx +++ b/frontend/src/components/gallery/UserPhotoUpload.tsx @@ -157,7 +157,11 @@ export const UserPhotoUpload: React.FC = ({ } try { - const response = await api.post<{ upload_id?: string }>(`/gallery/${eventId}/upload`, formData, { + const response = await api.post<{ + upload_id?: string; + count?: number; + errors?: Array<{ filename?: string; error?: string }>; + }>(`/gallery/${eventId}/upload`, formData, { headers: { 'Content-Type': 'multipart/form-data', }, @@ -174,16 +178,30 @@ export const UserPhotoUpload: React.FC = ({ } }, }); - // Request resolved → the bytes are stored. Processing continues in the - // background worker; `upload_id` is how the gallery follows it. - if (response.data?.upload_id) { - uploadIds.push(response.data.upload_id); - } setProcessingFiles(prev => { const next = { ...prev }; delete next[file.name]; return next; }); + + // A 202 does NOT mean the file landed: the route still answers 202 + // with `count: 0` and an `errors[]` entry when the queue refuses it + // (content/type mismatch, cap hit). Counting that as a success fired + // "Upload completed successfully" for a photo that never existed — + // the guest-side twin of QA P4-B.05 / 7.05. + const queuedCount = response.data?.count; + if (typeof queuedCount === 'number' && queuedCount === 0) { + failedCount++; + const reason = response.data?.errors?.[0]?.error || t('upload.someFilesFailed'); + toast.error(`${file.name}: ${reason}`); + continue; + } + + // Bytes are stored and queued. Processing continues in the background + // worker; `upload_id` is how the gallery follows it. + if (response.data?.upload_id) { + uploadIds.push(response.data.upload_id); + } successCount++; } catch (error: any) { // Upload error handled - user notified via UI diff --git a/frontend/src/components/gallery/__tests__/UserPhotoUpload.rejectedFile.test.tsx b/frontend/src/components/gallery/__tests__/UserPhotoUpload.rejectedFile.test.tsx new file mode 100644 index 00000000..443fa212 --- /dev/null +++ b/frontend/src/components/gallery/__tests__/UserPhotoUpload.rejectedFile.test.tsx @@ -0,0 +1,93 @@ +/** + * A guest must not be told their photo uploaded when it was refused. + * + * `POST /gallery/:id/upload` answers 202 even when the queue rejects the file + * (content/type mismatch, photo cap) — the refusal comes back as `count: 0` + * plus an `errors[]` entry. The uploader treated "the request resolved" as + * success, so the guest got "Upload completed successfully (1 photos)" for a + * photo that never existed: the guest-side twin of QA P4-B.05 / 7.05. + */ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { UserPhotoUpload } from '../UserPhotoUpload'; + +vi.mock('react-i18next', async () => { + const actual = await vi.importActual('react-i18next'); + return { + ...actual, + useTranslation: () => ({ + t: (key: string, second?: any) => (typeof second === 'string' ? second : key), + }), + }; +}); + +const toastMock = vi.hoisted(() => ({ + warning: vi.fn(), info: vi.fn(), error: vi.fn(), success: vi.fn(), +})); +vi.mock('react-toastify', () => ({ toast: toastMock })); + +const postMock = vi.fn(); +vi.mock('../../../config/api', () => ({ api: { post: (...a: any[]) => postMock(...a) } })); + +vi.mock('../../../hooks/usePublicSettings', () => ({ + usePublicSettings: () => ({ data: {} }), +})); + +const renderUploader = (onUploadComplete = vi.fn()) => { + const utils = render( + + ); + return { ...utils, onUploadComplete }; +}; + +async function pickAndUpload(container: HTMLElement, user: ReturnType, name: string) { + const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement; + await user.upload(fileInput, new File([new Uint8Array([1, 2, 3])], name, { type: 'image/png' })); + await user.click(screen.getByRole('button', { name: /common\.upload/ })); +} + +describe('UserPhotoUpload rejected file', () => { + beforeEach(() => postMock.mockReset()); + afterEach(() => vi.clearAllMocks()); + + it('does not report success for a 202 that queued nothing', async () => { + postMock.mockResolvedValue({ + data: { + upload_id: 'u1', + count: 0, + errors: [{ filename: 'fake.png', error: 'File content does not match declared type' }], + }, + }); + const user = userEvent.setup(); + const { container, onUploadComplete } = renderUploader(); + + await pickAndUpload(container, user, 'fake.png'); + + await waitFor(() => + expect(toastMock.error).toHaveBeenCalledWith( + 'fake.png: File content does not match declared type' + ) + ); + expect(toastMock.success).not.toHaveBeenCalled(); + // Nothing was queued, so there is no upload group worth polling. + expect(onUploadComplete).not.toHaveBeenCalled(); + }); + + it('still reports success and hands over the upload id when the file lands', async () => { + postMock.mockResolvedValue({ data: { upload_id: 'u1', count: 1 } }); + const user = userEvent.setup(); + const { container, onUploadComplete } = renderUploader(); + + await pickAndUpload(container, user, 'good.png'); + + await waitFor(() => expect(toastMock.success).toHaveBeenCalled()); + expect(onUploadComplete).toHaveBeenCalledWith(['u1']); + }); +}); diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index e9f85c6e..59f720b3 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -201,6 +201,7 @@ "processingStillRunning": "Ihr Upload wird noch verarbeitet — er erscheint in Kürze in der Galerie.", "retryFailed": "Fehlgeschlagene erneut versuchen", "uploadComplete": "Upload abgeschlossen!", + "partialComplete": "{{uploaded}} von {{total}} Dateien hochgeladen — {{failed}} konnten nicht hochgeladen werden.", "someFilesFailed": "Einige Dateien konnten nicht hochgeladen werden", "failures": { "title": "{{count}} Datei(en) konnten nicht hochgeladen werden", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index ce5f8a6d..f4f68f3e 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -201,6 +201,7 @@ "processingStillRunning": "Your upload is still being processed — it will appear in the gallery shortly.", "retryFailed": "Retry failed", "uploadComplete": "Upload complete!", + "partialComplete": "{{uploaded}} of {{total}} files uploaded — {{failed}} could not be uploaded.", "someFilesFailed": "Some files failed to upload", "failures": { "title": "{{count}} file(s) could not be uploaded", diff --git a/frontend/src/pages/admin/EventDetailsPage.tsx b/frontend/src/pages/admin/EventDetailsPage.tsx index 2bce00f8..da2066b1 100644 --- a/frontend/src/pages/admin/EventDetailsPage.tsx +++ b/frontend/src/pages/admin/EventDetailsPage.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useMemo, useCallback } from 'react'; import { useExpiryRefresh } from '../../hooks/useExpiryRefresh'; -import { useParams, useNavigate } from 'react-router-dom'; +import { useParams, useNavigate, useSearchParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { toast } from 'react-toastify'; import { useLocalizedDate } from '../../hooks/useLocalizedDate'; @@ -23,9 +23,16 @@ import { OverviewTab } from './event-details/OverviewTab'; import { PhotosTab } from './event-details/PhotosTab'; import { CategoriesTab } from './event-details/CategoriesTab'; +const ALL_TAB_KEYS: EventDetailsTab[] = ['overview', 'photos', 'categories', 'guests']; + +function isValidTab(value: string | null): value is EventDetailsTab { + return value !== null && (ALL_TAB_KEYS as string[]).includes(value); +} + export const EventDetailsPage: React.FC = () => { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); + const [searchParams, setSearchParams] = useSearchParams(); const queryClient = useQueryClient(); const { t } = useTranslation(); const { format } = useLocalizedDate(); @@ -55,7 +62,31 @@ export const EventDetailsPage: React.FC = () => { rate_limit_window_minutes: 15, rate_limit_max_requests: 10, }); - const [activeTab, setActiveTab] = useState('overview'); + // Read ?tab=… on mount, same shape as SettingsPage so both surfaces answer + // deep links identically; an unknown value falls back to the default tab and + // the sync effect below rewrites the URL to match (QA follow-up). + const [activeTab, setActiveTab] = useState( + isValidTab(searchParams.get('tab')) ? (searchParams.get('tab') as EventDetailsTab) : 'overview' + ); + + // Keep the URL in sync when the user clicks tabs, so copy-pasting the address + // lands the recipient on the same tab. + useEffect(() => { + if (searchParams.get('tab') === activeTab) return; + const next = new URLSearchParams(searchParams); + next.set('tab', activeTab); + setSearchParams(next, { replace: true }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeTab]); + + // Reflect external URL changes (back/forward) back into local state. + useEffect(() => { + const urlTab = searchParams.get('tab'); + if (isValidTab(urlTab) && urlTab !== activeTab) { + setActiveTab(urlTab); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchParams]); const [showPasswordReset, setShowPasswordReset] = useState(false); const [showNewPassword, setShowNewPassword] = useState(false); const [showRenameDialog, setShowRenameDialog] = useState(false); @@ -115,12 +146,22 @@ export const EventDetailsPage: React.FC = () => { useExpiryRefresh([event?.expires_at], bumpExpiryTick); // Fetch feedback settings - const { data: eventFeedbackSettings } = useQuery({ + const { data: eventFeedbackSettings, isLoading: feedbackSettingsLoading } = useQuery({ queryKey: ['admin-event-feedback-settings', id], queryFn: () => feedbackService.getEventFeedbackSettings(id!), enabled: !!id, }); + // Guests is only rendered in guest identity mode, so a ?tab=guests deep link + // on any other event would show an empty content area. Snap back once the + // settings have actually loaded — not while they're still undefined. + useEffect(() => { + if (feedbackSettingsLoading) return; + if (activeTab === 'guests' && eventFeedbackSettings?.identity_mode !== 'guest') { + setActiveTab('overview'); + } + }, [feedbackSettingsLoading, eventFeedbackSettings?.identity_mode, activeTab]); + // Update local feedback settings when fetched from server useEffect(() => { if (eventFeedbackSettings) { @@ -147,7 +188,7 @@ export const EventDetailsPage: React.FC = () => { // While any photo is still in pending/processing state we poll every // 2s so the admin grid auto-updates as the background worker drains // the queue. Once everything is complete/failed the polling stops. - const { data: photos = [], isLoading: photosLoading, refetch: refetchPhotos } = useQuery({ + const { data: photos = [], isLoading: photosLoading, isError: photosError, refetch: refetchPhotos } = useQuery({ queryKey: ['admin-event-photos', id, combinedPhotoFilters], queryFn: () => photosService.getEventPhotos(parseInt(id!), combinedPhotoFilters), enabled: !!id && (activeTab === 'photos' || isEditing), @@ -692,6 +733,7 @@ export const EventDetailsPage: React.FC = () => { id={id} photos={photos} photosLoading={photosLoading} + photosError={photosError} refetchPhotos={refetchPhotos} categories={categories} photoFilters={photoFilters} diff --git a/frontend/src/pages/admin/__tests__/eventDetailsPhotosTab.test.tsx b/frontend/src/pages/admin/__tests__/eventDetailsPhotosTab.test.tsx new file mode 100644 index 00000000..13b5048d --- /dev/null +++ b/frontend/src/pages/admin/__tests__/eventDetailsPhotosTab.test.tsx @@ -0,0 +1,145 @@ +/** + * Two QA follow-ups on /admin/events/:id, both observable on the Photos tab: + * + * - `?tab=photos` was ignored — the page always mounted on Overview, unlike + * Settings which seeds its tab state from the same param. + * - With the network down, the photos query failed and the grid fell through + * to its "no media uploaded yet" empty state, so an admin could reasonably + * conclude the photos were gone. + */ +import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +vi.mock('react-i18next', async () => { + const actual = await vi.importActual('react-i18next'); + return { + ...actual, + useTranslation: () => ({ + t: (k: string, fb?: unknown) => (typeof fb === 'string' ? fb : k), + i18n: { language: 'en' }, + }), + }; +}); + +vi.mock('react-toastify', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } })); + +const getEvent = vi.fn(); +vi.mock('../../../services/events.service', () => ({ + eventsService: { + getEvent: (...args: unknown[]) => getEvent(...args), + getEventCategories: vi.fn().mockResolvedValue([]), + updateEvent: vi.fn(), + deleteEvent: vi.fn(), + extendExpiration: vi.fn(), + duplicateEvent: vi.fn(), + resetPassword: vi.fn(), + publishEvent: vi.fn(), + renameEvent: vi.fn(), + revealNow: vi.fn(), + archiveEvent: vi.fn(), + sendGalleryEmail: vi.fn(), + }, +})); + +const getEventPhotos = vi.fn(); +vi.mock('../../../services/photos.service', () => ({ + photosService: { + getEventPhotos: (...args: unknown[]) => getEventPhotos(...args), + getFilterSummary: vi.fn().mockResolvedValue({}), + getExportFormats: vi.fn().mockResolvedValue([]), + }, +})); + +vi.mock('../../../services/feedback.service', () => ({ + feedbackService: { + getEventFeedbackSettings: vi.fn().mockResolvedValue({ identity_mode: 'simple' }), + }, +})); + +vi.mock('../../../services/cssTemplates.service', () => ({ + cssTemplatesService: { getEnabledTemplates: vi.fn().mockResolvedValue([]) }, +})); + +vi.mock('../../../hooks/usePublicSettings', () => ({ + PUBLIC_SETTINGS_QUERY_KEY: ['public-settings'], + usePublicSettings: () => ({ data: {} }), +})); + +vi.mock('../../../contexts/FeatureFlagsContext', () => ({ + useFeatureFlags: () => ({ flags: {}, isLoading: false }), + useFeatureEnabled: () => false, +})); + +vi.mock('../../../contexts/PermissionsContext', () => ({ + usePermissions: () => ({ hasAnyPermission: () => true, hasPermission: () => true, isLoading: false }), +})); + +import { EventDetailsPage } from '../EventDetailsPage'; + +const EVENT = { + id: 1, + event_name: 'ZZTEST', + slug: 'zztest', + event_type: 'wedding', + event_date: '2026-09-01T00:00:00.000Z', + expires_at: '2027-09-01T00:00:00.000Z', + is_active: true, + is_archived: false, + photo_count: 3, + source_mode: 'managed', +}; + +function renderPage(entry: string) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + + } /> + events list} /> + + + + ); +} + +describe('EventDetailsPage photos tab', () => { + beforeEach(() => { + vi.clearAllMocks(); + getEvent.mockResolvedValue(EVENT); + getEventPhotos.mockResolvedValue([]); + }); + + it('honours a ?tab=photos deep link instead of landing on Overview', async () => { + renderPage('/admin/events/1?tab=photos'); + + await waitFor(() => { + expect(screen.getByText('events.uploadPhotos')).toBeInTheDocument(); + }); + // Overview-only control must not be on screen. + expect(screen.queryByText('events.eventInformation')).not.toBeInTheDocument(); + }); + + it('falls back to Overview for an unknown ?tab= value', async () => { + renderPage('/admin/events/1?tab=nonsense'); + + await waitFor(() => { + expect(screen.queryByText('events.uploadPhotos')).not.toBeInTheDocument(); + }); + }); + + it('renders an error with retry, not the empty state, when the photos query fails', async () => { + getEventPhotos.mockRejectedValue(new Error('Network Error')); + + renderPage('/admin/events/1?tab=photos'); + + await waitFor(() => { + expect(screen.getByText('gallery.failedToLoad')).toBeInTheDocument(); + }); + expect(screen.getByRole('button', { name: 'common.retry' })).toBeInTheDocument(); + expect(screen.queryByText('No media uploaded yet')).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/pages/admin/event-details/PhotosTab.tsx b/frontend/src/pages/admin/event-details/PhotosTab.tsx index e198253f..8b160204 100644 --- a/frontend/src/pages/admin/event-details/PhotosTab.tsx +++ b/frontend/src/pages/admin/event-details/PhotosTab.tsx @@ -2,7 +2,7 @@ import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useQueryClient } from '@tanstack/react-query'; import { toast } from 'react-toastify'; -import { Upload, X } from 'lucide-react'; +import { AlertCircle, Upload, X } from 'lucide-react'; import type { Event } from '../../../types'; import { Button, Card, Loading } from '../../../components/common'; import { AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PhotoUploadModal, PhotoFilterPanel, PhotoExportMenu } from '../../../components/admin'; @@ -16,6 +16,7 @@ interface PhotosTabProps { id: string | undefined; photos: AdminPhoto[]; photosLoading: boolean; + photosError: boolean; refetchPhotos: () => void; categories: Array<{ id: number; name: string; slug: string; is_folder?: boolean }>; photoFilters: PhotoFilterParams; @@ -31,6 +32,7 @@ export const PhotosTab: React.FC = ({ id, photos, photosLoading, + photosError, refetchPhotos, categories, photoFilters, @@ -58,9 +60,13 @@ export const PhotosTab: React.FC = ({ onClose={() => setShowPhotoUpload(false)} eventId={parseInt(id!)} onUploadComplete={() => { + // Refresh-only. PhotoUpload fires this as bytes land AND again when + // processing finishes — including runs where every file was rejected + // — so a success toast here claimed "Upload completed successfully" + // over the top of the rejection warning (QA P4-B.05 / 7.05). The + // outcome toast belongs to PhotoUpload, which knows the counts. queryClient.invalidateQueries({ queryKey: ['admin-event', id] }); queryClient.invalidateQueries({ queryKey: ['admin-event-photos', id] }); - toast.success(t('toast.uploadSuccess')); refetchPhotos(); }} /> @@ -130,6 +136,21 @@ export const PhotosTab: React.FC = ({
+ ) : photosError ? ( + // Without this branch a failed fetch (offline, 5xx) fell through to the + // grid's "no media uploaded yet" empty state, which reads as "your + // photos are gone" rather than "we couldn't load them" (QA follow-up). + +
+ +
+

{t('gallery.failedToLoad')}

+ +
+
+
) : (