Compare commits

...

7 Commits

Author SHA1 Message Date
Maurice 457d436cf6 Merge pull request #778 from mauriceboe/fix/public-mobile-trip-photos-filter
fix: filter [Trip Photos] container from mobile public view (#764)
2026-04-21 11:29:53 +02:00
Julien G. 1127efb9c4 Merge pull request #777 from mauriceboe/fix/issues-773-774-backups-and-trip-files
fix(backups,files): auto-backups rejected by validator; trip file download broken after cookie migration
2026-04-21 11:24:44 +02:00
Maurice 0a98d3c2e7 fix: filter [Trip Photos] container from mobile public view (#764)
MobileMapTimeline received the raw entries array, bypassing the
synthetic-container filter applied to timelineEntries. On screens
below the lg breakpoint (<1024px) the [Trip Photos] sync container
leaked back into the combined map+timeline view.
2026-04-21 11:24:07 +02:00
jubnl 5eaf7492dc fix(backups,files): auto-backups rejected by validator; trip file download broken after cookie migration
Fixes #773: isValidBackupFilename regex anchored to ^backup- rejected all
auto-backup-* filenames, causing 400 on download/restore/delete. Broadened
to ^(?:auto-)?backup-.

Fixes #774: three regressions in the trip Files tab —
- openFile import shadowed by a local function of the same name inside
  FileManager; PDF preview modal was calling the local with a URL string,
  corrupting state and crashing on the second click (mime_type read on
  undefined). Fixed by aliasing the import as openFileUrl.
- GET /:id/download used a bespoke authenticateDownload that checked only
  Bearer header and ?token= query param, ignoring the trek_session cookie.
  After the JWT-to-cookie migration the client sends cookies only, so every
  download silently 401-ed. Extended authenticateDownload to accept req and
  check cookie → Bearer → query token in priority order.
- files.download and files.openError translation keys were missing from all
  15 locale files; t() was returning the raw key as a truthy string,
  defeating the || 'Download' fallback.
2026-04-21 11:18:17 +02:00
jubnl ee31c78db8 fix(maps): null stale proxy image_url entries instead of writing unbacked proxy URLs
Migration 107 and the previous fix both wrote /api/maps/place-photo/<id>/bytes
into places.image_url without ever fetching the photo bytes. photoService
short-circuits on that URL prefix and hits /bytes directly, which 404s because
nothing is on disk.

- Add migration to null proxy image_url rows with no backing google_place_photo_meta
  entry — restores the normal fetch-and-cache flow for affected rows
- Fix the previous legacy-URL migration to null instead of rewrite, so fresh
  installs don't hit the same 404 path

Fixes #770 (follow-up)
2026-04-21 00:46:29 +02:00
jubnl edf14e2ebc test(maps): update getPlacePhoto stubs to use text() instead of json()
mapsService now reads the details response body via .text() before parsing,
so test stubs need text() rather than json().
2026-04-21 00:16:54 +02:00
jubnl 2aad8f465c fix(maps): prevent server crash when legacy Google photo URLs are stored as placeIds
Migration 107 only rewrote image_url rows matching /places/%/photos/%; URLs using
the /place-photos/ or /places/<opaque> paths survived the upgrade and were passed
verbatim to the Places API, producing a malformed request whose empty/HTML response
body threw SyntaxError before detailsRes.ok was checked. The resulting rejection was
leaked by placePhotoCache.setInFlight via an unhandled .finally() chain, triggering
Node 22's default unhandledRejection=throw and terminating the process.

- placePhotoCache: add .catch() after .finally() to prevent unhandled rejection crash
- mapsService: reject URL-shaped placeIds early; read response as text before JSON.parse
- migrations: add migration to rewrite remaining googleusercontent/places.googleapis URLs
- MapView/MapViewGL: prefer stable proxy URL form of image_url before google_place_id

Fixes #770
2026-04-21 00:13:35 +02:00
28 changed files with 155 additions and 34 deletions
+4 -4
View File
@@ -10,7 +10,7 @@ import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
import { getAuthUrl } from '../../api/authUrl'
import { downloadFile, openFile } from '../../utils/fileDownload'
import { downloadFile, openFile as openFileUrl } from '../../utils/fileDownload'
function isImage(mimeType) {
if (!mimeType) return false
@@ -113,7 +113,7 @@ function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxProps) {
</span>
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
<button
onClick={() => openFile(file.url).catch(() => {})}
onClick={() => openFileUrl(file.url, file.original_name).catch(() => {})}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 4 }}
title={t('files.openTab')}>
<ExternalLink size={16} />
@@ -743,7 +743,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{previewFile.original_name}</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
<button
onClick={() => openFile(previewFile.url).catch(() => {})}
onClick={() => openFileUrl(previewFile.url, previewFile.original_name).catch(() => toast.error(t('files.openError')))}
style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'none', padding: '4px 8px', borderRadius: 6, transition: 'color 0.15s' }}
onMouseEnter={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-primary)'}
onMouseLeave={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-muted)'}>
@@ -771,7 +771,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
title={previewFile.original_name}
>
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>
<button onClick={() => openFile(previewFile.url).catch(() => {})} style={{ color: 'var(--text-primary)', textDecoration: 'underline', background: 'none', border: 'none', cursor: 'pointer', font: 'inherit' }}>{t('files.downloadPdf')}</button>
<button onClick={() => openFileUrl(previewFile.url, previewFile.original_name).catch(() => toast.error(t('files.openError')))} style={{ color: 'var(--text-primary)', textDecoration: 'underline', background: 'none', border: 'none', cursor: 'pointer', font: 'inherit' }}>{t('files.downloadPdf')}</button>
</p>
</object>
</div>
+5 -1
View File
@@ -477,7 +477,11 @@ export const MapView = memo(function MapView({
cleanups.push(onThumbReady(cacheKey, thumb => setThumb(cacheKey, thumb)))
if (!cached && !isLoading(cacheKey)) {
const photoId = place.image_url || place.google_place_id || place.osm_id
const photoId =
(place.image_url?.startsWith('/api/maps/place-photo/') ? place.image_url : null)
|| place.google_place_id
|| place.osm_id
|| place.image_url
if (photoId || (place.lat && place.lng)) {
fetchPhoto(cacheKey, photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name)
}
+5 -1
View File
@@ -366,7 +366,11 @@ export function MapViewGL({
}
cleanups.push(onThumbReady(cacheKey, thumb => setThumb(cacheKey, thumb)))
if (!cached && !isLoading(cacheKey)) {
const photoId = place.image_url || place.google_place_id || place.osm_id
const photoId =
(place.image_url?.startsWith('/api/maps/place-photo/') ? place.image_url : null)
|| place.google_place_id
|| place.osm_id
|| place.image_url
if (photoId || (place.lat && place.lng)) {
fetchPhoto(cacheKey, photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name)
}
+2
View File
@@ -1222,6 +1222,8 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'files.title': 'الملفات',
'files.pageTitle': 'الملفات والمستندات',
'files.subtitle': '{count} ملف لـ {trip}',
'files.download': 'تنزيل',
'files.openError': 'تعذر فتح الملف',
'files.downloadPdf': 'تنزيل PDF',
'files.count': '{count} ملفات',
'files.countSingular': 'ملف واحد',
+2
View File
@@ -1191,6 +1191,8 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'files.title': 'Arquivos',
'files.pageTitle': 'Arquivos e documentos',
'files.subtitle': '{count} arquivos para {trip}',
'files.download': 'Baixar',
'files.openError': 'Não foi possível abrir o arquivo',
'files.downloadPdf': 'Baixar PDF',
'files.count': '{count} arquivos',
'files.countSingular': '1 arquivo',
+2
View File
@@ -1220,6 +1220,8 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'files.title': 'Soubory',
'files.pageTitle': 'Soubory a dokumenty',
'files.subtitle': '{count} souborů pro {trip}',
'files.download': 'Stáhnout',
'files.openError': 'Soubor nelze otevřít',
'files.downloadPdf': 'Stáhnout PDF',
'files.count': '{count} souborů',
'files.countSingular': '1 soubor',
+2
View File
@@ -1224,6 +1224,8 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'files.title': 'Dateien',
'files.pageTitle': 'Dateien & Dokumente',
'files.subtitle': '{count} Dateien für {trip}',
'files.download': 'Herunterladen',
'files.openError': 'Datei konnte nicht geöffnet werden',
'files.downloadPdf': 'PDF herunterladen',
'files.count': '{count} Dateien',
'files.countSingular': '1 Datei',
+2
View File
@@ -1281,6 +1281,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'files.title': 'Files',
'files.pageTitle': 'Files & Documents',
'files.subtitle': '{count} files for {trip}',
'files.download': 'Download',
'files.openError': 'Could not open file',
'files.downloadPdf': 'Download PDF',
'files.count': '{count} files',
'files.countSingular': '1 file',
+2
View File
@@ -1168,6 +1168,8 @@ const es: Record<string, string> = {
'files.title': 'Archivos',
'files.pageTitle': 'Archivos y documentos',
'files.subtitle': '{count} archivos para {trip}',
'files.download': 'Descargar',
'files.openError': 'No se pudo abrir el archivo',
'files.downloadPdf': 'Descargar PDF',
'files.count': '{count} archivos',
'files.countSingular': '1 archivo',
+2
View File
@@ -1218,6 +1218,8 @@ const fr: Record<string, string> = {
'files.title': 'Fichiers',
'files.pageTitle': 'Fichiers et documents',
'files.subtitle': '{count} fichiers pour {trip}',
'files.download': 'Télécharger',
'files.openError': "Impossible d'ouvrir le fichier",
'files.downloadPdf': 'Télécharger le PDF',
'files.count': '{count} fichiers',
'files.countSingular': '1 fichier',
+2
View File
@@ -1219,6 +1219,8 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'files.title': 'Fájlok',
'files.pageTitle': 'Fájlok és dokumentumok',
'files.subtitle': '{count} fájl a következőhöz: {trip}',
'files.download': 'Letöltés',
'files.openError': 'A fájl megnyitása sikertelen',
'files.downloadPdf': 'PDF letöltése',
'files.count': '{count} fájl',
'files.countSingular': '1 fájl',
+2
View File
@@ -1279,6 +1279,8 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'files.title': 'File',
'files.pageTitle': 'File & Dokumen',
'files.subtitle': '{count} file untuk {trip}',
'files.download': 'Unduh',
'files.openError': 'Tidak dapat membuka file',
'files.downloadPdf': 'Unduh PDF',
'files.count': '{count} file',
'files.countSingular': '1 berkas',
+2
View File
@@ -1219,6 +1219,8 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'files.title': 'File',
'files.pageTitle': 'File e documenti',
'files.subtitle': '{count} file per {trip}',
'files.download': 'Scarica',
'files.openError': 'Impossibile aprire il file',
'files.downloadPdf': 'Scarica PDF',
'files.count': '{count} file',
'files.countSingular': '1 documento',
+2
View File
@@ -1218,6 +1218,8 @@ const nl: Record<string, string> = {
'files.title': 'Bestanden',
'files.pageTitle': 'Bestanden en documenten',
'files.subtitle': '{count} bestanden voor {trip}',
'files.download': 'Downloaden',
'files.openError': 'Bestand kon niet worden geopend',
'files.downloadPdf': 'PDF downloaden',
'files.count': '{count} bestanden',
'files.countSingular': '1 bestand',
+2
View File
@@ -1170,6 +1170,8 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'files.title': 'Pliki',
'files.pageTitle': 'Pliki i dokumenty',
'files.subtitle': '{count} plików dla {trip}',
'files.download': 'Pobierz',
'files.openError': 'Nie można otworzyć pliku',
'files.downloadPdf': 'Pobierz PDF',
'files.count': '{count} plików',
'files.countSingular': '1 plik',
+2
View File
@@ -1218,6 +1218,8 @@ const ru: Record<string, string> = {
'files.title': 'Файлы',
'files.pageTitle': 'Файлы и документы',
'files.subtitle': '{count} файлов для {trip}',
'files.download': 'Скачать',
'files.openError': 'Не удалось открыть файл',
'files.downloadPdf': 'Скачать PDF',
'files.count': '{count} файлов',
'files.countSingular': '1 файл',
+2
View File
@@ -1218,6 +1218,8 @@ const zh: Record<string, string> = {
'files.title': '文件',
'files.pageTitle': '文件与文档',
'files.subtitle': '{trip} 的 {count} 个文件',
'files.download': '下载',
'files.openError': '无法打开文件',
'files.downloadPdf': '下载 PDF',
'files.count': '{count} 个文件',
'files.countSingular': '1 个文件',
+2
View File
@@ -1278,6 +1278,8 @@ const zhTw: Record<string, string> = {
'files.title': '檔案',
'files.pageTitle': '檔案與文件',
'files.subtitle': '{trip} 的 {count} 個檔案',
'files.download': '下載',
'files.openError': '無法開啟檔案',
'files.downloadPdf': '下載 PDF',
'files.count': '{count} 個檔案',
'files.countSingular': '1 個檔案',
+1 -1
View File
@@ -219,7 +219,7 @@ export default function JourneyPublicPage() {
{/* Mobile combined map+timeline (public, read-only) */}
{isMobile && view === 'timeline' && perms.share_timeline && perms.share_map && (
<MobileMapTimeline
entries={entries}
entries={timelineEntries}
mapEntries={mapEntries.map(e => ({ id: String(e.id), lat: e.location_lat!, lng: e.location_lng!, title: e.title, mood: e.mood, entry_date: e.entry_date }))}
dark={document.documentElement.classList.contains('dark')}
readOnly
+40
View File
@@ -1906,6 +1906,46 @@ function runMigrations(db: Database.Database): void {
CREATE INDEX IF NOT EXISTS idx_day_accommodations_end_day_id ON day_accommodations(end_day_id);
`);
},
// Migration: null out proxy image_url entries that have no backing disk cache.
// Migrations 107 and the migration below wrote /api/maps/place-photo/<id>/bytes
// into places.image_url without actually fetching/caching the photo bytes. The
// photoService short-circuits on that prefix and hits /bytes directly → 404.
// Rows with a confirmed disk cache entry in google_place_photo_meta are left alone;
// only stale proxy URLs (never actually fetched) are cleared so the normal
// fetch-and-cache flow can repopulate them.
() => {
db.exec(`
UPDATE places
SET image_url = NULL, updated_at = CURRENT_TIMESTAMP
WHERE image_url LIKE '/api/maps/place-photo/%/bytes'
AND google_place_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM google_place_photo_meta
WHERE place_id = places.google_place_id
AND error_at IS NULL
)
`);
},
// Migration: clear legacy Google photo URLs missed by Migration 107.
// Migration 107 matched /places/%/photos/% only; lh3.googleusercontent.com URLs use
// /place-photos/ or /places/<opaque-id> paths and were skipped. NULL those stale URLs
// so the normal fetch-and-cache flow repopulates image_url with a real proxy URL.
() => {
db.exec(`
UPDATE places
SET image_url = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE image_url IS NOT NULL
AND image_url != ''
AND image_url NOT LIKE '/api/maps/place-photo/%'
AND (
image_url LIKE 'http://%googleusercontent.com/%'
OR image_url LIKE 'https://%googleusercontent.com/%'
OR image_url LIKE 'http://%places.googleapis.com/%'
OR image_url LIKE 'https://%places.googleapis.com/%'
)
`);
},
];
if (currentVersion < migrations.length) {
+2 -6
View File
@@ -77,15 +77,11 @@ const upload = multer({
// Routes
// ---------------------------------------------------------------------------
// Authenticated file download (supports Bearer header or ?token= query param)
// Authenticated file download (supports cookie, Bearer header, or ?token= query param)
router.get('/:id/download', (req: Request, res: Response) => {
const { tripId, id } = req.params;
const authHeader = req.headers['authorization'];
const bearerToken = authHeader && authHeader.split(' ')[1];
const queryToken = req.query.token as string | undefined;
const auth = authenticateDownload(bearerToken, queryToken);
const auth = authenticateDownload(req);
if ('error' in auth) return res.status(auth.status).json({ error: auth.error });
const trip = verifyTripAccess(tripId, auth.userId);
+1 -1
View File
@@ -62,7 +62,7 @@ export function parseAutoBackupBody(body: Record<string, unknown>): {
}
export function isValidBackupFilename(filename: string): boolean {
return /^backup-[\w\-]+\.zip$/.test(filename);
return /^(?:auto-)?backup-[\w-]+\.zip$/.test(filename);
}
export function backupFilePath(filename: string): string {
+17 -9
View File
@@ -1,5 +1,6 @@
import path from 'path';
import fs from 'fs';
import type { Request } from 'express';
import { db, canAccessTrip } from '../db/database';
import { consumeEphemeralToken } from './ephemeralTokens';
import { verifyJwtAndLoadUser } from '../middleware/auth';
@@ -72,23 +73,30 @@ export function resolveFilePath(filename: string): { resolved: string; safe: boo
// Token-based download auth
// ---------------------------------------------------------------------------
export function authenticateDownload(bearerToken: string | undefined, queryToken: string | undefined): { userId: number } | { error: string; status: number } {
if (!bearerToken && !queryToken) {
return { error: 'Authentication required', status: 401 };
}
export function authenticateDownload(req: Request): { userId: number } | { error: string; status: number } {
const cookieToken = (req as any).cookies?.trek_session as string | undefined;
const authHeader = req.headers['authorization'];
const bearerToken = authHeader ? (authHeader.split(' ')[1] || undefined) : undefined;
const queryToken = req.query.token as string | undefined;
if (bearerToken) {
// Cookie and Bearer both carry a full JWT — try them first (cookie wins).
const jwtToken = cookieToken || bearerToken;
if (jwtToken) {
// Use the shared helper so the password_version gate applies here too;
// previously this bypassed the check and stolen download tokens stayed
// valid across a password reset.
const user = verifyJwtAndLoadUser(bearerToken);
const user = verifyJwtAndLoadUser(jwtToken);
if (!user) return { error: 'Invalid or expired token', status: 401 };
return { userId: user.id };
}
const uid = consumeEphemeralToken(queryToken!, 'download');
if (!uid) return { error: 'Invalid or expired token', status: 401 };
return { userId: uid };
if (queryToken) {
const uid = consumeEphemeralToken(queryToken, 'download');
if (!uid) return { error: 'Invalid or expired token', status: 401 };
return { userId: uid };
}
return { error: 'Authentication required', status: 401 };
}
// ---------------------------------------------------------------------------
+11 -3
View File
@@ -648,6 +648,12 @@ export async function getPlacePhoto(
return null;
}
// Reject URL-shaped placeIds — legacy DBs may store raw photo URLs in image_url
if (/^https?:\/\//i.test(placeId)) {
placePhotoCache.markError(placeId);
return null;
}
// Google Photos — fetch details to get photo name
const detailsRes = await googleFetch(`https://places.googleapis.com/v1/places/${placeId}`, `getPlacePhoto/details(${placeId})`, {
headers: {
@@ -655,13 +661,15 @@ export async function getPlacePhoto(
'X-Goog-FieldMask': 'photos',
},
});
const details = await detailsRes.json() as GooglePlaceDetails & { error?: { message?: string } };
const body = await detailsRes.text();
if (!detailsRes.ok) {
console.error('Google Places photo details error:', details.error?.message || detailsRes.status);
console.error('Google Places photo details error:', detailsRes.status, body.slice(0, 200));
placePhotoCache.markError(placeId);
return null;
}
let details: GooglePlaceDetails & { error?: { message?: string } };
try { details = body ? JSON.parse(body) : { photos: [] }; }
catch { placePhotoCache.markError(placeId); return null; }
if (!details.photos?.length) {
placePhotoCache.markError(placeId);
+3 -1
View File
@@ -96,7 +96,9 @@ export function getInFlight(placeId: string): Promise<{ filePath: string; attrib
export function setInFlight(placeId: string, promise: Promise<{ filePath: string; attribution: string | null } | null>): void {
inFlight.set(placeId, promise);
promise.finally(() => inFlight.delete(placeId));
promise
.finally(() => inFlight.delete(placeId))
.catch(() => { /* awaiter logs; this .catch only prevents unhandledRejection */ });
}
export function serveFilePath(placeId: string): string | null {
+15 -2
View File
@@ -365,13 +365,12 @@ describe('File download', () => {
expect(res.status).toBe(401);
});
it('FILE-008 — GET /:id/download with Bearer JWT downloads or 404s (no physical file in tests)', async () => {
it('FILE-008 — GET /:id/download with Bearer JWT downloads file', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const upload = await uploadFile(trip.id, user.id, FIXTURE_PDF);
const fileId = upload.body.file.id;
// authenticateDownload accepts a signed JWT as Bearer token
const token = generateToken(user.id);
const dl = await request(app)
@@ -380,4 +379,18 @@ describe('File download', () => {
// multer stores the file to disk during uploadFile — physical file exists
expect(dl.status).toBe(200);
});
it('FILE-011 — GET /:id/download with trek_session cookie downloads file', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const upload = await uploadFile(trip.id, user.id, FIXTURE_PDF);
const fileId = upload.body.file.id;
const token = generateToken(user.id);
const dl = await request(app)
.get(`/api/trips/${trip.id}/files/${fileId}/download`)
.set('Cookie', `trek_session=${token}`);
expect(dl.status).toBe(200);
});
});
@@ -234,6 +234,22 @@ describe('BACKUP-034 isValidBackupFilename', () => {
it('accepts filename with hyphens and underscores', () => {
expect(isValidBackupFilename('backup-my_trek-2026.zip')).toBe(true);
});
it('accepts auto-backup filename', () => {
expect(isValidBackupFilename('auto-backup-2026-04-21T00-00-00.zip')).toBe(true);
});
it('rejects auto-backup with empty body', () => {
expect(isValidBackupFilename('auto-backup-.zip')).toBe(false);
});
it('rejects backup with empty body', () => {
expect(isValidBackupFilename('backup-.zip')).toBe(false);
});
it('rejects arbitrary auto- prefix that is not auto-backup', () => {
expect(isValidBackupFilename('auto-notbackup-2026.zip')).toBe(false);
});
});
// ---------------------------------------------------------------------------
@@ -1235,7 +1235,7 @@ describe('getPlacePhoto (fetch stubbed)', () => {
// First call: get place details (with photos)
.mockResolvedValueOnce({
ok: true,
json: async () => ({
text: async () => JSON.stringify({
photos: [{ name: 'places/ChIJABC/photos/photo1', authorAttributions: [{ displayName: 'Photographer' }] }],
}),
})
@@ -1258,7 +1258,7 @@ describe('getPlacePhoto (fetch stubbed)', () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: false,
status: 403,
json: async () => ({ error: { message: 'Forbidden' } }),
text: async () => JSON.stringify({ error: { message: 'Forbidden' } }),
}));
const { getPlacePhoto } = await import('../../../src/services/mapsService');
const errId = `ChIJErr-${Date.now()}`;
@@ -1269,7 +1269,7 @@ describe('getPlacePhoto (fetch stubbed)', () => {
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ photos: [] }),
text: async () => JSON.stringify({ photos: [] }),
}));
const { getPlacePhoto } = await import('../../../src/services/mapsService');
const noPhotoId = `ChIJNone-${Date.now()}`;
@@ -1281,7 +1281,7 @@ describe('getPlacePhoto (fetch stubbed)', () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({
text: async () => JSON.stringify({
photos: [{ name: 'places/ChIJXYZ/photos/photo1', authorAttributions: [] }],
}),
})
@@ -1301,7 +1301,7 @@ describe('getPlacePhoto (fetch stubbed)', () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({
text: async () => JSON.stringify({
photos: [{ name: 'places/ChIJNoAttr/photos/photo1', authorAttributions: [] }],
}),
})