diff --git a/backend/src/routes/__tests__/adminPhotosListMapper.test.js b/backend/src/routes/__tests__/adminPhotosListMapper.test.js new file mode 100644 index 00000000..860aa6aa --- /dev/null +++ b/backend/src/routes/__tests__/adminPhotosListMapper.test.js @@ -0,0 +1,36 @@ +/** + * `GET /api/admin/photos/:eventId/photos` hand-rolls its response object + * field by field instead of spreading the row, so any column the admin UI + * reads has to be listed explicitly. `visibility` (#172) was missing, which + * meant the grid/list "Hidden" badge could never render and a photo hidden + * from clients looked identical to a visible one (QA warning) — the same + * class of omission that previously hid view_count / download_count. + * + * Source inspection rather than an HTTP round-trip: the defect is purely + * "the key isn't in the literal", and this needs no database. + */ +const fs = require('fs'); +const path = require('path'); + +const SOURCE = fs.readFileSync(path.join(__dirname, '..', 'adminPhotos.js'), 'utf8'); + +// The `res.json({ photos: photos.map(photo => ({ ... })) })` literal. +const LIST_MAPPER = /photos: photos\.map\(photo => \(\{([\s\S]*?)\n {6}\}\)\)/.exec(SOURCE); + +describe('admin photo list mapper', () => { + it('has a recognisable photos.map() response literal', () => { + expect(LIST_MAPPER).not.toBeNull(); + }); + + it.each([ + 'visibility', + 'view_count', + 'download_count', + ])('exposes %s so the admin grid can render it', (field) => { + expect(LIST_MAPPER[1]).toContain(`${field}:`); + }); + + it('normalises visibility to the two values the UI switches on', () => { + expect(LIST_MAPPER[1]).toContain('visibility: photo.visibility === \'hidden\' ? \'hidden\' : \'visible\''); + }); +}); diff --git a/backend/src/routes/adminPhotos.js b/backend/src/routes/adminPhotos.js index 226f75b8..459f1031 100644 --- a/backend/src/routes/adminPhotos.js +++ b/backend/src/routes/adminPhotos.js @@ -1368,6 +1368,9 @@ router.get('/:eventId/photos', adminAuth, requirePermission('photos.view'), requ // so the admin grid's "Hidden" badge could never render and a photo // hidden from clients looked identical to a visible one (QA warning). visibility: photo.visibility === 'hidden' ? 'hidden' : 'visible', + // Same omission: the grid's "Processing…" and "Failed"/Retry + // placeholders read this, so neither could ever render either. + processing_status: photo.processing_status || 'complete', category_id: photo.category_id || photo.type, category_name: photo.pc_name || (photo.type === 'individual' ? 'Individual Photos' : 'Collages'), category_slug: photo.pc_slug || photo.type, diff --git a/frontend/src/components/admin/AdminPhotoGrid.tsx b/frontend/src/components/admin/AdminPhotoGrid.tsx index ca009457..67ba4bd9 100644 --- a/frontend/src/components/admin/AdminPhotoGrid.tsx +++ b/frontend/src/components/admin/AdminPhotoGrid.tsx @@ -361,6 +361,7 @@ export const AdminPhotoGrid: React.FC = ({ const isVideo = (photo.media_type === 'video') || (photo.mime_type && photo.mime_type.startsWith('video/')) || photo.type === 'video'; + const isHidden = (photo as any).visibility === 'hidden'; return (
= ({
- {/* Visibility badge (#172) */} - {(photo as any).visibility === 'hidden' && ( -
- + {/* Visibility badge (#172). Same badge vocabulary as the list + view's row badges — icon + short label, tooltip carrying the + explanation. It shares the top-left corner with the category + badge, so that one drops a row while this is showing. */} + {isHidden && ( +
+ {t('admin.photos.hidden', 'Hidden')} @@ -496,7 +506,7 @@ export const AdminPhotoGrid: React.FC = ({ {/* Category Badge - move to top-left and prevent overlap with select checkbox */} {photo.category_name && ( -
+
{photo.category_name} @@ -700,7 +710,10 @@ export const AdminPhotoGrid: React.FC = ({ )} {isHidden && ( - + {t('admin.photos.hidden', 'Hidden')} diff --git a/frontend/src/components/admin/__tests__/AdminPhotoGrid.hiddenBadge.test.tsx b/frontend/src/components/admin/__tests__/AdminPhotoGrid.hiddenBadge.test.tsx new file mode 100644 index 00000000..9c3a4273 --- /dev/null +++ b/frontend/src/components/admin/__tests__/AdminPhotoGrid.hiddenBadge.test.tsx @@ -0,0 +1,121 @@ +/** + * A photo hidden from client access has to be distinguishable from a visible + * one on the admin Photos tab — otherwise the admin cannot tell what guests + * actually see (QA warning). Both layouts carry the same badge vocabulary: + * EyeOff icon + "Hidden" label + an explanatory tooltip. + * + * The badge markup already existed; what was missing was `visibility` in the + * list route's response (guarded separately in the backend suite). These + * tests pin the UI half of that contract. + */ +import { render, screen } 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 type { ReactElement, ReactNode } from 'react'; + +import { AdminPhotoGrid } from '../AdminPhotoGrid'; +import type { AdminPhoto } from '../../../services/photos.service'; + +vi.mock('react-i18next', async () => { + const actual = await vi.importActual('react-i18next'); + return { + ...actual, + useTranslation: () => ({ + t: (_key: string, fallback?: any) => + typeof fallback === 'string' ? fallback : _key, + i18n: { language: 'en' } + }) + }; +}); + +vi.mock('../AdminAuthenticatedImage', () => ({ + AdminAuthenticatedImage: ({ alt }: { alt: string }) => {alt} +})); + +vi.mock('../../../services/photos.service', () => ({ + photosService: { + formatBytes: (n: number) => `${n} B` + } +})); + +vi.mock('../PermissionGate', () => ({ + PermissionGate: ({ children }: { children: ReactNode }) => <>{children} +})); + +const renderWithQueryClient = (ui: ReactElement) => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } } + }); + return render({ui}); +}; + +const basePhoto = { + path: '/x.jpg', url: '/x.jpg', thumbnail_url: '/t/x.jpg', + type: 'photo', category_id: null, category_slug: null, + size: 1234, uploaded_at: '2026-01-01T00:00:00Z' +}; + +const photos = [ + { ...basePhoto, id: 1, filename: 'hidden.jpg', category_name: 'Ceremony', visibility: 'hidden' }, + { ...basePhoto, id: 2, filename: 'visible.jpg', category_name: 'Ceremony', visibility: 'visible' } +] as unknown as AdminPhoto[]; + +const renderGrid = () => + renderWithQueryClient( + + ); + +describe('AdminPhotoGrid hidden-photo indicator', () => { + beforeEach(() => localStorage.clear()); + afterEach(() => localStorage.clear()); + + it('badges only the hidden tile in grid view', () => { + renderGrid(); + + expect(screen.getByTestId('admin-photo-hidden-badge-1')).toBeInTheDocument(); + expect(screen.queryByTestId('admin-photo-hidden-badge-2')).not.toBeInTheDocument(); + }); + + it('carries a tooltip explaining what "hidden" means', () => { + renderGrid(); + + const badge = screen.getByTestId('admin-photo-hidden-badge-1').firstElementChild; + expect(badge).toHaveAttribute('title', expect.stringContaining('Hidden from guests')); + }); + + it('does not let the category badge cover the hidden badge', () => { + renderGrid(); + + // Both live in the tile's top-left corner; the category badge drops a + // row while the hidden badge is showing. + const tile = screen.getByTestId('admin-photo-tile-1'); + const category = Array.from(tile.querySelectorAll('div')).find( + (node) => node.textContent === 'Ceremony' && node.className.includes('absolute') + ); + expect(category?.className).toContain('top-9'); + + const visibleTile = screen.getByTestId('admin-photo-tile-2'); + const visibleCategory = Array.from(visibleTile.querySelectorAll('div')).find( + (node) => node.textContent === 'Ceremony' && node.className.includes('absolute') + ); + expect(visibleCategory?.className).toContain('top-2'); + }); + + it('badges the hidden photo in list view too', async () => { + const user = userEvent.setup(); + renderGrid(); + + await user.click(screen.getByRole('radio', { name: /list view/i })); + + const hiddenRow = screen.getByTestId('admin-photo-row-1'); + const visibleRow = screen.getByTestId('admin-photo-row-2'); + expect(hiddenRow).toHaveTextContent('Hidden'); + expect(visibleRow).not.toHaveTextContent('Hidden'); + }); +}); diff --git a/frontend/src/pages/admin/CreateEventPage.tsx b/frontend/src/pages/admin/CreateEventPage.tsx index dad7714c..30b8509e 100644 --- a/frontend/src/pages/admin/CreateEventPage.tsx +++ b/frontend/src/pages/admin/CreateEventPage.tsx @@ -1059,11 +1059,18 @@ export const CreateEventPage: React.FC = () => {
+ {/* `max` is required, not cosmetic: without it Blink + reports the spin button's range as unbounded and the + a11y tree exposes aria-valuemax="0" (QA warning), and + an out-of-range value only fails at INSERT time. The + ceiling is the events.photo_cap column's own — a + signed 32-bit integer (migration 074). */} setFormData({ ...formData, photo_cap: parseInt(e.target.value) || 0 })} min={0} + max={2147483647} leftIcon={} />