mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 05:11:46 +00:00
d152f9d02b
* fix(shared-view): render each leg of multi-leg flights correctly The read-only shared view showed the overall trip start/end airports and the first leg's flight number on every leg of a multi-leg flight. The Day Plan already expands legs (each carries __leg), but the renderer ignored it and read flat top-level metadata; the Bookings tab had the same bug. - Day Plan: use __leg for per-leg airline/flight number/route, plus dep-arr time - Bookings tab: list each leg via getFlightLegs() - unique React keys for multi-leg rows Closes #1219 * feat(pdf): add legs to pdf export * fix(demo): skip first-run admin seed in demo mode When DEMO_MODE is on, the demo seeder creates its own admin (admin@trek.app, username "admin") right after the generic seeds run. The first-run admin bootstrap was grabbing username "admin" first, so the demo seeder hit the UNIQUE(username) constraint and aborted before the demo user was ever created - which surfaced as a 500 "Demo user not found" on demo-login. Skip the generic admin bootstrap when demo mode owns the admin account. * fix(docker): ship the encryption-key migration script in the image The production image only copied server/dist, so the documented rotation command `node --import tsx scripts/migrate-encryption.ts` failed inside the container with a module-not-found error - the raw .ts was never present. The script runs via tsx straight from source and only pulls node builtins plus better-sqlite3 (both prod deps), so copying the single file into /app/server/scripts is enough to make the rotation work again. * fix(vacay): keep the mode toolbar above the mobile bottom nav The floating Vacation/Company toolbar was pinned at bottom-3 with z-30, so on mobile it landed in the same band as the fixed bottom nav (z-60) and got hidden behind it - and could scroll out of reach entirely. Pin it above the nav with the shared --bottom-nav-h variable (0px on desktop, so nothing changes there) and reserve matching space below the calendar grid so it never gets swallowed. * fix(dashboard): show the correct reservation date regardless of timezone The upcoming-reservations widget built the date with new Date(reservation_time) .toISOString(), which reinterprets the stored naive local time as UTC and can roll the displayed day forward in non-UTC timezones (e.g. a 23:30 reservation showing the next day). Read the date and time straight from the stored string parts via splitReservationDateTime, and format the time with the shared formatTime helper so it also honours the user's 12h/24h preference. * fix(atlas): cursor-following tooltips and removing countries from search Two related Atlas fixes: - Country tooltips were bound with sticky:false, which anchors them at the feature's bounds centre. For countries with overseas territories (e.g. France) that centre sits far out in the ocean, so the tooltip popped up nowhere near the area being hovered. Make them sticky so they track the cursor. - Selecting an already-visited country from the search bar always opened the "Mark / Bucket" dialog, with no way to remove it. Tiny countries like Vatican City or Singapore are hard to hit on the map, so search was the only way in. Mirror the map-click behaviour: a manually-marked country opens the Remove confirmation, a trip/place-backed one opens its detail. * fix(oidc): keep dots in generated usernames The OIDC username sanitizer stripped dots because they were missing from the allowed character class, so a name claim like "first.last" became "firstlast". Dots are valid usernames (the profile validator already allows ^[a-zA-Z0-9_.-]+$), so add the dot to the sanitizer. * fix(collab): show poll option labels in the UI The poll API formatted each option as { label, voters }, but the React poll component renders opt.text - so every option button came out blank. Emit text alongside label (kept for any other consumer) so options render again. * feat(backup): make the upload size limit configurable The restore upload was capped at a hard-coded 500 MB, so instances whose backup archive (uploads/ included) grew past that got a 413 "File too large" with no way to raise it. Add a BACKUP_UPLOAD_LIMIT_MB env var (default 500, invalid values warn and fall back), documented in .env.example. * feat(costs): create an expense from a booking, fix editing total-only items Replace the inline price + budget-category fields in the Transport and Reservation booking modals with a "Create expense" flow: the modal saves the booking, then opens the full Costs editor prefilled (name + category mapped from the booking type) and linked to the reservation. A booking with a linked expense shows it inline with edit / remove. Also fix the Costs editor so an expense with a recorded total but no payers (transport-derived or pre-rework items) opens with its amount, lets you set the currency, and saves - it previously showed 0 everywhere and could not be saved. Legacy / localized categories now map to the fixed keys, and changing a booking's type keeps its linked expense category in sync (unless it was manually set). - shared: reservation_id on budget create, typeToCostCategory helper, i18n keys - server: createBudgetItem stores reservation_id; keep total_price for payerless items; a booking update no longer wipes its linked expense and syncs the category on type change - client: shared BookingCostsSection, exported ExpenseModal with prefill and an editable total, page-level save-then-open wiring * test(reservations): align syncBudgetOnUpdate unit tests with no-wipe + type-sync The service now leaves a linked expense alone when no budget entry is on the payload (only an explicit total_price 0 deletes it) and syncs the category on a booking type change. Update the unit tests accordingly - the old "price cleared" case passed entry: undefined, which is now a no-op and left a mocked return queued that leaked into the next test. * fix(planner): keep a reservation on its day when edited (#1237) Editing a booking forced its day_id to the globally selected day, which is null when editing from the Book tab - so the booking lost its day and vanished from the Plan. Preserve the reservation own day_id on edit instead. * fix(planner): derive a booking day from its date when none is set (#1237) The client always sends day_id on a reservation update, so the server only derived it from reservation_time when the field was absent. A non-transport booking saved without a selected day (Book tab) therefore got day_id null and vanished from the Plan, even though its date matched a day. Derive the day from reservation_time whenever day_id is null, mirroring create. * fix(planner): let a booking's day follow its date when edited (#1237) Preserving the old day_id on edit left a re-dated booking on its previous start day while end_day_id followed the new date, so it spanned both. Stop sending day_id from the edit modal entirely - the server derives both ends from the booking's date (and keeps the current day when there is no date), so a re-dated booking moves cleanly to the matching day. * fix(atlas): keep the continent breakdown in sync on mark/unmark (#1225) The optimistic mark/unmark updates bumped the country total but never the per-continent counts, so the continent column froze until a full reload. Move the country to continent map into @trek/shared (single source for server and client) and adjust the matching continent count at every optimistic site: the country confirm flow plus the choose / region mark and region unmark handlers. * feat(admin): let admins set a default currency for new users Adds a currency picker to Admin > User Defaults. Stored as the default_currency user-default, so users who have not picked their own currency inherit it in Costs. * fix(atlas): give every sub-national region a distinct code (#1217) geoBoundaries fills shapeISO with the bare country code for some countries (every Spanish region got "ESP", every Chinese "CHN", also Chile/Oman), so marking one region lit up the whole country. build-atlas-geo.mjs now keeps shapeISO only when it is a real "XX-..." subdivision code and otherwise synthesizes a unique per-country id from the region name. Regenerated admin1.geojson.gz: Spain/China/ Chile/Oman now carry distinct region codes (countries with real codes, e.g. Germany, are unchanged). * fix(dashboard): never crash on a malformed reservation date A reservation with an invalid date blanked the whole My Trips page: the old Upcoming widget did new Date(value).toISOString(), which throws "Invalid time value" (fixed in #1222 by reading the string parts). Also guard splitDate so a bad date renders a dash instead of "Invalid Date" or throwing. * fix(airtrail): gate airtrail update behind a user setting, on airtrail update: rebuild payload from fresh data to prevent any data loss * fix(airtrail): add back missing tests * fix(costs): rework the cost panel UX wise and apply prettier on the shared package * chore(prettier) prettier this file * fix(airtrail): don't use cabin class as seat on import When an AirTrail flight has a cabin class but no seat number, the mapper fell back to the class for metadata.seat, so reservations showed e.g. "economy" as the seat. Use only the seat number; leave the seat blank otherwise. The class is still surfaced separately in the import picker. Closes #1246 * fix(airtrail): import scheduled flight times instead of actual AirTrail exposes both scheduled (departureScheduled/arrivalScheduled) and actual (departure/arrival) times. TREK read the actual times, so a delayed or early flight imported the wrong time for planning. Read the scheduled times on import and on poll-sync (both go through mapFlightToReservation); when a flight has no scheduled time, leave the clock blank (date preserved) rather than fabricating 00:00 or falling back to actual. The change-detection hash now tracks the scheduled values, so existing linked reservations re-sync once on the next poll. The opt-in writeback mirrors the read, pushing TREK edits to the scheduled fields so they round-trip. * fix(planner): hydrate per-assignment times when editing a place from the pool Times live per day-assignment, not on the pool place, so reopening a place from the Places panel / inspector showed empty Start/End fields (#1247). The editor now resolves a place's lone assignment when no day is in context and hydrates the fields from it; ambiguous (0 or 2+ days) edits hide the fields instead of showing non-persisting inputs. * fix(mcp): make write tools return client-valid, hydrated entities Audit of all write tools under server/src/mcp/tools (issue #1244 anchor). S1 (broken): - create_budget_item / create_budget_item_with_members now default the split to all trip members when member_ids omitted, so the entry passes the client save-gate instead of being member-less (#1244). - create_transport / update_transport backfill lat/lng/timezone for code-only flight endpoints (NOT NULL columns) and return a clean error for unresolvable endpoints instead of crashing. S2 (under-hydration): set_budget_item_members, create_journey, create_journey_entry, create_packing_bag, bulk_import_packing and update_vacay_plan now return the hydrated shape the matching read/REST route returns; bulk_import widened to accept bag/weight_grams/checked. S3 (parity): check_in_end added to accommodation tools; atlas mark_region_visited echoes the client shape; update_journey_entry/ update_journey_preferences, set_bag_members, set_packing_category_assignees, apply_packing_template return hydrated payloads; set_vacay_color echoes the color. Auth: save_packing_template now requires admin, matching the REST gate. Also refactors server/src/config.ts (JWT-secret handling). Adds getBudgetItem hydrated getter, exports EndpointInput, and MCP regression tests (incl. new tools-transports and tools-journey suites). * fix(mcp): fix ICS/maps/accommodation bugs, add settlement & template tools Bugs: - export_trip_ics: include flights that store times per-endpoint (local_date/local_time) instead of a top-level reservation_time - resolve_maps_url: follow redirects for cid=/share links and fall back to parsing the page body, all SSRF-guarded - link_hotel_accommodation: normalize accommodation_id (TEXT column) to an integer in the reservation read paths so it no longer returns "14.0" Gaps: - packing: save_packing_template returns the new template id; add list_packing_templates (read) and delete_packing_template (admin) - budget: update_budget_item accepts payers/member_ids; clarify create/ update/members descriptions to ask which members share the expense and who paid - budget: add settlement tools — get_settlement_summary, list_settlements, create/update/delete_settlement (budget_edit, mirrors REST + WS events) * chore: bump nodemailer * chore: bump multer --------- Co-authored-by: Maurice <mauriceboe@icloud.com>
1470 lines
62 KiB
TypeScript
1470 lines
62 KiB
TypeScript
/**
|
||
* Unit tests for mapsService — MAPS-001 through MAPS-080.
|
||
* Covers parseOpeningHours, buildOsmDetails, getMapsKey, reverseGeocode,
|
||
* resolveGoogleMapsUrl (coordinate extraction + short URL / SSRF),
|
||
* searchNominatim, fetchOverpassDetails, fetchWikimediaPhoto, searchPlaces,
|
||
* getPlaceDetails, and getPlacePhoto (all branches including cache logic).
|
||
* fetch is stubbed; DB and ssrfGuard are mocked.
|
||
*/
|
||
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
||
|
||
const { mockDbGet, mockDbRun, mockCheckSsrf, mockCacheGet, mockCacheGetErrored, mockCachePut, mockCacheGetInFlight, mockCacheSetInFlight } = vi.hoisted(() => ({
|
||
mockDbGet: vi.fn(() => undefined as any),
|
||
mockDbRun: vi.fn(),
|
||
mockCheckSsrf: vi.fn(async () => ({ allowed: true })),
|
||
mockCacheGet: vi.fn(() => null as any),
|
||
mockCacheGetErrored: vi.fn(() => false),
|
||
mockCachePut: vi.fn(async (placeId: string, _bytes: Buffer, attribution: string | null) => ({
|
||
photoUrl: `/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`,
|
||
filePath: `/tmp/${placeId}.jpg`,
|
||
attribution,
|
||
})),
|
||
mockCacheGetInFlight: vi.fn(() => undefined),
|
||
mockCacheSetInFlight: vi.fn(),
|
||
}));
|
||
|
||
vi.mock('../../../src/db/database', () => ({
|
||
db: {
|
||
prepare: () => ({ get: mockDbGet, all: vi.fn(() => []), run: mockDbRun }),
|
||
},
|
||
}));
|
||
|
||
vi.mock('../../../src/utils/ssrfGuard', () => {
|
||
class SsrfBlockedError extends Error {
|
||
constructor(message: string) {
|
||
super(message);
|
||
this.name = 'SsrfBlockedError';
|
||
}
|
||
}
|
||
return {
|
||
checkSsrf: mockCheckSsrf,
|
||
SsrfBlockedError,
|
||
// Mirror the real per-hop helper closely enough for unit tests: run the
|
||
// (mocked) SSRF check, then fetch through the (stubbed) global fetch. The
|
||
// fetch stubs in these tests already return the final resolved response.
|
||
safeFetchFollow: vi.fn(async (url: string, init?: any) => {
|
||
const ssrf = await mockCheckSsrf(url);
|
||
if (!ssrf.allowed) throw new SsrfBlockedError(ssrf.error ?? 'Request blocked by SSRF guard');
|
||
return (globalThis.fetch as any)(url, init);
|
||
}),
|
||
};
|
||
});
|
||
|
||
vi.mock('../../../src/services/apiKeyCrypto', () => ({
|
||
decrypt_api_key: (v: string | null) => v,
|
||
}));
|
||
|
||
vi.mock('../../../src/config', () => ({
|
||
JWT_SECRET: 'test-secret',
|
||
ENCRYPTION_KEY: '0'.repeat(64),
|
||
}));
|
||
|
||
vi.mock('../../../src/services/placePhotoCache', () => ({
|
||
get: (placeId: string) => mockCacheGet(placeId),
|
||
getErrored: (placeId: string) => mockCacheGetErrored(placeId),
|
||
put: (placeId: string, bytes: Buffer, attribution: string | null) => mockCachePut(placeId, bytes, attribution),
|
||
markError: vi.fn(),
|
||
getInFlight: (placeId: string) => mockCacheGetInFlight(placeId),
|
||
setInFlight: (placeId: string, p: Promise<any>) => mockCacheSetInFlight(placeId, p),
|
||
serveFilePath: vi.fn(() => null),
|
||
}));
|
||
|
||
import {
|
||
parseOpeningHours,
|
||
buildOsmDetails,
|
||
getMapsKey,
|
||
} from '../../../src/services/mapsService';
|
||
|
||
afterEach(() => {
|
||
vi.unstubAllGlobals();
|
||
mockDbGet.mockReset();
|
||
mockDbGet.mockReturnValue(undefined);
|
||
mockDbRun.mockReset();
|
||
mockCheckSsrf.mockReset();
|
||
mockCheckSsrf.mockResolvedValue({ allowed: true });
|
||
mockCacheGet.mockReset();
|
||
mockCacheGet.mockReturnValue(null);
|
||
mockCacheGetErrored.mockReset();
|
||
mockCacheGetErrored.mockReturnValue(false);
|
||
mockCachePut.mockReset();
|
||
mockCachePut.mockImplementation(async (placeId: string, _bytes: Buffer, attribution: string | null) => ({
|
||
photoUrl: `/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`,
|
||
filePath: `/tmp/${placeId}.jpg`,
|
||
attribution,
|
||
}));
|
||
mockCacheGetInFlight.mockReset();
|
||
mockCacheGetInFlight.mockReturnValue(undefined);
|
||
mockCacheSetInFlight.mockReset();
|
||
});
|
||
|
||
// ── parseOpeningHours ─────────────────────────────────────────────────────────
|
||
|
||
describe('parseOpeningHours', () => {
|
||
it('MAPS-001: returns 7 weekday descriptions and openNow', () => {
|
||
const result = parseOpeningHours('Mo-Fr 09:00-18:00');
|
||
expect(result.weekdayDescriptions).toHaveLength(7);
|
||
expect(result.weekdayDescriptions[0]).toContain('Monday: 09:00-18:00');
|
||
expect(typeof result.openNow === 'boolean' || result.openNow === null).toBe(true);
|
||
});
|
||
|
||
it('MAPS-002: marks unknown days with ?', () => {
|
||
const result = parseOpeningHours('Mo 10:00-12:00');
|
||
expect(result.weekdayDescriptions[1]).toContain('?');
|
||
});
|
||
|
||
it('MAPS-003: handles multiple segments separated by semicolons', () => {
|
||
const result = parseOpeningHours('Mo-Fr 09:00-18:00; Sa 10:00-14:00');
|
||
expect(result.weekdayDescriptions[5]).toContain('Saturday: 10:00-14:00');
|
||
expect(result.weekdayDescriptions[0]).toContain('Monday: 09:00-18:00');
|
||
});
|
||
|
||
it('MAPS-004: handles 24/7 string gracefully (no crash)', () => {
|
||
const result = parseOpeningHours('24/7');
|
||
expect(result.weekdayDescriptions).toHaveLength(7);
|
||
});
|
||
|
||
it('MAPS-005: returns openNow null for unparseable format', () => {
|
||
const result = parseOpeningHours('invalid-hours-string');
|
||
expect(result.openNow).toBeNull();
|
||
});
|
||
|
||
it('MAPS-006: handles comma-separated days', () => {
|
||
const result = parseOpeningHours('Mo,We,Fr 08:00-17:00');
|
||
expect(result.weekdayDescriptions[0]).toContain('Monday: 08:00-17:00');
|
||
expect(result.weekdayDescriptions[2]).toContain('Wednesday: 08:00-17:00');
|
||
expect(result.weekdayDescriptions[4]).toContain('Friday: 08:00-17:00');
|
||
expect(result.weekdayDescriptions[1]).toContain('?');
|
||
});
|
||
|
||
it('MAPS-007 (ReDoS): opening hours regex on adversarial input < 100ms', () => {
|
||
const adversarial = 'Mo' + ',Mo'.repeat(500) + ' closed';
|
||
const start = Date.now();
|
||
parseOpeningHours(adversarial);
|
||
const elapsed = Date.now() - start;
|
||
expect(elapsed).toBeLessThan(100);
|
||
});
|
||
});
|
||
|
||
// ── buildOsmDetails ───────────────────────────────────────────────────────────
|
||
|
||
describe('buildOsmDetails', () => {
|
||
it('MAPS-008: returns website from tags', () => {
|
||
const result = buildOsmDetails({ website: 'https://example.com' }, 'way', '123');
|
||
expect(result.website).toBe('https://example.com');
|
||
});
|
||
|
||
it('MAPS-009: prefers contact:website over website', () => {
|
||
const result = buildOsmDetails({ 'contact:website': 'https://contact.example.com', website: 'https://other.com' }, 'node', '1');
|
||
expect(result.website).toBe('https://contact.example.com');
|
||
});
|
||
|
||
it('MAPS-010: returns null website when no tag', () => {
|
||
const result = buildOsmDetails({}, 'node', '1');
|
||
expect(result.website).toBeNull();
|
||
});
|
||
|
||
it('MAPS-011: builds correct osm_url', () => {
|
||
const result = buildOsmDetails({}, 'way', '99999');
|
||
expect(result.osm_url).toBe('https://www.openstreetmap.org/way/99999');
|
||
});
|
||
|
||
it('MAPS-012: includes parsed opening_hours when valid', () => {
|
||
const result = buildOsmDetails({ opening_hours: 'Mo-Fr 09:00-18:00' }, 'node', '1');
|
||
expect(result.opening_hours).not.toBeNull();
|
||
expect(Array.isArray(result.opening_hours)).toBe(true);
|
||
});
|
||
|
||
it('MAPS-013: opening_hours is null when tag is missing', () => {
|
||
const result = buildOsmDetails({}, 'node', '1');
|
||
expect(result.opening_hours).toBeNull();
|
||
expect(result.open_now).toBeNull();
|
||
});
|
||
|
||
it('MAPS-014: source is always openstreetmap', () => {
|
||
expect(buildOsmDetails({}, 'node', '1').source).toBe('openstreetmap');
|
||
});
|
||
|
||
it('MAPS-014b: opening_hours is null when all days have unknown times (all "?")', () => {
|
||
// "closed" does not match the day+time pattern so all days remain "?"
|
||
const result = buildOsmDetails({ opening_hours: 'closed' }, 'node', '1');
|
||
expect(result.opening_hours).toBeNull();
|
||
expect(result.open_now).toBeNull();
|
||
});
|
||
});
|
||
|
||
// ── getMapsKey ────────────────────────────────────────────────────────────────
|
||
|
||
describe('getMapsKey', () => {
|
||
it('MAPS-015: returns user key when user has one', () => {
|
||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'user-api-key' });
|
||
expect(getMapsKey(1)).toBe('user-api-key');
|
||
});
|
||
|
||
it('MAPS-016: falls back to admin key when user has none', () => {
|
||
mockDbGet.mockReturnValueOnce({ maps_api_key: null });
|
||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'admin-api-key' });
|
||
expect(getMapsKey(1)).toBe('admin-api-key');
|
||
});
|
||
|
||
it('MAPS-017: returns null when neither user nor admin has a key', () => {
|
||
mockDbGet.mockReturnValue(undefined);
|
||
expect(getMapsKey(1)).toBeNull();
|
||
});
|
||
});
|
||
|
||
// ── reverseGeocode ────────────────────────────────────────────────────────────
|
||
|
||
describe('reverseGeocode (fetch stubbed)', () => {
|
||
it('MAPS-018: returns name and address from nominatim response', async () => {
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({
|
||
name: 'Eiffel Tower',
|
||
display_name: 'Eiffel Tower, Paris, France',
|
||
address: {},
|
||
}),
|
||
}));
|
||
const { reverseGeocode } = await import('../../../src/services/mapsService');
|
||
const result = await reverseGeocode('48.8584', '2.2945');
|
||
expect(result.name).toBe('Eiffel Tower');
|
||
expect(result.address).toBe('Eiffel Tower, Paris, France');
|
||
});
|
||
|
||
it('MAPS-019: returns nulls when fetch fails', async () => {
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }));
|
||
const { reverseGeocode } = await import('../../../src/services/mapsService');
|
||
const result = await reverseGeocode('0', '0');
|
||
expect(result.name).toBeNull();
|
||
expect(result.address).toBeNull();
|
||
});
|
||
|
||
it('MAPS-019b: falls back to address.tourism when name is absent', async () => {
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({
|
||
display_name: 'Some Museum, Paris',
|
||
address: { tourism: 'Some Museum' },
|
||
}),
|
||
}));
|
||
const { reverseGeocode } = await import('../../../src/services/mapsService');
|
||
const result = await reverseGeocode('48.85', '2.35');
|
||
expect(result.name).toBe('Some Museum');
|
||
});
|
||
|
||
it('MAPS-019c: falls back to address.amenity when name and tourism are absent', async () => {
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({
|
||
display_name: 'A Cafe, Paris',
|
||
address: { amenity: 'A Cafe' },
|
||
}),
|
||
}));
|
||
const { reverseGeocode } = await import('../../../src/services/mapsService');
|
||
const result = await reverseGeocode('48.85', '2.35');
|
||
expect(result.name).toBe('A Cafe');
|
||
});
|
||
|
||
it('MAPS-019d: falls back to address.road when no higher-priority field exists', async () => {
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({
|
||
display_name: 'Rue de Rivoli, Paris',
|
||
address: { road: 'Rue de Rivoli' },
|
||
}),
|
||
}));
|
||
const { reverseGeocode } = await import('../../../src/services/mapsService');
|
||
const result = await reverseGeocode('48.85', '2.35');
|
||
expect(result.name).toBe('Rue de Rivoli');
|
||
});
|
||
|
||
it('MAPS-019e: returns null name when address has no recognized fields', async () => {
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({
|
||
display_name: 'Somewhere',
|
||
address: {},
|
||
}),
|
||
}));
|
||
const { reverseGeocode } = await import('../../../src/services/mapsService');
|
||
const result = await reverseGeocode('0', '0');
|
||
expect(result.name).toBeNull();
|
||
expect(result.address).toBe('Somewhere');
|
||
});
|
||
});
|
||
|
||
// Nominatim stub used by resolveGoogleMapsUrl after coordinate extraction
|
||
const nominatimStub = vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({ display_name: 'Paris, France', name: null, address: {} }),
|
||
});
|
||
|
||
// ── resolveGoogleMapsUrl coordinate extraction ────────────────────────────────
|
||
|
||
describe('resolveGoogleMapsUrl coordinate extraction (ReDoS guards)', () => {
|
||
it('MAPS-020: extracts lat/lng from @lat,lng pattern', async () => {
|
||
vi.stubGlobal('fetch', nominatimStub);
|
||
const { resolveGoogleMapsUrl } = await import('../../../src/services/mapsService');
|
||
const result = await resolveGoogleMapsUrl('https://www.google.com/maps/@48.8566,2.3522,15z');
|
||
expect(result.lat).toBeCloseTo(48.8566, 3);
|
||
expect(result.lng).toBeCloseTo(2.3522, 3);
|
||
});
|
||
|
||
it('MAPS-021: extracts lat/lng from !3d!4d data pattern', async () => {
|
||
vi.stubGlobal('fetch', nominatimStub);
|
||
const { resolveGoogleMapsUrl } = await import('../../../src/services/mapsService');
|
||
const result = await resolveGoogleMapsUrl('https://www.google.com/maps/place/Eiffel+Tower/data=!3d48.8584!4d2.2945');
|
||
expect(result.lat).toBeCloseTo(48.8584, 3);
|
||
expect(result.lng).toBeCloseTo(2.2945, 3);
|
||
});
|
||
|
||
it('MAPS-022: extracts lat/lng from ?q=lat,lng pattern', async () => {
|
||
vi.stubGlobal('fetch', nominatimStub);
|
||
const { resolveGoogleMapsUrl } = await import('../../../src/services/mapsService');
|
||
const result = await resolveGoogleMapsUrl('https://www.google.com/maps?q=48.8566,2.3522');
|
||
expect(result.lat).toBeCloseTo(48.8566, 3);
|
||
expect(result.lng).toBeCloseTo(2.3522, 3);
|
||
});
|
||
|
||
it('MAPS-023: extracts place name from /place/ path', async () => {
|
||
vi.stubGlobal('fetch', nominatimStub);
|
||
const { resolveGoogleMapsUrl } = await import('../../../src/services/mapsService');
|
||
const result = await resolveGoogleMapsUrl('https://www.google.com/maps/place/Eiffel+Tower/@48.8584,2.2945,15z');
|
||
expect(result.name).toBe('Eiffel Tower');
|
||
});
|
||
|
||
it('MAPS-CID-001: resolves a cid= URL by following the redirect to a coordinate URL', async () => {
|
||
// cid URLs (what get_place_details returns, and Google "Share" links) carry no
|
||
// inline coords; the redirect target carries the !3d!4d data param.
|
||
const fetchMock = vi.fn(async (u: string) => {
|
||
if (u.includes('nominatim')) {
|
||
return { ok: true, json: async () => ({ display_name: 'Paris, France', name: 'Eiffel Tower', address: {} }) };
|
||
}
|
||
return { url: 'https://www.google.com/maps/place/Eiffel+Tower/data=!3d48.8584!4d2.2945', text: async () => '' };
|
||
});
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
const { resolveGoogleMapsUrl } = await import('../../../src/services/mapsService');
|
||
const result = await resolveGoogleMapsUrl('https://maps.google.com/?cid=1234567890');
|
||
expect(result.lat).toBeCloseTo(48.8584, 3);
|
||
expect(result.lng).toBeCloseTo(2.2945, 3);
|
||
});
|
||
|
||
it('MAPS-CID-002: falls back to parsing coordinates from the page body', async () => {
|
||
const fetchMock = vi.fn(async (u: string) => {
|
||
if (u.includes('nominatim')) {
|
||
return { ok: true, json: async () => ({ display_name: 'NYC, USA', name: null, address: {} }) };
|
||
}
|
||
if (u.includes('cid=')) {
|
||
// Redirect target has no inline coords.
|
||
return { url: 'https://www.google.com/maps/place/Somewhere', text: async () => '' };
|
||
}
|
||
// Body fetch of the resolved URL embeds coords in the map data.
|
||
return { url: 'https://www.google.com/maps/place/Somewhere', text: async () => 'x!3d40.6892!4d-74.0445y' };
|
||
});
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
const { resolveGoogleMapsUrl } = await import('../../../src/services/mapsService');
|
||
const result = await resolveGoogleMapsUrl('https://www.google.com/maps?cid=999');
|
||
expect(result.lat).toBeCloseTo(40.6892, 3);
|
||
expect(result.lng).toBeCloseTo(-74.0445, 3);
|
||
});
|
||
|
||
it('MAPS-024 (ReDoS): /@(-?\\d+\\.?\\d*),(-?\\d+\\.?\\d*)/ on adversarial input < 500ms', () => {
|
||
const adversarial = '/@' + '1'.repeat(10000) + '.';
|
||
const start = Date.now();
|
||
adversarial.match(/@(-?\d+\.?\d*),(-?\d+\.?\d*)/);
|
||
expect(Date.now() - start).toBeLessThan(500);
|
||
});
|
||
|
||
it('MAPS-025 (ReDoS): /!3d(-?\\d+\\.?\\d*)!4d/ on adversarial input < 500ms', () => {
|
||
const adversarial = '!3d' + '1'.repeat(10000) + '.';
|
||
const start = Date.now();
|
||
adversarial.match(/!3d(-?\d+\.?\d*)!4d(-?\d+\.?\d*)/);
|
||
expect(Date.now() - start).toBeLessThan(500);
|
||
});
|
||
|
||
it('MAPS-026 (ReDoS): /[?&]q=(-?\\d+\\.?\\d*)/ on adversarial input < 500ms', () => {
|
||
const adversarial = '?q=' + '1'.repeat(10000) + '.';
|
||
const start = Date.now();
|
||
adversarial.match(/[?&]q=(-?\d+\.?\d*),(-?\d+\.?\d*)/);
|
||
expect(Date.now() - start).toBeLessThan(500);
|
||
});
|
||
|
||
it('MAPS-027 (ReDoS): /<[^>]+>/ HTML strip on adversarial input < 100ms', () => {
|
||
const adversarial = '<' + 'a'.repeat(10000);
|
||
const start = Date.now();
|
||
adversarial.replace(/<[^>]+>/g, '');
|
||
expect(Date.now() - start).toBeLessThan(100);
|
||
});
|
||
|
||
it('MAPS-028: throws when no coordinates found in URL', async () => {
|
||
vi.stubGlobal('fetch', nominatimStub);
|
||
const { resolveGoogleMapsUrl } = await import('../../../src/services/mapsService');
|
||
await expect(resolveGoogleMapsUrl('https://www.google.com/maps')).rejects.toThrow();
|
||
});
|
||
|
||
it('MAPS-028b: throws 403 when short URL is blocked by SSRF check', async () => {
|
||
mockCheckSsrf.mockResolvedValueOnce({ allowed: false });
|
||
const { resolveGoogleMapsUrl } = await import('../../../src/services/mapsService');
|
||
await expect(
|
||
resolveGoogleMapsUrl('https://goo.gl/maps/abc123')
|
||
).rejects.toMatchObject({ status: 403 });
|
||
});
|
||
|
||
it('MAPS-028c: follows redirect for short goo.gl URL and extracts coordinates', async () => {
|
||
const redirectFetch = vi.fn()
|
||
// First call: the redirect (goo.gl), returns resolved URL in .url
|
||
.mockResolvedValueOnce({
|
||
url: 'https://www.google.com/maps/@48.8566,2.3522,15z',
|
||
})
|
||
// Second call: the Nominatim reverse geocode
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
json: async () => ({ display_name: 'Paris, France', name: 'Paris', address: {} }),
|
||
});
|
||
vi.stubGlobal('fetch', redirectFetch);
|
||
const { resolveGoogleMapsUrl } = await import('../../../src/services/mapsService');
|
||
const result = await resolveGoogleMapsUrl('https://goo.gl/maps/abc123');
|
||
expect(result.lat).toBeCloseTo(48.8566, 3);
|
||
expect(result.lng).toBeCloseTo(2.3522, 3);
|
||
});
|
||
|
||
it('MAPS-028d: falls back to nominatim address fields when no placeName in URL', async () => {
|
||
const fetchMock = vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({
|
||
display_name: 'Louvre Museum, Paris',
|
||
name: null,
|
||
address: { tourism: 'Louvre Museum' },
|
||
}),
|
||
});
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
const { resolveGoogleMapsUrl } = await import('../../../src/services/mapsService');
|
||
// URL with coordinates but no /place/ path segment
|
||
const result = await resolveGoogleMapsUrl('https://www.google.com/maps/@48.8606,2.3376,15z');
|
||
expect(result.name).toBe('Louvre Museum');
|
||
});
|
||
});
|
||
|
||
// ── searchNominatim (fetch-dependent) ────────────────────────────────────────
|
||
|
||
describe('searchNominatim (fetch stubbed)', () => {
|
||
it('MAPS-029: returns mapped nominatim results on success', async () => {
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => [
|
||
{ osm_type: 'way', osm_id: '1', lat: '48.8', lon: '2.3', name: 'Paris', display_name: 'Paris, France' },
|
||
],
|
||
}));
|
||
const { searchNominatim } = await import('../../../src/services/mapsService');
|
||
const results = await searchNominatim('Paris');
|
||
expect(results).toHaveLength(1);
|
||
expect((results[0] as any).address).toBe('Paris, France');
|
||
expect((results[0] as any).source).toBe('openstreetmap');
|
||
});
|
||
|
||
it('MAPS-030: throws on fetch failure', async () => {
|
||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error')));
|
||
const { searchNominatim } = await import('../../../src/services/mapsService');
|
||
await expect(searchNominatim('fail')).rejects.toThrow();
|
||
});
|
||
|
||
it('MAPS-030b: throws when nominatim response is not ok', async () => {
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||
ok: false,
|
||
status: 500,
|
||
statusText: 'Internal Server Error',
|
||
text: async () => '',
|
||
}));
|
||
const { searchNominatim } = await import('../../../src/services/mapsService');
|
||
await expect(searchNominatim('fail')).rejects.toThrow('Nominatim API error');
|
||
});
|
||
|
||
it('MAPS-030c: falls back to display_name split when name is absent', async () => {
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => [
|
||
{ osm_type: 'node', osm_id: '2', lat: '51.5', lon: '-0.1', display_name: 'London, UK' },
|
||
],
|
||
}));
|
||
const { searchNominatim } = await import('../../../src/services/mapsService');
|
||
const results = await searchNominatim('London');
|
||
expect((results[0] as any).name).toBe('London');
|
||
});
|
||
});
|
||
|
||
// ── fetchOverpassDetails (fetch stubbed) ─────────────────────────────────────
|
||
|
||
describe('fetchOverpassDetails (fetch stubbed)', () => {
|
||
it('MAPS-031: returns element tags on success', async () => {
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({ elements: [{ tags: { name: 'Eiffel Tower', website: 'https://eiffel.com' } }] }),
|
||
}));
|
||
const { fetchOverpassDetails } = await import('../../../src/services/mapsService');
|
||
const result = await fetchOverpassDetails('way', '12345');
|
||
expect(result).toBeDefined();
|
||
expect((result as any).tags.name).toBe('Eiffel Tower');
|
||
});
|
||
|
||
it('MAPS-032: returns null for unknown osmType', async () => {
|
||
const { fetchOverpassDetails } = await import('../../../src/services/mapsService');
|
||
const result = await fetchOverpassDetails('unknown', '12345');
|
||
expect(result).toBeNull();
|
||
});
|
||
|
||
it('MAPS-033: returns null when fetch throws', async () => {
|
||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error')));
|
||
const { fetchOverpassDetails } = await import('../../../src/services/mapsService');
|
||
const result = await fetchOverpassDetails('node', '99999');
|
||
expect(result).toBeNull();
|
||
});
|
||
|
||
it('MAPS-034: returns null when response is not ok', async () => {
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }));
|
||
const { fetchOverpassDetails } = await import('../../../src/services/mapsService');
|
||
const result = await fetchOverpassDetails('node', '99999');
|
||
expect(result).toBeNull();
|
||
});
|
||
|
||
it('MAPS-034b: returns null when elements array is empty', async () => {
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({ elements: [] }),
|
||
}));
|
||
const { fetchOverpassDetails } = await import('../../../src/services/mapsService');
|
||
const result = await fetchOverpassDetails('node', '1');
|
||
expect(result).toBeNull();
|
||
});
|
||
});
|
||
|
||
// ── fetchWikimediaPhoto (fetch stubbed) ───────────────────────────────────────
|
||
|
||
describe('fetchWikimediaPhoto (fetch stubbed)', () => {
|
||
it('MAPS-035: returns photo from Wikipedia article image (strategy 1)', async () => {
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({
|
||
query: { pages: { '1': { thumbnail: { source: 'https://example.com/thumb.jpg' } } } },
|
||
}),
|
||
}));
|
||
const { fetchWikimediaPhoto } = await import('../../../src/services/mapsService');
|
||
const result = await fetchWikimediaPhoto(48.8, 2.3, 'Eiffel Tower');
|
||
expect(result).toBeDefined();
|
||
expect(result!.photoUrl).toBe('https://example.com/thumb.jpg');
|
||
expect(result!.attribution).toBe('Wikipedia');
|
||
});
|
||
|
||
it('MAPS-036: falls through to geosearch when Wikipedia has no thumbnail', async () => {
|
||
const wikiResponse = { ok: true, json: async () => ({ query: { pages: { '-1': {} } } }) };
|
||
const commonsResponse = {
|
||
ok: true,
|
||
json: async () => ({
|
||
query: { pages: { '1': {
|
||
imageinfo: [{ url: 'https://commons.org/img.jpg', mime: 'image/jpeg', extmetadata: { Artist: { value: 'Alice' } } }],
|
||
} } },
|
||
}),
|
||
};
|
||
vi.stubGlobal('fetch', vi.fn()
|
||
.mockResolvedValueOnce(wikiResponse)
|
||
.mockResolvedValueOnce(commonsResponse));
|
||
const { fetchWikimediaPhoto } = await import('../../../src/services/mapsService');
|
||
const result = await fetchWikimediaPhoto(48.8, 2.3, 'Some Place');
|
||
expect(result).toBeDefined();
|
||
expect(result!.photoUrl).toBe('https://commons.org/img.jpg');
|
||
expect(result!.attribution).toBe('Alice');
|
||
});
|
||
|
||
it('MAPS-036b: geosearch prefers the scaled thumburl over the full-res original', async () => {
|
||
const wikiResponse = { ok: true, json: async () => ({ query: { pages: { '-1': {} } } }) };
|
||
const commonsResponse = {
|
||
ok: true,
|
||
json: async () => ({
|
||
query: { pages: { '1': {
|
||
imageinfo: [{
|
||
url: 'https://commons.org/original-16mb.jpg',
|
||
thumburl: 'https://commons.org/thumb-400.jpg',
|
||
mime: 'image/jpeg',
|
||
extmetadata: { Artist: { value: 'Alice' } },
|
||
}],
|
||
} } },
|
||
}),
|
||
};
|
||
vi.stubGlobal('fetch', vi.fn()
|
||
.mockResolvedValueOnce(wikiResponse)
|
||
.mockResolvedValueOnce(commonsResponse));
|
||
const { fetchWikimediaPhoto } = await import('../../../src/services/mapsService');
|
||
const result = await fetchWikimediaPhoto(48.8, 2.3, 'Some Place');
|
||
expect(result).toBeDefined();
|
||
expect(result!.photoUrl).toBe('https://commons.org/thumb-400.jpg');
|
||
expect(result!.attribution).toBe('Alice');
|
||
});
|
||
|
||
it('MAPS-037: returns null when both strategies find nothing', async () => {
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({ query: { pages: {} } }),
|
||
}));
|
||
const { fetchWikimediaPhoto } = await import('../../../src/services/mapsService');
|
||
const result = await fetchWikimediaPhoto(48.8, 2.3);
|
||
expect(result).toBeNull();
|
||
});
|
||
|
||
it('MAPS-037b: skips strategy 1 entirely when name is undefined', async () => {
|
||
// Only one fetch call is made (the Commons geosearch), not two
|
||
const fetchMock = vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({ query: { pages: {} } }),
|
||
});
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
const { fetchWikimediaPhoto } = await import('../../../src/services/mapsService');
|
||
await fetchWikimediaPhoto(48.8, 2.3);
|
||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it('MAPS-037c: falls through to geosearch when Wikipedia fetch throws', async () => {
|
||
const commonsResponse = {
|
||
ok: true,
|
||
json: async () => ({
|
||
query: { pages: { '1': {
|
||
imageinfo: [{ url: 'https://commons.org/fallback.jpg', mime: 'image/png', extmetadata: {} }],
|
||
} } },
|
||
}),
|
||
};
|
||
vi.stubGlobal('fetch', vi.fn()
|
||
.mockRejectedValueOnce(new Error('Wikipedia network error'))
|
||
.mockResolvedValueOnce(commonsResponse));
|
||
const { fetchWikimediaPhoto } = await import('../../../src/services/mapsService');
|
||
const result = await fetchWikimediaPhoto(48.8, 2.3, 'Some Place');
|
||
expect(result).toBeDefined();
|
||
expect(result!.photoUrl).toBe('https://commons.org/fallback.jpg');
|
||
// no Artist in extmetadata -> attribution null
|
||
expect(result!.attribution).toBeNull();
|
||
});
|
||
|
||
it('MAPS-037d: falls through to geosearch when Wikipedia response is not ok', async () => {
|
||
const wikiNotOk = { ok: false };
|
||
const commonsResponse = {
|
||
ok: true,
|
||
json: async () => ({
|
||
query: { pages: { '1': {
|
||
imageinfo: [{ url: 'https://commons.org/photo.jpg', mime: 'image/jpeg', extmetadata: { Artist: { value: '<b>Bob</b>' } } }],
|
||
} } },
|
||
}),
|
||
};
|
||
vi.stubGlobal('fetch', vi.fn()
|
||
.mockResolvedValueOnce(wikiNotOk)
|
||
.mockResolvedValueOnce(commonsResponse));
|
||
const { fetchWikimediaPhoto } = await import('../../../src/services/mapsService');
|
||
const result = await fetchWikimediaPhoto(48.8, 2.3, 'Some Place');
|
||
expect(result).toBeDefined();
|
||
// HTML tags stripped from attribution
|
||
expect(result!.attribution).toBe('Bob');
|
||
});
|
||
|
||
it('MAPS-037e: returns null when Commons geosearch returns not ok', async () => {
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }));
|
||
const { fetchWikimediaPhoto } = await import('../../../src/services/mapsService');
|
||
const result = await fetchWikimediaPhoto(48.8, 2.3);
|
||
expect(result).toBeNull();
|
||
});
|
||
|
||
it('MAPS-037f: returns null when Commons geosearch returns no query.pages', async () => {
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({ query: {} }),
|
||
}));
|
||
const { fetchWikimediaPhoto } = await import('../../../src/services/mapsService');
|
||
const result = await fetchWikimediaPhoto(48.8, 2.3);
|
||
expect(result).toBeNull();
|
||
});
|
||
|
||
it('MAPS-037g: returns null when Commons fetch throws', async () => {
|
||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Commons network error')));
|
||
const { fetchWikimediaPhoto } = await import('../../../src/services/mapsService');
|
||
const result = await fetchWikimediaPhoto(48.8, 2.3);
|
||
expect(result).toBeNull();
|
||
});
|
||
|
||
it('MAPS-037h: skips Commons page entries with non-photo MIME type (SVG)', async () => {
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({
|
||
query: { pages: { '1': {
|
||
imageinfo: [{ url: 'https://commons.org/diagram.svg', mime: 'image/svg+xml' }],
|
||
} } },
|
||
}),
|
||
}));
|
||
const { fetchWikimediaPhoto } = await import('../../../src/services/mapsService');
|
||
const result = await fetchWikimediaPhoto(48.8, 2.3);
|
||
expect(result).toBeNull();
|
||
});
|
||
|
||
it('MAPS-037i: accepts PNG mime type as valid photo', async () => {
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({
|
||
query: { pages: { '1': {
|
||
imageinfo: [{ url: 'https://commons.org/photo.png', mime: 'image/png', extmetadata: { Artist: { value: 'Carol' } } }],
|
||
} } },
|
||
}),
|
||
}));
|
||
const { fetchWikimediaPhoto } = await import('../../../src/services/mapsService');
|
||
const result = await fetchWikimediaPhoto(48.8, 2.3);
|
||
expect(result!.photoUrl).toBe('https://commons.org/photo.png');
|
||
expect(result!.attribution).toBe('Carol');
|
||
});
|
||
|
||
it('MAPS-037j: returns null attribution when Artist extmetadata is absent', async () => {
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({
|
||
query: { pages: { '1': {
|
||
imageinfo: [{ url: 'https://commons.org/noattr.jpg', mime: 'image/jpeg', extmetadata: {} }],
|
||
} } },
|
||
}),
|
||
}));
|
||
const { fetchWikimediaPhoto } = await import('../../../src/services/mapsService');
|
||
const result = await fetchWikimediaPhoto(48.8, 2.3);
|
||
expect(result!.attribution).toBeNull();
|
||
});
|
||
});
|
||
|
||
// ── searchPlaces (fetch stubbed) ─────────────────────────────────────────────
|
||
|
||
describe('searchPlaces (fetch stubbed)', () => {
|
||
it('MAPS-038: uses Nominatim when user has no API key', async () => {
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => [
|
||
{ osm_type: 'node', osm_id: '1', lat: '48.8', lon: '2.3', display_name: 'Paris, France', name: 'Paris' },
|
||
],
|
||
}));
|
||
const { searchPlaces } = await import('../../../src/services/mapsService');
|
||
const result = await searchPlaces(999, 'Paris');
|
||
expect(result.source).toBe('openstreetmap');
|
||
expect(Array.isArray(result.places)).toBe(true);
|
||
});
|
||
|
||
it('MAPS-039: uses Google when user has an API key', async () => {
|
||
mockDbGet
|
||
.mockReturnValueOnce({ maps_api_key: 'ENCRYPTED' })
|
||
.mockReturnValueOnce(null);
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({
|
||
places: [{ id: 'gid1', displayName: { text: 'Eiffel Tower' }, formattedAddress: 'Paris', location: { latitude: 48.8, longitude: 2.3 } }],
|
||
}),
|
||
}));
|
||
const { searchPlaces } = await import('../../../src/services/mapsService');
|
||
const result = await searchPlaces(1, 'Eiffel Tower');
|
||
expect(result.source).toBe('google');
|
||
expect((result.places[0] as any).google_place_id).toBe('gid1');
|
||
});
|
||
|
||
it('MAPS-039b: throws with Google error status when Google API returns non-ok', async () => {
|
||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' });
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||
ok: false,
|
||
status: 403,
|
||
json: async () => ({ error: { message: 'API key invalid' } }),
|
||
}));
|
||
const { searchPlaces } = await import('../../../src/services/mapsService');
|
||
await expect(searchPlaces(1, 'anything')).rejects.toMatchObject({
|
||
message: 'API key invalid',
|
||
status: 403,
|
||
});
|
||
});
|
||
|
||
it('MAPS-039c: throws with generic message when Google error has no message', async () => {
|
||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' });
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||
ok: false,
|
||
status: 500,
|
||
json: async () => ({ error: {} }),
|
||
}));
|
||
const { searchPlaces } = await import('../../../src/services/mapsService');
|
||
await expect(searchPlaces(1, 'anything')).rejects.toMatchObject({
|
||
message: 'Google Places API error',
|
||
status: 500,
|
||
});
|
||
});
|
||
|
||
it('MAPS-039d: returns empty places array when Google returns no results', async () => {
|
||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' });
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({ places: [] }),
|
||
}));
|
||
const { searchPlaces } = await import('../../../src/services/mapsService');
|
||
const result = await searchPlaces(1, 'very obscure place');
|
||
expect(result.source).toBe('google');
|
||
expect(result.places).toHaveLength(0);
|
||
});
|
||
|
||
it('MAPS-039e: handles Google result with optional fields absent', async () => {
|
||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' });
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({
|
||
// id only, no displayName, formattedAddress, location, etc.
|
||
places: [{ id: 'gid-sparse' }],
|
||
}),
|
||
}));
|
||
const { searchPlaces } = await import('../../../src/services/mapsService');
|
||
const result = await searchPlaces(1, 'sparse');
|
||
const place = result.places[0] as any;
|
||
expect(place.google_place_id).toBe('gid-sparse');
|
||
expect(place.name).toBe('');
|
||
expect(place.address).toBe('');
|
||
expect(place.lat).toBeNull();
|
||
expect(place.lng).toBeNull();
|
||
expect(place.rating).toBeNull();
|
||
expect(place.website).toBeNull();
|
||
expect(place.phone).toBeNull();
|
||
});
|
||
});
|
||
|
||
// ── autocompletePlaces (fetch stubbed) ──────────────────────────────────────
|
||
|
||
describe('autocompletePlaces (fetch stubbed)', () => {
|
||
it('MAPS-081: uses Nominatim when user has no API key', async () => {
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => [
|
||
{ osm_type: 'node', osm_id: '1', lat: '48.8', lon: '2.3', display_name: 'Paris, Île-de-France, France', name: 'Paris' },
|
||
],
|
||
}));
|
||
const { autocompletePlaces } = await import('../../../src/services/mapsService');
|
||
const result = await autocompletePlaces(999, 'Paris');
|
||
expect(result.source).toBe('nominatim');
|
||
expect(result.suggestions).toHaveLength(1);
|
||
expect(result.suggestions[0].mainText).toBe('Paris');
|
||
expect(result.suggestions[0].placeId).toBe('node:1');
|
||
});
|
||
|
||
it('MAPS-082: uses Google when user has an API key', async () => {
|
||
mockDbGet
|
||
.mockReturnValueOnce({ maps_api_key: 'ENCRYPTED' })
|
||
.mockReturnValueOnce(null);
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({
|
||
suggestions: [
|
||
{
|
||
placePrediction: {
|
||
placeId: 'ChIJ1234',
|
||
structuredFormat: {
|
||
mainText: { text: 'Eiffel Tower' },
|
||
secondaryText: { text: 'Paris, France' },
|
||
},
|
||
},
|
||
},
|
||
],
|
||
}),
|
||
}));
|
||
const { autocompletePlaces } = await import('../../../src/services/mapsService');
|
||
const result = await autocompletePlaces(1, 'Eiffel');
|
||
expect(result.source).toBe('google');
|
||
expect(result.suggestions).toHaveLength(1);
|
||
expect(result.suggestions[0].placeId).toBe('ChIJ1234');
|
||
expect(result.suggestions[0].mainText).toBe('Eiffel Tower');
|
||
expect(result.suggestions[0].secondaryText).toBe('Paris, France');
|
||
});
|
||
|
||
it('MAPS-083: throws with Google error status when API returns non-ok', async () => {
|
||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' });
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||
ok: false,
|
||
status: 403,
|
||
json: async () => ({ error: { message: 'API key invalid' } }),
|
||
}));
|
||
const { autocompletePlaces } = await import('../../../src/services/mapsService');
|
||
await expect(autocompletePlaces(1, 'anything')).rejects.toMatchObject({
|
||
message: 'API key invalid',
|
||
status: 403,
|
||
});
|
||
});
|
||
|
||
it('MAPS-084: throws generic message when Google error has no message', async () => {
|
||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' });
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||
ok: false,
|
||
status: 500,
|
||
json: async () => ({ error: {} }),
|
||
}));
|
||
const { autocompletePlaces } = await import('../../../src/services/mapsService');
|
||
await expect(autocompletePlaces(1, 'anything')).rejects.toMatchObject({
|
||
message: 'Google Places Autocomplete error',
|
||
status: 500,
|
||
});
|
||
});
|
||
|
||
it('MAPS-085: returns empty suggestions when Google returns no results', async () => {
|
||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' });
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({ suggestions: [] }),
|
||
}));
|
||
const { autocompletePlaces } = await import('../../../src/services/mapsService');
|
||
const result = await autocompletePlaces(1, 'very obscure place');
|
||
expect(result.source).toBe('google');
|
||
expect(result.suggestions).toHaveLength(0);
|
||
});
|
||
|
||
it('MAPS-086: filters out suggestions without placePrediction', async () => {
|
||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' });
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({
|
||
suggestions: [
|
||
{ placePrediction: { placeId: 'A', structuredFormat: { mainText: { text: 'Good' } } } },
|
||
{ queryPrediction: { text: 'some query' } },
|
||
{ placePrediction: { placeId: 'B', structuredFormat: { mainText: { text: 'Also Good' } } } },
|
||
],
|
||
}),
|
||
}));
|
||
const { autocompletePlaces } = await import('../../../src/services/mapsService');
|
||
const result = await autocompletePlaces(1, 'test');
|
||
expect(result.suggestions).toHaveLength(2);
|
||
expect(result.suggestions[0].placeId).toBe('A');
|
||
expect(result.suggestions[1].placeId).toBe('B');
|
||
});
|
||
|
||
it('MAPS-087: limits results to 5 suggestions', async () => {
|
||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' });
|
||
const manySuggestions = Array.from({ length: 10 }, (_, i) => ({
|
||
placePrediction: {
|
||
placeId: `id-${i}`,
|
||
structuredFormat: { mainText: { text: `Place ${i}` } },
|
||
},
|
||
}));
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({ suggestions: manySuggestions }),
|
||
}));
|
||
const { autocompletePlaces } = await import('../../../src/services/mapsService');
|
||
const result = await autocompletePlaces(1, 'test');
|
||
expect(result.suggestions).toHaveLength(5);
|
||
});
|
||
|
||
it('MAPS-088: includes locationBias in Google request when provided', async () => {
|
||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'test-key' });
|
||
const fetchMock = vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({ suggestions: [] }),
|
||
});
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
const { autocompletePlaces } = await import('../../../src/services/mapsService');
|
||
await autocompletePlaces(1, 'test', 'en', { low: { lat: 48.5, lng: 2.0 }, high: { lat: 49.0, lng: 2.8 } });
|
||
|
||
expect(fetchMock).toHaveBeenCalledOnce();
|
||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||
expect(body.locationBias).toEqual({
|
||
rectangle: {
|
||
low: { latitude: 48.5, longitude: 2.0 },
|
||
high: { latitude: 49.0, longitude: 2.8 },
|
||
},
|
||
});
|
||
});
|
||
|
||
it('MAPS-089: omits locationBias from Google request when not provided', async () => {
|
||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'test-key' });
|
||
const fetchMock = vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({ suggestions: [] }),
|
||
});
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
const { autocompletePlaces } = await import('../../../src/services/mapsService');
|
||
await autocompletePlaces(1, 'test', 'en');
|
||
|
||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||
expect(body.locationBias).toBeUndefined();
|
||
});
|
||
|
||
it('MAPS-090: handles missing structuredFormat fields gracefully', async () => {
|
||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' });
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({
|
||
suggestions: [
|
||
{ placePrediction: { placeId: 'sparse-id' } },
|
||
],
|
||
}),
|
||
}));
|
||
const { autocompletePlaces } = await import('../../../src/services/mapsService');
|
||
const result = await autocompletePlaces(1, 'sparse');
|
||
expect(result.suggestions[0].placeId).toBe('sparse-id');
|
||
expect(result.suggestions[0].mainText).toBe('');
|
||
expect(result.suggestions[0].secondaryText).toBe('');
|
||
});
|
||
|
||
it('MAPS-091: Nominatim fallback returns empty suggestions on searchNominatim error', async () => {
|
||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error')));
|
||
const { autocompletePlaces } = await import('../../../src/services/mapsService');
|
||
const result = await autocompletePlaces(999, 'fail');
|
||
expect(result.source).toBe('nominatim');
|
||
expect(result.suggestions).toHaveLength(0);
|
||
});
|
||
|
||
it('MAPS-092: Nominatim fallback splits address into mainText and secondaryText', async () => {
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => [
|
||
{ osm_type: 'way', osm_id: '42', lat: '51.5', lon: '-0.1', display_name: 'Big Ben, Westminster, London, UK', name: 'Big Ben' },
|
||
],
|
||
}));
|
||
const { autocompletePlaces } = await import('../../../src/services/mapsService');
|
||
const result = await autocompletePlaces(999, 'Big Ben');
|
||
expect(result.suggestions[0].mainText).toBe('Big Ben');
|
||
expect(result.suggestions[0].secondaryText).toBe('Westminster, London, UK');
|
||
});
|
||
|
||
it('MAPS-093: Nominatim fallback filters out results with empty osm_id', async () => {
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => [
|
||
{ osm_type: 'node', osm_id: '1', lat: '48.8', lon: '2.3', display_name: 'Paris, France', name: 'Paris' },
|
||
{ osm_type: 'node', osm_id: '', lat: '51.5', lon: '-0.1', display_name: 'London, UK', name: 'London' },
|
||
{ osm_type: 'way', osm_id: '3', lat: '52.5', lon: '13.4', display_name: 'Berlin, Germany', name: 'Berlin' },
|
||
],
|
||
}));
|
||
const { autocompletePlaces } = await import('../../../src/services/mapsService');
|
||
const result = await autocompletePlaces(999, 'test');
|
||
expect(result.suggestions).toHaveLength(2);
|
||
expect(result.suggestions.map((s) => s.placeId)).toEqual(['node:1', 'way:3']);
|
||
});
|
||
});
|
||
|
||
// ── getPlaceDetails (fetch stubbed) ─────────────────────────────────────────
|
||
|
||
describe('getPlaceDetails (fetch stubbed)', () => {
|
||
it('MAPS-040: handles OSM placeId (way:id) via Overpass', async () => {
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({ elements: [{ tags: { website: 'https://eiffel.com' } }] }),
|
||
}));
|
||
const { getPlaceDetails } = await import('../../../src/services/mapsService');
|
||
const result = await getPlaceDetails(1, 'way:12345');
|
||
expect(result.place).toBeDefined();
|
||
expect((result.place as any).source).toBe('openstreetmap');
|
||
expect((result.place as any).website).toBe('https://eiffel.com');
|
||
});
|
||
|
||
it('MAPS-040b: handles OSM placeId when Overpass returns no tags (element missing)', async () => {
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({ elements: [] }),
|
||
}));
|
||
const { getPlaceDetails } = await import('../../../src/services/mapsService');
|
||
const result = await getPlaceDetails(1, 'node:99999');
|
||
expect((result.place as any).source).toBe('openstreetmap');
|
||
expect((result.place as any).website).toBeNull();
|
||
});
|
||
|
||
it('MAPS-041: throws 400 when Google placeId given but no API key', async () => {
|
||
const { getPlaceDetails } = await import('../../../src/services/mapsService');
|
||
await expect(getPlaceDetails(999, 'ChIJNotAnOsmId')).rejects.toMatchObject({ status: 400 });
|
||
});
|
||
|
||
it('MAPS-041b: returns full Google place details on happy path', async () => {
|
||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({
|
||
id: 'ChIJ123',
|
||
displayName: { text: 'Eiffel Tower' },
|
||
formattedAddress: 'Champ de Mars, 5 Av. Anatole France, 75007 Paris',
|
||
location: { latitude: 48.8584, longitude: 2.2945 },
|
||
rating: 4.7,
|
||
userRatingCount: 200000,
|
||
websiteUri: 'https://www.toureiffel.paris',
|
||
nationalPhoneNumber: '+33 892 70 12 39',
|
||
regularOpeningHours: {
|
||
weekdayDescriptions: ['Monday: 9:00 AM – 12:00 AM'],
|
||
openNow: true,
|
||
},
|
||
googleMapsUri: 'https://maps.google.com/?cid=123',
|
||
editorialSummary: { text: 'Iconic iron tower.' },
|
||
reviews: [
|
||
{
|
||
authorAttribution: { displayName: 'John', photoUri: 'https://photo.url' },
|
||
rating: 5,
|
||
text: { text: 'Amazing!' },
|
||
relativePublishTimeDescription: '2 weeks ago',
|
||
},
|
||
],
|
||
photos: [{ name: 'places/ChIJ123/photos/photo1', authorAttributions: [{ displayName: 'Jane' }] }],
|
||
}),
|
||
}));
|
||
const { getPlaceDetails } = await import('../../../src/services/mapsService');
|
||
const result = await getPlaceDetails(1, 'ChIJ123');
|
||
const place = result.place as any;
|
||
expect(place.google_place_id).toBe('ChIJ123');
|
||
expect(place.name).toBe('Eiffel Tower');
|
||
expect(place.rating).toBe(4.7);
|
||
expect(place.rating_count).toBe(200000);
|
||
expect(place.open_now).toBe(true);
|
||
expect(place.source).toBe('google');
|
||
// Lean mask — reviews/summary not fetched in getPlaceDetails; use getPlaceDetailsExpanded for those
|
||
expect(place.reviews).toHaveLength(0);
|
||
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 () => {
|
||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||
ok: false,
|
||
status: 404,
|
||
json: async () => ({ error: { message: 'Place not found' } }),
|
||
}));
|
||
const { getPlaceDetails } = await import('../../../src/services/mapsService');
|
||
await expect(getPlaceDetails(1, 'ChIJMissing')).rejects.toMatchObject({
|
||
message: 'Place not found',
|
||
status: 404,
|
||
});
|
||
});
|
||
|
||
it('MAPS-041d: getPlaceDetailsExpanded maps reviews with optional fields absent to null', async () => {
|
||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
|
||
// expanded=1 cache miss → return undefined
|
||
mockDbGet.mockReturnValueOnce(undefined);
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({
|
||
id: 'ChIJ456',
|
||
reviews: [
|
||
// All optional fields absent
|
||
{},
|
||
],
|
||
}),
|
||
}));
|
||
const { getPlaceDetailsExpanded } = await import('../../../src/services/mapsService');
|
||
const result = await getPlaceDetailsExpanded(1, 'ChIJ456');
|
||
const review = (result.place as any).reviews[0];
|
||
expect(review.author).toBeNull();
|
||
expect(review.rating).toBeNull();
|
||
expect(review.text).toBeNull();
|
||
expect(review.time).toBeNull();
|
||
expect(review.photo).toBeNull();
|
||
});
|
||
|
||
it('MAPS-040c: OSM path enriches name/address/coords from Nominatim (serial fetch)', async () => {
|
||
const fetchMock = vi.fn()
|
||
// First call: Overpass (returns element with tags but no coords)
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
json: async () => ({ elements: [{ tags: { website: 'https://example.com' } }] }),
|
||
})
|
||
// Second call: Nominatim /lookup
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
json: async () => [
|
||
{ osm_type: 'way', osm_id: '5', lat: '48.85', lon: '2.29', display_name: 'Eiffel Tower, Paris, France', name: 'Eiffel Tower' },
|
||
],
|
||
});
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
const { getPlaceDetails } = await import('../../../src/services/mapsService');
|
||
const result = await getPlaceDetails(1, 'way:5');
|
||
const place = result.place as any;
|
||
expect(place.name).toBe('Eiffel Tower');
|
||
expect(place.address).toBe('Eiffel Tower, Paris, France');
|
||
expect(place.lat).toBeCloseTo(48.85);
|
||
expect(place.lng).toBeCloseTo(2.29);
|
||
expect(place.source).toBe('openstreetmap');
|
||
// Overpass first, then Nominatim — two total fetch calls
|
||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||
const overpassUrl = fetchMock.mock.calls[0][0] as string;
|
||
const nominatimUrl = fetchMock.mock.calls[1][0] as string;
|
||
expect(overpassUrl).toContain('overpass');
|
||
expect(nominatimUrl).toContain('nominatim');
|
||
});
|
||
|
||
it('MAPS-041e: open_now is null when regularOpeningHours.openNow is undefined', async () => {
|
||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({
|
||
id: 'ChIJ789',
|
||
regularOpeningHours: {
|
||
weekdayDescriptions: ['Monday: 9:00 AM – 5:00 PM'],
|
||
// openNow intentionally absent
|
||
},
|
||
}),
|
||
}));
|
||
const { getPlaceDetails } = await import('../../../src/services/mapsService');
|
||
const result = await getPlaceDetails(1, 'ChIJ789');
|
||
expect((result.place as any).open_now).toBeNull();
|
||
});
|
||
|
||
it('MAPS-041f: open_now is false when regularOpeningHours.openNow is false', async () => {
|
||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({
|
||
id: 'ChIJClosed',
|
||
regularOpeningHours: {
|
||
weekdayDescriptions: ['Monday: 9:00 AM – 5:00 PM'],
|
||
openNow: false,
|
||
},
|
||
}),
|
||
}));
|
||
const { getPlaceDetails } = await import('../../../src/services/mapsService');
|
||
const result = await getPlaceDetails(1, 'ChIJClosed');
|
||
// false is preserved (not coerced to null) via the ?? null operator
|
||
expect((result.place as any).open_now).toBe(false);
|
||
});
|
||
|
||
it('MAPS-041g: getPlaceDetailsExpanded truncates reviews to first 5 entries', async () => {
|
||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
|
||
// expanded=1 cache miss
|
||
mockDbGet.mockReturnValueOnce(undefined);
|
||
const manyReviews = Array.from({ length: 8 }, (_, i) => ({
|
||
authorAttribution: { displayName: `User${i}` },
|
||
rating: 4,
|
||
text: { text: 'Good' },
|
||
relativePublishTimeDescription: '1 day ago',
|
||
}));
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({ id: 'ChIJMany', reviews: manyReviews }),
|
||
}));
|
||
const { getPlaceDetailsExpanded } = await import('../../../src/services/mapsService');
|
||
const result = await getPlaceDetailsExpanded(1, 'ChIJMany');
|
||
expect((result.place as any).reviews).toHaveLength(5);
|
||
});
|
||
});
|
||
|
||
// ── getPlacePhoto (fetch stubbed) ────────────────────────────────────────────
|
||
|
||
describe('getPlacePhoto (fetch stubbed)', () => {
|
||
it('MAPS-042: returns proxy URL for coordinate-based lookup via Wikimedia (no API key)', async () => {
|
||
vi.stubGlobal('fetch', vi.fn()
|
||
// First call: Wikimedia Commons API
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
json: async () => ({
|
||
query: { pages: { '1': { thumbnail: { source: 'https://wiki.org/photo.jpg' } } } },
|
||
}),
|
||
})
|
||
// Second call: fetch Wikimedia image bytes
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
arrayBuffer: async () => new ArrayBuffer(100),
|
||
})
|
||
);
|
||
const { getPlacePhoto } = await import('../../../src/services/mapsService');
|
||
const placeId = 'coords:48.8,2.3';
|
||
const result = await getPlacePhoto(999, placeId, 48.8, 2.3, 'Eiffel Tower');
|
||
expect(result.photoUrl).toBe(`/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`);
|
||
expect(mockCachePut).toHaveBeenCalledOnce();
|
||
});
|
||
|
||
it('MAPS-043: throws 404 when Wikimedia returns nothing and no API key', async () => {
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({ query: { pages: {} } }),
|
||
}));
|
||
const { getPlacePhoto } = await import('../../../src/services/mapsService');
|
||
await expect(getPlacePhoto(999, 'coords:0.0,0.0', 0, 0)).rejects.toMatchObject({ status: 404 });
|
||
});
|
||
|
||
it('MAPS-043b: returns cached photo when disk cache returns a hit', async () => {
|
||
const placeId = `coords:cache-test-${Date.now()}`;
|
||
const cachedUrl = `/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`;
|
||
mockCacheGet.mockReturnValue({
|
||
photoUrl: cachedUrl,
|
||
filePath: `/tmp/${placeId}.jpg`,
|
||
attribution: null,
|
||
});
|
||
const fetchMock = vi.fn();
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
const { getPlacePhoto } = await import('../../../src/services/mapsService');
|
||
const result = await getPlacePhoto(999, placeId, 48.8, 2.3, 'Cache Test');
|
||
expect(result.photoUrl).toBe(cachedUrl);
|
||
expect(fetchMock).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('MAPS-043c: throws 404 from error cache without making a network request', async () => {
|
||
mockCacheGetErrored.mockReturnValue(true);
|
||
const fetchMock = vi.fn();
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
const { getPlacePhoto } = await import('../../../src/services/mapsService');
|
||
const errorId = `coords:error-cache-${Date.now()}`;
|
||
await expect(getPlacePhoto(999, errorId, 0, 0)).rejects.toMatchObject({ status: 404 });
|
||
expect(fetchMock).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('MAPS-043d: throws 404 when lat/lng are NaN and no API key', async () => {
|
||
const { getPlacePhoto } = await import('../../../src/services/mapsService');
|
||
const nanId = `coords:nan-test-${Date.now()}`;
|
||
await expect(getPlacePhoto(999, nanId, NaN, NaN)).rejects.toMatchObject({ status: 404 });
|
||
});
|
||
|
||
it('MAPS-043e: falls through and throws 404 when Wikimedia fetch throws', async () => {
|
||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network fail')));
|
||
const { getPlacePhoto } = await import('../../../src/services/mapsService');
|
||
const throwId = `coords:throw-test-${Date.now()}`;
|
||
await expect(getPlacePhoto(999, throwId, 48.8, 2.3, 'Place')).rejects.toMatchObject({ status: 404 });
|
||
});
|
||
|
||
it('MAPS-044: returns proxy URL via Google path when API key present and photos exist', async () => {
|
||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
|
||
const fetchMock = vi.fn()
|
||
// First call: get place details (with photos)
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
text: async () => JSON.stringify({
|
||
photos: [{ name: 'places/ChIJABC/photos/photo1', authorAttributions: [{ displayName: 'Photographer' }] }],
|
||
}),
|
||
})
|
||
// Second call: fetch image bytes
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
arrayBuffer: async () => new ArrayBuffer(200),
|
||
});
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
const { getPlacePhoto } = await import('../../../src/services/mapsService');
|
||
const uniqueId = `ChIJABC-${Date.now()}`;
|
||
const result = await getPlacePhoto(1, uniqueId, 48.8, 2.3, 'Place');
|
||
expect(result.photoUrl).toBe(`/api/maps/place-photo/${encodeURIComponent(uniqueId)}/bytes`);
|
||
expect(result.attribution).toBe('Photographer');
|
||
expect(mockCachePut).toHaveBeenCalledOnce();
|
||
});
|
||
|
||
it('MAPS-044b: throws 404 when Google details fetch returns non-ok', async () => {
|
||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||
ok: false,
|
||
status: 403,
|
||
text: async () => JSON.stringify({ error: { message: 'Forbidden' } }),
|
||
}));
|
||
const { getPlacePhoto } = await import('../../../src/services/mapsService');
|
||
const errId = `ChIJErr-${Date.now()}`;
|
||
await expect(getPlacePhoto(1, errId, 48.8, 2.3)).rejects.toMatchObject({ status: 404 });
|
||
});
|
||
|
||
it('MAPS-044c: throws 404 when Google place has no photos', async () => {
|
||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
text: async () => JSON.stringify({ photos: [] }),
|
||
}));
|
||
const { getPlacePhoto } = await import('../../../src/services/mapsService');
|
||
const noPhotoId = `ChIJNone-${Date.now()}`;
|
||
await expect(getPlacePhoto(1, noPhotoId, 48.8, 2.3)).rejects.toMatchObject({ status: 404 });
|
||
});
|
||
|
||
it('MAPS-044d: throws 404 when media endpoint returns non-ok status', async () => {
|
||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
|
||
const fetchMock = vi.fn()
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
text: async () => JSON.stringify({
|
||
photos: [{ name: 'places/ChIJXYZ/photos/photo1', authorAttributions: [] }],
|
||
}),
|
||
})
|
||
.mockResolvedValueOnce({
|
||
ok: false,
|
||
status: 403,
|
||
arrayBuffer: async () => new ArrayBuffer(0),
|
||
});
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
const { getPlacePhoto } = await import('../../../src/services/mapsService');
|
||
const noUriId = `ChIJXYZ-${Date.now()}`;
|
||
await expect(getPlacePhoto(1, noUriId, 48.8, 2.3)).rejects.toMatchObject({ status: 404 });
|
||
});
|
||
|
||
it('MAPS-044e: returns proxy URL with null attribution when authorAttributions is empty', async () => {
|
||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
|
||
const fetchMock = vi.fn()
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
text: async () => JSON.stringify({
|
||
photos: [{ name: 'places/ChIJNoAttr/photos/photo1', authorAttributions: [] }],
|
||
}),
|
||
})
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
arrayBuffer: async () => new ArrayBuffer(150),
|
||
});
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
const { getPlacePhoto } = await import('../../../src/services/mapsService');
|
||
const noAttrId = `ChIJNoAttr-${Date.now()}`;
|
||
const result = await getPlacePhoto(1, noAttrId, 48.8, 2.3);
|
||
expect(result.photoUrl).toBe(`/api/maps/place-photo/${encodeURIComponent(noAttrId)}/bytes`);
|
||
expect(result.attribution).toBeNull();
|
||
});
|
||
|
||
it('MAPS-044f: uses Wikimedia and returns proxy URL when API key present but placeId is coords: prefix', async () => {
|
||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
|
||
vi.stubGlobal('fetch', vi.fn()
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
json: async () => ({
|
||
query: { pages: { '1': { thumbnail: { source: 'https://wiki.org/coords-photo.jpg' } } } },
|
||
}),
|
||
})
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
arrayBuffer: async () => new ArrayBuffer(120),
|
||
})
|
||
);
|
||
const { getPlacePhoto } = await import('../../../src/services/mapsService');
|
||
const uniqueId = `coords:44f-test-${Date.now()}`;
|
||
const result = await getPlacePhoto(1, uniqueId, 48.8, 2.3, 'Coords Place');
|
||
expect(result.photoUrl).toBe(`/api/maps/place-photo/${encodeURIComponent(uniqueId)}/bytes`);
|
||
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();
|
||
});
|
||
});
|