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}',