diff --git a/backend/src/routes/adminPhotos.js b/backend/src/routes/adminPhotos.js index 00ee79ee..1a8058c0 100644 --- a/backend/src/routes/adminPhotos.js +++ b/backend/src/routes/adminPhotos.js @@ -1226,12 +1226,17 @@ router.get('/:eventId/photos', adminAuth, requirePermission('photos.view'), requ query = query.where({ 'photos.type': type }); } - // Search by filename + // Search by filename. original_filename is included because that is the + // name printed on every card ("Original: …") — matching only the stored + // renamed filename returned 0 results for a substring the admin can read + // on screen. Grouped, because the feedback AND/OR conditions are appended + // right below and a bare orWhere would leak across them. if (search) { - query = query.whereRaw( - likeWithEscape('photos.filename'), - [`%${escapeLikePattern(search)}%`] - ); + const pattern = `%${escapeLikePattern(search)}%`; + query = query.where((qb) => { + qb.whereRaw(likeWithEscape('photos.filename'), [pattern]) + .orWhereRaw(likeWithEscape('photos.original_filename'), [pattern]); + }); } // Feedback filters (has likes / favorites / comments / min rating) with AND/OR logic diff --git a/frontend/src/components/gallery/layouts/GalleryStoryLayout.tsx b/frontend/src/components/gallery/layouts/GalleryStoryLayout.tsx index 2866284b..6f91e82e 100644 --- a/frontend/src/components/gallery/layouts/GalleryStoryLayout.tsx +++ b/frontend/src/components/gallery/layouts/GalleryStoryLayout.tsx @@ -108,12 +108,17 @@ export const GalleryStoryLayout: React.FC = ({ const scenes = useMemo(() => { const photosByCategory: PhotosByCategory = {}; - // Filter by search query + // Filter by search query. `original_filename` is in here because that is + // the camera name the guest actually sees on the card/lightbox — matching + // only the internal renamed `filename` gave "no results" for a substring + // the guest could read on screen (QA P4-B.02). const filteredPhotos = searchQuery - ? photos.filter(p => - p.filename.toLowerCase().includes(searchQuery.toLowerCase()) || - (p.category_name && p.category_name.toLowerCase().includes(searchQuery.toLowerCase())) - ) + ? photos.filter(p => { + const term = searchQuery.toLowerCase(); + return p.filename.toLowerCase().includes(term) || + (p.original_filename?.toLowerCase().includes(term) ?? false) || + (p.category_name && p.category_name.toLowerCase().includes(term)); + }) : photos; // Group by category diff --git a/frontend/src/components/gallery/layouts/__tests__/GalleryStoryLayout.search.test.tsx b/frontend/src/components/gallery/layouts/__tests__/GalleryStoryLayout.search.test.tsx new file mode 100644 index 00000000..b82503f4 --- /dev/null +++ b/frontend/src/components/gallery/layouts/__tests__/GalleryStoryLayout.search.test.tsx @@ -0,0 +1,103 @@ +/** + * Gallery search matched only the internal, renamed `filename`, never the + * camera `original_filename` the guest can actually read on the card and in + * the lightbox. Typing a substring of the visible name returned "no photos + * found" for a photo sitting right there (QA P4-B.02 / G.08). + */ +import React from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; + +import { GalleryStoryLayout } from '../GalleryStoryLayout'; +import type { Photo } from '../../../../types'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, fallback?: unknown) => (typeof fallback === 'string' ? fallback : key), + }), +})); + +vi.mock('framer-motion', () => { + const stub = (tag: string) => + React.forwardRef>(({ children, className, onClick }, ref) => + React.createElement(tag, { ref, className, onClick }, children as React.ReactNode) + ); + return { + motion: new Proxy({} as Record, { + get: (cache, tag: string) => (cache[tag] ??= stub(tag)), + }), + AnimatePresence: ({ children }: { children?: React.ReactNode }) => <>{children}, + useInView: () => true, + }; +}); + +vi.mock('../../../common', () => ({ + AuthenticatedImage: ({ src, alt }: { src: string; alt?: string }) => {alt}, + PoweredBy: () => null, +})); + +vi.mock('../../PhotoLightbox', () => ({ PhotoLightbox: () =>
})); + +vi.mock('../../../../services/feedback.service', () => ({ + feedbackService: { submitFeedback: vi.fn().mockResolvedValue({}) }, +})); +vi.mock('../../../../services/gallery.service', () => ({ + galleryService: { downloadSelectedPhotos: vi.fn() }, +})); +vi.mock('../../../../services/analytics.service', () => ({ + analyticsService: { trackGalleryEvent: vi.fn() }, +})); + +// Mirrors a real upload: the stored name is the renamed one, the camera name +// survives in original_filename and is what the guest sees. +const photos: Photo[] = [1, 2, 3].map((i) => ({ + id: i, + filename: `ZZTEST-Hochzeit_individual_000${i}_a1b2c3.jpg`, + original_filename: `zztest-photo-${i}.jpg`, + url: `/api/gallery/x/photo/${i}`, + thumbnail_url: `/api/gallery/x/thumbnail/${i}`, + type: 'individual', + size: 1, + uploaded_at: '2026-01-01T00:00:00Z', + category_name: 'Ceremony', +} as Photo)); + +const props = { + photos, + slug: 'x', + eventName: 'Sarah & Tom', + onPhotoClick: () => {}, + onDownload: () => {}, + selectedPhotos: new Set(), + isSelectionMode: false, + allowDownloads: true, +} as never; + +function search(container: HTMLElement, term: string) { + fireEvent.change(screen.getByPlaceholderText('Search memories...'), { target: { value: term } }); + return Array.from(container.querySelectorAll('a[data-photo-id]')).map((a) => + a.getAttribute('data-photo-id') + ); +} + +describe('GalleryStoryLayout search', () => { + it('matches a substring of the visible original filename', () => { + const { container } = render(); + expect(search(container, 'photo-2')).toEqual(['2']); + }); + + it('still matches the stored/renamed filename', () => { + const { container } = render(); + expect(search(container, 'individual_0003')).toEqual(['3']); + }); + + it('still matches the category name', () => { + const { container } = render(); + expect(search(container, 'ceremony')).toEqual(['1', '2', '3']); + }); + + it('returns nothing for a term present in neither name', () => { + const { container } = render(); + expect(search(container, 'nomatch')).toEqual([]); + }); +}); diff --git a/frontend/src/pages/admin/EventsListPage.tsx b/frontend/src/pages/admin/EventsListPage.tsx index 0d631c34..f6cfc507 100644 --- a/frontend/src/pages/admin/EventsListPage.tsx +++ b/frontend/src/pages/admin/EventsListPage.tsx @@ -552,7 +552,7 @@ export const EventsListPage: React.FC = () => { {event.event_type} - {event.event_date ? format(parseISO(event.event_date), 'MMM d, yyyy') : 'N/A'} + {event.event_date ? format(parseISO(event.event_date)) : 'N/A'} {event.photo_count ?? 0} @@ -563,7 +563,7 @@ export const EventsListPage: React.FC = () => { - {event.expires_at ? format(parseISO(event.expires_at), 'MMM d, yyyy') : 'N/A'} + {event.expires_at ? format(parseISO(event.expires_at)) : 'N/A'} e.stopPropagation()}>