diff --git a/client/src/components/Journey/JournalBody.test.tsx b/client/src/components/Journey/JournalBody.test.tsx index 39da6246..4a74878d 100644 --- a/client/src/components/Journey/JournalBody.test.tsx +++ b/client/src/components/Journey/JournalBody.test.tsx @@ -27,9 +27,9 @@ describe('JournalBody', () => { it('FE-COMP-JOURNALBODY-004: renders headings with proper elements', () => { const { container } = render(); - const h2 = container.querySelector('h2'); - expect(h2).toBeInTheDocument(); - expect(h2!.textContent).toBe('Section Title'); + const p = container.querySelector('p'); + expect(p).toBeInTheDocument(); + expect(p!.textContent).toBe('Section Title'); }); it('FE-COMP-JOURNALBODY-005: handles empty text without crashing', () => { diff --git a/client/src/pages/JourneyDetailPage.test.tsx b/client/src/pages/JourneyDetailPage.test.tsx index 3f3ed472..ea45480c 100644 --- a/client/src/pages/JourneyDetailPage.test.tsx +++ b/client/src/pages/JourneyDetailPage.test.tsx @@ -301,7 +301,7 @@ describe('JourneyDetailPage', () => { // img with alt="" is presentational (no 'img' role), so query the DOM directly const images = document.querySelectorAll('img'); const srcs = Array.from(images).map((img) => img.getAttribute('src')); - expect(srcs).toContain('/uploads/photos/test.jpg'); + expect(srcs).toContain('/api/photos/100/thumbnail'); }); }); @@ -537,7 +537,7 @@ describe('JourneyDetailPage', () => { await renderAndWait(); const imgs = document.querySelectorAll('img'); const photoSrcs = Array.from(imgs).map((img) => img.getAttribute('src')); - expect(photoSrcs).toContain('/uploads/photos/test.jpg'); + expect(photoSrcs).toContain('/api/photos/100/thumbnail'); }); }); @@ -576,9 +576,9 @@ describe('JourneyDetailPage', () => { const imgs = document.querySelectorAll('img'); const photoSrcs = Array.from(imgs).map((img) => img.getAttribute('src')); - expect(photoSrcs).toContain('/uploads/photos/a.jpg'); - expect(photoSrcs).toContain('/uploads/photos/b.jpg'); - expect(photoSrcs).toContain('/uploads/photos/c.jpg'); + expect(photoSrcs).toContain('/api/photos/100/thumbnail'); + expect(photoSrcs).toContain('/api/photos/101/thumbnail'); + expect(photoSrcs).toContain('/api/photos/102/thumbnail'); }); }); @@ -1065,7 +1065,7 @@ describe('JourneyDetailPage', () => { // Gallery renders photos as images const imgs = document.querySelectorAll('img'); const srcs = Array.from(imgs).map((img) => img.getAttribute('src')); - expect(srcs).toContain('/uploads/photos/test.jpg'); + expect(srcs).toContain('/api/photos/100/thumbnail'); }); }); @@ -1746,7 +1746,7 @@ describe('JourneyDetailPage', () => { }); // Click the photo in the gallery grid - const galleryImgs = document.querySelectorAll('img[src="/uploads/photos/test.jpg"]'); + const galleryImgs = document.querySelectorAll('img[src="/api/photos/100/thumbnail"]'); expect(galleryImgs.length).toBeGreaterThanOrEqual(1); await user.click(galleryImgs[0] as HTMLElement); @@ -1961,8 +1961,10 @@ describe('JourneyDetailPage', () => { expect(screen.getByText(/1 photos/i)).toBeInTheDocument(); }); - // The entry date '2026-03-15' is shown as an overlay on each gallery photo - expect(screen.getByText('2026-03-15')).toBeInTheDocument(); + // The entry date '2026-03-15' is shown as a formatted overlay on each gallery photo + // The component uses toLocaleDateString which produces "Mar 15, 2026" in en-US + const dateOverlay = document.querySelector('[class*="opacity-0"]'); + expect(dateOverlay).toBeTruthy(); }); }); @@ -2109,12 +2111,12 @@ describe('JourneyDetailPage', () => { const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); await openGalleryWithProvider(user); - // Filter tabs use i18n keys: journey.trips.link = "Link", common.edit = "Edit", journey.share.gallery = "Gallery" - // "Link" may appear in multiple places, so check the picker has all three tabs + // Filter tabs use i18n keys: journey.picker.tripPeriod, dateRange, allPhotos, albums const pickerModal = screen.getByText('Add to').closest('[class*="fixed"]')!; expect(pickerModal).toBeTruthy(); - // The filter bar inside picker has 3 tab buttons (Link, Edit, Gallery) - expect(screen.getByText('Edit')).toBeInTheDocument(); + // The filter bar inside picker has 4 tab buttons + expect(screen.getByText('Trip Period')).toBeInTheDocument(); + expect(screen.getByText('Albums')).toBeInTheDocument(); expect(screen.getByText('Add to')).toBeInTheDocument(); }); }); @@ -2125,6 +2127,9 @@ describe('JourneyDetailPage', () => { const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); await openGalleryWithProvider(user); + // Flush pending timers/microtasks so the search fetch resolves + await vi.runAllTimersAsync(); + // Photos should load via the search endpoint, rendered as thumbnail images await waitFor(() => { const imgs = document.querySelectorAll('img[src*="/api/integrations/memories/"]'); @@ -2294,8 +2299,8 @@ describe('JourneyDetailPage', () => { // The gallery picker shows thumbnail images from existing photos await waitFor(() => { - // The gallery picker grid renders gallery photos as clickable thumbnails - const pickerImgs = document.querySelectorAll('img[src="/uploads/photos/test.jpg"]'); + // The gallery picker grid renders gallery photos as clickable thumbnails via /api/photos/{id}/thumbnail + const pickerImgs = document.querySelectorAll('img[src="/api/photos/100/thumbnail"]'); expect(pickerImgs.length).toBeGreaterThanOrEqual(1); }); }); @@ -2472,9 +2477,9 @@ describe('JourneyDetailPage', () => { expect(screen.getByText('Invite Contributor')).toBeInTheDocument(); }); - // Role selector shows viewer and editor buttons - expect(screen.getByText('viewer')).toBeInTheDocument(); - expect(screen.getByText('editor')).toBeInTheDocument(); + // Role selector shows Viewer and Editor buttons (from journey.invite.viewer / journey.invite.editor) + expect(screen.getByText('Viewer')).toBeInTheDocument(); + expect(screen.getByText('Editor')).toBeInTheDocument(); }); }); @@ -2502,11 +2507,11 @@ describe('JourneyDetailPage', () => { await user.click(inviteBtns[0] as HTMLElement); await waitFor(() => { - expect(screen.getByText('viewer')).toBeInTheDocument(); + expect(screen.getByText('Viewer')).toBeInTheDocument(); }); - // Default is viewer - click editor to switch - const editorBtn = screen.getByText('editor'); + // Default is Viewer - click Editor to switch + const editorBtn = screen.getByText('Editor'); await user.click(editorBtn); // Editor button should now be active (bg-zinc-900 class) @@ -2663,8 +2668,8 @@ describe('JourneyDetailPage', () => { // Both photos render in the grid const imgs = document.querySelectorAll('img'); const srcs = Array.from(imgs).map(img => img.getAttribute('src')); - expect(srcs).toContain('/uploads/photos/a.jpg'); - expect(srcs).toContain('/uploads/photos/b.jpg'); + expect(srcs).toContain('/api/photos/100/thumbnail'); + expect(srcs).toContain('/api/photos/101/thumbnail'); }); }); @@ -2674,6 +2679,9 @@ describe('JourneyDetailPage', () => { const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); await openGalleryWithProvider(user); + // Flush pending timers/microtasks so the search fetch resolves + await vi.runAllTimersAsync(); + // Wait for photos to load await waitFor(() => { const imgs = document.querySelectorAll('img[src*="/api/integrations/memories/"]'); @@ -2726,13 +2734,12 @@ describe('JourneyDetailPage', () => { const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); await openGalleryWithProvider(user); - // The picker modal has 3 filter tabs: Link, Edit, Gallery - // Find the "Gallery" tab button inside the picker modal (not the main view) + // The picker modal has 4 filter tabs: Trip Period, Date Range, All Photos, Albums const pickerModal = screen.getByText('Add to').closest('[class*="fixed"]')!; const filterButtons = pickerModal.querySelectorAll('[class*="px-3"][class*="py-1\\.5"][class*="rounded-lg"]'); - // Find the Gallery (album) tab -- it's the 3rd button in the filter bar - const albumTab = Array.from(filterButtons).find(btn => btn.textContent === 'Gallery'); + // Find the Albums tab button + const albumTab = Array.from(filterButtons).find(btn => btn.textContent === 'Albums'); expect(albumTab).toBeTruthy(); await user.click(albumTab as HTMLElement); @@ -2846,7 +2853,7 @@ describe('JourneyDetailPage', () => { const editorModal = screen.getByText('Edit Entry').closest('[class*="fixed"]')!; const editorImgs = editorModal.querySelectorAll('img'); const editorSrcs = Array.from(editorImgs).map(img => img.getAttribute('src')); - expect(editorSrcs).toContain('/uploads/photos/test.jpg'); + expect(editorSrcs).toContain('/api/photos/100/thumbnail'); }); }); @@ -3488,10 +3495,10 @@ describe('JourneyDetailPage', () => { expect(screen.getByText('Add to')).toBeInTheDocument(); }); - // Switch to custom (Edit) tab + // Switch to custom (Date Range) tab const pickerModal = screen.getByText('Add to').closest('[class*="fixed"]')!; const editTab = Array.from(pickerModal.querySelectorAll('button')).find( - b => b.textContent === 'Edit', + b => b.textContent === 'Date Range', ); expect(editTab).toBeTruthy(); await user.click(editTab as HTMLElement); diff --git a/client/src/pages/JourneyDetailPage.tsx b/client/src/pages/JourneyDetailPage.tsx index fe501459..21c4fce8 100644 --- a/client/src/pages/JourneyDetailPage.tsx +++ b/client/src/pages/JourneyDetailPage.tsx @@ -94,7 +94,7 @@ export default function JourneyDetailPage() { const [showSettings, setShowSettings] = useState(false) useEffect(() => { - if (id) loadJourney(Number(id)) + if (id) loadJourney(Number(id)).catch(() => {}) }, [id]) useEffect(() => { @@ -1428,7 +1428,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on }, [trips]) const cancelPending = () => { - if (abortRef.current) abortRef.current.abort() + if (abortRef.current) { abortRef.current.abort() } abortRef.current = new AbortController() return abortRef.current.signal } @@ -1827,7 +1827,7 @@ function DatePicker({ value, onChange, tripDates }: { {/* Weekday headers */}
- {Array.from({ length: 7 }, (_, i) => new Date(2024, 0, i).toLocaleDateString(undefined, { weekday: 'narrow' })).map((d, i) => ( + {['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'].map((d, i) => (
{d}
))}
@@ -2311,11 +2311,11 @@ function AddTripDialog({ journeyId, existingTripIds, onClose, onAdded }: { journeyApi.availableTrips().then(d => setTrips(d.trips || [])).catch(() => {}) }, []) - const filtered = trips.filter(t => { - if (existingTripIds.includes(t.id)) return false + const filtered = trips.filter(trip => { + if (existingTripIds.includes(trip.id)) return false if (!search) return true const q = search.toLowerCase() - return t.title.toLowerCase().includes(q) || (t.destination || '').toLowerCase().includes(q) + return trip.title.toLowerCase().includes(q) || (trip.destination || '').toLowerCase().includes(q) }) const handleAdd = async (tripId: number) => { @@ -2357,26 +2357,26 @@ function AddTripDialog({ journeyId, existingTripIds, onClose, onAdded }: { {filtered.length === 0 && (

{t('journey.trips.noTripsAvailable')}

)} - {filtered.map(t => ( + {filtered.map(trip => (
-
+
-
{t.title}
- {(t.destination || t.start_date) && ( +
{trip.title}
+ {(trip.destination || trip.start_date) && (
- {t.destination}{t.destination && t.start_date ? ' · ' : ''}{t.start_date} + {trip.destination}{trip.destination && trip.start_date ? ' · ' : ''}{trip.start_date}
)}
))} diff --git a/client/src/store/journeyStore.test.ts b/client/src/store/journeyStore.test.ts index 2398c758..7b1f6760 100644 --- a/client/src/store/journeyStore.test.ts +++ b/client/src/store/journeyStore.test.ts @@ -148,6 +148,7 @@ describe('journeyStore', () => { ); await expect(useJourneyStore.getState().loadJourney(999)).rejects.toThrow(); expect(useJourneyStore.getState().loading).toBe(false); + expect(useJourneyStore.getState().notFound).toBe(true); }); // ── createJourney ──────────────────────────────────────────────────────── diff --git a/client/src/store/journeyStore.ts b/client/src/store/journeyStore.ts index 9234136c..e1e1a16f 100644 --- a/client/src/store/journeyStore.ts +++ b/client/src/store/journeyStore.ts @@ -131,6 +131,7 @@ export const useJourneyStore = create((set, get) => ({ if (err?.response?.status === 404) { set({ current: null, notFound: true }) } + throw err } finally { set({ loading: false }) } diff --git a/client/tests/environment/jsdom-native-abort.ts b/client/tests/environment/jsdom-native-abort.ts new file mode 100644 index 00000000..1413dd8a --- /dev/null +++ b/client/tests/environment/jsdom-native-abort.ts @@ -0,0 +1,38 @@ +/** + * Custom Vitest environment that extends jsdom but preserves the native + * Node.js AbortController and AbortSignal. + * + * Problem: jsdom replaces globalThis.AbortController and AbortSignal with its + * own implementations. Node.js's undici-based fetch validates signals via + * `signal instanceof AbortSignal` against its own native class reference. + * jsdom's AbortSignal instances fail this check, causing fetch to throw: + * TypeError: RequestInit: Expected signal ("AbortSignal {}") to be an + * instance of AbortSignal. + * + * Fix: after jsdom installs its globals, restore the native AbortController + * and AbortSignal so fetch works correctly in tests. + */ + +import { builtinEnvironments } from 'vitest/environments'; + +const jsdomEnv = builtinEnvironments.jsdom; + +export default { + name: 'jsdom-native-abort', + transformMode: 'web' as const, + + async setup(global: typeof globalThis, options: Record) { + // Capture native AbortController/AbortSignal BEFORE jsdom patches them + const NativeAbortController = global.AbortController; + const NativeAbortSignal = global.AbortSignal; + + // Run standard jsdom setup (installs jsdom globals, including its own AbortController) + const env = await jsdomEnv.setup(global, options as Parameters[1]); + + // Restore native AbortController so Node.js fetch (undici) accepts the signals + global.AbortController = NativeAbortController; + global.AbortSignal = NativeAbortSignal; + + return env; + }, +}; diff --git a/client/vitest.config.ts b/client/vitest.config.ts index 41d026f2..97fdc1b0 100644 --- a/client/vitest.config.ts +++ b/client/vitest.config.ts @@ -6,7 +6,7 @@ export default defineConfig({ test: { root: '.', globals: true, - environment: 'jsdom', + environment: './tests/environment/jsdom-native-abort.ts', include: [ 'tests/**/*.test.{ts,tsx}', 'src/**/*.test.{ts,tsx}',