From 504a8b6faeab7293ff7e171d23a0d00735c42fc9 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Tue, 1 Sep 2026 19:15:04 +0200 Subject: [PATCH] fix(events): honour ?tab=, show a load error, and stop lying about uploads Three warnings on the event-details surface. ?tab= deep links were ignored -- activeTab was hardcoded to 'overview' and nothing read or wrote the search param, unlike Settings. Mirrors SettingsPage's pattern exactly (module-level key list + type guard, seed useState from the param, write-back and reflect-back effects), plus a snap-back for the `guests` tab, which only renders when identity_mode is 'guest' -- a deep link to it on any other event would otherwise show a tab bar with no content. The snap-back is guarded on the query's isLoading so it cannot fire against undefined settings and kill a legitimate deep link. Worth recording: the two effects ping-pong infinitely if activeTab and a valid URL tab disagree at mount, which is exactly the pre-fix state. The seeding is what makes them agree, so the fix is also what makes the pair safe. Offline Photos tab rendered the "no media uploaded yet" empty state on a failed fetch, because `data: photos = []` makes a rejected query indistinguishable from an empty one -- a user could reasonably think their photos were gone. Threaded isError through and added a third branch, reusing TaxReportPage's existing error-with-retry shape. Needed no new keys. The spurious "Upload completed successfully" toast was in the host, not the uploader: PhotosTab hung toast.success off PhotoUpload's onUploadComplete, which is documented as a grid-refresh signal and fires as soon as the transfer loop exits -- including when the request 400'd on the photo cap or every file was rejected by magic-byte validation. PhotoUpload's own toasts were already correct. Removed it, and added a real partial-success branch reporting the actual split instead of a plain "Upload complete!". The guest uploader had a variant of the same bug in a different place: its toast is gated on successCount, but successCount++ fired on any resolved request -- and the upload route answers 202 with count: 0 and an errors[] entry when the file is refused. So a refused guest photo produced "Upload completed successfully (1 photos)" and pushed a useless upload_id into the processing poll. Now gated on count. Refs testplan REPORT.md, ?tab= / offline-empty-state / spurious-toast warnings. --- frontend/src/components/admin/PhotoUpload.tsx | 11 + .../PhotoUpload.outcomeToast.test.tsx | 189 ++++++++++++++++++ .../components/gallery/UserPhotoUpload.tsx | 30 ++- .../UserPhotoUpload.rejectedFile.test.tsx | 93 +++++++++ frontend/src/i18n/locales/de.json | 1 + frontend/src/i18n/locales/en.json | 1 + frontend/src/pages/admin/EventDetailsPage.tsx | 50 ++++- .../__tests__/eventDetailsPhotosTab.test.tsx | 145 ++++++++++++++ .../pages/admin/event-details/PhotosTab.tsx | 25 ++- 9 files changed, 533 insertions(+), 12 deletions(-) create mode 100644 frontend/src/components/admin/__tests__/PhotoUpload.outcomeToast.test.tsx create mode 100644 frontend/src/components/gallery/__tests__/UserPhotoUpload.rejectedFile.test.tsx create mode 100644 frontend/src/pages/admin/__tests__/eventDetailsPhotosTab.test.tsx 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')}

+ +
+
+
) : (