mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
Fix a batch of reported bugs (#1145)
* fix(maps): fall back to OSM/Wikipedia for place photos and normalize non-standard language codes (#1137) * fix(auth): refuse password reset for OIDC/SSO-linked accounts (#1129) * fix(docker): ship server/assets (airports + atlas geo) in the runtime image (#1133, #1119) * fix(unraid): point the template at a PNG icon Unraid can render (#1073) * fix(offline): serve cached file blobs when offline or on network failure (#1046, #1069) * fix(map): centre the selected pin in the visible map area above the bottom panel (#1125) * fix(pdf): render persisted place-photo proxy URLs as images (#1130) * fix(planner): show the selected place category in the edit form (#1134) * fix(dashboard): collapse list-view trip cards to a compact row on mobile (#1132)
This commit is contained in:
@@ -68,6 +68,11 @@ ENV QT_QPA_PLATFORM=offscreen
|
|||||||
ENV KITINERARY_EXTRACTOR_PATH=/usr/local/bin/kitinerary-extractor
|
ENV KITINERARY_EXTRACTOR_PATH=/usr/local/bin/kitinerary-extractor
|
||||||
|
|
||||||
COPY --from=server-builder /app/server/dist ./server/dist
|
COPY --from=server-builder /app/server/dist ./server/dist
|
||||||
|
# Runtime data assets read from server/assets at runtime: airports.json (flight
|
||||||
|
# transport search) and atlas/*.geojson.gz (Atlas country/region map). The build
|
||||||
|
# only emits dist, so these must be copied explicitly or the features silently
|
||||||
|
# degrade to empty in the image.
|
||||||
|
COPY --from=server-builder /app/server/assets ./server/assets
|
||||||
# tsconfig-paths/register reads this at runtime to resolve MCP SDK paths.
|
# tsconfig-paths/register reads this at runtime to resolve MCP SDK paths.
|
||||||
COPY server/tsconfig.json ./server/
|
COPY server/tsconfig.json ./server/
|
||||||
COPY --from=shared-builder /app/shared/dist ./shared/dist
|
COPY --from=shared-builder /app/shared/dist ./shared/dist
|
||||||
|
|||||||
@@ -131,10 +131,21 @@ function SelectionController({ places, selectedPlaceId, dayPlaces, paddingOpts }
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedPlaceId && selectedPlaceId !== prev.current) {
|
if (selectedPlaceId && selectedPlaceId !== prev.current) {
|
||||||
// Pan to the selected place without changing zoom
|
// Pan to the selected place without changing zoom. Offset the centre by the
|
||||||
|
// side-panel + bottom-inspector padding so the pin lands in the middle of the
|
||||||
|
// *visible* map area rather than the geometric centre (where the bottom panel
|
||||||
|
// would cover it). Reuses the same paddingOpts the fit-bounds path uses.
|
||||||
const selected = places.find(p => p.id === selectedPlaceId)
|
const selected = places.find(p => p.id === selectedPlaceId)
|
||||||
if (selected?.lat && selected?.lng) {
|
if (selected?.lat != null && selected?.lng != null) {
|
||||||
map.panTo([selected.lat, selected.lng], { animate: true })
|
const latlng: [number, number] = [selected.lat, selected.lng]
|
||||||
|
const tl = paddingOpts.paddingTopLeft as [number, number] | undefined
|
||||||
|
const br = paddingOpts.paddingBottomRight as [number, number] | undefined
|
||||||
|
if (tl && br && typeof map.project === 'function' && typeof map.unproject === 'function') {
|
||||||
|
const point = map.project(latlng).add([(br[0] - tl[0]) / 2, (br[1] - tl[1]) / 2])
|
||||||
|
map.panTo(map.unproject(point), { animate: true })
|
||||||
|
} else {
|
||||||
|
map.panTo(latlng, { animate: true })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
prev.current = selectedPlaceId
|
prev.current = selectedPlaceId
|
||||||
|
|||||||
@@ -553,6 +553,10 @@ export function MapViewGL({
|
|||||||
zoom: Math.max(map.getZoom(), 14),
|
zoom: Math.max(map.getZoom(), 14),
|
||||||
pitch: mapbox3d ? 45 : 0,
|
pitch: mapbox3d ? 45 : 0,
|
||||||
duration: 400,
|
duration: 400,
|
||||||
|
// Account for the side panels and the bottom inspector / day-detail panel
|
||||||
|
// so the selected pin lands in the centre of the *visible* map area rather
|
||||||
|
// than the geometric centre (where the bottom panel would cover it).
|
||||||
|
padding: paddingOpts,
|
||||||
})
|
})
|
||||||
} catch { /* noop */ }
|
} catch { /* noop */ }
|
||||||
}, [selectedPlaceId, mapbox3d]) // eslint-disable-line react-hooks/exhaustive-deps
|
}, [selectedPlaceId, mapbox3d]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|||||||
@@ -259,6 +259,23 @@ describe('downloadTripPDF', () => {
|
|||||||
expect(iframe!.srcdoc).toContain('colosseum.jpg')
|
expect(iframe!.srcdoc).toContain('colosseum.jpg')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('FE-COMP-TRIPPDF-018b: renders a persisted place-photo proxy image_url as an <img>, not the category icon (#1130)', async () => {
|
||||||
|
const args = {
|
||||||
|
...richArgs,
|
||||||
|
assignments: {
|
||||||
|
'10': [{
|
||||||
|
...assignmentForDay,
|
||||||
|
place: { ...placeWithDetails, image_url: '/api/maps/place-photo/ChIJabc/bytes' },
|
||||||
|
}],
|
||||||
|
} as any,
|
||||||
|
}
|
||||||
|
await downloadTripPDF(args)
|
||||||
|
const iframe = getIframe()
|
||||||
|
// The proxy path (no file extension) must still embed as an absolute <img>.
|
||||||
|
expect(iframe!.srcdoc).toContain('http://localhost:3000/api/maps/place-photo/ChIJabc/bytes')
|
||||||
|
expect(iframe!.srcdoc).toContain('class="place-thumb"')
|
||||||
|
})
|
||||||
|
|
||||||
it('FE-COMP-TRIPPDF-019: fetches google place photos for places with google_place_id', async () => {
|
it('FE-COMP-TRIPPDF-019: fetches google place photos for places with google_place_id', async () => {
|
||||||
let photoCalled = false
|
let photoCalled = false
|
||||||
server.use(
|
server.use(
|
||||||
|
|||||||
@@ -55,6 +55,10 @@ function absUrl(url) {
|
|||||||
function safeImg(url) {
|
function safeImg(url) {
|
||||||
if (!url) return null
|
if (!url) return null
|
||||||
if (url.startsWith('https://') || url.startsWith('http://')) return url
|
if (url.startsWith('https://') || url.startsWith('http://')) return url
|
||||||
|
// The in-app place-photo proxy always streams a JPEG but has no file extension
|
||||||
|
// (it ends in …/bytes), so the extension check below would wrongly reject it —
|
||||||
|
// which is why persisted place photos showed as category icons in the PDF.
|
||||||
|
if (url.startsWith('/api/maps/place-photo/')) return absUrl(url)
|
||||||
return /\.(jpe?g|png|webp|bmp|tiff?)(\?.*)?$/i.test(url) ? absUrl(url) : null
|
return /\.(jpe?g|png|webp|bmp|tiff?)(\?.*)?$/i.test(url) ? absUrl(url) : null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,9 +258,10 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
|||||||
const cat = categories.find(c => c.id === place.category_id)
|
const cat = categories.find(c => c.id === place.category_id)
|
||||||
const color = cat?.color || '#6366f1'
|
const color = cat?.color || '#6366f1'
|
||||||
|
|
||||||
// Image: direct > google photo > fallback icon
|
// Image: direct > google photo > fallback icon. Both go through safeImg
|
||||||
|
// so the proxy path is resolved to an absolute URL the PDF can load.
|
||||||
const directImg = safeImg(place.image_url)
|
const directImg = safeImg(place.image_url)
|
||||||
const googleImg = photoMap[place.id] || null
|
const googleImg = safeImg(photoMap[place.id])
|
||||||
const img = directImg || googleImg
|
const img = directImg || googleImg
|
||||||
|
|
||||||
const iconSvg = categoryIconSvg(cat?.icon, color, 24)
|
const iconSvg = categoryIconSvg(cat?.icon, color, 24)
|
||||||
|
|||||||
@@ -270,6 +270,18 @@ describe('PlaceFormModal', () => {
|
|||||||
expect(screen.getByText(/No category/i)).toBeInTheDocument();
|
expect(screen.getByText(/No category/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-PLACEFORM-023b: editing a place shows its assigned category, not the placeholder (#1134)', () => {
|
||||||
|
// Regression: form.category_id is a string but the option values were numbers,
|
||||||
|
// so CustomSelect's strict-equality match failed and the trigger fell back to
|
||||||
|
// "No category". With string option values the chosen category renders.
|
||||||
|
const cat = buildCategory({ name: 'Museums' });
|
||||||
|
const place = buildPlace({ name: 'Louvre', category_id: cat.id });
|
||||||
|
render(<PlaceFormModal {...defaultProps} place={place} categories={[cat]} />);
|
||||||
|
// Dropdown is closed, so the only place the category name can appear is the trigger.
|
||||||
|
expect(screen.getByText('Museums')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/No category/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it('FE-PLANNER-PLACEFORM-024: onCategoryCreated is called when creating a category', async () => {
|
it('FE-PLANNER-PLACEFORM-024: onCategoryCreated is called when creating a category', async () => {
|
||||||
const onCategoryCreated = vi.fn().mockResolvedValue({ id: 99, name: 'Beaches', color: '#6366f1', icon: 'MapPin' });
|
const onCategoryCreated = vi.fn().mockResolvedValue({ id: 99, name: 'Beaches', color: '#6366f1', icon: 'MapPin' });
|
||||||
// Directly invoke handleCreateCategory by setting showNewCategory via the category name input
|
// Directly invoke handleCreateCategory by setting showNewCategory via the category name input
|
||||||
|
|||||||
@@ -636,7 +636,10 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
|
|||||||
options={[
|
options={[
|
||||||
{ value: '', label: t('places.noCategory') },
|
{ value: '', label: t('places.noCategory') },
|
||||||
...(categories || []).map(c => ({
|
...(categories || []).map(c => ({
|
||||||
value: c.id,
|
// form.category_id is a string; CustomSelect matches options by
|
||||||
|
// strict equality, so the option value must be a string too —
|
||||||
|
// otherwise the chosen category never renders in the trigger.
|
||||||
|
value: String(c.id),
|
||||||
label: c.name,
|
label: c.name,
|
||||||
})),
|
})),
|
||||||
]}
|
]}
|
||||||
|
|||||||
@@ -148,6 +148,24 @@ export async function upsertSyncMeta(meta: SyncMeta): Promise<void> {
|
|||||||
await offlineDb.syncMeta.put(meta);
|
await offlineDb.syncMeta.put(meta);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read a pre-downloaded file blob for offline use. Returns null when the file
|
||||||
|
* was never cached (or on any read error). The stored MIME is reapplied so the
|
||||||
|
* caller's inline-vs-download decision stays correct even if the persisted Blob
|
||||||
|
* lost its type.
|
||||||
|
*/
|
||||||
|
export async function getCachedBlob(url: string): Promise<Blob | null> {
|
||||||
|
try {
|
||||||
|
const entry = await offlineDb.blobCache.get(url);
|
||||||
|
if (!entry) return null;
|
||||||
|
return entry.blob.type
|
||||||
|
? entry.blob
|
||||||
|
: new Blob([entry.blob], { type: entry.mime || 'application/octet-stream' });
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Eviction / cleanup ────────────────────────────────────────────────────────
|
// ── Eviction / cleanup ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Delete all cached data for one trip (eviction or explicit clear). */
|
/** Delete all cached data for one trip (eviction or explicit clear). */
|
||||||
|
|||||||
@@ -580,6 +580,23 @@
|
|||||||
.trek-dash .trips { grid-template-columns: 1fr; gap: 16px; margin-bottom: 28px; }
|
.trek-dash .trips { grid-template-columns: 1fr; gap: 16px; margin-bottom: 28px; }
|
||||||
.trek-dash .add-trip-card { min-height: 180px; }
|
.trek-dash .add-trip-card { min-height: 180px; }
|
||||||
|
|
||||||
|
/* Compact list row on mobile — keeps the list view distinct from the grid. The
|
||||||
|
desktop list row uses a 520px cover, which overflowed the phone width: the
|
||||||
|
cover was clipped, the body pushed off-screen, and the fixed 100px cover
|
||||||
|
height left a white strip beneath it. Use a fitting cover that stretches to
|
||||||
|
the row, and show just the title + dates (the counts live in grid view and
|
||||||
|
on the trip itself). */
|
||||||
|
.trek-dash .trips.list-view .trip-card { grid-template-columns: 42% 1fr; min-height: 92px; }
|
||||||
|
.trek-dash .trips.list-view .trip-cover { height: auto; aspect-ratio: unset; }
|
||||||
|
.trek-dash .trips.list-view .trip-cover-content { left: 14px; right: 14px; bottom: 12px; }
|
||||||
|
.trek-dash .trips.list-view .trip-name {
|
||||||
|
font-size: 17px; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
|
.trek-dash .trips.list-view .trip-body { display: flex; align-items: center; justify-content: flex-start; padding: 12px 16px; }
|
||||||
|
.trek-dash .trips.list-view .trip-dates { margin-bottom: 0; justify-content: flex-start; }
|
||||||
|
.trek-dash .trips.list-view .trip-meta { display: none; }
|
||||||
|
|
||||||
/* Tools — stacked full-width cards (mockup) */
|
/* Tools — stacked full-width cards (mockup) */
|
||||||
.trek-dash .page-sidebar { flex-direction: column; flex-wrap: nowrap; gap: 14px; margin: 0; padding: 0; }
|
.trek-dash .page-sidebar { flex-direction: column; flex-wrap: nowrap; gap: 14px; margin: 0; padding: 0; }
|
||||||
.trek-dash .page-sidebar .tool { flex: none; width: auto; }
|
.trek-dash .page-sidebar .tool { flex: none; width: auto; }
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { getCachedBlob } from '../db/offlineDb'
|
||||||
|
|
||||||
// MIME types safe to open inline (will not execute script in any browser).
|
// MIME types safe to open inline (will not execute script in any browser).
|
||||||
// Everything else (text/html, image/svg+xml, text/javascript, …) is forced to
|
// Everything else (text/html, image/svg+xml, text/javascript, …) is forced to
|
||||||
// download so a maliciously-named upload cannot run code in the TREK origin.
|
// download so a maliciously-named upload cannot run code in the TREK origin.
|
||||||
@@ -39,17 +41,46 @@ function isIosStandalone(): boolean {
|
|||||||
return (navigator as any).standalone === true
|
return (navigator as any).standalone === true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a protected file to a Blob, preferring the live server but falling
|
||||||
|
* back to the offline cache (pre-downloaded by the trip sync manager). This is
|
||||||
|
* what lets attachments open in a PWA / airplane mode. When offline we go
|
||||||
|
* straight to the cache; when online we fetch live and only fall back if the
|
||||||
|
* network actually fails — which also covers flaky links where navigator.onLine
|
||||||
|
* still reports true ("sometimes it works, sometimes it doesn't").
|
||||||
|
*/
|
||||||
|
async function getFileBlob(url: string): Promise<Blob> {
|
||||||
|
assertRelativeUrl(url)
|
||||||
|
if (typeof navigator !== 'undefined' && navigator.onLine === false) {
|
||||||
|
const cached = await getCachedBlob(url)
|
||||||
|
if (cached) return cached
|
||||||
|
throw new Error('File not available offline')
|
||||||
|
}
|
||||||
|
let resp: Response
|
||||||
|
try {
|
||||||
|
resp = await fetch(url, { credentials: 'include' })
|
||||||
|
} catch (err) {
|
||||||
|
// Genuine network failure — the fetch itself rejected (offline, or a flaky
|
||||||
|
// link even though navigator.onLine is true). Serve the pre-downloaded copy.
|
||||||
|
const cached = await getCachedBlob(url)
|
||||||
|
if (cached) return cached
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
// The server answered: a non-ok status (401/403/404/…) is a real error and must
|
||||||
|
// surface, not be masked by a stale cached copy.
|
||||||
|
if (!resp.ok) throw new Error(resp.status === 401 ? 'Unauthorized' : `HTTP ${resp.status}`)
|
||||||
|
return await resp.blob()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches a protected file using cookie auth (credentials: include) and
|
* Fetches a protected file using cookie auth (credentials: include) and
|
||||||
* triggers a browser download. Works inside PWA standalone mode because the
|
* triggers a browser download. Works inside PWA standalone mode because the
|
||||||
* fetch stays in the PWA's WebView rather than handing off to the system
|
* fetch stays in the PWA's WebView rather than handing off to the system
|
||||||
* browser (which would lose the session cookie).
|
* browser (which would lose the session cookie). Falls back to the offline
|
||||||
|
* cache when the network is unavailable.
|
||||||
*/
|
*/
|
||||||
export async function downloadFile(url: string, filename?: string): Promise<void> {
|
export async function downloadFile(url: string, filename?: string): Promise<void> {
|
||||||
assertRelativeUrl(url)
|
const blob = await getFileBlob(url)
|
||||||
const resp = await fetch(url, { credentials: 'include' })
|
|
||||||
if (!resp.ok) throw new Error(resp.status === 401 ? 'Unauthorized' : `HTTP ${resp.status}`)
|
|
||||||
const blob = await resp.blob()
|
|
||||||
const blobUrl = URL.createObjectURL(blob)
|
const blobUrl = URL.createObjectURL(blob)
|
||||||
triggerAnchorDownload(blobUrl, filename)
|
triggerAnchorDownload(blobUrl, filename)
|
||||||
}
|
}
|
||||||
@@ -72,10 +103,7 @@ export async function downloadFile(url: string, filename?: string): Promise<void
|
|||||||
* spurious in-page download is triggered.
|
* spurious in-page download is triggered.
|
||||||
*/
|
*/
|
||||||
export async function openFile(url: string, filename?: string): Promise<void> {
|
export async function openFile(url: string, filename?: string): Promise<void> {
|
||||||
assertRelativeUrl(url)
|
const blob = await getFileBlob(url)
|
||||||
const resp = await fetch(url, { credentials: 'include' })
|
|
||||||
if (!resp.ok) throw new Error(resp.status === 401 ? 'Unauthorized' : `HTTP ${resp.status}`)
|
|
||||||
const blob = await resp.blob()
|
|
||||||
const blobUrl = URL.createObjectURL(blob)
|
const blobUrl = URL.createObjectURL(blob)
|
||||||
|
|
||||||
// Force download for MIME types that can execute script when rendered inline
|
// Force download for MIME types that can execute script when rendered inline
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
import { downloadFile, openFile } from '../../../src/utils/fileDownload'
|
import { downloadFile, openFile } from '../../../src/utils/fileDownload'
|
||||||
|
import { getCachedBlob } from '../../../src/db/offlineDb'
|
||||||
|
|
||||||
|
// Mock the offline DB so these tests never touch Dexie/IndexedDB.
|
||||||
|
vi.mock('../../../src/db/offlineDb', () => ({ getCachedBlob: vi.fn() }))
|
||||||
|
|
||||||
function makeFetchMock(status: number, blob: Blob = new Blob(['data'], { type: 'application/pdf' })) {
|
function makeFetchMock(status: number, blob: Blob = new Blob(['data'], { type: 'application/pdf' })) {
|
||||||
return vi.fn().mockResolvedValue({
|
return vi.fn().mockResolvedValue({
|
||||||
@@ -170,3 +174,52 @@ describe('openFile', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('offline fallback (#1046)', () => {
|
||||||
|
function setOnline(value: boolean) {
|
||||||
|
Object.defineProperty(navigator, 'onLine', { value, configurable: true })
|
||||||
|
}
|
||||||
|
beforeEach(() => vi.mocked(getCachedBlob).mockReset())
|
||||||
|
afterEach(() => setOnline(true))
|
||||||
|
|
||||||
|
it('serves the cached blob without a network call when offline', async () => {
|
||||||
|
setOnline(false)
|
||||||
|
const blob = new Blob(['x'], { type: 'application/pdf' })
|
||||||
|
vi.mocked(getCachedBlob).mockResolvedValue(blob)
|
||||||
|
const fetchSpy = vi.fn()
|
||||||
|
vi.stubGlobal('fetch', fetchSpy)
|
||||||
|
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||||
|
|
||||||
|
await downloadFile('/uploads/files/cached.pdf')
|
||||||
|
|
||||||
|
expect(fetchSpy).not.toHaveBeenCalled()
|
||||||
|
expect(getCachedBlob).toHaveBeenCalledWith('/uploads/files/cached.pdf')
|
||||||
|
expect(URL.createObjectURL).toHaveBeenCalledWith(blob)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to the cache when a live fetch rejects (network error) while online', async () => {
|
||||||
|
setOnline(true)
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network down')))
|
||||||
|
const blob = new Blob(['x'], { type: 'application/pdf' })
|
||||||
|
vi.mocked(getCachedBlob).mockResolvedValue(blob)
|
||||||
|
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||||
|
|
||||||
|
await downloadFile('/uploads/files/cached.pdf')
|
||||||
|
|
||||||
|
expect(getCachedBlob).toHaveBeenCalledWith('/uploads/files/cached.pdf')
|
||||||
|
expect(URL.createObjectURL).toHaveBeenCalledWith(blob)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws when offline and the file was never cached', async () => {
|
||||||
|
setOnline(false)
|
||||||
|
vi.mocked(getCachedBlob).mockResolvedValue(null)
|
||||||
|
await expect(downloadFile('/uploads/files/missing.pdf')).rejects.toThrow(/offline/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not consult the cache on an HTTP error — a 401 still surfaces', async () => {
|
||||||
|
setOnline(true)
|
||||||
|
vi.stubGlobal('fetch', makeFetchMock(401))
|
||||||
|
await expect(downloadFile('/uploads/files/secret.pdf')).rejects.toThrow('Unauthorized')
|
||||||
|
expect(getCachedBlob).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -1194,9 +1194,13 @@ export function requestPasswordReset(rawEmail: string, createdIp: string | null)
|
|||||||
if (!user) {
|
if (!user) {
|
||||||
return { tokenForDelivery: null, userId: null, userEmail: null, reason: 'no_user' };
|
return { tokenForDelivery: null, userId: null, userEmail: null, reason: 'no_user' };
|
||||||
}
|
}
|
||||||
// OIDC-only account (no local password) — we can't reset what isn't there.
|
// SSO-linked account — refuse a reset. OIDC users are created with a random
|
||||||
|
// bcrypt hash (so password_hash is never empty), which is why we must key off
|
||||||
|
// oidc_sub rather than a missing hash. Letting the reset proceed would set a
|
||||||
|
// local password and revoke session/credential state, which breaks the SSO
|
||||||
|
// login; admins (or the user, with their current password) can still set one.
|
||||||
// The client still gets the generic "if that email exists…" response.
|
// The client still gets the generic "if that email exists…" response.
|
||||||
if (!user.password_hash && user.oidc_sub) {
|
if (user.oidc_sub) {
|
||||||
return { tokenForDelivery: null, userId: user.id, userEmail: user.email, reason: 'oidc_only' };
|
return { tokenForDelivery: null, userId: user.id, userEmail: user.email, reason: 'oidc_only' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,24 @@ interface GooglePlaceDetails extends GooglePlaceResult {
|
|||||||
|
|
||||||
const UA = 'TREK Travel Planner (https://github.com/mauriceboe/TREK)';
|
const UA = 'TREK Travel Planner (https://github.com/mauriceboe/TREK)';
|
||||||
|
|
||||||
|
// TREK's internal language codes mostly coincide with valid BCP-47 codes, but a
|
||||||
|
// couple don't: 'br' is Brazilian Portuguese here (BCP-47 'pt-BR'; bare 'br' is
|
||||||
|
// Breton) and 'gr' is Greek (BCP-47 'el'). Outbound geo APIs (Google Places,
|
||||||
|
// Nominatim) expect BCP-47, so normalise before sending — otherwise names and
|
||||||
|
// opening hours come back in the wrong language. Codes not listed here pass
|
||||||
|
// through unchanged (they are already valid), as do locale forms the client
|
||||||
|
// sometimes sends (e.g. 'pt-BR').
|
||||||
|
const API_LANG_OVERRIDES: Record<string, string> = {
|
||||||
|
br: 'pt-BR',
|
||||||
|
gr: 'el',
|
||||||
|
'el-GR': 'el',
|
||||||
|
};
|
||||||
|
function toApiLang(lang: string | undefined, fallback = 'en'): string {
|
||||||
|
const code = (lang || '').trim();
|
||||||
|
if (!code) return fallback;
|
||||||
|
return API_LANG_OVERRIDES[code] ?? code;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Photo cache (disk-backed) ────────────────────────────────────────────────
|
// ── Photo cache (disk-backed) ────────────────────────────────────────────────
|
||||||
import * as placePhotoCache from './placePhotoCache';
|
import * as placePhotoCache from './placePhotoCache';
|
||||||
|
|
||||||
@@ -115,7 +133,7 @@ export async function searchNominatim(query: string, lang?: string) {
|
|||||||
format: 'json',
|
format: 'json',
|
||||||
addressdetails: '1',
|
addressdetails: '1',
|
||||||
limit: '10',
|
limit: '10',
|
||||||
'accept-language': lang || 'en',
|
'accept-language': toApiLang(lang),
|
||||||
});
|
});
|
||||||
const response = await fetch(`https://nominatim.openstreetmap.org/search?${params}`, {
|
const response = await fetch(`https://nominatim.openstreetmap.org/search?${params}`, {
|
||||||
headers: { 'User-Agent': UA },
|
headers: { 'User-Agent': UA },
|
||||||
@@ -148,7 +166,7 @@ export async function lookupNominatim(osmType: string, osmId: string, lang?: str
|
|||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
osm_ids: `${typePrefix}${osmId}`,
|
osm_ids: `${typePrefix}${osmId}`,
|
||||||
format: 'json',
|
format: 'json',
|
||||||
'accept-language': lang || 'en',
|
'accept-language': toApiLang(lang),
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`https://nominatim.openstreetmap.org/lookup?${params}`, {
|
const res = await fetch(`https://nominatim.openstreetmap.org/lookup?${params}`, {
|
||||||
@@ -339,7 +357,7 @@ export async function searchPlaces(userId: number, query: string, lang?: string)
|
|||||||
'X-Goog-Api-Key': apiKey,
|
'X-Goog-Api-Key': apiKey,
|
||||||
'X-Goog-FieldMask': 'places.id,places.displayName,places.formattedAddress,places.location,places.rating,places.websiteUri,places.nationalPhoneNumber,places.types',
|
'X-Goog-FieldMask': 'places.id,places.displayName,places.formattedAddress,places.location,places.rating,places.websiteUri,places.nationalPhoneNumber,places.types',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ textQuery: query, languageCode: lang || 'en' }),
|
body: JSON.stringify({ textQuery: query, languageCode: toApiLang(lang) }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json() as { places?: GooglePlaceResult[]; error?: { message?: string } };
|
const data = await response.json() as { places?: GooglePlaceResult[]; error?: { message?: string } };
|
||||||
@@ -381,7 +399,7 @@ export async function autocompletePlaces(
|
|||||||
|
|
||||||
const body: Record<string, unknown> = {
|
const body: Record<string, unknown> = {
|
||||||
input,
|
input,
|
||||||
languageCode: lang || 'en',
|
languageCode: toApiLang(lang),
|
||||||
};
|
};
|
||||||
if (locationBias) {
|
if (locationBias) {
|
||||||
body.locationBias = {
|
body.locationBias = {
|
||||||
@@ -472,7 +490,7 @@ export async function getPlaceDetails(userId: number, placeId: string, lang?: st
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Google details
|
// Google details
|
||||||
const langKey = lang || 'de';
|
const langKey = toApiLang(lang, 'de');
|
||||||
const apiKey = getMapsKey(userId);
|
const apiKey = getMapsKey(userId);
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
throw Object.assign(new Error('Google Maps API key not configured'), { status: 400 });
|
throw Object.assign(new Error('Google Maps API key not configured'), { status: 400 });
|
||||||
@@ -532,7 +550,7 @@ export async function getPlaceDetails(userId: number, placeId: string, lang?: st
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getPlaceDetailsExpanded(userId: number, placeId: string, lang?: string, refresh = false): Promise<{ place: Record<string, unknown> }> {
|
export async function getPlaceDetailsExpanded(userId: number, placeId: string, lang?: string, refresh = false): Promise<{ place: Record<string, unknown> }> {
|
||||||
const langKey = lang || 'de';
|
const langKey = toApiLang(lang, 'de');
|
||||||
const apiKey = getMapsKey(userId);
|
const apiKey = getMapsKey(userId);
|
||||||
if (!apiKey) throw Object.assign(new Error('Google Maps API key not configured'), { status: 400 });
|
if (!apiKey) throw Object.assign(new Error('Google Maps API key not configured'), { status: 400 });
|
||||||
|
|
||||||
@@ -628,90 +646,93 @@ export async function getPlacePhoto(
|
|||||||
const apiKey = getMapsKey(userId);
|
const apiKey = getMapsKey(userId);
|
||||||
const isCoordLookup = placeId.startsWith('coords:');
|
const isCoordLookup = placeId.startsWith('coords:');
|
||||||
|
|
||||||
// No Google key or coordinate-only lookup → try Wikimedia (URL-based, not byte-cached)
|
// Coordinate-based Wikipedia/Wikimedia lookup. Used for coordinate-only
|
||||||
if (!apiKey || isCoordLookup) {
|
// (right-click) places and as a fallback when a Google place yields no photo,
|
||||||
if (!isNaN(lat) && !isNaN(lng)) {
|
// so a place added via search still gets a marker image when Google returns
|
||||||
try {
|
// nothing. Returns null (without marking an error) so the caller decides.
|
||||||
const wiki = await fetchWikimediaPhoto(lat, lng, name);
|
const fetchWikimediaFallback = async (): Promise<{ filePath: string; attribution: string | null } | null> => {
|
||||||
if (wiki) {
|
if (isNaN(lat) || isNaN(lng)) return null;
|
||||||
// Wikimedia photos: fetch bytes and cache to disk. Follow redirects
|
try {
|
||||||
// manually so each hop (the image URL can 3xx to a CDN host) is
|
const wiki = await fetchWikimediaPhoto(lat, lng, name);
|
||||||
// re-validated against the SSRF guard, not just the first URL.
|
if (!wiki) return null;
|
||||||
const imgRes = await safeFetchFollow(wiki.photoUrl, undefined, { bypassInternalIpAllowed: true });
|
// Follow redirects manually so each hop (the image URL can 3xx to a CDN
|
||||||
if (imgRes.ok) {
|
// host) is re-validated against the SSRF guard, not just the first URL.
|
||||||
const bytes = Buffer.from(await imgRes.arrayBuffer());
|
const imgRes = await safeFetchFollow(wiki.photoUrl, undefined, { bypassInternalIpAllowed: true });
|
||||||
const cached = await placePhotoCache.put(placeId, bytes, wiki.attribution);
|
if (!imgRes.ok) return null;
|
||||||
return { filePath: cached.filePath, attribution: cached.attribution };
|
const bytes = Buffer.from(await imgRes.arrayBuffer());
|
||||||
}
|
const cached = await placePhotoCache.put(placeId, bytes, wiki.attribution);
|
||||||
}
|
return { filePath: cached.filePath, attribution: cached.attribution };
|
||||||
} catch { /* fall through */ }
|
} catch {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
placePhotoCache.markError(placeId);
|
};
|
||||||
return null;
|
|
||||||
|
// Google Places photo for a Google place_id. Returns null (without marking an
|
||||||
|
// error) on any miss — no key, URL-shaped id, request rejected, no photos, or
|
||||||
|
// a failed media download — so the caller can fall back to Wikimedia.
|
||||||
|
const fetchGooglePhoto = async (): Promise<{ filePath: string; attribution: string | null } | null> => {
|
||||||
|
// URL-shaped placeIds aren't Google IDs — legacy DBs may store raw photo URLs in image_url
|
||||||
|
if (!apiKey || /^https?:\/\//i.test(placeId)) return null;
|
||||||
|
|
||||||
|
// Fetch details to get the photo name
|
||||||
|
const detailsRes = await googleFetch(`https://places.googleapis.com/v1/places/${placeId}`, `getPlacePhoto/details(${placeId})`, {
|
||||||
|
headers: {
|
||||||
|
'X-Goog-Api-Key': apiKey,
|
||||||
|
'X-Goog-FieldMask': 'photos',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const body = await detailsRes.text();
|
||||||
|
if (!detailsRes.ok) {
|
||||||
|
console.error('Google Places photo details error:', detailsRes.status, body.slice(0, 200));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
let details: GooglePlaceDetails & { error?: { message?: string } };
|
||||||
|
try { details = body ? JSON.parse(body) : { photos: [] }; }
|
||||||
|
catch { return null; }
|
||||||
|
if (!details.photos?.length) return null;
|
||||||
|
|
||||||
|
const photo = details.photos[0];
|
||||||
|
const photoName = photo.name;
|
||||||
|
const attribution = photo.authorAttributions?.[0]?.displayName || null;
|
||||||
|
|
||||||
|
// Fetch actual image bytes
|
||||||
|
const mediaRes = await googleFetch(
|
||||||
|
`https://places.googleapis.com/v1/${photoName}/media?maxHeightPx=400`,
|
||||||
|
`getPlacePhoto/media(${placeId})`,
|
||||||
|
{ headers: { 'X-Goog-Api-Key': apiKey } }
|
||||||
|
);
|
||||||
|
if (!mediaRes.ok) return null;
|
||||||
|
|
||||||
|
const bytes = Buffer.from(await mediaRes.arrayBuffer());
|
||||||
|
if (!bytes.length) return null;
|
||||||
|
|
||||||
|
const cached = await placePhotoCache.put(placeId, bytes, attribution);
|
||||||
|
|
||||||
|
// Persist stable proxy URL to database
|
||||||
|
try {
|
||||||
|
db.prepare(
|
||||||
|
'UPDATE places SET image_url = ?, updated_at = CURRENT_TIMESTAMP WHERE google_place_id = ? AND (image_url IS NULL OR image_url = \'\')'
|
||||||
|
).run(cached.photoUrl, placeId);
|
||||||
|
} catch (dbErr) {
|
||||||
|
console.error('Failed to persist photo URL to database:', dbErr);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { filePath: cached.filePath, attribution };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Prefer the Google photo (higher quality); if Google yields nothing, fall
|
||||||
|
// back to the same coordinate-based Wikipedia/OSM lookup that right-click
|
||||||
|
// places use. Coordinate-only ids skip Google entirely.
|
||||||
|
if (!isCoordLookup) {
|
||||||
|
const googlePhoto = await fetchGooglePhoto();
|
||||||
|
if (googlePhoto) return googlePhoto;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reject URL-shaped placeIds — legacy DBs may store raw photo URLs in image_url
|
const fallback = await fetchWikimediaFallback();
|
||||||
if (/^https?:\/\//i.test(placeId)) {
|
if (fallback) return fallback;
|
||||||
placePhotoCache.markError(placeId);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Google Photos — fetch details to get photo name
|
placePhotoCache.markError(placeId);
|
||||||
const detailsRes = await googleFetch(`https://places.googleapis.com/v1/places/${placeId}`, `getPlacePhoto/details(${placeId})`, {
|
return null;
|
||||||
headers: {
|
|
||||||
'X-Goog-Api-Key': apiKey,
|
|
||||||
'X-Goog-FieldMask': 'photos',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const body = await detailsRes.text();
|
|
||||||
if (!detailsRes.ok) {
|
|
||||||
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);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const photo = details.photos[0];
|
|
||||||
const photoName = photo.name;
|
|
||||||
const attribution = photo.authorAttributions?.[0]?.displayName || null;
|
|
||||||
|
|
||||||
// Fetch actual image bytes
|
|
||||||
const mediaRes = await googleFetch(
|
|
||||||
`https://places.googleapis.com/v1/${photoName}/media?maxHeightPx=400`,
|
|
||||||
`getPlacePhoto/media(${placeId})`,
|
|
||||||
{ headers: { 'X-Goog-Api-Key': apiKey } }
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!mediaRes.ok) {
|
|
||||||
placePhotoCache.markError(placeId);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const bytes = Buffer.from(await mediaRes.arrayBuffer());
|
|
||||||
if (!bytes.length) {
|
|
||||||
placePhotoCache.markError(placeId);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cached = await placePhotoCache.put(placeId, bytes, attribution);
|
|
||||||
|
|
||||||
// Persist stable proxy URL to database
|
|
||||||
try {
|
|
||||||
db.prepare(
|
|
||||||
'UPDATE places SET image_url = ?, updated_at = CURRENT_TIMESTAMP WHERE google_place_id = ? AND (image_url IS NULL OR image_url = \'\')'
|
|
||||||
).run(cached.photoUrl, placeId);
|
|
||||||
} catch (dbErr) {
|
|
||||||
console.error('Failed to persist photo URL to database:', dbErr);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { filePath: cached.filePath, attribution };
|
|
||||||
} finally {
|
} finally {
|
||||||
releasePhotoFetchSlot();
|
releasePhotoFetchSlot();
|
||||||
}
|
}
|
||||||
@@ -729,7 +750,7 @@ export async function getPlacePhoto(
|
|||||||
export async function reverseGeocode(lat: string, lng: string, lang?: string): Promise<{ name: string | null; address: string | null }> {
|
export async function reverseGeocode(lat: string, lng: string, lang?: string): Promise<{ name: string | null; address: string | null }> {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
lat, lon: lng, format: 'json', addressdetails: '1', zoom: '18',
|
lat, lon: lng, format: 'json', addressdetails: '1', zoom: '18',
|
||||||
'accept-language': lang || 'en',
|
'accept-language': toApiLang(lang),
|
||||||
});
|
});
|
||||||
const response = await fetch(`https://nominatim.openstreetmap.org/reverse?${params}`, {
|
const response = await fetch(`https://nominatim.openstreetmap.org/reverse?${params}`, {
|
||||||
headers: { 'User-Agent': UA },
|
headers: { 'User-Agent': UA },
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ import {
|
|||||||
validateInviteToken,
|
validateInviteToken,
|
||||||
registerUser,
|
registerUser,
|
||||||
loginUser,
|
loginUser,
|
||||||
|
requestPasswordReset,
|
||||||
changePassword,
|
changePassword,
|
||||||
verifyMfaLogin,
|
verifyMfaLogin,
|
||||||
createMcpToken,
|
createMcpToken,
|
||||||
@@ -106,6 +107,35 @@ beforeEach(() => resetTestDb(testDb));
|
|||||||
|
|
||||||
afterAll(() => testDb.close());
|
afterAll(() => testDb.close());
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// requestPasswordReset — OIDC/SSO accounts (#1129)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('requestPasswordReset — OIDC/SSO accounts', () => {
|
||||||
|
it('AUTH-DB-PR1: refuses a reset for an OIDC-linked account that has a (random) password hash', () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
// OIDC users are created with a random bcrypt hash, so password_hash is set —
|
||||||
|
// the old guard keyed off a missing hash and therefore let the reset through.
|
||||||
|
testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?')
|
||||||
|
.run('sub-1129', 'https://idp.example', user.id);
|
||||||
|
|
||||||
|
const result = requestPasswordReset(user.email, null);
|
||||||
|
|
||||||
|
expect(result.reason).toBe('oidc_only');
|
||||||
|
expect(result.tokenForDelivery).toBeNull();
|
||||||
|
const { n } = testDb.prepare('SELECT COUNT(*) AS n FROM password_reset_tokens WHERE user_id = ?')
|
||||||
|
.get(user.id) as { n: number };
|
||||||
|
expect(n).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('AUTH-DB-PR2: still issues a reset for a normal local (non-SSO) account', () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const result = requestPasswordReset(user.email, null);
|
||||||
|
expect(result.reason).toBe('issued');
|
||||||
|
expect(result.tokenForDelivery).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// updateSettings
|
// updateSettings
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1049,6 +1049,26 @@ describe('getPlaceDetails (fetch stubbed)', () => {
|
|||||||
expect(place.summary).toBeNull();
|
expect(place.summary).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('MAPS-041b2: normalises non-standard TREK language codes for Google (br→pt-BR, gr→el)', async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ id: 'ChIJ1', displayName: { text: 'X' }, location: { latitude: 0, longitude: 0 } }),
|
||||||
|
});
|
||||||
|
mockDbGet.mockReturnValue({ maps_api_key: 'gkey' });
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
const { getPlaceDetails } = await import('../../../src/services/mapsService');
|
||||||
|
|
||||||
|
await getPlaceDetails(1, 'ChIJ-br', 'br');
|
||||||
|
expect(String(fetchMock.mock.calls[0][0])).toContain('languageCode=pt-BR');
|
||||||
|
|
||||||
|
await getPlaceDetails(1, 'ChIJ-gr', 'gr');
|
||||||
|
expect(String(fetchMock.mock.calls[1][0])).toContain('languageCode=el');
|
||||||
|
|
||||||
|
// A code that is already valid passes through unchanged.
|
||||||
|
await getPlaceDetails(1, 'ChIJ-de', 'de');
|
||||||
|
expect(String(fetchMock.mock.calls[2][0])).toContain('languageCode=de');
|
||||||
|
});
|
||||||
|
|
||||||
it('MAPS-041c: throws with status when Google API returns non-ok response', async () => {
|
it('MAPS-041c: throws with status when Google API returns non-ok response', async () => {
|
||||||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
|
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
|
||||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||||
@@ -1354,4 +1374,36 @@ describe('getPlacePhoto (fetch stubbed)', () => {
|
|||||||
expect(result.photoUrl).toBe(`/api/maps/place-photo/${encodeURIComponent(uniqueId)}/bytes`);
|
expect(result.photoUrl).toBe(`/api/maps/place-photo/${encodeURIComponent(uniqueId)}/bytes`);
|
||||||
expect(mockCachePut).toHaveBeenCalledOnce();
|
expect(mockCachePut).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('MAPS-044g: falls back to Wikipedia/OSM for a Google place_id when the Google photo call fails', async () => {
|
||||||
|
// A key is present and the placeId is a Google id, but Google rejects the
|
||||||
|
// photo request (e.g. 403). The lookup must still return an image via the
|
||||||
|
// coordinate-based Wikipedia fallback instead of giving up with a 404 —
|
||||||
|
// matching what right-click (coords:) places already do.
|
||||||
|
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
|
||||||
|
vi.stubGlobal('fetch', vi.fn()
|
||||||
|
// 1) Google photo details → 403
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 403,
|
||||||
|
text: async () => JSON.stringify({ error: { message: 'PERMISSION_DENIED' } }),
|
||||||
|
})
|
||||||
|
// 2) Wikipedia pageimages → thumbnail
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ query: { pages: { '1': { thumbnail: { source: 'https://wiki.org/guinness.jpg' } } } } }),
|
||||||
|
})
|
||||||
|
// 3) image bytes
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
arrayBuffer: async () => new ArrayBuffer(200),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const { getPlacePhoto } = await import('../../../src/services/mapsService');
|
||||||
|
const placeId = `ChIJFallback-${Date.now()}`;
|
||||||
|
const result = await getPlacePhoto(1, placeId, 53.34, -6.28, 'Guinness Storehouse');
|
||||||
|
expect(result.photoUrl).toBe(`/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`);
|
||||||
|
expect(result.attribution).toBe('Wikipedia');
|
||||||
|
expect(mockCachePut).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+1
-1
@@ -15,7 +15,7 @@
|
|||||||
<Category>Productivity: Tools:</Category>
|
<Category>Productivity: Tools:</Category>
|
||||||
<WebUI>http://[IP]:[PORT:3000]</WebUI>
|
<WebUI>http://[IP]:[PORT:3000]</WebUI>
|
||||||
<TemplateURL>https://raw.githubusercontent.com/mauriceboe/TREK/main/unraid-template.xml</TemplateURL>
|
<TemplateURL>https://raw.githubusercontent.com/mauriceboe/TREK/main/unraid-template.xml</TemplateURL>
|
||||||
<Icon>https://raw.githubusercontent.com/mauriceboe/TREK/main/client/public/icons/icon-dark.svg</Icon>
|
<Icon>https://raw.githubusercontent.com/mauriceboe/TREK/main/docs/trek-icon.png</Icon>
|
||||||
<ExtraParams/>
|
<ExtraParams/>
|
||||||
<PostArgs/>
|
<PostArgs/>
|
||||||
<DonateText>Support TREK development</DonateText>
|
<DonateText>Support TREK development</DonateText>
|
||||||
|
|||||||
Reference in New Issue
Block a user