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}',
diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts
index f7076a77..7059391c 100644
--- a/server/src/db/migrations.ts
+++ b/server/src/db/migrations.ts
@@ -1467,6 +1467,8 @@ function runMigrations(db: Database.Database): void {
if (assetCol) {
const providerExpr = hasProvider ? 'provider' : "'immich'";
+ // Qualified alias needed in JOIN context where both trip_photos and trek_photos have provider
+ const providerJoinExpr = hasProvider ? 'tp.provider' : "'immich'";
const sharedExpr = tpColNames.has('shared') ? 'shared' : '1';
const addedAtExpr = tpColNames.has('added_at') ? 'COALESCE(added_at, CURRENT_TIMESTAMP)' : 'CURRENT_TIMESTAMP';
const albumLinkExpr = hasAlbumLink ? 'album_link_id' : 'NULL';
@@ -1496,7 +1498,7 @@ function runMigrations(db: Database.Database): void {
INSERT OR IGNORE INTO trip_photos_new (trip_id, user_id, photo_id, shared, album_link_id, added_at)
SELECT tp.trip_id, tp.user_id, tkp.id, ${sharedExpr}, ${albumLinkExpr}, ${addedAtExpr}
FROM trip_photos tp
- JOIN trek_photos tkp ON tkp.provider = ${providerExpr} AND tkp.asset_id = tp.${assetCol} AND tkp.owner_id = tp.user_id
+ JOIN trek_photos tkp ON tkp.provider = ${providerJoinExpr} AND tkp.asset_id = tp.${assetCol} AND tkp.owner_id = tp.user_id
`);
} else {
// No asset column at all — just recreate empty
diff --git a/server/src/routes/memories/immich.ts b/server/src/routes/memories/immich.ts
index e5bc4c9e..87f3fa0f 100644
--- a/server/src/routes/memories/immich.ts
+++ b/server/src/routes/memories/immich.ts
@@ -60,10 +60,16 @@ router.get('/browse', authenticate, async (req: Request, res: Response) => {
router.post('/search', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
- const { from, to, page, size } = req.body;
- const result = await searchPhotos(authReq.user.id, from, to, Number(page) || 1, Math.min(Number(size) || 50, 200));
- if (result.error) return res.status(result.status!).json({ error: result.error });
- res.json({ assets: result.assets, hasMore: result.hasMore });
+ const { from, to, size } = req.body;
+ const pageSize = Math.min(Number(size) || 50, 200);
+ const allAssets: any[] = [];
+ for (let page = 1; page <= 20; page++) {
+ const result = await searchPhotos(authReq.user.id, from, to, page, pageSize);
+ if (result.error) return res.status(result.status!).json({ error: result.error });
+ if (result.assets) allAssets.push(...result.assets);
+ if (!result.hasMore) break;
+ }
+ res.json({ assets: allAssets });
});
// ── Asset Details ──────────────────────────────────────────────────────────
diff --git a/server/src/services/atlasService.ts b/server/src/services/atlasService.ts
index 1c4f06da..c05d060f 100644
--- a/server/src/services/atlasService.ts
+++ b/server/src/services/atlasService.ts
@@ -173,17 +173,21 @@ export const CONTINENT_MAP: Record = {
let lastNominatimCall = 0;
+// Shared throttle: enforces ≥1.1s between any Nominatim request, across all callers.
+async function throttleNominatim() {
+ const elapsed = Date.now() - lastNominatimCall;
+ if (elapsed < 1100) await new Promise(r => setTimeout(r, 1100 - elapsed));
+ lastNominatimCall = Date.now();
+}
+
export async function reverseGeocodeCountry(lat: number, lng: number): Promise {
const key = roundKey(lat, lng);
if (geocodeCache.has(key)) return geocodeCache.get(key)!;
- // Nominatim rate limit: max 1 req/sec
- const now = Date.now();
- const elapsed = now - lastNominatimCall;
- if (elapsed < 1100) await new Promise(r => setTimeout(r, 1100 - elapsed));
- lastNominatimCall = Date.now();
+ await throttleNominatim();
try {
const res = await fetch(`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&zoom=3&accept-language=en`, {
headers: { 'User-Agent': 'TREK Travel Planner (https://github.com/mauriceboe/TREK)' },
+ signal: AbortSignal.timeout(10_000),
});
if (!res.ok) return null;
const data = await res.json() as { address?: { country_code?: string } };
@@ -460,15 +464,22 @@ export function unmarkRegionVisited(userId: number, regionCode: string): void {
interface RegionInfo { country_code: string; region_code: string; region_name: string }
+// Tracks place IDs currently being geocoded in the background to prevent duplicate enqueuing.
+const geocodingInFlight = new Set();
+
const regionCache = new Map();
async function reverseGeocodeRegion(lat: number, lng: number): Promise {
const key = roundKey(lat, lng);
if (regionCache.has(key)) return regionCache.get(key)!;
+ await throttleNominatim();
try {
const res = await fetch(
`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&zoom=8&accept-language=en`,
- { headers: { 'User-Agent': 'TREK Travel Planner' } }
+ {
+ headers: { 'User-Agent': 'TREK Travel Planner (https://github.com/mauriceboe/TREK)' },
+ signal: AbortSignal.timeout(10_000),
+ }
);
if (!res.ok) return null;
const data = await res.json() as { address?: Record };
@@ -505,20 +516,27 @@ export async function getVisitedRegions(userId: number): Promise<{ regions: Reco
: [];
const cachedMap = new Map(cached.map(c => [c.place_id, c]));
- // Resolve uncached places (rate-limited to avoid hammering Nominatim)
- const uncached = places.filter(p => p.lat && p.lng && !cachedMap.has(p.id));
- const insertStmt = db.prepare('INSERT OR REPLACE INTO place_regions (place_id, country_code, region_code, region_name) VALUES (?, ?, ?, ?)');
-
- for (const place of uncached) {
- const info = await reverseGeocodeRegion(place.lat!, place.lng!);
- if (info) {
- insertStmt.run(place.id, info.country_code, info.region_code, info.region_name);
- cachedMap.set(place.id, { place_id: place.id, ...info });
- }
- // Nominatim rate limit: 1 req/sec
- if (uncached.indexOf(place) < uncached.length - 1) {
- await new Promise(r => setTimeout(r, 1100));
- }
+ // Kick off background geocoding for uncached places; return cached data immediately.
+ const uncached = places.filter(p => p.lat && p.lng && !cachedMap.has(p.id) && !geocodingInFlight.has(p.id));
+ if (uncached.length > 0) {
+ const insertStmt = db.prepare('INSERT OR REPLACE INTO place_regions (place_id, country_code, region_code, region_name) VALUES (?, ?, ?, ?)');
+ for (const p of uncached) geocodingInFlight.add(p.id);
+ void (async () => {
+ try {
+ for (const place of uncached) {
+ try {
+ const info = await reverseGeocodeRegion(place.lat!, place.lng!);
+ if (info) insertStmt.run(place.id, info.country_code, info.region_code, info.region_name);
+ } catch {
+ // individual failure — continue with remaining places
+ } finally {
+ geocodingInFlight.delete(place.id);
+ }
+ }
+ } catch {
+ for (const p of uncached) geocodingInFlight.delete(p.id);
+ }
+ })();
}
// Group by country → regions with place counts
diff --git a/server/tests/helpers/factories.ts b/server/tests/helpers/factories.ts
index d2d3f2f6..a4bd57af 100644
--- a/server/tests/helpers/factories.ts
+++ b/server/tests/helpers/factories.ts
@@ -558,10 +558,23 @@ export function addTripPhoto(
provider: string,
opts: { shared?: boolean; albumLinkId?: number } = {}
): TestTripPhoto {
+ // Insert into trek_photos first (central registry)
+ db.prepare(
+ 'INSERT OR IGNORE INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)'
+ ).run(provider, assetId, userId);
+ const trekPhoto = db.prepare(
+ 'SELECT id FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?'
+ ).get(provider, assetId, userId) as { id: number };
+
const result = db.prepare(
- 'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, asset_id, provider, shared, album_link_id) VALUES (?, ?, ?, ?, ?, ?)'
- ).run(tripId, userId, assetId, provider, opts.shared ? 1 : 0, opts.albumLinkId ?? null);
- return db.prepare('SELECT * FROM trip_photos WHERE id = ?').get(result.lastInsertRowid) as TestTripPhoto;
+ 'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, photo_id, shared, album_link_id) VALUES (?, ?, ?, ?, ?)'
+ ).run(tripId, userId, trekPhoto.id, opts.shared ? 1 : 0, opts.albumLinkId ?? null);
+ return db.prepare(`
+ SELECT tp.id, tp.trip_id, tp.user_id, tkp.asset_id, tkp.provider, tp.shared, tp.album_link_id
+ FROM trip_photos tp
+ JOIN trek_photos tkp ON tkp.id = tp.photo_id
+ WHERE tp.id = ?
+ `).get(result.lastInsertRowid) as TestTripPhoto;
}
export interface TestAlbumLink {
diff --git a/server/tests/integration/immich.test.ts b/server/tests/integration/immich.test.ts
index 6a4c40f8..4ddd82f3 100644
--- a/server/tests/integration/immich.test.ts
+++ b/server/tests/integration/immich.test.ts
@@ -190,11 +190,16 @@ describe('Immich album links', () => {
.get(trip.id, user.id, 'album-xyz', 'Album XYZ', 'immich') as any;
// Insert photos synced from the album
- testDb.prepare('INSERT INTO trip_photos (trip_id, user_id, asset_id, provider, shared, album_link_id) VALUES (?, ?, ?, ?, 1, ?)').run(trip.id, user.id, 'asset-001', 'immich', linkResult.id);
- testDb.prepare('INSERT INTO trip_photos (trip_id, user_id, asset_id, provider, shared, album_link_id) VALUES (?, ?, ?, ?, 1, ?)').run(trip.id, user.id, 'asset-002', 'immich', linkResult.id);
+ for (const assetId of ['asset-001', 'asset-002']) {
+ testDb.prepare('INSERT OR IGNORE INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)').run('immich', assetId, user.id);
+ const tkp = testDb.prepare('SELECT id FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?').get('immich', assetId, user.id) as any;
+ testDb.prepare('INSERT INTO trip_photos (trip_id, user_id, photo_id, shared, album_link_id) VALUES (?, ?, ?, 1, ?)').run(trip.id, user.id, tkp.id, linkResult.id);
+ }
// Insert an individually-added photo (no album_link_id)
- testDb.prepare('INSERT INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, ?, 1)').run(trip.id, user.id, 'asset-manual', 'immich');
+ testDb.prepare('INSERT OR IGNORE INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)').run('immich', 'asset-manual', user.id);
+ const tkpManual = testDb.prepare('SELECT id FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?').get('immich', 'asset-manual', user.id) as any;
+ testDb.prepare('INSERT INTO trip_photos (trip_id, user_id, photo_id, shared) VALUES (?, ?, ?, 1)').run(trip.id, user.id, tkpManual.id);
const res = await request(app)
.delete(`/api/integrations/memories/unified/trips/${trip.id}/album-links/${linkResult.id}`)
@@ -204,7 +209,11 @@ describe('Immich album links', () => {
expect(res.body.success).toBe(true);
// Album-linked photos should be gone
- const remainingPhotos = testDb.prepare('SELECT * FROM trip_photos WHERE trip_id = ?').all(trip.id) as any[];
+ const remainingPhotos = testDb.prepare(`
+ SELECT tp.*, tkp.asset_id FROM trip_photos tp
+ JOIN trek_photos tkp ON tkp.id = tp.photo_id
+ WHERE tp.trip_id = ?
+ `).all(trip.id) as any[];
expect(remainingPhotos.length).toBe(1);
expect(remainingPhotos[0].asset_id).toBe('asset-manual');
@@ -220,7 +229,9 @@ describe('Immich album links', () => {
const linkResult = testDb.prepare('INSERT INTO trip_album_links (trip_id, user_id, album_id, album_name, provider) VALUES (?, ?, ?, ?, ?) RETURNING *')
.get(trip.id, owner.id, 'album-secret', 'Secret Album', 'immich') as any;
- testDb.prepare('INSERT INTO trip_photos (trip_id, user_id, asset_id, provider, shared, album_link_id) VALUES (?, ?, ?, ?, 1, ?)').run(trip.id, owner.id, 'asset-owned', 'immich', linkResult.id);
+ testDb.prepare('INSERT OR IGNORE INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)').run('immich', 'asset-owned', owner.id);
+ const tkpOwned = testDb.prepare('SELECT id FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?').get('immich', 'asset-owned', owner.id) as any;
+ testDb.prepare('INSERT INTO trip_photos (trip_id, user_id, photo_id, shared, album_link_id) VALUES (?, ?, ?, 1, ?)').run(trip.id, owner.id, tkpOwned.id, linkResult.id);
// Non-member tries to delete owner's album link — should be denied
const res = await request(app)
@@ -232,7 +243,11 @@ describe('Immich album links', () => {
// Link and photos should still exist
const link = testDb.prepare('SELECT * FROM trip_album_links WHERE id = ?').get(linkResult.id);
expect(link).toBeDefined();
- const photo = testDb.prepare('SELECT * FROM trip_photos WHERE asset_id = ?').get('asset-owned');
+ const photo = testDb.prepare(`
+ SELECT tp.* FROM trip_photos tp
+ JOIN trek_photos tkp ON tkp.id = tp.photo_id
+ WHERE tkp.asset_id = ?
+ `).get('asset-owned');
expect(photo).toBeDefined();
});
diff --git a/server/tests/integration/memories-immich.test.ts b/server/tests/integration/memories-immich.test.ts
index 876e5d99..7cd07bab 100644
--- a/server/tests/integration/memories-immich.test.ts
+++ b/server/tests/integration/memories-immich.test.ts
@@ -119,8 +119,8 @@ vi.mock('../../src/utils/ssrfGuard', async () => {
body: null,
});
}
- // /api/albums — list albums
- if (/\/api\/albums$/.test(u)) {
+ // /api/albums — list albums (owned and shared?=true variant)
+ if (/\/api\/albums(\?.*)?$/.test(u)) {
return Promise.resolve({
ok: true, status: 200,
headers: { get: () => null },
@@ -415,9 +415,11 @@ describe('Immich asset proxy', () => {
const { user: member } = createUser(testDb);
// Insert a shared photo referencing a trip that doesn't exist (FK disabled temporarily)
testDb.exec('PRAGMA foreign_keys = OFF');
+ testDb.prepare('INSERT OR IGNORE INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)').run('immich', 'asset-notrip', owner.id);
+ const tkpNotrip = testDb.prepare('SELECT id FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?').get('immich', 'asset-notrip', owner.id) as any;
testDb.prepare(
- 'INSERT INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, ?, ?)'
- ).run(9999, owner.id, 'asset-notrip', 'immich', 1);
+ 'INSERT INTO trip_photos (trip_id, user_id, photo_id, shared) VALUES (?, ?, ?, ?)'
+ ).run(9999, owner.id, tkpNotrip.id, 1);
testDb.exec('PRAGMA foreign_keys = ON');
const res = await request(app)
@@ -531,7 +533,11 @@ describe('Immich syncAlbumAssets', () => {
expect(typeof res.body.added).toBe('number');
// Verify photos were inserted into the DB
- const photos = testDb.prepare('SELECT * FROM trip_photos WHERE trip_id = ? AND user_id = ?').all(trip.id, user.id) as any[];
+ const photos = testDb.prepare(`
+ SELECT tp.*, tkp.provider FROM trip_photos tp
+ JOIN trek_photos tkp ON tkp.id = tp.photo_id
+ WHERE tp.trip_id = ? AND tp.user_id = ?
+ `).all(trip.id, user.id) as any[];
expect(photos.length).toBeGreaterThan(0);
expect(photos[0].provider).toBe('immich');
});
diff --git a/server/tests/integration/memories-synology.test.ts b/server/tests/integration/memories-synology.test.ts
index 11371bea..b8afc049 100644
--- a/server/tests/integration/memories-synology.test.ts
+++ b/server/tests/integration/memories-synology.test.ts
@@ -470,9 +470,11 @@ describe('Synology asset access', () => {
const { user: member } = createUser(testDb);
// Insert a shared photo referencing a trip that doesn't exist (FK disabled temporarily)
testDb.exec('PRAGMA foreign_keys = OFF');
+ testDb.prepare('INSERT OR IGNORE INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)').run('synologyphotos', '101_cachekey', owner.id);
+ const tkpSyno35 = testDb.prepare('SELECT id FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?').get('synologyphotos', '101_cachekey', owner.id) as any;
testDb.prepare(
- 'INSERT INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, ?, ?)'
- ).run(9999, owner.id, '101_cachekey', 'synologyphotos', 1);
+ 'INSERT INTO trip_photos (trip_id, user_id, photo_id, shared) VALUES (?, ?, ?, ?)'
+ ).run(9999, owner.id, tkpSyno35.id, 1);
testDb.exec('PRAGMA foreign_keys = ON');
const res = await request(app)
@@ -568,7 +570,11 @@ describe('Synology syncSynologyAlbumLink', () => {
expect(typeof res.body.total).toBe('number');
// Verify photos were inserted into the DB
- const photos = testDb.prepare('SELECT * FROM trip_photos WHERE trip_id = ? AND user_id = ?').all(trip.id, user.id) as any[];
+ const photos = testDb.prepare(`
+ SELECT tp.*, tkp.provider FROM trip_photos tp
+ JOIN trek_photos tkp ON tkp.id = tp.photo_id
+ WHERE tp.trip_id = ? AND tp.user_id = ?
+ `).all(trip.id, user.id) as any[];
expect(photos.length).toBeGreaterThan(0);
expect(photos[0].provider).toBe('synologyphotos');
});
diff --git a/server/tests/integration/memories-unified.test.ts b/server/tests/integration/memories-unified.test.ts
index 2d10e8f2..2d856201 100644
--- a/server/tests/integration/memories-unified.test.ts
+++ b/server/tests/integration/memories-unified.test.ts
@@ -146,7 +146,11 @@ describe('Unified photo management', () => {
expect(res.status).toBe(200);
expect(res.body.added).toBe(2);
- const rows = testDb.prepare('SELECT asset_id FROM trip_photos WHERE trip_id = ?').all(trip.id) as any[];
+ const rows = testDb.prepare(`
+ SELECT tkp.asset_id FROM trip_photos tp
+ JOIN trek_photos tkp ON tkp.id = tp.photo_id
+ WHERE tp.trip_id = ?
+ `).all(trip.id) as any[];
expect(rows.map((r: any) => r.asset_id)).toEqual(expect.arrayContaining(['asset-a', 'asset-b']));
});
@@ -178,14 +182,23 @@ describe('Unified photo management', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
addTripPhoto(testDb, trip.id, user.id, 'asset-tog', 'immich', { shared: false });
+ const trekRef = testDb.prepare(`
+ SELECT tp.photo_id FROM trip_photos tp
+ JOIN trek_photos tkp ON tkp.id = tp.photo_id
+ WHERE tp.trip_id = ? AND tkp.asset_id = ?
+ `).get(trip.id, 'asset-tog') as any;
const res = await request(app)
.put(`${photosUrl(trip.id)}/sharing`)
.set('Cookie', authCookie(user.id))
- .send({ provider: 'immich', asset_id: 'asset-tog', shared: true });
+ .send({ photo_id: trekRef.photo_id, shared: true });
expect(res.status).toBe(200);
- const row = testDb.prepare('SELECT shared FROM trip_photos WHERE asset_id = ?').get('asset-tog') as any;
+ const row = testDb.prepare(`
+ SELECT tp.shared FROM trip_photos tp
+ JOIN trek_photos tkp ON tkp.id = tp.photo_id
+ WHERE tkp.asset_id = ?
+ `).get('asset-tog') as any;
expect(row.shared).toBe(1);
});
@@ -206,14 +219,23 @@ describe('Unified photo management', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
addTripPhoto(testDb, trip.id, user.id, 'asset-del', 'immich');
+ const trekRef = testDb.prepare(`
+ SELECT tp.photo_id FROM trip_photos tp
+ JOIN trek_photos tkp ON tkp.id = tp.photo_id
+ WHERE tp.trip_id = ? AND tkp.asset_id = ?
+ `).get(trip.id, 'asset-del') as any;
const res = await request(app)
.delete(photosUrl(trip.id))
.set('Cookie', authCookie(user.id))
- .send({ provider: 'immich', asset_id: 'asset-del' });
+ .send({ photo_id: trekRef.photo_id });
expect(res.status).toBe(200);
- const row = testDb.prepare('SELECT * FROM trip_photos WHERE asset_id = ?').get('asset-del');
+ const row = testDb.prepare(`
+ SELECT tp.* FROM trip_photos tp
+ JOIN trek_photos tkp ON tkp.id = tp.photo_id
+ WHERE tkp.asset_id = ?
+ `).get('asset-del');
expect(row).toBeUndefined();
});
diff --git a/server/tests/unit/services/atlasService.test.ts b/server/tests/unit/services/atlasService.test.ts
index 6248cf44..83282b3d 100644
--- a/server/tests/unit/services/atlasService.test.ts
+++ b/server/tests/unit/services/atlasService.test.ts
@@ -473,10 +473,12 @@ describe('getVisitedRegions', () => {
const trip = createTrip(testDb, user.id, { title: 'Paris Trip' });
insertPlaceWithCoords(testDb, trip.id, 'Paris Hotel', 48.85, 2.35);
- const resultPromise = getVisitedRegions(user.id);
+ // First call triggers the background geocoding fire-and-forget
+ await getVisitedRegions(user.id);
// Advance all pending timers (including the 1100ms Nominatim rate-limit delay)
await vi.runAllTimersAsync();
- const result = await resultPromise;
+ // Second call returns now-cached data
+ const result = await getVisitedRegions(user.id);
expect(result.regions['FR']).toBeDefined();
diff --git a/server/tests/unit/services/journeyService.test.ts b/server/tests/unit/services/journeyService.test.ts
index db3fa985..50d3ea4b 100644
--- a/server/tests/unit/services/journeyService.test.ts
+++ b/server/tests/unit/services/journeyService.test.ts
@@ -1132,7 +1132,11 @@ describe('setPhotoProvider', () => {
setPhotoProvider(photo!.id, 'immich', 'immich-asset-789', user.id);
- const updated = testDb.prepare('SELECT * FROM journey_photos WHERE id = ?').get(photo!.id) as any;
+ const updated = testDb.prepare(`
+ SELECT jp.*, tkp.provider, tkp.asset_id, tkp.owner_id
+ FROM journey_photos jp JOIN trek_photos tkp ON tkp.id = jp.photo_id
+ WHERE jp.id = ?
+ `).get(photo!.id) as any;
expect(updated.provider).toBe('immich');
expect(updated.asset_id).toBe('immich-asset-789');
expect(updated.owner_id).toBe(user.id);
@@ -1321,9 +1325,11 @@ describe('Edge cases', () => {
).get(journey.id) as any;
expect(photoEntry).toBeDefined();
- const photos = testDb.prepare(
- 'SELECT * FROM journey_photos WHERE entry_id = ?'
- ).all(photoEntry.id);
+ const photos = testDb.prepare(`
+ SELECT jp.*, tkp.asset_id FROM journey_photos jp
+ JOIN trek_photos tkp ON tkp.id = jp.photo_id
+ WHERE jp.entry_id = ?
+ `).all(photoEntry.id);
expect(photos.length).toBe(1);
expect((photos[0] as any).asset_id).toBe('immich-photo-1');
});
diff --git a/server/tests/unit/services/journeyShareService.test.ts b/server/tests/unit/services/journeyShareService.test.ts
index e62c06b7..371e170e 100644
--- a/server/tests/unit/services/journeyShareService.test.ts
+++ b/server/tests/unit/services/journeyShareService.test.ts
@@ -63,10 +63,17 @@ function insertJourneyPhoto(
entryId: number,
opts: { filePath?: string; assetId?: string; ownerId?: number } = {}
): number {
+ const provider = opts.assetId ? 'immich' : 'local';
+ const filePath = !opts.assetId ? (opts.filePath ?? '/photos/test.jpg') : null;
+ const trekResult = testDb.prepare(`
+ INSERT INTO trek_photos (provider, asset_id, file_path, owner_id, created_at)
+ VALUES (?, ?, ?, ?, ?)
+ `).run(provider, opts.assetId ?? null, filePath, opts.ownerId ?? null, Date.now());
+ const trekId = trekResult.lastInsertRowid as number;
const result = testDb.prepare(`
- INSERT INTO journey_photos (entry_id, file_path, caption, sort_order, created_at, asset_id, owner_id)
- VALUES (?, ?, NULL, 0, ?, ?, ?)
- `).run(entryId, opts.filePath ?? '/photos/test.jpg', Date.now(), opts.assetId ?? null, opts.ownerId ?? null);
+ INSERT INTO journey_photos (entry_id, photo_id, caption, sort_order, created_at)
+ VALUES (?, ?, NULL, 0, ?)
+ `).run(entryId, trekId, Date.now());
return result.lastInsertRowid as number;
}