mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 05:56:53 +00:00
00cbc5aae3
tests/setup.ts mocks src/config once with tests/helpers/test-config.ts. A suite that needs other values calls overrideTestConfig(), and the setup file restores the defaults after every case. The 191 per-file factories are gone; config.test.ts takes the real module through vi.unmock, and the few suites that signed tokens with their own literal secret read it from TEST_CONFIG.
2480 lines
126 KiB
TypeScript
2480 lines
126 KiB
TypeScript
/**
|
|
* Unit tests for PlacesService — the place SQL, the GPX/KML importers, the
|
|
* Google-list importer, the Unsplash image search and the collaborative
|
|
* ratings (PLACE-SVC-*, moved verbatim from tests/unit/services/placeService.test.ts
|
|
* when the domain went DI-native) plus the automatic track colours the Nest
|
|
* service already owned (PLACES-SVC-*, #776).
|
|
*
|
|
* Uses a real in-memory SQLite DB so the SQL is exercised faithfully; the
|
|
* service is constructed directly, no Nest container needed. External fetches
|
|
* are mocked where needed.
|
|
*/
|
|
import { asLegacyResult } from '../../helpers/domain-error';
|
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach, afterAll } from 'vitest';
|
|
import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service';
|
|
import { UnsplashService } from '../../../src/nest/unsplash/unsplash.service';
|
|
import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
|
|
import { TRACK_COLORS, COORD_DEDUP_TOLERANCE } from '@trek/shared';
|
|
import { ADDRESS_BACKFILL_MAX_PLACES } from '../../../src/nest/places/places.helpers';
|
|
|
|
// ── DB setup ──────────────────────────────────────────────────────────────────
|
|
|
|
vi.mock('../../../src/db/database', async () => {
|
|
|
|
const { createSnapshotTestDb } = await import('../../helpers/db-mock');
|
|
const db = createSnapshotTestDb();
|
|
const mock = {
|
|
db,
|
|
closeDb: () => {},
|
|
reinitialize: () => {},
|
|
// Task 9 fix wave (item 10, A-L4c follow-up): the `getPlaceWithTags`/
|
|
// `isOwner` fakes removed — both already went through the real ORM
|
|
// (`entityManager().getRepository(Places/Trips)...`), never through this
|
|
// mocked `db/database` module's exports, so these module-level fakes
|
|
// were dead. Plan 4 Task 4: `DatabaseService` itself is gone now too —
|
|
// `PlacesService`/`AccommodationsService` are fully repository-backed.
|
|
};
|
|
return mock;
|
|
});
|
|
|
|
// Spy on the photo-cache reclaim hook so delete tests assert the wiring without
|
|
// touching disk. The removal logic itself is covered in placePhotoCache.test.ts.
|
|
// Injected stub since the photo-cache fold (was a partial path mock).
|
|
// Only `removeIfUnreferenced` is stubbed: it is the single cache method anything
|
|
// in this file reaches. MapsService takes the same instance (as it does in
|
|
// production) but never gets far enough to call it, since every maps path here
|
|
// stops at the missing API key.
|
|
const removeIfUnreferencedSpy = vi.fn();
|
|
const photoCacheStub = { removeIfUnreferenced: removeIfUnreferencedSpy } as unknown as PlacePhotoCacheService;
|
|
|
|
import { db as testDb } from '../../../src/db/database';
|
|
import { resetTestDb } from '../../helpers/test-db';
|
|
import { accommodationsOver } from '../../helpers/accommodations-service';
|
|
import { createUser, createTrip, createPlace, createDay, createCategory, createTag, addTripMember } from '../../helpers/factories';
|
|
import path from 'path';
|
|
import fs from 'fs';
|
|
import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
|
|
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
|
|
import { PlacesService } from '../../../src/nest/places/places.service';
|
|
import { buildPlaceImportService } from '../../helpers/place-import';
|
|
import { legacyBoundIntegerText } from '../../../src/nest/common/row-id';
|
|
import { MapsService } from '../../../src/nest/maps/maps.service';
|
|
import { buildMapsService } from '../../helpers/maps-service';
|
|
import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpers.service';
|
|
import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service';
|
|
import { TrekPhotoRegistrationService } from '../../../src/nest/photos/trek-photo-registration.service';
|
|
import { TrekPhotos } from '../../../src/db/entities/TrekPhotos.entity';
|
|
import { TripPhotos } from '../../../src/db/entities/TripPhotos.entity';
|
|
import { BudgetItems } from '../../../src/db/entities/BudgetItems.entity';
|
|
import { CollectionPlaces } from '../../../src/db/entities/CollectionPlaces.entity';
|
|
import { Collections } from '../../../src/db/entities/Collections.entity';
|
|
import { DayAccommodations } from '../../../src/db/entities/DayAccommodations.entity';
|
|
import { DayAssignments } from '../../../src/db/entities/DayAssignments.entity';
|
|
import { FileLinks } from '../../../src/db/entities/FileLinks.entity';
|
|
import { PlaceRatings } from '../../../src/db/entities/PlaceRatings.entity';
|
|
import { PlaceRegions } from '../../../src/db/entities/PlaceRegions.entity';
|
|
import { Places } from '../../../src/db/entities/Places.entity';
|
|
import { Reservations } from '../../../src/db/entities/Reservations.entity';
|
|
import { Tours } from '../../../src/db/entities/Tours.entity';
|
|
import { TourWaypoints } from '../../../src/db/entities/TourWaypoints.entity';
|
|
import { TripFiles } from '../../../src/db/entities/TripFiles.entity';
|
|
import type { TestOrm } from '../../helpers/test-orm';
|
|
import { countRows, deleteRows, findRow, findRows, insertRow, insertRows, updateRows } from '../../helpers/factories/rows';
|
|
import { dbNow } from '../../../src/db/types/db-timestamp.type';
|
|
import { makeStorageFixture } from '../../helpers/storage-fixture';
|
|
import { noGoogleQuota } from '../../helpers/google-quota';
|
|
import { createTestUnitOfWork, createTestAppSettingsRepo, createTestUsersRepo, createTestTagsRepo, createTestPlaceRatingsRepo, createTestAssignmentParticipantsRepo, createTestPlacesRepo, createTestTripMembersRepo, createTestDayAssignmentsRepo, createTestCategoriesRepo, createTestTripsRepo, sharedTestOrm } from '../../helpers/test-uow';
|
|
import { createTestBudgetItemsRepo } from '../../helpers/files-repos';
|
|
import { createTestCollectionPlacesRepo } from '../../helpers/test-uow';
|
|
import {
|
|
createTestJourneysRepo, createTestJourneyContributorsRepo, createTestJourneyTripsRepo, createTestJourneyEntriesRepo,
|
|
createTestJourneyPhotosRepo, createTestJourneyEntryPhotosRepo,
|
|
} from '../../helpers/journey-repos';
|
|
import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository';
|
|
import type { UsersRepository } from '../../../src/db/repositories/Users.repository';
|
|
import { isUpdateConflict, type UpdateConflict } from '../../../src/nest/common/conflictResult';
|
|
|
|
/**
|
|
* Narrows `PlacesService.update`'s `PlaceWithTags | UpdateConflict | null`
|
|
* union to the successful-write case, for tests that only ever exercise the
|
|
* happy path and previously reached for `as any` to read a field off the
|
|
* result (L4/L1 — no new `any`).
|
|
*/
|
|
function asUpdatedPlace(
|
|
result: Awaited<ReturnType<PlacesService['update']>>,
|
|
): Exclude<Awaited<ReturnType<PlacesService['update']>>, null | UpdateConflict> {
|
|
if (result === null || isUpdateConflict(result)) {
|
|
throw new Error('expected a successful update, got null or a conflict');
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// The default `maps` MapsService below never has its key-resolving methods
|
|
// exercised by this file's own cases (a real caller passes its own `maps`
|
|
// when it needs that) — these are stand-ins only so the constructor call
|
|
// typechecks and never throws if a future case does reach them.
|
|
const noAppSettings = { getValue: async () => null } as unknown as AppSettingsRepository;
|
|
const noUsers = { getApiKeyColumn: async () => null } as unknown as UsersRepository;
|
|
|
|
const GPX_FIXTURE = path.join(__dirname, '../../fixtures/test.gpx');
|
|
const KML_FIXTURE = path.join(__dirname, '../../fixtures/test.kml');
|
|
|
|
/**
|
|
* Same collaborator set the container hands PlacesService,
|
|
* built once here so the two construction sites cannot drift apart again. The
|
|
* journey domain is a real instance on the same in-memory DB rather than a stub:
|
|
* its place hooks are fire-and-forget behind a catch, so a missing one would have
|
|
* looked like a pass while silently swallowing a TypeError. No test in this file
|
|
* creates a journey, so every hook returns on its first lookup.
|
|
* `maps` is a parameter because the enrichment cases hand in their own provider.
|
|
*/
|
|
const placesStorageFx = makeStorageFixture('');
|
|
|
|
async function makePlacesService(
|
|
maps: MapsService = buildMapsService(photoCacheStub, noAppSettings, noUsers, {} as never, {} as never, noGoogleQuota),
|
|
): Promise<PlacesService> {
|
|
return new PlacesService(
|
|
new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)),
|
|
new RealtimeService(),
|
|
maps,
|
|
new QueryHelpersService(await createTestTagsRepo(testDb), await createTestPlaceRatingsRepo(testDb), await createTestAssignmentParticipantsRepo(testDb)),
|
|
new UnsplashService(await createTestAppSettingsRepo(testDb), await createTestUsersRepo(testDb), new RuntimeEnvService(), placesStorageFx.storage),
|
|
photoCacheStub,
|
|
new JourneyDomainService(
|
|
new RealtimeService(), new TrekPhotoRegistrationService((await sharedTestOrm(testDb)).repo(TrekPhotos), (await sharedTestOrm(testDb)).repo(TripPhotos), await createTestJourneyPhotosRepo(testDb)), await createTestUnitOfWork(testDb),
|
|
await createTestJourneysRepo(testDb), await createTestJourneyContributorsRepo(testDb),
|
|
await createTestJourneyTripsRepo(testDb), await createTestJourneyEntriesRepo(testDb), await createTestTripsRepo(testDb),
|
|
// Plan 3g Task 2 constructor-ripple: JourneyPhotosRepository/JourneyEntryPhotosRepository/PlacesRepository.
|
|
await createTestJourneyPhotosRepo(testDb), await createTestJourneyEntryPhotosRepo(testDb), await createTestPlacesRepo(testDb),
|
|
),
|
|
placesStorageFx.storage,
|
|
await accommodationsOver(testDb), await createTestUnitOfWork(testDb),
|
|
await createTestPlacesRepo(testDb),
|
|
await createTestTagsRepo(testDb),
|
|
await createTestPlaceRatingsRepo(testDb),
|
|
await createTestTripMembersRepo(testDb),
|
|
await createTestDayAssignmentsRepo(testDb),
|
|
await createTestCategoriesRepo(testDb),
|
|
await createTestTripsRepo(testDb),
|
|
await createTestBudgetItemsRepo(testDb),
|
|
await createTestCollectionPlacesRepo(testDb),
|
|
buildPlaceImportService(),
|
|
);
|
|
}
|
|
|
|
let accommodations: Awaited<ReturnType<typeof accommodationsOver>>;
|
|
let svc: Awaited<ReturnType<typeof makePlacesService>>;
|
|
let orm: TestOrm;
|
|
beforeAll(async () => {
|
|
// Plan 4 Task 4: `DatabaseService` is gone — `PlacesService`/
|
|
// `AccommodationsService` are fully repository-backed now, so the
|
|
// `dbs.canAccessTrip`/`dbs.getPlaceWithTags` spies this block used to
|
|
// route to a real `DatabaseService` are dead; removed with it.
|
|
accommodations = await accommodationsOver(testDb);
|
|
svc = await makePlacesService();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
resetTestDb(testDb);
|
|
orm = await sharedTestOrm(testDb);
|
|
});
|
|
|
|
afterAll(() => {
|
|
testDb.close();
|
|
});
|
|
|
|
// ── list ──────────────────────────────────────────────────────────────────────
|
|
|
|
describe('list', () => {
|
|
it('PLACE-SVC-001 — returns empty array when trip has no places', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
expect(await svc.list(String(trip.id), {})).toEqual([]);
|
|
});
|
|
|
|
it('PLACE-SVC-002 — returns all places for a trip', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
createPlace(testDb, trip.id, { name: 'Alpha' });
|
|
createPlace(testDb, trip.id, { name: 'Beta' });
|
|
const places = (await svc.list(String(trip.id), {})) as any[];
|
|
expect(places).toHaveLength(2);
|
|
});
|
|
|
|
it('PLACE-SVC-TOURS-002 — identifies Tours by their facet in the ordinary Place read model', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const tour = createPlace(testDb, trip.id, { name: 'Facet-backed tour' }) as any;
|
|
const place = createPlace(testDb, trip.id, { name: 'Ordinary place' }) as any;
|
|
await insertRow(orm, Tours, { place: tour.id, tourTypeRef: 'hike' });
|
|
|
|
const byId = new Map(((await svc.list(String(trip.id), {})) as any[]).map(item => [item.id, item]));
|
|
|
|
expect(byId.get(tour.id)).toMatchObject({ tour_place_id: tour.id });
|
|
expect(byId.get(place.id)).toMatchObject({ tour_place_id: null });
|
|
});
|
|
|
|
it('PLACE-SVC-003 — does not return places from other trips', async () => {
|
|
const { user } = createUser(testDb);
|
|
const t1 = createTrip(testDb, user.id);
|
|
const t2 = createTrip(testDb, user.id);
|
|
createPlace(testDb, t1.id, { name: 'T1 Place' });
|
|
createPlace(testDb, t2.id, { name: 'T2 Place' });
|
|
const places = (await svc.list(String(t1.id), {})) as any[];
|
|
expect(places).toHaveLength(1);
|
|
expect(places[0].name).toBe('T1 Place');
|
|
});
|
|
|
|
it('PLACE-SVC-004 — filters by search term (name)', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
createPlace(testDb, trip.id, { name: 'Eiffel Tower' });
|
|
createPlace(testDb, trip.id, { name: 'Louvre Museum' });
|
|
const places = (await svc.list(String(trip.id), { search: 'Eiffel' })) as any[];
|
|
expect(places).toHaveLength(1);
|
|
expect(places[0].name).toBe('Eiffel Tower');
|
|
});
|
|
|
|
it('PLACE-SVC-005b — carries where each place lies: the cached region, else the bundled borders (#2537)', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const berlin = createPlace(testDb, trip.id, { name: 'Brandenburger Tor', lat: 52.5163, lng: 13.3777 });
|
|
const paris = createPlace(testDb, trip.id, { name: 'Louvre', lat: 48.8606, lng: 2.3376 });
|
|
const nowhere = createPlace(testDb, trip.id, { name: 'Unplaced' });
|
|
await updateRows(orm, Places, { id: nowhere.id }, { lat: null, lng: null, address: null });
|
|
await insertRow(orm, PlaceRegions, { place: berlin.id, country_code: 'DE', region_code: 'DE-BE', region_name: 'Berlin' });
|
|
const byId = new Map(((await svc.list(String(trip.id), {})) as any[]).map(p => [p.id, p]));
|
|
expect(byId.get(berlin.id)).toMatchObject({ country_code: 'DE', region_name: 'Berlin' });
|
|
expect(byId.get(paris.id)).toMatchObject({ country_code: 'FR', region_name: null });
|
|
expect(byId.get(nowhere.id)!.country_code).toBeNull();
|
|
});
|
|
|
|
it('PLACE-SVC-005 — attaches tags array to each place (empty when none)', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
createPlace(testDb, trip.id, { name: 'No Tags' });
|
|
const places = (await svc.list(String(trip.id), {})) as any[];
|
|
expect(Array.isArray(places[0].tags)).toBe(true);
|
|
expect(places[0].tags).toHaveLength(0);
|
|
});
|
|
|
|
it('PLACE-SVC-006 — attaches category object when place has a category', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const cat = createCategory(testDb, { name: 'Museum', user_id: user.id }) as any;
|
|
const place = createPlace(testDb, trip.id, { name: 'Art Museum' }) as any;
|
|
await updateRows(orm, Places, { id: place.id }, { category: cat.id });
|
|
|
|
const places = (await svc.list(String(trip.id), {})) as any[];
|
|
expect(places[0].category).toBeDefined();
|
|
expect(places[0].category!.name).toBe('Museum');
|
|
});
|
|
});
|
|
|
|
// ── create ────────────────────────────────────────────────────────────────────
|
|
|
|
describe('create', () => {
|
|
it('PLACE-SVC-007 — creates a place and returns it with tags array', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const place = await svc.create(String(trip.id), { name: 'New Place', lat: 48.8, lng: 2.3 }) as any;
|
|
expect(place).toBeDefined();
|
|
expect(place.name).toBe('New Place');
|
|
expect(Array.isArray(place.tags)).toBe(true);
|
|
});
|
|
|
|
it('PLACE-SVC-007b: the place and its tags are one write: a failing tag insert leaves no place', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const tag = createTag(testDb, user.id, { name: 'Highlight' });
|
|
const tags = await createTestTagsRepo(testDb);
|
|
const spy = vi.spyOn(tags, 'insertIgnore').mockRejectedValueOnce(new Error('disk full'));
|
|
|
|
await expect(svc.create(String(trip.id), { name: 'Half Place', tags: [tag.id] })).rejects.toThrow('disk full');
|
|
|
|
expect(await findRows(orm, Places, { name: 'Half Place' })).toEqual([]);
|
|
spy.mockRestore();
|
|
});
|
|
|
|
it('PLACE-SVC-008 — creates a place with tags', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const tag = createTag(testDb, user.id, { name: 'Highlight' }) as any;
|
|
const place = await svc.create(String(trip.id), { name: 'Tagged Place', tags: [tag.id] }) as any;
|
|
expect(place.tags).toHaveLength(1);
|
|
expect(place.tags[0].id).toBe(tag.id);
|
|
});
|
|
|
|
// A tag belongs to a user, not a trip, and the place body is an open record, so
|
|
// an id from someone outside the trip could be attached and then read straight
|
|
// back — the tag projection carries the owner's user_id with it.
|
|
it('PLACE-SVC-008a — drops a tag owned by someone outside the trip', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { user: outsider } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const mine = createTag(testDb, user.id, { name: 'Mine' }) as any;
|
|
const theirs = createTag(testDb, outsider.id, { name: 'Theirs' }) as any;
|
|
|
|
const place = await svc.create(String(trip.id), { name: 'Tagged Place', tags: [mine.id, theirs.id] }) as any;
|
|
|
|
expect(place.tags.map((t: any) => t.id)).toEqual([mine.id]);
|
|
});
|
|
|
|
it('PLACE-SVC-008b — keeps a co-traveller tag, so a shared place survives a foreign re-save', async () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const { user: member } = createUser(testDb);
|
|
const trip = createTrip(testDb, owner.id);
|
|
addTripMember(testDb, trip.id, member.id);
|
|
const theirTag = createTag(testDb, member.id, { name: 'Theirs' }) as any;
|
|
|
|
const place = await svc.create(String(trip.id), { name: 'Shared Place', tags: [theirTag.id] }) as any;
|
|
|
|
expect(place.tags.map((t: any) => t.id)).toEqual([theirTag.id]);
|
|
});
|
|
|
|
it('PLACE-SVC-009 — place is associated with correct trip', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const place = await svc.create(String(trip.id), { name: 'My Place' }) as any;
|
|
const row = await findRow(orm, Places, { id: place.id }) as any;
|
|
expect(row.trip_id).toBe(trip.id);
|
|
});
|
|
});
|
|
|
|
// ── get ───────────────────────────────────────────────────────────────────────
|
|
|
|
describe('get', () => {
|
|
it('PLACE-SVC-010 — returns the place when tripId and placeId match', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const place = createPlace(testDb, trip.id, { name: 'Find Me' }) as any;
|
|
const found = await svc.get(String(trip.id), String(place.id)) as any;
|
|
expect(found).toBeDefined();
|
|
expect(found.name).toBe('Find Me');
|
|
});
|
|
|
|
it('PLACE-SVC-011 — returns null when place belongs to different trip', async () => {
|
|
const { user } = createUser(testDb);
|
|
const t1 = createTrip(testDb, user.id);
|
|
const t2 = createTrip(testDb, user.id);
|
|
const place = createPlace(testDb, t1.id, { name: 'T1 Place' }) as any;
|
|
expect(await svc.get(String(t2.id), String(place.id))).toBeNull();
|
|
});
|
|
|
|
it('PLACE-SVC-012 — returns null for non-existent placeId', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
expect(await svc.get(String(trip.id), '99999')).toBeNull();
|
|
});
|
|
|
|
// Task 3 review H1's lesson, applied to every place-id gate this task
|
|
// converts: a non-canonical id shape (`"<id> "`, `"<id>.0"`, `"+<id>"`)
|
|
// must resolve to not-found HERE, in the gate — SQLite's own affinity
|
|
// would otherwise match it, and this service's writes downstream convert
|
|
// with `toRowId`, which rejects the same shapes.
|
|
it('PLACE-SVC-012b (H1) — non-canonical id shapes ("<id> ", "<id>.0", "+<id>") also return null, not just non-numeric ids', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const place = createPlace(testDb, trip.id);
|
|
expect(await svc.get(String(trip.id), `${place.id} `)).toBeNull();
|
|
expect(await svc.get(String(trip.id), `${place.id}.0`)).toBeNull();
|
|
expect(await svc.get(String(trip.id), `+${place.id}`)).toBeNull();
|
|
// The canonical shape still finds it, proving the fix didn't just null everything.
|
|
expect(await svc.get(String(trip.id), String(place.id))).not.toBeNull();
|
|
});
|
|
});
|
|
|
|
// ── update ────────────────────────────────────────────────────────────────────
|
|
|
|
describe('update', () => {
|
|
it('PLACE-SVC-013 — updates place name and lat/lng', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const place = createPlace(testDb, trip.id, { name: 'Old', lat: 0, lng: 0 }) as any;
|
|
const updated = await svc.update(String(trip.id), String(place.id), { name: 'New', lat: 48.8, lng: 2.3 }) as any;
|
|
expect(updated.name).toBe('New');
|
|
expect(updated.lat).toBe(48.8);
|
|
expect(updated.lng).toBe(2.3);
|
|
});
|
|
|
|
it('PLACE-SVC-2472 — keeps an e-mail and hand-kept hours, trims the address, empty clears', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const week = '[{"closed":false,"open":"09:00","close":"17:00"},{"closed":false},{"closed":false},{"closed":false},{"closed":false},{"closed":true},{"closed":true}]';
|
|
const place = await svc.create(String(trip.id), { name: 'Bakery', email: ' shop@example.com ', opening_hours: week }) as any;
|
|
expect(place.email).toBe('shop@example.com');
|
|
expect(place.opening_hours).toBe(week);
|
|
|
|
const renamed = await svc.update(String(trip.id), String(place.id), { name: 'Baker' }) as any;
|
|
expect(renamed.email).toBe('shop@example.com');
|
|
expect(renamed.opening_hours).toBe(week);
|
|
|
|
const cleared = await svc.update(String(trip.id), String(place.id), { email: '', opening_hours: '' }) as any;
|
|
expect(cleared.email).toBeNull();
|
|
expect(cleared.opening_hours).toBeNull();
|
|
});
|
|
|
|
it('PLACE-SVC-014 — returns null for non-existent place', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
expect(await svc.update(String(trip.id), '99999', { name: 'Ghost' })).toBeNull();
|
|
});
|
|
|
|
it('PLACE-SVC-014b (H1) — a non-canonical id ("<id> ") is null, not a write: the row is left untouched', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const place = createPlace(testDb, trip.id, { name: 'Untouched' });
|
|
const result = await svc.update(String(trip.id), `${place.id} `, { name: 'Clobbered' });
|
|
expect(result).toBeNull();
|
|
const row = await findRow(orm, Places, { id: place.id }) as { name: string };
|
|
expect(row.name).toBe('Untouched');
|
|
});
|
|
|
|
it('PLACE-SVC-015 — updates tags (replaces old set)', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const tag1 = createTag(testDb, user.id, { name: 'Old Tag' }) as any;
|
|
const tag2 = createTag(testDb, user.id, { name: 'New Tag' }) as any;
|
|
const place = await svc.create(String(trip.id), { name: 'Taggable', tags: [tag1.id] }) as any;
|
|
|
|
const updated = await svc.update(String(trip.id), String(place.id), { tags: [tag2.id] }) as any;
|
|
expect(updated.tags).toHaveLength(1);
|
|
expect(updated.tags[0].id).toBe(tag2.id);
|
|
});
|
|
|
|
it('PLACE-SVC-016 — clears tags when tags: [] is passed', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const tag = createTag(testDb, user.id, { name: 'Temp' }) as any;
|
|
const place = await svc.create(String(trip.id), { name: 'Untaggable', tags: [tag.id] }) as any;
|
|
|
|
const updated = await svc.update(String(trip.id), String(place.id), { tags: [] }) as any;
|
|
expect(updated.tags).toHaveLength(0);
|
|
});
|
|
|
|
// ── Track colour (#776) ─────────────────────────────────────────────────────
|
|
|
|
it('PLACE-SVC-052 — stores a picked route_color', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const place = createPlace(testDb, trip.id, { name: 'Walk' }) as any;
|
|
const updated = await svc.update(String(trip.id), String(place.id), { route_color: '#e11d48' }) as any;
|
|
expect(updated.route_color).toBe('#e11d48');
|
|
});
|
|
|
|
it('PLACE-SVC-053 — an explicit null clears it again (the reset to auto)', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const place = createPlace(testDb, trip.id, { name: 'Walk' }) as any;
|
|
await svc.update(String(trip.id), String(place.id), { route_color: '#e11d48' });
|
|
// Guards the COALESCE trap: name/currency/transport_mode can never be
|
|
// emptied, and route_color built that way would be a one-way door.
|
|
const cleared = await svc.update(String(trip.id), String(place.id), { route_color: null }) as any;
|
|
expect(cleared.route_color).toBeNull();
|
|
});
|
|
|
|
it('PLACE-SVC-054 — an unrelated update leaves the colour alone', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const place = createPlace(testDb, trip.id, { name: 'Walk' }) as any;
|
|
await svc.update(String(trip.id), String(place.id), { route_color: '#059669' });
|
|
const renamed = await svc.update(String(trip.id), String(place.id), { name: 'Hike' }) as any;
|
|
expect(renamed.name).toBe('Hike');
|
|
expect(renamed.route_color).toBe('#059669');
|
|
});
|
|
|
|
it('PLACE-SVC-055 — create carries geometry and colour instead of dropping them', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const created = await svc.create(String(trip.id), {
|
|
name: 'Restored track',
|
|
route_geometry: '[[48.0,2.0],[49.0,3.0]]',
|
|
route_color: '#7c3aed',
|
|
}) as any;
|
|
expect(created.route_geometry).toBe('[[48.0,2.0],[49.0,3.0]]');
|
|
expect(created.route_color).toBe('#7c3aed');
|
|
});
|
|
|
|
// ── PL11's three COALESCE columns (Task 4 review M2) ─────────────────────────
|
|
//
|
|
// `name`/`currency`/`transport_mode` bind `x || null` and fold with
|
|
// `?? existingPlace.x` — a falsy input (undefined, null OR '') keeps the old
|
|
// value, exactly like SQL's `COALESCE(?, col)` with a null bind. Only a
|
|
// truthy string writes. This is the opposite polarity of route_color/
|
|
// stop_type/fill_percent just above (an explicit null clears those). The
|
|
// review's mutation table: M3 (`name: name ?? existing`, so `''` would
|
|
// write instead of keeping) and M4/M4b (`transport_mode`/`currency` treated
|
|
// as `!== undefined ? x : existing`, so `null` would clear instead of
|
|
// keeping) all SURVIVED every test in this file before these three.
|
|
|
|
it('PLACE-SVC-013c (PL11, M3) — an empty-string name keeps the old name, it does not clear it', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const place = createPlace(testDb, trip.id, { name: 'Original' });
|
|
const updated = asUpdatedPlace(await svc.update(String(trip.id), String(place.id), { name: '' }));
|
|
expect(updated.name).toBe('Original');
|
|
});
|
|
|
|
it('PLACE-SVC-013d (PL11, M3) — an explicit null name keeps the old name', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const place = createPlace(testDb, trip.id, { name: 'Original' });
|
|
const updated = asUpdatedPlace(await svc.update(String(trip.id), String(place.id), { name: null as unknown as string }));
|
|
expect(updated.name).toBe('Original');
|
|
});
|
|
|
|
it('PLACE-SVC-013e (PL11) — a truthy name still overwrites (the COALESCE bind is not a one-way lock)', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const place = createPlace(testDb, trip.id, { name: 'Original' });
|
|
const updated = asUpdatedPlace(await svc.update(String(trip.id), String(place.id), { name: 'Renamed' }));
|
|
expect(updated.name).toBe('Renamed');
|
|
});
|
|
|
|
it('PLACE-SVC-013f (PL11, M4b) — an explicit null currency keeps the old currency, it does not clear it', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const place = createPlace(testDb, trip.id, { name: 'Priced' });
|
|
await svc.update(String(trip.id), String(place.id), { currency: 'EUR' });
|
|
const updated = asUpdatedPlace(await svc.update(String(trip.id), String(place.id), { currency: null }));
|
|
expect(updated.currency).toBe('EUR');
|
|
});
|
|
|
|
it('PLACE-SVC-013g (PL11, M4b) — an empty-string currency keeps the old currency', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const place = createPlace(testDb, trip.id, { name: 'Priced' });
|
|
await svc.update(String(trip.id), String(place.id), { currency: 'EUR' });
|
|
const updated = asUpdatedPlace(await svc.update(String(trip.id), String(place.id), { currency: '' }));
|
|
expect(updated.currency).toBe('EUR');
|
|
});
|
|
|
|
it('PLACE-SVC-013h (PL11) — a truthy currency still overwrites', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const place = createPlace(testDb, trip.id, { name: 'Priced' });
|
|
await svc.update(String(trip.id), String(place.id), { currency: 'EUR' });
|
|
const updated = asUpdatedPlace(await svc.update(String(trip.id), String(place.id), { currency: 'USD' }));
|
|
expect(updated.currency).toBe('USD');
|
|
});
|
|
|
|
it('PLACE-SVC-013i (PL11, M4) — an explicit null transport_mode keeps the old mode, it does not clear it', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const place = createPlace(testDb, trip.id, { name: 'Moving' });
|
|
await svc.update(String(trip.id), String(place.id), { transport_mode: 'driving' });
|
|
const updated = asUpdatedPlace(await svc.update(String(trip.id), String(place.id), { transport_mode: null }));
|
|
expect(updated.transport_mode).toBe('driving');
|
|
});
|
|
|
|
it('PLACE-SVC-013j (PL11, M4) — an empty-string transport_mode keeps the old mode', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const place = createPlace(testDb, trip.id, { name: 'Moving' });
|
|
await svc.update(String(trip.id), String(place.id), { transport_mode: 'driving' });
|
|
const updated = asUpdatedPlace(await svc.update(String(trip.id), String(place.id), { transport_mode: '' }));
|
|
expect(updated.transport_mode).toBe('driving');
|
|
});
|
|
|
|
it('PLACE-SVC-013k (PL11) — a truthy transport_mode still overwrites', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const place = createPlace(testDb, trip.id, { name: 'Moving' });
|
|
await svc.update(String(trip.id), String(place.id), { transport_mode: 'driving' });
|
|
const updated = asUpdatedPlace(await svc.update(String(trip.id), String(place.id), { transport_mode: 'cycling' }));
|
|
expect(updated.transport_mode).toBe('cycling');
|
|
});
|
|
});
|
|
|
|
// ── updateMany ────────────────────────────────────────────────────────────────
|
|
|
|
describe('updateMany', () => {
|
|
it('PLACE-SVC-039 — applies the same fields to many places, preserving the rest', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const a = createPlace(testDb, trip.id, { name: 'A' }) as any;
|
|
const b = createPlace(testDb, trip.id, { name: 'B' }) as any;
|
|
const c = createPlace(testDb, trip.id, { name: 'C' }) as any;
|
|
|
|
const updated = await svc.updateMany(String(trip.id), [a.id, b.id, c.id], { notes: 'visited', transport_mode: 'walking' });
|
|
|
|
expect(updated).toHaveLength(3);
|
|
for (const p of updated) {
|
|
expect((p as any).notes).toBe('visited');
|
|
expect((p as any).transport_mode).toBe('walking');
|
|
}
|
|
// Only the provided fields change — names are untouched.
|
|
expect(updated.map(p => (p as any).name).sort()).toEqual(['A', 'B', 'C']);
|
|
});
|
|
|
|
it('PLACE-SVC-040 — skips ids that are not in the trip and reports the rest', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const other = createTrip(testDb, user.id);
|
|
const mine = createPlace(testDb, trip.id, { name: 'Mine' }) as any;
|
|
const foreign = createPlace(testDb, other.id, { name: 'Foreign' }) as any;
|
|
|
|
const updated = await svc.updateMany(String(trip.id), [mine.id, foreign.id, 99999], { notes: 'tagged' });
|
|
|
|
expect(updated).toHaveLength(1);
|
|
expect((updated[0] as any).id).toBe(mine.id);
|
|
// The place from the other trip stays untouched.
|
|
expect((await svc.get(String(other.id), String(foreign.id)) as any).notes).toBeNull();
|
|
});
|
|
|
|
it('PLACE-SVC-041 — returns [] for an empty id list', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
expect(await svc.updateMany(String(trip.id), [], { notes: 'x' })).toEqual([]);
|
|
});
|
|
});
|
|
|
|
// ── remove ────────────────────────────────────────────────────────────────────
|
|
|
|
describe('remove', () => {
|
|
it('PLACE-SVC-017 — deletes a place and returns true', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const place = createPlace(testDb, trip.id, { name: 'To Delete' }) as any;
|
|
expect((await svc.remove(String(trip.id), String(place.id))).deleted).toBe(true);
|
|
expect(await svc.get(String(trip.id), String(place.id))).toBeNull();
|
|
});
|
|
|
|
it('PLACE-SVC-018 — returns false for non-existent place', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
expect((await svc.remove(String(trip.id), '99999')).deleted).toBe(false);
|
|
});
|
|
|
|
it('PLACE-SVC-018b (H1) — a non-canonical id ("<id> ") is not deleted: deleted:false, row intact', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const place = createPlace(testDb, trip.id, { name: 'Survives' });
|
|
const result = await svc.remove(String(trip.id), `${place.id} `);
|
|
expect(result.deleted).toBe(false);
|
|
expect(await svc.get(String(trip.id), String(place.id))).not.toBeNull();
|
|
});
|
|
|
|
it('PLACE-SVC-019 — deleting one place does not remove others', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const p1 = createPlace(testDb, trip.id, { name: 'Keep' }) as any;
|
|
const p2 = createPlace(testDb, trip.id, { name: 'Remove' }) as any;
|
|
await svc.remove(String(trip.id), String(p2.id));
|
|
const remaining = (await svc.list(String(trip.id), {})) as any[];
|
|
expect(remaining).toHaveLength(1);
|
|
expect(remaining[0].id).toBe(p1.id);
|
|
});
|
|
|
|
it('PLACE-SVC-019d — the night booked at a place goes with the place', async () => {
|
|
// Left behind, a stay keeps its place_id as NULL: still drawn in the day header,
|
|
// still naming a hotel through its partner booking, and pointing nowhere. Its day
|
|
// stop and that booking go too, which is the accommodations cascade doing its job
|
|
// rather than a second copy of it here.
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const day = createDay(testDb, trip.id);
|
|
const place = createPlace(testDb, trip.id, { name: 'Hotel Adlon' }) as any;
|
|
const { accommodation } = (await accommodations.createAccommodation(trip.id, {
|
|
place_id: place.id, start_day_id: day.id, end_day_id: day.id,
|
|
})) as any;
|
|
expect(await findRow(orm, Reservations, { accommodation_id: legacyBoundIntegerText(accommodation.id) })).toBeTruthy();
|
|
|
|
await svc.remove(String(trip.id), String(place.id));
|
|
|
|
expect(await findRow(orm, DayAccommodations, { id: accommodation.id })).toBeNull();
|
|
expect(await findRow(orm, Reservations, { accommodation_id: legacyBoundIntegerText(accommodation.id) })).toBeNull();
|
|
expect(await findRows(orm, DayAssignments, { day: day.id })).toEqual([]);
|
|
});
|
|
|
|
it('PLACE-SVC-TOURS-001 — reports Tour deletion and cascades only its facet, waypoints, assignments, and links', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const day = createDay(testDb, trip.id);
|
|
const tour = createPlace(testDb, trip.id, { name: 'Tour to delete' }) as any;
|
|
const keptTour = createPlace(testDb, trip.id, { name: 'Tour to keep' }) as any;
|
|
const keptPlace = createPlace(testDb, trip.id, { name: 'Ordinary place' }) as any;
|
|
for (const place of [tour, keptTour]) {
|
|
await insertRow(orm, Tours, { place: place.id, tourTypeRef: 'hike', max_hiking_difficulty: 4 });
|
|
await insertRow(orm, TourWaypoints, { place: place.id, lat: 48, lng: 11, role: 'start', sequence: 0 });
|
|
await insertRow(orm, DayAssignments, { day: day.id, place: place.id, order_index: 0 });
|
|
}
|
|
const tourFileId = Number((await insertRow(orm, TripFiles, { trip: trip.id, place: tour.id, filename: 'tour.gpx', original_name: 'tour.gpx' })));
|
|
const keptFileId = Number((await insertRow(orm, TripFiles, { trip: trip.id, place: keptTour.id, filename: 'kept.gpx', original_name: 'kept.gpx' })));
|
|
await insertRow(orm, FileLinks, { file: tourFileId, place: tour.id });
|
|
|
|
const result = await svc.remove(String(trip.id), String(tour.id));
|
|
|
|
expect(result).toMatchObject({ deleted: true, deletedTourPlaceIds: [tour.id] });
|
|
expect(await findRow(orm, Places, { id: tour.id })).toBeNull();
|
|
expect(await findRow(orm, Tours, { place: tour.id })).toBeNull();
|
|
expect(await findRows(orm, TourWaypoints, { place: tour.id })).toEqual([]);
|
|
expect(await findRows(orm, DayAssignments, { place: tour.id })).toEqual([]);
|
|
expect(await findRow(orm, TripFiles, { id: tourFileId })).toMatchObject({ id: tourFileId, place_id: null });
|
|
expect(await findRows(orm, FileLinks, { file: tourFileId })).toEqual([]);
|
|
expect(await findRow(orm, Tours, { place: keptTour.id })).toBeTruthy();
|
|
expect(await findRows(orm, TourWaypoints, { place: keptTour.id })).toHaveLength(1);
|
|
expect(await findRows(orm, DayAssignments, { place: keptTour.id })).toHaveLength(1);
|
|
expect(await findRow(orm, Places, { id: keptPlace.id })).toBeTruthy();
|
|
expect(await findRow(orm, TripFiles, { id: keptFileId })).toMatchObject({ id: keptFileId, place_id: keptTour.id });
|
|
});
|
|
|
|
it('PLACE-SVC-019e — a place with no booking is untouched by that', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const day = createDay(testDb, trip.id);
|
|
const hotel = createPlace(testDb, trip.id, { name: 'Hotel Adlon' }) as any;
|
|
const museum = createPlace(testDb, trip.id, { name: 'Pergamon' }) as any;
|
|
const { accommodation } = (await accommodations.createAccommodation(trip.id, {
|
|
place_id: hotel.id, start_day_id: day.id, end_day_id: day.id,
|
|
})) as any;
|
|
|
|
await svc.remove(String(trip.id), String(museum.id));
|
|
|
|
expect(await findRow(orm, DayAccommodations, { id: accommodation.id })).toBeTruthy();
|
|
});
|
|
|
|
it('PLACE-SVC-019c — the linked expense goes with the place (#1298)', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const place = createPlace(testDb, trip.id, { name: 'Louvre' }) as any;
|
|
const other = createPlace(testDb, trip.id, { name: 'Orsay', lat: 48.86, lng: 2.3266 }) as any;
|
|
const linked = Number((await insertRow(orm, BudgetItems, { trip: trip.id, name: 'Tickets', total_price: 34, place: place.id })));
|
|
const untouched = Number((await insertRow(orm, BudgetItems, { trip: trip.id, name: 'Other tickets', total_price: 12, place: other.id })));
|
|
const standalone = Number((await insertRow(orm, BudgetItems, { trip: trip.id, name: 'Coffee', total_price: 3 })));
|
|
|
|
// Read the link before the delete — that is what the controller broadcasts.
|
|
expect(await svc.linkedExpenseIds(trip.id, [place.id])).toEqual([linked]);
|
|
expect((await svc.remove(String(trip.id), String(place.id))).deleted).toBe(true);
|
|
|
|
const rows = await findRows(orm, BudgetItems, {}, { id: 'asc' }) as { id: number }[];
|
|
expect(rows.map(r => r.id)).toEqual([untouched, standalone]);
|
|
});
|
|
|
|
it('PLACE-SVC-019d — removeMany takes the expense of every deleted place with it', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const a = createPlace(testDb, trip.id, { name: 'A' }) as any;
|
|
const b = createPlace(testDb, trip.id, { name: 'B', lat: 48.86, lng: 2.3266 }) as any;
|
|
const keep = createPlace(testDb, trip.id, { name: 'C', lat: 48.87, lng: 2.34 }) as any;
|
|
for (const p of [a, b, keep]) {
|
|
await insertRow(orm, BudgetItems, { trip: trip.id, name: 'x', total_price: 1, place: p.id });
|
|
}
|
|
|
|
expect(await svc.linkedExpenseIds(trip.id, [a.id, b.id])).toHaveLength(2);
|
|
await svc.removeMany(String(trip.id), [a.id, b.id]);
|
|
|
|
const rows = await findRows(orm, BudgetItems, {}) as { place_id: number }[];
|
|
expect(rows.map(r => r.place_id)).toEqual([keep.id]);
|
|
});
|
|
|
|
it('PLACE-SVC-019e — linkedExpenseIds ignores places of another trip and an empty list', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const other = createTrip(testDb, user.id);
|
|
const place = createPlace(testDb, other.id, { name: 'Elsewhere' }) as any;
|
|
await insertRow(orm, BudgetItems, { trip: other.id, name: 'x', total_price: 1, place: place.id });
|
|
|
|
expect(await svc.linkedExpenseIds(trip.id, [place.id])).toEqual([]);
|
|
expect(await svc.linkedExpenseIds(trip.id, [])).toEqual([]);
|
|
});
|
|
|
|
it('PLACE-SVC-019b — reclaims the photo cache for the deleted place', async () => {
|
|
removeIfUnreferencedSpy.mockClear();
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const place = createPlace(testDb, trip.id, { name: 'With Photo' }) as any;
|
|
await updateRows(orm, Places, { id: place.id }, { google_place_id: 'ChIJgid' });
|
|
|
|
await svc.remove(String(trip.id), String(place.id));
|
|
|
|
expect(removeIfUnreferencedSpy).toHaveBeenCalledWith('ChIJgid');
|
|
});
|
|
|
|
it('PLACE-SVC-019c (R5, MikroORM 7.2.1 nested transactional on SQLite) — a stay\'s own transaction nests as a SAVEPOINT under this transaction: a later failure in the OUTER write rolls the already-released inner savepoint back too', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const day = createDay(testDb, trip.id);
|
|
const hotel = createPlace(testDb, trip.id, { name: 'Hotel Adlon' });
|
|
const { accommodation } = (await accommodations.createAccommodation(trip.id, {
|
|
place_id: hotel.id, start_day_id: day.id, end_day_id: day.id,
|
|
})) as { accommodation: { id: number } };
|
|
const reservation = await findRow(orm, Reservations, { accommodation_id: legacyBoundIntegerText(accommodation.id) }) as { id: number };
|
|
|
|
// cancelStaysAt (PL16) calls AccommodationsService.deleteAccommodation
|
|
// FIRST inside this transaction — its own `uow.transactional` call nests
|
|
// as a SAVEPOINT (UnitOfWork's NESTED propagation) and releases
|
|
// successfully, deleting the stay and its reservation. Only THEN does
|
|
// the place row's own DELETE fail. If the nested call were an
|
|
// independent, already-committed transaction instead of a true
|
|
// savepoint, the stay and its reservation would stay deleted regardless
|
|
// of what happens to the place afterward — they do not.
|
|
testDb.exec("CREATE TRIGGER boom BEFORE DELETE ON places BEGIN SELECT RAISE(ABORT, 'boom'); END");
|
|
try {
|
|
await expect(svc.remove(String(trip.id), String(hotel.id))).rejects.toThrow();
|
|
expect(await findRow(orm, DayAccommodations, { id: accommodation.id })).not.toBeNull();
|
|
expect(await findRow(orm, Reservations, { id: reservation.id })).not.toBeNull();
|
|
expect(await findRow(orm, Places, { id: hotel.id })).not.toBeNull();
|
|
} finally {
|
|
testDb.exec('DROP TRIGGER boom');
|
|
}
|
|
});
|
|
});
|
|
|
|
// ── removeMany ────────────────────────────────────────────────────────────────
|
|
|
|
describe('removeMany', () => {
|
|
it('PLACE-SVC-TOURS-003 — mixed bulk deletion reports only the deleted Tour facets', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const tour = createPlace(testDb, trip.id, { name: 'Tour to delete' }) as any;
|
|
const ordinary = createPlace(testDb, trip.id, { name: 'Ordinary place' }) as any;
|
|
const keptTour = createPlace(testDb, trip.id, { name: 'Tour to keep' }) as any;
|
|
for (const place of [tour, keptTour]) {
|
|
await insertRow(orm, Tours, { place: place.id, tourTypeRef: 'hike' });
|
|
}
|
|
|
|
const result = await svc.removeMany(String(trip.id), [ordinary.id, tour.id]);
|
|
|
|
expect(result.deleted).toEqual([ordinary.id, tour.id]);
|
|
expect(result.deletedTourPlaceIds).toEqual([tour.id]);
|
|
expect(await findRow(orm, Tours, { place: keptTour.id })).toBeTruthy();
|
|
expect(await findRow(orm, Places, { id: keptTour.id })).toBeTruthy();
|
|
});
|
|
|
|
it('PLACE-SVC-056 — deletes the trip-scoped ids in one transaction and reports them', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const other = createTrip(testDb, user.id);
|
|
const a = createPlace(testDb, trip.id, { name: 'A' }) as any;
|
|
const b = createPlace(testDb, trip.id, { name: 'B' }) as any;
|
|
const foreign = createPlace(testDb, other.id, { name: 'Foreign' }) as any;
|
|
|
|
const { deleted } = await svc.removeMany(String(trip.id), [a.id, b.id, foreign.id, 99999]);
|
|
|
|
expect(deleted.sort()).toEqual([a.id, b.id].sort());
|
|
expect(await svc.get(String(other.id), String(foreign.id))).not.toBeNull();
|
|
});
|
|
|
|
it('PLACE-SVC-057b — a place delete reports the booking and expense its cancelled night took down', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const day = createDay(testDb, trip.id) as any;
|
|
const hotel = createPlace(testDb, trip.id, { name: 'Hotel Adlon' }) as any;
|
|
// Booked through the accommodations domain, so it gets its partner hotel
|
|
// reservation the way the booking form writes one.
|
|
const { accommodation } = (await accommodations.createAccommodation(trip.id, {
|
|
place_id: hotel.id, start_day_id: day.id, end_day_id: day.id,
|
|
})) as { accommodation: { id: number } };
|
|
const reservation = await findRow(orm, Reservations, { accommodation_id: legacyBoundIntegerText(accommodation.id) }) as { id: number };
|
|
// An expense hung off the reservation rather than the place: linkedExpenseIds
|
|
// selects on budget_items.place_id and never finds this one.
|
|
const itemId = Number((await insertRow(orm, BudgetItems, { trip: trip.id, name: 'Hotel stay', total_price: 240, reservation: reservation.id })));
|
|
|
|
const { deleted, cancelled } = await svc.remove(String(trip.id), String(hotel.id));
|
|
|
|
expect(deleted).toBe(true);
|
|
expect(cancelled.reservationIds).toEqual([reservation.id]);
|
|
expect(cancelled.budgetItemIds).toEqual([itemId]);
|
|
expect(await findRow(orm, BudgetItems, { id: itemId })).toBeNull();
|
|
});
|
|
|
|
it('PLACE-SVC-057 — returns [] for an empty id list', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
expect((await svc.removeMany(String(trip.id), [])).deleted).toEqual([]);
|
|
});
|
|
});
|
|
|
|
// ── importGpx ─────────────────────────────────────────────────────────────────
|
|
|
|
describe('importGpx', () => {
|
|
it('PLACE-SVC-020 — returns null when buffer has no <gpx> root', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const result = await svc.importGpx(String(trip.id), Buffer.from('<not-gpx/>'));
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('PLACE-SVC-021 — imports <wpt> waypoints as places', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const gpx = Buffer.from(`<?xml version="1.0"?><gpx version="1.1">
|
|
<wpt lat="48.8566" lon="2.3522"><name>Paris</name></wpt>
|
|
<wpt lat="51.5074" lon="-0.1278"><name>London</name></wpt>
|
|
</gpx>`);
|
|
const result = await svc.importGpx(String(trip.id), gpx) as any;
|
|
expect(result.places).toHaveLength(2);
|
|
expect(result.places[0].name).toBe('Paris');
|
|
expect(result.places[1].name).toBe('London');
|
|
});
|
|
|
|
// Task 5 review M2: the four importers' hand-written 25-field `insertPlace`
|
|
// partials had no test at the service level — only repository-level tests
|
|
// that already seed `duration_minutes: 60`/`transport_mode: 'walking'` in
|
|
// their own fixture, so they test `insertPlace` in general, not what THIS
|
|
// service actually passes it. Mutants that survived every suite until this
|
|
// test: GPX `transport_mode → 'driving'`, `duration_minutes: 30`.
|
|
it('PLACE-SVC-021b (M2) — the full stored row matches the legacy 7-column GPX shape, every other column null', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const gpx = Buffer.from(`<?xml version="1.0"?><gpx version="1.1">
|
|
<wpt lat="48.8566" lon="2.3522"><name>Paris</name></wpt>
|
|
</gpx>`);
|
|
const result = await svc.importGpx(String(trip.id), gpx) as { places: { id: number }[] };
|
|
const row = await findRow(orm, Places, { id: result.places[0].id }) as Record<string, unknown>;
|
|
expect(row).toMatchObject({
|
|
trip_id: trip.id, name: 'Paris', description: null, lat: 48.8566, lng: 2.3522,
|
|
address: null, category_id: null, price: null, currency: null, place_time: null, end_time: null,
|
|
duration_minutes: 60, notes: null, image_url: null, google_place_id: null, google_ftid: null,
|
|
osm_id: null, amap_poi_id: null, website: null, phone: null, transport_mode: 'walking',
|
|
route_geometry: null, route_color: null, stop_type: null, fill_percent: null,
|
|
});
|
|
});
|
|
|
|
it('PLACE-SVC-021c (L2) — an import is atomic: a failure partway through the loop leaves nothing stored', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const placesRepo = await createTestPlacesRepo(testDb);
|
|
const realInsertPlace = placesRepo.insertPlace.bind(placesRepo);
|
|
let calls = 0;
|
|
const insertSpy = vi.spyOn(placesRepo, 'insertPlace').mockImplementation(async (input) => {
|
|
calls++;
|
|
if (calls === 2) throw new Error('boom');
|
|
return realInsertPlace(input);
|
|
});
|
|
const gpx = Buffer.from(`<?xml version="1.0"?><gpx version="1.1">
|
|
<wpt lat="48.8566" lon="2.3522"><name>Paris</name></wpt>
|
|
<wpt lat="51.5074" lon="-0.1278"><name>London</name></wpt>
|
|
</gpx>`);
|
|
await expect(svc.importGpx(String(trip.id), gpx)).rejects.toThrow('boom');
|
|
const count = ({ n: await countRows(orm, Places, { trip: trip.id }) }) as { n: number };
|
|
expect(count.n).toBe(0);
|
|
insertSpy.mockRestore();
|
|
});
|
|
|
|
it('PLACE-SVC-022 — imports <rte> as a single polyline-place with routeGeometry', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const gpx = Buffer.from(`<?xml version="1.0"?><gpx version="1.1">
|
|
<rte>
|
|
<name>My Route</name>
|
|
<rtept lat="48.8566" lon="2.3522"><name>Start</name></rtept>
|
|
<rtept lat="51.5074" lon="-0.1278"><name>End</name></rtept>
|
|
</rte>
|
|
</gpx>`);
|
|
const result = await svc.importGpx(String(trip.id), gpx) as any;
|
|
expect(result.places).toHaveLength(1);
|
|
expect(result.places[0].name).toBe('My Route');
|
|
expect(result.places[0].lat).toBe(48.8566);
|
|
expect(result.places[0].lng).toBe(2.3522);
|
|
expect(result.places[0].route_geometry).toBeTruthy();
|
|
const coords = JSON.parse(result.places[0].route_geometry);
|
|
expect(coords).toHaveLength(2);
|
|
});
|
|
|
|
it('PLACE-SVC-023 — imports <trk> track as a single place with routeGeometry', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const gpx = Buffer.from(`<?xml version="1.0"?><gpx version="1.1">
|
|
<trk>
|
|
<name>My Track</name>
|
|
<trkseg>
|
|
<trkpt lat="48.8566" lon="2.3522"><ele>100</ele></trkpt>
|
|
<trkpt lat="48.8570" lon="2.3530"><ele>102</ele></trkpt>
|
|
</trkseg>
|
|
</trk>
|
|
</gpx>`);
|
|
const result = await svc.importGpx(String(trip.id), gpx) as any;
|
|
expect(result.places).toHaveLength(1);
|
|
expect(result.places[0].name).toBe('My Track');
|
|
const geometry = JSON.parse(result.places[0].route_geometry);
|
|
expect(Array.isArray(geometry)).toBe(true);
|
|
expect(geometry).toHaveLength(2);
|
|
});
|
|
|
|
it('PLACE-SVC-024 — <wpt> and <trk> together: waypoints plus track appended', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const gpx = Buffer.from(`<?xml version="1.0"?><gpx version="1.1">
|
|
<wpt lat="48.8566" lon="2.3522"><name>POI</name></wpt>
|
|
<trk>
|
|
<name>Track</name>
|
|
<trkseg>
|
|
<trkpt lat="48.8566" lon="2.3522"></trkpt>
|
|
<trkpt lat="48.8570" lon="2.3530"></trkpt>
|
|
</trkseg>
|
|
</trk>
|
|
</gpx>`);
|
|
const result = await svc.importGpx(String(trip.id), gpx) as any;
|
|
// 1 wpt + 1 trk
|
|
expect(result.places).toHaveLength(2);
|
|
const trackPlace = result.places.find((p: any) => p.name === 'Track') as any;
|
|
expect(trackPlace).toBeDefined();
|
|
const geometry = JSON.parse(trackPlace.route_geometry);
|
|
expect(geometry).toHaveLength(2);
|
|
});
|
|
|
|
it('PLACE-SVC-025 — returns null when GPX has no usable elements', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const gpx = Buffer.from(`<?xml version="1.0"?><gpx version="1.1"></gpx>`);
|
|
const result = await svc.importGpx(String(trip.id), gpx);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('PLACE-SVC-037 — multiple unnamed tracks in one file get distinct names instead of collapsing to one', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const gpx = Buffer.from(`<?xml version="1.0"?><gpx version="1.1">
|
|
<trk><trkseg>
|
|
<trkpt lat="48.8566" lon="2.3522"></trkpt>
|
|
<trkpt lat="48.8570" lon="2.3530"></trkpt>
|
|
</trkseg></trk>
|
|
<trk><trkseg>
|
|
<trkpt lat="40.0000" lon="-3.0000"></trkpt>
|
|
<trkpt lat="40.1000" lon="-3.1000"></trkpt>
|
|
</trkseg></trk>
|
|
</gpx>`);
|
|
const result = await svc.importGpx(String(trip.id), gpx) as any;
|
|
expect(result.places).toHaveLength(2);
|
|
const names = result.places.map((p: any) => p.name);
|
|
expect(new Set(names).size).toBe(2);
|
|
});
|
|
|
|
it('PLACE-SVC-038 — unnamed tracks fall back to the source filename', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const gpx = Buffer.from(`<?xml version="1.0"?><gpx version="1.1">
|
|
<trk><trkseg>
|
|
<trkpt lat="48.8566" lon="2.3522"></trkpt>
|
|
<trkpt lat="48.8570" lon="2.3530"></trkpt>
|
|
</trkseg></trk>
|
|
</gpx>`);
|
|
const result = await svc.importGpx(String(trip.id), gpx, { defaultName: 'morning-hike.gpx' }) as any;
|
|
expect(result.places).toHaveLength(1);
|
|
expect(result.places[0].name).toBe('morning-hike');
|
|
});
|
|
});
|
|
|
|
// ── importGoogleList ──────────────────────────────────────────────────────────
|
|
|
|
describe('importGoogleList', () => {
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it('PLACE-SVC-026 — returns error when list ID cannot be extracted from URL', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const result = await asLegacyResult(svc.importGoogleList(String(trip.id), 'https://example.com/no-id-here')) as any;
|
|
expect(result.error).toMatch(/Could not extract list ID/);
|
|
expect(result.status).toBe(400);
|
|
});
|
|
|
|
it('PLACE-SVC-026b — a single-place link gives a guiding error instead of the generic one (#1304)', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const url = 'https://www.google.com/maps/place/Eiffel+Tower/@48.8584,2.2945,17z/data=!3m1';
|
|
const result = await asLegacyResult(svc.importGoogleList(String(trip.id), url)) as any;
|
|
expect(result.status).toBe(400);
|
|
expect(result.error).toMatch(/single place/i);
|
|
});
|
|
|
|
it('PLACE-SVC-027 — returns error when Google Maps API responds with non-ok status', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, text: async () => '', status: 502 }));
|
|
const url = 'https://www.google.com/maps/placelists/list/ABC123DEF456';
|
|
const result = await asLegacyResult(svc.importGoogleList(String(trip.id), url)) as any;
|
|
expect(result.error).toMatch(/Failed to fetch list/);
|
|
expect(result.status).toBe(502);
|
|
});
|
|
|
|
it('PLACE-SVC-028 — imports places from a valid Google Maps list response', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
|
|
const listPayload = [
|
|
[null, null, null, null, 'My Test List', null, null, null, [
|
|
[null, [null, null, null, null, null, [null, null, 48.8566, 2.3522]], 'Paris', null],
|
|
[null, [null, null, null, null, null, [null, null, 51.5074, -0.1278]], 'London', 'Great city'],
|
|
]],
|
|
];
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
text: async () => 'prefix\n' + JSON.stringify(listPayload),
|
|
}));
|
|
|
|
const url = 'https://www.google.com/maps/placelists/list/ABC123DEF456';
|
|
const result = await asLegacyResult(svc.importGoogleList(String(trip.id), url)) as any;
|
|
expect(result.listName).toBe('My Test List');
|
|
expect(result.places).toHaveLength(2);
|
|
expect(result.places[0].name).toBe('Paris');
|
|
expect(result.places[1].name).toBe('London');
|
|
});
|
|
|
|
// Task 5 review M2/L2. Mutants that survived every suite until these
|
|
// tests: Google `notes → null`, `duration_minutes: 30`, an insert loop
|
|
// outside `uow.transactional`.
|
|
it('PLACE-SVC-028f (M2) — the full stored row matches the legacy 7-column Google shape, notes included, every other column null', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const listPayload = [
|
|
[null, null, null, null, 'My Test List', null, null, null, [
|
|
[null, [null, null, null, null, null, [null, null, 51.5074, -0.1278]], 'London', 'Great city'],
|
|
]],
|
|
];
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, text: async () => 'prefix\n' + JSON.stringify(listPayload) }));
|
|
const result = await asLegacyResult(svc.importGoogleList(String(trip.id), 'https://www.google.com/maps/placelists/list/ABC123DEF456')) as { places: { id: number }[] };
|
|
const row = await findRow(orm, Places, { id: result.places[0].id }) as Record<string, unknown>;
|
|
expect(row).toMatchObject({
|
|
trip_id: trip.id, name: 'London', description: null, lat: 51.5074, lng: -0.1278,
|
|
address: null, category_id: null, price: null, currency: null, place_time: null, end_time: null,
|
|
duration_minutes: 60, notes: 'Great city', image_url: null, google_place_id: null,
|
|
osm_id: null, amap_poi_id: null, website: null, phone: null, transport_mode: 'walking',
|
|
route_geometry: null, route_color: null, stop_type: null, fill_percent: null,
|
|
});
|
|
});
|
|
|
|
it('PLACE-SVC-028g (L2) — a failure partway through the Google-list loop leaves nothing stored', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const listPayload = [
|
|
[null, null, null, null, 'My Test List', null, null, null, [
|
|
[null, [null, null, null, null, null, [null, null, 48.8566, 2.3522]], 'Paris', null],
|
|
[null, [null, null, null, null, null, [null, null, 51.5074, -0.1278]], 'London', 'Great city'],
|
|
]],
|
|
];
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, text: async () => 'prefix\n' + JSON.stringify(listPayload) }));
|
|
const placesRepo = await createTestPlacesRepo(testDb);
|
|
const realInsertPlace = placesRepo.insertPlace.bind(placesRepo);
|
|
let calls = 0;
|
|
const insertSpy = vi.spyOn(placesRepo, 'insertPlace').mockImplementation(async (input) => {
|
|
calls++;
|
|
if (calls === 2) throw new Error('boom');
|
|
return realInsertPlace(input);
|
|
});
|
|
await expect(svc.importGoogleList(String(trip.id), 'https://www.google.com/maps/placelists/list/ABC123DEF456')).rejects.toThrow('boom');
|
|
const count = ({ n: await countRows(orm, Places, { trip: trip.id }) }) as { n: number };
|
|
expect(count.n).toBe(0);
|
|
insertSpy.mockRestore();
|
|
});
|
|
|
|
it('PLACE-SVC-028b — stores a Google Maps ftid separately from google_place_id', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
|
|
const listPayload = [
|
|
[null, null, null, null, 'My Test List', null, null, null, [
|
|
[null, [null, null, null, null, '878 Weber St N', [null, null, 43.5118527, -80.5542617], ['-8634542354666695567', '-8822026229683971437']], "St. Jacobs Farmers' Market"],
|
|
]],
|
|
];
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
text: async () => 'prefix\n' + JSON.stringify(listPayload),
|
|
}));
|
|
|
|
const url = 'https://www.google.com/maps/placelists/list/ABC123DEF456';
|
|
const result = await asLegacyResult(svc.importGoogleList(String(trip.id), url)) as any;
|
|
|
|
expect(result.places).toHaveLength(1);
|
|
expect(result.places[0].google_place_id).toBeNull();
|
|
expect(result.places[0].google_ftid).toBe('0x882bf179e806d471:0x8591dde29c821a93');
|
|
});
|
|
|
|
it('PLACE-SVC-028c — backfills google_ftid when re-import skips a duplicate', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const existing = createPlace(testDb, trip.id, {
|
|
name: "St. Jacobs Farmers' Market",
|
|
lat: 43.5118527,
|
|
lng: -80.5542617,
|
|
}) as any;
|
|
|
|
const listPayload = [
|
|
[null, null, null, null, 'My Test List', null, null, null, [
|
|
[null, [null, null, null, null, '878 Weber St N', [null, null, 43.5118527, -80.5542617], ['-8634542354666695567', '-8822026229683971437']], "St. Jacobs Farmers' Market"],
|
|
]],
|
|
];
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
text: async () => 'prefix\n' + JSON.stringify(listPayload),
|
|
}));
|
|
|
|
const url = 'https://www.google.com/maps/placelists/list/ABC123DEF456';
|
|
const result = await asLegacyResult(svc.importGoogleList(String(trip.id), url)) as any;
|
|
const row = await findRow(orm, Places, { id: existing.id }) as any;
|
|
|
|
expect(result.places).toHaveLength(0);
|
|
expect(result.skipped).toBe(1);
|
|
expect(row.google_place_id).toBeNull();
|
|
expect(row.google_ftid).toBe('0x882bf179e806d471:0x8591dde29c821a93');
|
|
});
|
|
|
|
it('PLACE-SVC-028e — the backfill lands on the row the provider id names, not on a namesake', async () => {
|
|
// The importer's parsed item spells the id `googleFtid`, the match rule reads
|
|
// `google_ftid`, so the raw object used to reach findDuplicatePlace with no id
|
|
// at all. The name then decided, and a second place sharing the name took the
|
|
// ftid that belonged to the renamed one.
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const renamed = createPlace(testDb, trip.id, {
|
|
name: 'Saturday market run',
|
|
lat: 43.5118527,
|
|
lng: -80.5542617,
|
|
}) as any;
|
|
await updateRows(orm, Places, { id: renamed.id }, { google_ftid: '0x882bf179e806d471:0x8591dde29c821a93' });
|
|
const namesake = createPlace(testDb, trip.id, {
|
|
name: "St. Jacobs Farmers' Market",
|
|
lat: 40.0,
|
|
lng: -80.0,
|
|
}) as any;
|
|
|
|
const listPayload = [
|
|
[null, null, null, null, 'My Test List', null, null, null, [
|
|
[null, [null, null, null, null, '878 Weber St N', [null, null, 43.5118527, -80.5542617], ['-8634542354666695567', '-8822026229683971437']], "St. Jacobs Farmers' Market"],
|
|
]],
|
|
];
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
text: async () => 'prefix\n' + JSON.stringify(listPayload),
|
|
}));
|
|
|
|
const result = await asLegacyResult(svc.importGoogleList(String(trip.id), 'https://www.google.com/maps/placelists/list/ABC123DEF456')) as any;
|
|
const other = await findRow(orm, Places, { id: namesake.id }) as any;
|
|
|
|
expect(result.skipped).toBe(1);
|
|
expect(other.google_ftid).toBeNull();
|
|
});
|
|
|
|
it('PLACE-SVC-028d — a renamed place is not re-imported as a twin (#1550)', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
|
|
const listPayload = [
|
|
[null, null, null, null, 'My Test List', null, null, null, [
|
|
[null, [null, null, null, null, '878 Weber St N', [null, null, 43.5118527, -80.5542617], ['-8634542354666695567', '-8822026229683971437']], "St. Jacobs Farmers' Market"],
|
|
]],
|
|
];
|
|
const respond = () => vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
text: async () => 'prefix\n' + JSON.stringify(listPayload),
|
|
}));
|
|
const url = 'https://www.google.com/maps/placelists/list/ABC123DEF456';
|
|
|
|
respond();
|
|
const first = await asLegacyResult(svc.importGoogleList(String(trip.id), url)) as any;
|
|
expect(first.places).toHaveLength(1);
|
|
|
|
// What the reporter does: rename it to something they can actually read, and
|
|
// move it far enough that the coordinate fallback would not save us either.
|
|
await updateRows(orm, Places, { id: first.places[0].id }, { name: 'Saturday market', lat: 43.6, lng: -80.6 });
|
|
|
|
respond();
|
|
const second = await asLegacyResult(svc.importGoogleList(String(trip.id), url)) as any;
|
|
expect(second.places).toHaveLength(0);
|
|
expect(second.skipped).toBe(1);
|
|
expect(({ c: await countRows(orm, Places, { trip: trip.id }) })).toEqual({ c: 1 });
|
|
});
|
|
|
|
it('PLACE-SVC-028e — two places at the same coordinates still both import', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
|
|
// A bar and a diner in one building: same spot, different feature ids.
|
|
const listPayload = [
|
|
[null, null, null, null, 'One Building', null, null, null, [
|
|
[null, [null, null, null, null, 'Same street 1', [null, null, 52.52, 13.405], ['1', '2']], 'Rooftop Bar'],
|
|
[null, [null, null, null, null, 'Same street 1', [null, null, 52.52, 13.405], ['3', '4']], 'Ground Floor Diner'],
|
|
]],
|
|
];
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
text: async () => 'prefix\n' + JSON.stringify(listPayload),
|
|
}));
|
|
|
|
const result = await asLegacyResult(svc.importGoogleList(String(trip.id), 'https://www.google.com/maps/placelists/list/ABC123DEF456')) as any;
|
|
expect(result.places).toHaveLength(2);
|
|
expect(result.skipped).toBe(0);
|
|
});
|
|
|
|
it('PLACE-SVC-029 — returns error when list items array is empty', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
|
|
const listPayload = [[null, null, null, null, 'Empty List', null, null, null, []]];
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
text: async () => 'prefix\n' + JSON.stringify(listPayload),
|
|
}));
|
|
|
|
const url = 'https://www.google.com/maps/placelists/list/ABC123DEF456';
|
|
const result = await asLegacyResult(svc.importGoogleList(String(trip.id), url)) as any;
|
|
expect(result.error).toBeDefined();
|
|
expect(result.status).toBe(400);
|
|
});
|
|
});
|
|
|
|
// ── searchImage ───────────────────────────────────────────────────────────────
|
|
|
|
describe('searchImage', () => {
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it('PLACE-SVC-030 — returns 404 when place does not exist', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const result = await asLegacyResult(svc.searchImage(String(trip.id), '99999', user.id)) as any;
|
|
expect(result.error).toBeDefined();
|
|
expect(result.status).toBe(404);
|
|
});
|
|
|
|
it('PLACE-SVC-030b (H1) — a non-canonical id ("<id> ") also 404s', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const place = createPlace(testDb, trip.id);
|
|
const result = await asLegacyResult(svc.searchImage(String(trip.id), `${place.id} `, user.id));
|
|
expect(result).toEqual({ error: 'Place not found', status: 404 });
|
|
});
|
|
|
|
it('PLACE-SVC-031 — searches Unsplash without a stored API key', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const place = createPlace(testDb, trip.id, { name: 'Eiffel Tower' }) as any;
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({
|
|
results: [
|
|
{ id: 'photo1', urls: { regular: 'https://img.example.com/1', thumb: 'https://img.example.com/t1' }, description: 'Tower', user: { name: 'Photographer' }, links: { html: 'https://unsplash.com/1' } },
|
|
],
|
|
}),
|
|
status: 200,
|
|
}));
|
|
|
|
const result = await asLegacyResult(svc.searchImage(String(trip.id), String(place.id), user.id)) as any;
|
|
expect(result.photos).toHaveLength(1);
|
|
const [url] = (fetch as any).mock.calls[0];
|
|
expect(url).toContain('https://unsplash.com/napi/search/photos?');
|
|
expect(url).not.toContain('client_id=');
|
|
});
|
|
|
|
it('PLACE-SVC-032 — returns photos when Unsplash API responds successfully', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const place = createPlace(testDb, trip.id, { name: 'Eiffel Tower' }) as any;
|
|
|
|
const mockPhotos = [
|
|
{ id: 'photo1', urls: { regular: 'https://img.example.com/1', thumb: 'https://img.example.com/t1' }, description: 'Tower', user: { name: 'Photographer' }, links: { html: 'https://unsplash.com/1' } },
|
|
];
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({ results: mockPhotos }),
|
|
status: 200,
|
|
}));
|
|
|
|
const result = await asLegacyResult(svc.searchImage(String(trip.id), String(place.id), user.id)) as any;
|
|
expect(result.photos).toHaveLength(1);
|
|
expect(result.photos[0].id).toBe('photo1');
|
|
expect(result.photos[0].url).toBe('https://img.example.com/1');
|
|
expect(result.photos[0].photographer).toBe('Photographer');
|
|
});
|
|
});
|
|
|
|
// ── Import deduplication ──────────────────────────────────────────────────────
|
|
|
|
describe('importGpx deduplication', () => {
|
|
it('PLACE-SVC-033 — skips waypoints already in trip by name', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const buf = fs.readFileSync(GPX_FIXTURE);
|
|
|
|
// First import
|
|
const first = await svc.importGpx(String(trip.id), buf) as any;
|
|
expect(first.count).toBeGreaterThan(0);
|
|
|
|
// Second import — all names already present, nothing new created
|
|
const second = await svc.importGpx(String(trip.id), buf) as any;
|
|
expect(second.count).toBe(0);
|
|
expect(second.skipped).toBe(first.count);
|
|
|
|
// Total places in DB should equal first import count
|
|
const total = ((await svc.list(String(trip.id), {})) as any[]).length;
|
|
expect(total).toBe(first.count);
|
|
});
|
|
|
|
it('PLACE-SVC-034 — imports new places while skipping existing ones', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const buf = fs.readFileSync(GPX_FIXTURE);
|
|
|
|
const first = await svc.importGpx(String(trip.id), buf) as any;
|
|
// Manually add a brand-new place so total > first.count
|
|
createPlace(testDb, trip.id, { name: 'Unique Extra Place', lat: 99, lng: 99 });
|
|
|
|
// Re-import: the fixture places are skipped, the extra place remains untouched
|
|
const second = await svc.importGpx(String(trip.id), buf) as any;
|
|
expect(second.count).toBe(0);
|
|
|
|
const total = ((await svc.list(String(trip.id), {})) as any[]).length;
|
|
expect(total).toBe(first.count + 1);
|
|
});
|
|
});
|
|
|
|
describe('importKmlPlaces deduplication', () => {
|
|
it('PLACE-SVC-035 — skips placemarks already in trip by name', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const buf = fs.readFileSync(KML_FIXTURE);
|
|
|
|
const first = await svc.importKmlPlaces(String(trip.id), buf);
|
|
expect(first.count).toBeGreaterThan(0);
|
|
|
|
const second = await svc.importKmlPlaces(String(trip.id), buf);
|
|
expect(second.count).toBe(0);
|
|
expect(second.summary.skippedCount).toBeGreaterThanOrEqual(first.count);
|
|
expect(second.summary.warnings.some((w: string) => w.includes('skipped'))).toBe(true);
|
|
});
|
|
|
|
it('PLACE-SVC-036 — deduplicates within the same file (intra-batch)', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
// Craft a KML with two placemarks sharing the same name
|
|
const kml = Buffer.from(`<?xml version="1.0" encoding="UTF-8"?>
|
|
<kml xmlns="http://www.opengis.net/kml/2.2"><Document>
|
|
<Placemark><name>Dupe Place</name><Point><coordinates>2.0,48.0,0</coordinates></Point></Placemark>
|
|
<Placemark><name>Dupe Place</name><Point><coordinates>2.1,48.1,0</coordinates></Point></Placemark>
|
|
</Document></kml>`);
|
|
|
|
const result = await svc.importKmlPlaces(String(trip.id), kml);
|
|
expect(result.count).toBe(1);
|
|
expect(result.summary.skippedCount).toBe(1);
|
|
});
|
|
});
|
|
|
|
// Task 5 review M2/L2 — the same full-stored-row/atomicity gap for KML.
|
|
// Mutants that survived every suite until these tests: KML `route_geometry
|
|
// → null`, `duration_minutes: 30`, the GPX loop's `uow.transactional`
|
|
// removal class repeated here for KML's own loop.
|
|
describe('importKmlPlaces — full stored row and atomicity (M2/L2)', () => {
|
|
it('PLACE-SVC-036b (M2) — a LineString placemark stores route_geometry, duration_minutes: 60 and transport_mode: walking', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const kml = Buffer.from(`<?xml version="1.0" encoding="UTF-8"?>
|
|
<kml xmlns="http://www.opengis.net/kml/2.2"><Document>
|
|
<Placemark><name>Ridge Path</name><LineString><coordinates>2.0,48.0,0 2.1,48.1,0</coordinates></LineString></Placemark>
|
|
</Document></kml>`);
|
|
const result = await svc.importKmlPlaces(String(trip.id), kml);
|
|
expect(result.count).toBe(1);
|
|
const row = await findRow(orm, Places, { trip: trip.id, name: 'Ridge Path' }) as Record<string, unknown>;
|
|
expect(row.route_geometry).toBeTruthy();
|
|
expect(JSON.parse(row.route_geometry as string)).toHaveLength(2);
|
|
expect(row.duration_minutes).toBe(60);
|
|
expect(row.transport_mode).toBe('walking');
|
|
expect(row.category_id).toBeNull();
|
|
});
|
|
|
|
it('PLACE-SVC-036c (L2) — a failure partway through the KML loop leaves nothing stored', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const placesRepo = await createTestPlacesRepo(testDb);
|
|
const realInsertPlace = placesRepo.insertPlace.bind(placesRepo);
|
|
let calls = 0;
|
|
const insertSpy = vi.spyOn(placesRepo, 'insertPlace').mockImplementation(async (input) => {
|
|
calls++;
|
|
if (calls === 2) throw new Error('boom');
|
|
return realInsertPlace(input);
|
|
});
|
|
const kml = Buffer.from(`<?xml version="1.0" encoding="UTF-8"?>
|
|
<kml xmlns="http://www.opengis.net/kml/2.2"><Document>
|
|
<Placemark><name>A</name><Point><coordinates>2.0,48.0,0</coordinates></Point></Placemark>
|
|
<Placemark><name>B</name><Point><coordinates>2.1,48.1,0</coordinates></Point></Placemark>
|
|
</Document></kml>`);
|
|
await expect(svc.importKmlPlaces(String(trip.id), kml)).rejects.toThrow('boom');
|
|
const count = ({ n: await countRows(orm, Places, { trip: trip.id }) }) as { n: number };
|
|
expect(count.n).toBe(0);
|
|
insertSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
// ── Custom place image reclaim (#1136) ──────────────────────────────────────────
|
|
|
|
describe('custom place image reclaim', () => {
|
|
function writePlaceImage(name: string): string {
|
|
const filePath = path.join(placesStorageFx.root, name);
|
|
fs.writeFileSync(filePath, 'jpeg-bytes');
|
|
return filePath;
|
|
}
|
|
|
|
it('PLACE-SVC-046 — replacing image_url unlinks the previous upload once unreferenced', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const place = createPlace(testDb, trip.id, { name: 'Photo' }) as any;
|
|
const fileA = writePlaceImage('svc-replace-a.jpg');
|
|
await updateRows(orm, Places, { id: place.id }, { image_url: '/uploads/places/svc-replace-a.jpg' });
|
|
expect(fs.existsSync(fileA)).toBe(true);
|
|
|
|
await svc.update(String(trip.id), String(place.id), { image_url: '/uploads/places/svc-replace-b.jpg' });
|
|
expect(fs.existsSync(fileA)).toBe(false);
|
|
});
|
|
|
|
it('PLACE-SVC-047 — clearing image_url to null unlinks the previous upload', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const place = createPlace(testDb, trip.id, { name: 'Photo' }) as any;
|
|
const fileA = writePlaceImage('svc-clear.jpg');
|
|
await updateRows(orm, Places, { id: place.id }, { image_url: '/uploads/places/svc-clear.jpg' });
|
|
expect(fs.existsSync(fileA)).toBe(true);
|
|
|
|
await svc.update(String(trip.id), String(place.id), { image_url: null } as any);
|
|
expect(fs.existsSync(fileA)).toBe(false);
|
|
});
|
|
|
|
it('PLACE-SVC-048 — remove unlinks the uploaded image', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const place = createPlace(testDb, trip.id, { name: 'Photo' }) as any;
|
|
const fileA = writePlaceImage('svc-delete.jpg');
|
|
await updateRows(orm, Places, { id: place.id }, { image_url: '/uploads/places/svc-delete.jpg' });
|
|
expect(fs.existsSync(fileA)).toBe(true);
|
|
|
|
await svc.remove(String(trip.id), String(place.id));
|
|
expect(fs.existsSync(fileA)).toBe(false);
|
|
});
|
|
|
|
it('PLACE-SVC-049 — a collection_places reference keeps the file when the trip place is deleted', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const place = createPlace(testDb, trip.id, { name: 'Shared Photo' }) as any;
|
|
const fileA = writePlaceImage('svc-shared.jpg');
|
|
await updateRows(orm, Places, { id: place.id }, { image_url: '/uploads/places/svc-shared.jpg' });
|
|
// A saved-place in a collection holds the same uploaded file — the ref-count guard must protect it.
|
|
const col = await insertRow(orm, Collections, { owner: user.id, name: 'Saved' });
|
|
await insertRow(orm, CollectionPlaces, { collection: col, owner: user.id, name: 'Shared Photo', image_url: '/uploads/places/svc-shared.jpg' });
|
|
expect(fs.existsSync(fileA)).toBe(true);
|
|
|
|
await svc.remove(String(trip.id), String(place.id));
|
|
expect(fs.existsSync(fileA)).toBe(true);
|
|
|
|
// resetTestDb does not clear collections; drop what this test inserted and its file.
|
|
testDb.exec('DELETE FROM collection_places; DELETE FROM collections;');
|
|
fs.unlinkSync(fileA);
|
|
});
|
|
|
|
// ── Collaborative ratings (#1435) ──────────────────────────────────────────
|
|
it('PLACE-SVC-050 — rate stores one vote per user, replaces on re-vote, clears with null', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const place = createPlace(testDb, trip.id, { name: 'Rated' }) as { id: number };
|
|
|
|
await svc.rate(String(trip.id), String(place.id), user.id, 5);
|
|
let rows = (await findRows(orm, PlaceRatings, { place: place.id, user: user.id })).map((r) => ({ rating: r.rating }));
|
|
expect(rows).toEqual([{ rating: 5 }]);
|
|
|
|
await svc.rate(String(trip.id), String(place.id), user.id, 2); // re-vote replaces via the UNIQUE upsert
|
|
rows = (await findRows(orm, PlaceRatings, { place: place.id, user: user.id })).map((r) => ({ rating: r.rating }));
|
|
expect(rows).toEqual([{ rating: 2 }]);
|
|
|
|
await svc.rate(String(trip.id), String(place.id), user.id, null); // clear
|
|
const count = ({ n: await countRows(orm, PlaceRatings, { place: place.id }) }) as { n: number };
|
|
expect(count.n).toBe(0);
|
|
});
|
|
|
|
it('PLACE-SVC-051 — rate returns null and writes nothing when the place is not in the trip', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const otherTrip = createTrip(testDb, user.id);
|
|
const place = createPlace(testDb, otherTrip.id, { name: 'Elsewhere' }) as { id: number };
|
|
|
|
expect(await svc.rate(String(trip.id), String(place.id), user.id, 4)).toBeNull();
|
|
const count = ({ n: await countRows(orm, PlaceRatings, { place: place.id }) }) as { n: number };
|
|
expect(count.n).toBe(0);
|
|
});
|
|
|
|
it('PLACE-SVC-051b (H1) — a non-canonical id ("<id> ") is null and writes nothing', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const place = createPlace(testDb, trip.id) as { id: number };
|
|
expect(await svc.rate(String(trip.id), `${place.id} `, user.id, 4)).toBeNull();
|
|
const count = ({ n: await countRows(orm, PlaceRatings, { place: place.id }) }) as { n: number };
|
|
expect(count.n).toBe(0);
|
|
});
|
|
|
|
// PL51's ruling: an ON CONFLICT DO UPDATE limited to `rating` must never
|
|
// touch `places.updated_at` — a vote must not 409 another member's
|
|
// offline edit (#1435). Proven directly against the column, not just
|
|
// against the response shape.
|
|
it('PLACE-SVC-051c (PL51) — rating a place never bumps places.updated_at', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const place = createPlace(testDb, trip.id, { name: 'Silent Vote' }) as { id: number };
|
|
// Task 4 review M2: a freshly-created row's `updated_at` can share the
|
|
// same one-second `datetime('now')` bucket as a bump this test means to
|
|
// catch, so `before === after` proved nothing either way (M1 — `upsertRating`
|
|
// also bumping `places.updated_at` — survived this test as written). Seed a
|
|
// visibly stale timestamp first so any bump is unmistakably a different value.
|
|
await updateRows(orm, Places, { id: place.id }, { updated_at: dbNow(new Date(Date.now() - 60 * 60 * 1000)) });
|
|
const before = (await findRow(orm, Places, { id: place.id }) as { updated_at: string | null }).updated_at;
|
|
|
|
await svc.rate(String(trip.id), String(place.id), user.id, 5);
|
|
// Re-vote (the ON CONFLICT DO UPDATE path) — the one statement in the
|
|
// whole cluster that could plausibly touch `updated_at` via a careless
|
|
// merge-field list.
|
|
await svc.rate(String(trip.id), String(place.id), user.id, 2);
|
|
const after = (await findRow(orm, Places, { id: place.id }) as { updated_at: string | null }).updated_at;
|
|
expect(after).toBe(before);
|
|
});
|
|
});
|
|
|
|
// ── Automatic track colours (#776) ────────────────────────────────────────────
|
|
|
|
// Three tracks plus a plain waypoint — the shared fixture only has waypoints,
|
|
// and the whole point here is what happens to geometry.
|
|
const GPX_WITH_TRACKS = `<?xml version="1.0" encoding="UTF-8"?>
|
|
<gpx version="1.1" creator="TREK Tests" xmlns="http://www.topografix.com/GPX/1/1">
|
|
<wpt lat="48.8566" lon="2.3522"><name>Trailhead</name></wpt>
|
|
<trk><name>Morning walk</name><trkseg>
|
|
<trkpt lat="48.10" lon="2.10"/><trkpt lat="48.11" lon="2.11"/>
|
|
</trkseg></trk>
|
|
<trk><name>Afternoon walk</name><trkseg>
|
|
<trkpt lat="48.20" lon="2.20"/><trkpt lat="48.21" lon="2.21"/>
|
|
</trkseg></trk>
|
|
<trk><name>Evening walk</name><trkseg>
|
|
<trkpt lat="48.30" lon="2.30"/><trkpt lat="48.31" lon="2.31"/>
|
|
</trkseg></trk>
|
|
</gpx>`;
|
|
|
|
async function importFixture(tripId: number) {
|
|
return await svc.importGpx(String(tripId), Buffer.from(GPX_WITH_TRACKS), {
|
|
importWaypoints: true, importRoutes: true, importTracks: true,
|
|
});
|
|
}
|
|
|
|
async function tracksOf(tripId: number) {
|
|
return await findRows(orm, Places, { trip: tripId, route_geometry: { $ne: null } }, { id: 'asc' }) as { id: number; route_color: string | null }[];
|
|
}
|
|
|
|
describe('PlacesService — automatic track colours (#776)', () => {
|
|
it('PLACES-SVC-001 — every imported track gets a colour from the shared palette', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await importFixture(trip.id);
|
|
|
|
const tracks = await tracksOf(trip.id);
|
|
expect(tracks.length).toBeGreaterThan(0);
|
|
for (const track of tracks) {
|
|
expect(TRACK_COLORS).toContain(track.route_color);
|
|
}
|
|
});
|
|
|
|
it('PLACES-SVC-002 — the returned places carry the colour, not just the DB rows', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const result = await importFixture(trip.id) as { places: any[] } | null;
|
|
|
|
const returnedTracks = (result?.places ?? []).filter(p => p.route_geometry);
|
|
expect(returnedTracks.length).toBeGreaterThan(0);
|
|
for (const track of returnedTracks) {
|
|
expect(TRACK_COLORS).toContain(track.route_color);
|
|
}
|
|
});
|
|
|
|
it('PLACES-SVC-003 — plain waypoints keep no colour at all', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await importFixture(trip.id);
|
|
|
|
const waypoints = await findRows(orm, Places, { trip: trip.id, route_geometry: null }) as { route_color: string | null }[];
|
|
for (const wp of waypoints) {
|
|
expect(wp.route_color).toBeNull();
|
|
}
|
|
});
|
|
|
|
it('PLACES-SVC-004 — a second import continues the palette instead of repeating it', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await importFixture(trip.id);
|
|
const first = (await tracksOf(trip.id)).map(t => t.route_color);
|
|
|
|
// Same fixture again: dedup skips the identical rows, so seed a distinct
|
|
// track directly and let the service colour the next import round.
|
|
await insertRow(orm, Places, { trip: trip.id, name: 'Second walk', lat: 1, lng: 1, route_geometry: '[[1,1],[2,2]]' });
|
|
const seeded = await findRow(orm, Places, { name: 'Second walk' }) as { id: number };
|
|
await (svc as any).colorizeImportedTracks(String(trip.id), {
|
|
places: [{ id: seeded.id, route_geometry: '[[1,1],[2,2]]', route_color: null }],
|
|
});
|
|
|
|
const seededColor = (await findRow(orm, Places, { id: seeded.id }) as any).route_color;
|
|
expect(TRACK_COLORS).toContain(seededColor);
|
|
expect(first).not.toContain(seededColor);
|
|
});
|
|
|
|
it('PLACES-SVC-006 — a colour already in use by hand is not handed out again', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
// Someone recoloured an existing track to the palette's second entry. A
|
|
// plain row count would hand exactly that colour to the next import.
|
|
await insertRow(orm, Places, { trip: trip.id, name: 'Old walk', lat: 1, lng: 1, route_geometry: '[[1,1],[2,2]]', route_color: TRACK_COLORS[1] });
|
|
|
|
await importFixture(trip.id);
|
|
|
|
const colors = (await tracksOf(trip.id)).map(t => t.route_color);
|
|
expect(new Set(colors).size).toBe(colors.length);
|
|
});
|
|
|
|
it('PLACES-SVC-007 — gaps left by deleted tracks are reused, not skipped past', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await importFixture(trip.id);
|
|
// Drop the first track; its colour becomes free again.
|
|
const first = (await tracksOf(trip.id))[0];
|
|
await deleteRows(orm, Places, { id: first.id });
|
|
|
|
const seeded = { id: await insertRow(orm, Places, { trip: trip.id, name: 'Later walk', lat: 9, lng: 9, route_geometry: '[[9,9],[8,8]]' }) };
|
|
await (svc as any).colorizeImportedTracks(String(trip.id), {
|
|
places: [{ id: seeded.id, route_geometry: '[[9,9],[8,8]]', route_color: null }],
|
|
});
|
|
|
|
const colors = (await tracksOf(trip.id)).map(t => t.route_color);
|
|
expect(new Set(colors).size).toBe(colors.length);
|
|
});
|
|
|
|
it('PLACES-SVC-005 — an import that yields nothing colours nothing', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
|
|
await expect((svc as any).colorizeImportedTracks(String(trip.id), null)).resolves.toBeUndefined();
|
|
await expect((svc as any).colorizeImportedTracks(String(trip.id), { places: [] })).resolves.toBeUndefined();
|
|
|
|
expect(await tracksOf(trip.id)).toEqual([]);
|
|
});
|
|
|
|
// Task 5 review M1: `PLACEREPO-039` proves the driver's single-connection
|
|
// serialisation for `t.em.transactional`, not that `colorizeImportedTracks`
|
|
// actually wraps its read+write in one transaction — removing
|
|
// `this.uow.transactional` from the service survived every suite until
|
|
// this test. Numbered 018 (not the review's suggested 008) to avoid
|
|
// colliding with the pre-existing PLACES-SVC-008 in the
|
|
// `findMatchingPlaceId` describe block below.
|
|
it('PLACES-SVC-018 (M1) — two concurrent colourings of the same trip never share a colour', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const ids = await insertRows(orm, Places, [1, 2].map((n) => (
|
|
{ trip: trip.id, name: 'cc' + n, lat: 1, lng: 1, route_geometry: '[[1,1],[2,2]]' }
|
|
)));
|
|
await Promise.all(ids.map((id) => (svc as unknown as { colorizeImportedTracks: (tripId: string, result: { places: { id: number; route_geometry: string; route_color: string | null }[] }) => Promise<void> }).colorizeImportedTracks(String(trip.id), {
|
|
places: [{ id, route_geometry: '[[1,1],[2,2]]', route_color: null }],
|
|
})));
|
|
expect(new Set((await tracksOf(trip.id)).map(t => t.route_color)).size).toBe(2);
|
|
});
|
|
|
|
// Task 5 review L1: the palette-wrap arm (`TRACK_COLORS[(i - free.length) %
|
|
// len]`) had no test — a mutation collapsing every wrapped index to
|
|
// `TRACK_COLORS[0]` survived every suite until this test.
|
|
it('PLACES-SVC-019 (L1) — more tracks than free colours wrap from the palette start', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const placeIds = await insertRows(orm, Places, Array.from({ length: TRACK_COLORS.length + 2 }, (_, i) => (
|
|
{ trip: trip.id, name: 'w' + i, lat: 1, lng: 1, route_geometry: '[[1,1],[2,2]]' }
|
|
)));
|
|
const places = placeIds.map((id) => ({
|
|
id,
|
|
route_geometry: '[[1,1],[2,2]]',
|
|
route_color: null as string | null,
|
|
}));
|
|
await (svc as unknown as { colorizeImportedTracks: (tripId: string, result: { places: typeof places }) => Promise<void> }).colorizeImportedTracks(String(trip.id), { places });
|
|
const stored = (await tracksOf(trip.id)).map(t => t.route_color);
|
|
expect(stored).toEqual([...TRACK_COLORS, TRACK_COLORS[0], TRACK_COLORS[1]]);
|
|
});
|
|
});
|
|
|
|
// ── Import enrichment (#886) ──────────────────────────────────────────────────
|
|
|
|
describe('enrichImportedPlaces', () => {
|
|
// Deliberately partial: each case stubs only the provider calls its path reaches.
|
|
function enrichSvc(maps: Partial<MapsService>) {
|
|
return makePlacesService(maps as MapsService);
|
|
}
|
|
|
|
it('PLACE-SVC-058 — no-ops when no Google Maps key is configured', async () => {
|
|
const searchPlaces = vi.fn();
|
|
const svcNoKey = await enrichSvc({ getMapsKey: vi.fn(() => null), searchPlaces });
|
|
await svcNoKey.enrichImportedPlaces('1', 1, [{ id: 1, name: 'A', lat: 1, lng: 2 }]);
|
|
expect(searchPlaces).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('PLACE-SVC-058b — a file import enriches its points, never its paths (#2536)', async () => {
|
|
const service = await enrichSvc({ getMapsKey: vi.fn(() => null) });
|
|
const spy = vi.spyOn(service, 'enrichImportedPlaces').mockResolvedValue();
|
|
service.enrichImportedFilePlaces('1', 1, [{ id: 1 }, { id: 2, route_geometry: '[[1,2],[3,4]]' }]);
|
|
expect(spy).toHaveBeenCalledWith('1', 1, [{ id: 1 }]);
|
|
});
|
|
|
|
it('PLACE-SVC-059 — no-ops for an empty batch without touching the provider', async () => {
|
|
const getMapsKey = vi.fn(() => Promise.resolve('key'));
|
|
await (await enrichSvc({ getMapsKey })).enrichImportedPlaces('1', 1, []);
|
|
expect(getMapsKey).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('PLACE-SVC-060 — fills only the empty columns and persists the resolved ids', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const place = createPlace(testDb, trip.id, { name: 'Bar', lat: 48.85, lng: 2.35 }) as any;
|
|
// An address the import already captured must survive the COALESCE.
|
|
await updateRows(orm, Places, { id: place.id }, { address: 'Imported address' });
|
|
|
|
const svcWithMaps = await enrichSvc({
|
|
getMapsKey: vi.fn(() => 'key'),
|
|
searchPlaces: vi.fn(async () => ({
|
|
source: 'google',
|
|
places: [{ google_place_id: 'ChIJ1', google_ftid: '0x1:0x2', address: 'Google address', website: 'https://x', phone: '+33', lat: 48.85, lng: 2.35 }],
|
|
})),
|
|
getPlacePhoto: vi.fn(async () => ({ photoUrl: '/api/maps/place-photo/ChIJ1/bytes', attribution: null })),
|
|
} as never);
|
|
|
|
await svcWithMaps.enrichImportedPlaces(String(trip.id), user.id, [{ id: place.id, name: 'Bar', lat: 48.85, lng: 2.35 }]);
|
|
|
|
const row = await findRow(orm, Places, { id: place.id }) as any;
|
|
expect(row.google_place_id).toBe('ChIJ1');
|
|
expect(row.google_ftid).toBe('0x1:0x2');
|
|
expect(row.address).toBe('Imported address'); // NOT clobbered
|
|
expect(row.website).toBe('https://x');
|
|
expect(row.phone).toBe('+33');
|
|
expect(row.image_url).toBe('/api/maps/place-photo/ChIJ1/bytes');
|
|
});
|
|
|
|
it('PLACE-SVC-060b (PL45) — broadcasts place:updated with socketId: undefined, no exclusion (the importer\'s own client gets the late update)', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const place = createPlace(testDb, trip.id, { name: 'Bar', lat: 48.85, lng: 2.35 });
|
|
const broadcastSpy = vi.spyOn(RealtimeService.prototype, 'broadcast');
|
|
|
|
const svcWithMaps = await enrichSvc({
|
|
getMapsKey: vi.fn(() => 'key'),
|
|
searchPlaces: vi.fn(async () => ({ source: 'google', places: [{ google_place_id: 'ChIJ1', lat: 48.85, lng: 2.35 }] })),
|
|
getPlacePhoto: vi.fn(async () => ({ photoUrl: null, attribution: null })),
|
|
} as never);
|
|
|
|
await svcWithMaps.enrichImportedPlaces(String(trip.id), user.id, [{ id: place.id, name: 'Bar', lat: 48.85, lng: 2.35 }]);
|
|
|
|
expect(broadcastSpy).toHaveBeenCalledTimes(1);
|
|
const [tripArg, event, payload, socketId] = broadcastSpy.mock.calls[0];
|
|
expect(tripArg).toBe(String(trip.id));
|
|
expect(event).toBe('place:updated');
|
|
// Task 5 review L7: the payload carries the just-written value, not a
|
|
// stale pre-enrichment snapshot (the qb read bypasses the identity map).
|
|
expect((payload as { place: { id: number; google_place_id: string | null } }).place.id).toBe(place.id);
|
|
expect((payload as { place: { google_place_id: string | null } }).place.google_place_id).toBe('ChIJ1');
|
|
expect(socketId).toBeUndefined();
|
|
broadcastSpy.mockRestore();
|
|
});
|
|
|
|
it('PLACE-SVC-061 — leaves the place alone when no candidate is close enough', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const place = createPlace(testDb, trip.id, { name: 'Bar', lat: 48.85, lng: 2.35 }) as any;
|
|
|
|
const svcWithMaps = await enrichSvc({
|
|
getMapsKey: vi.fn(() => 'key'),
|
|
// ~1.2 km away — beyond MATCH_RADIUS_METERS.
|
|
searchPlaces: vi.fn(async () => ({ source: 'google', places: [{ google_place_id: 'ChIJfar', lat: 48.86, lng: 2.36 }] })),
|
|
} as never);
|
|
|
|
await svcWithMaps.enrichImportedPlaces(String(trip.id), user.id, [{ id: place.id, name: 'Bar', lat: 48.85, lng: 2.35 }]);
|
|
|
|
const row = await findRow(orm, Places, { id: place.id }) as any;
|
|
expect(row.google_place_id).toBeNull();
|
|
});
|
|
|
|
it('PLACE-SVC-062 — a failed photo fetch never undoes the rest of the enrichment', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const place = createPlace(testDb, trip.id, { name: 'Bar', lat: 48.85, lng: 2.35 }) as any;
|
|
|
|
const svcWithMaps = await enrichSvc({
|
|
getMapsKey: vi.fn(() => 'key'),
|
|
searchPlaces: vi.fn(async () => ({ source: 'google', places: [{ google_place_id: 'ChIJ1', lat: 48.85, lng: 2.35 }] })),
|
|
getPlacePhoto: vi.fn(async () => { throw new Error('provider down'); }),
|
|
} as never);
|
|
|
|
await svcWithMaps.enrichImportedPlaces(String(trip.id), user.id, [{ id: place.id, name: 'Bar', lat: 48.85, lng: 2.35 }]);
|
|
|
|
const row = await findRow(orm, Places, { id: place.id }) as any;
|
|
expect(row.google_place_id).toBe('ChIJ1');
|
|
expect(row.image_url).toBeNull();
|
|
});
|
|
|
|
it('PLACE-SVC-063 — a per-place failure is swallowed so the batch never throws', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const err = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
const svcWithMaps = await enrichSvc({
|
|
getMapsKey: vi.fn(() => 'key'),
|
|
searchPlaces: vi.fn(async () => { throw new Error('lookup exploded'); }),
|
|
} as never);
|
|
|
|
await expect(
|
|
svcWithMaps.enrichImportedPlaces(String(trip.id), user.id, [{ id: 1, name: 'A', lat: 1, lng: 2 }]),
|
|
).resolves.toBeUndefined();
|
|
expect(err).toHaveBeenCalled();
|
|
err.mockRestore();
|
|
});
|
|
|
|
it('PLACE-SVC-064 — skips a place that is already linked or has no coordinates', async () => {
|
|
const searchPlaces = vi.fn();
|
|
const svcWithMaps = await enrichSvc({ getMapsKey: vi.fn(() => 'key'), searchPlaces } as never);
|
|
await svcWithMaps.enrichImportedPlaces('1', 1, [
|
|
{ id: 1, name: 'Linked', lat: 1, lng: 2, google_place_id: 'ChIJalready' },
|
|
{ id: 2, name: 'Coordless', lat: null as never, lng: null as never },
|
|
]);
|
|
expect(searchPlaces).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
// ── Falsy-coercion fixes (#1745) ──────────────────────────────────────────────
|
|
//
|
|
// The legacy service ran every optional field through `x || fallback`, which
|
|
// cannot tell "absent" from a legitimate zero. Coordinates on the equator or
|
|
// the prime meridian were silently dropped and a zero duration/price was
|
|
// replaced by the default.
|
|
|
|
describe('zero-valued numeric fields', () => {
|
|
it('PLACE-SVC-065 — create keeps lat/lng of exactly 0 instead of nulling them', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const place = await svc.create(String(trip.id), { name: 'Null Island', lat: 0, lng: 0 }) as any;
|
|
expect(place.lat).toBe(0);
|
|
expect(place.lng).toBe(0);
|
|
// A genuinely absent coordinate still lands as NULL.
|
|
const noCoords = await svc.create(String(trip.id), { name: 'Unlocated' }) as any;
|
|
expect(noCoords.lat).toBeNull();
|
|
expect(noCoords.lng).toBeNull();
|
|
});
|
|
|
|
it('PLACE-SVC-066 — create keeps duration_minutes and price of 0', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const place = await svc.create(String(trip.id), { name: 'Drive-by', duration_minutes: 0, price: 0 }) as any;
|
|
expect(place.duration_minutes).toBe(0);
|
|
expect(place.price).toBe(0);
|
|
// The 60-minute default still applies when the field is absent.
|
|
expect((await svc.create(String(trip.id), { name: 'Default' }) as any).duration_minutes).toBe(60);
|
|
});
|
|
|
|
it('PLACE-SVC-067 — update can set duration_minutes to 0', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const place = createPlace(testDb, trip.id, { name: 'Stop' }) as any;
|
|
await updateRows(orm, Places, { id: place.id }, { duration_minutes: 90 });
|
|
|
|
const zeroed = await svc.update(String(trip.id), String(place.id), { duration_minutes: 0 }) as any;
|
|
expect(zeroed.duration_minutes).toBe(0);
|
|
|
|
// An omitted duration still leaves the stored value alone.
|
|
const untouched = await svc.update(String(trip.id), String(place.id), { name: 'Stop 2' }) as any;
|
|
expect(untouched.duration_minutes).toBe(0);
|
|
});
|
|
|
|
it('PLACE-SVC-067b — an explicit null clears the planned stay length', async () => {
|
|
// What the write contract and the MCP tool both advertise. Behind COALESCE,
|
|
// null and absent were the same thing, so the field promised a reset it never
|
|
// performed and the day plan kept budgeting the old ninety minutes.
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const place = createPlace(testDb, trip.id, { name: 'Stop' }) as any;
|
|
await updateRows(orm, Places, { id: place.id }, { duration_minutes: 90 });
|
|
|
|
const cleared = await svc.update(String(trip.id), String(place.id), { duration_minutes: null }) as any;
|
|
expect(cleared.duration_minutes).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ── LIKE metacharacter escaping (#1745) ───────────────────────────────────────
|
|
|
|
describe('setImageFromFile (#1242)', () => {
|
|
const attach = async (tripId: number, name: string, mime: string, size = 3) => {
|
|
fs.writeFileSync(path.join(placesStorageFx.root, name), 'img');
|
|
return insertRow(orm, TripFiles, { trip: tripId, filename: name, original_name: name, file_size: size, mime_type: mime });
|
|
};
|
|
|
|
it('PLACE-SVC-IMGFILE-001 — copies an attached picture in as the place image', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const place = createPlace(testDb, trip.id);
|
|
const fileId = await attach(trip.id, 'holiday.jpg', 'image/jpeg');
|
|
const updated = await svc.setImageFromFile(String(trip.id), String(place.id), fileId) as any;
|
|
expect(updated.image_url).toMatch(/^\/uploads\/places\/[0-9a-f-]+\.jpg$/);
|
|
expect(fs.existsSync(path.join(placesStorageFx.root, path.basename(updated.image_url)))).toBe(true);
|
|
// A copy: the attachment itself is still there.
|
|
expect(fs.existsSync(path.join(placesStorageFx.root, 'holiday.jpg'))).toBe(true);
|
|
});
|
|
|
|
it('PLACE-SVC-IMGFILE-002 — refuses a file of another trip, a document and an oversized image', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const other = createTrip(testDb, user.id);
|
|
const place = createPlace(testDb, trip.id);
|
|
expect(await svc.setImageFromFile(String(trip.id), String(place.id), await attach(other.id, 'x.jpg', 'image/jpeg'))).toBe('not_found');
|
|
expect(await svc.setImageFromFile(String(trip.id), String(place.id), await attach(trip.id, 'ticket.pdf', 'application/pdf'))).toBe('not_image');
|
|
expect(await svc.setImageFromFile(String(trip.id), String(place.id), await attach(trip.id, 'vector.svg', 'image/svg+xml'))).toBe('not_image');
|
|
expect(await svc.setImageFromFile(String(trip.id), String(place.id), await attach(trip.id, 'huge.jpg', 'image/jpeg', 50 * 1024 * 1024))).toBe('too_large');
|
|
});
|
|
});
|
|
|
|
describe('list search escaping', () => {
|
|
it('PLACE-SVC-068 — a % or _ in the search term matches literally, not as a wildcard', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
createPlace(testDb, trip.id, { name: '50% off shop' });
|
|
createPlace(testDb, trip.id, { name: 'Bar' });
|
|
createPlace(testDb, trip.id, { name: 'a_b cafe' });
|
|
createPlace(testDb, trip.id, { name: 'axb cafe' });
|
|
|
|
// '%' used to match every row.
|
|
expect(((await svc.list(String(trip.id), { search: '%' })) as any[]).map(p => p.name)).toEqual(['50% off shop']);
|
|
// '_' used to match any single character.
|
|
expect(((await svc.list(String(trip.id), { search: 'a_b' })) as any[]).map(p => p.name)).toEqual(['a_b cafe']);
|
|
});
|
|
|
|
it('PLACE-SVC-069 — ordinary search terms are unaffected', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
createPlace(testDb, trip.id, { name: 'Eiffel Tower' });
|
|
createPlace(testDb, trip.id, { name: 'Louvre' });
|
|
expect(((await svc.list(String(trip.id), { search: 'eiff' })) as any[]).map(p => p.name)).toEqual(['Eiffel Tower']);
|
|
});
|
|
});
|
|
|
|
// ── Trip-scoped id filter for the journey delete hook (#1745) ─────────────────
|
|
|
|
describe('scopedIds', () => {
|
|
it('PLACE-SVC-070 — returns only the ids that belong to the trip, preserving input order', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const other = createTrip(testDb, user.id);
|
|
const a = createPlace(testDb, trip.id, { name: 'A' }) as any;
|
|
const b = createPlace(testDb, trip.id, { name: 'B' }) as any;
|
|
const foreign = createPlace(testDb, other.id, { name: 'Foreign' }) as any;
|
|
|
|
expect(await svc.scopedIds(String(trip.id), [b.id, foreign.id, a.id, 99999])).toEqual([b.id, a.id]);
|
|
expect(await svc.scopedIds(String(trip.id), [])).toEqual([]);
|
|
});
|
|
});
|
|
|
|
// ── Provider-payload hardening for the Google list import (#1745) ─────────────
|
|
|
|
describe('importGoogleList provider payload', () => {
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it('PLACE-SVC-071 — a malformed provider body is the documented 400, not a thrown SyntaxError', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, text: async () => 'prefix\nnot-json-at-all' }));
|
|
const result = await asLegacyResult(svc.importGoogleList(String(trip.id), 'https://www.google.com/maps/placelists/list/ABC123DEF456')) as any;
|
|
expect(result).toEqual({ error: 'Invalid list data received from Google Maps', status: 400 });
|
|
});
|
|
|
|
it('PLACE-SVC-072 — a non-array provider payload is rejected the same way', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, text: async () => 'prefix\n{"unexpected":true}' }));
|
|
const result = await asLegacyResult(svc.importGoogleList(String(trip.id), 'https://www.google.com/maps/placelists/list/ABC123DEF456')) as any;
|
|
expect(result).toEqual({ error: 'Invalid list data received from Google Maps', status: 400 });
|
|
});
|
|
|
|
it('PLACE-SVC-073b — an over-large chunked response (no content-length) is refused after the read', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
headers: { get: () => null }, // chunked: the declared check cannot help
|
|
text: async () => 'x'.repeat(9 * 1024 * 1024),
|
|
}));
|
|
const result = await asLegacyResult(svc.importGoogleList(String(trip.id), 'https://www.google.com/maps/placelists/list/ABC123DEF456')) as any;
|
|
expect(result).toEqual({ error: 'Failed to fetch list from Google Maps', status: 502 });
|
|
});
|
|
|
|
it('PLACE-SVC-073 — an over-large declared response is refused before it is read', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const text = vi.fn();
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
headers: { get: (h: string) => (h.toLowerCase() === 'content-length' ? String(20 * 1024 * 1024) : null) },
|
|
text,
|
|
}));
|
|
const result = await asLegacyResult(svc.importGoogleList(String(trip.id), 'https://www.google.com/maps/placelists/list/ABC123DEF456')) as any;
|
|
expect(result).toEqual({ error: 'Failed to fetch list from Google Maps', status: 502 });
|
|
expect(text).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
// ── Provider-payload hardening for the Naver list import (#1745) ──────────────
|
|
|
|
describe('importNaverList provider payload', () => {
|
|
const FOLDER_URL = 'https://map.naver.com/v5/favorite/myPlace/folder/abc123';
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it('PLACE-SVC-074 — an over-large declared page is refused before it is read', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const text = vi.fn();
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
headers: { get: (h: string) => (h.toLowerCase() === 'content-length' ? String(20 * 1024 * 1024) : null) },
|
|
text,
|
|
}));
|
|
const result = await asLegacyResult(svc.importNaverList(String(trip.id), FOLDER_URL)) as any;
|
|
expect(result).toEqual({ error: 'Failed to fetch list from Naver Maps', status: 502 });
|
|
expect(text).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('PLACE-SVC-075 — an over-large chunked page (no content-length) is refused after the read', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
headers: { get: () => null }, // chunked: the declared check cannot help
|
|
text: async () => 'x'.repeat(9 * 1024 * 1024),
|
|
}));
|
|
const result = await asLegacyResult(svc.importNaverList(String(trip.id), FOLDER_URL)) as any;
|
|
expect(result).toEqual({ error: 'Failed to fetch list from Naver Maps', status: 502 });
|
|
});
|
|
|
|
it('PLACE-SVC-076 — a malformed page body is still the documented 400', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, text: async () => 'not-json-at-all' }));
|
|
const result = await asLegacyResult(svc.importNaverList(String(trip.id), FOLDER_URL)) as any;
|
|
expect(result).toEqual({ error: 'Invalid list data received from Naver Maps', status: 400 });
|
|
});
|
|
|
|
it('PLACE-SVC-077 — an ordinary page still imports', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
text: async () => JSON.stringify({
|
|
folder: { name: 'Seoul', bookmarkCount: 1 },
|
|
bookmarkList: [{ name: 'Gyeongbokgung', px: 126.977, py: 37.5796, memo: null, address: 'Sejongno' }],
|
|
}),
|
|
}));
|
|
const result = await asLegacyResult(svc.importNaverList(String(trip.id), FOLDER_URL)) as any;
|
|
expect(result.listName).toBe('Seoul');
|
|
expect(result.places).toHaveLength(1);
|
|
expect(result.places[0].name).toBe('Gyeongbokgung');
|
|
});
|
|
|
|
// Task 5 review M2/L2. Mutants that survived every suite until these
|
|
// tests: `duration_minutes: 30` (all four importers), an insert loop
|
|
// outside `uow.transactional`.
|
|
it('PLACE-SVC-077b (M2) — the full stored row matches the legacy 7-column Naver shape, address+notes included, every other column null', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
text: async () => JSON.stringify({
|
|
folder: { name: 'Seoul', bookmarkCount: 1 },
|
|
bookmarkList: [{ name: 'Gyeongbokgung', px: 126.977, py: 37.5796, memo: 'A palace', address: 'Sejongno' }],
|
|
}),
|
|
}));
|
|
const result = await asLegacyResult(svc.importNaverList(String(trip.id), FOLDER_URL)) as { places: { id: number }[] };
|
|
const row = await findRow(orm, Places, { id: result.places[0].id }) as Record<string, unknown>;
|
|
expect(row).toMatchObject({
|
|
trip_id: trip.id, name: 'Gyeongbokgung', description: null, lat: 37.5796, lng: 126.977,
|
|
address: 'Sejongno', category_id: null, price: null, currency: null, place_time: null, end_time: null,
|
|
duration_minutes: 60, notes: 'A palace', image_url: null, google_place_id: null, google_ftid: null,
|
|
osm_id: null, amap_poi_id: null, website: null, phone: null, transport_mode: 'walking',
|
|
route_geometry: null, route_color: null, stop_type: null, fill_percent: null,
|
|
});
|
|
});
|
|
|
|
it('PLACE-SVC-077c (L2) — a failure partway through the Naver-list loop leaves nothing stored', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
text: async () => JSON.stringify({
|
|
folder: { name: 'Seoul', bookmarkCount: 2 },
|
|
bookmarkList: [
|
|
{ name: 'Gyeongbokgung', px: 126.977, py: 37.5796, memo: null, address: 'Sejongno' },
|
|
{ name: 'Namsan Tower', px: 126.988, py: 37.5512, memo: null, address: 'Yongsan' },
|
|
],
|
|
}),
|
|
}));
|
|
const placesRepo = await createTestPlacesRepo(testDb);
|
|
const realInsertPlace = placesRepo.insertPlace.bind(placesRepo);
|
|
let calls = 0;
|
|
const insertSpy = vi.spyOn(placesRepo, 'insertPlace').mockImplementation(async (input) => {
|
|
calls++;
|
|
if (calls === 2) throw new Error('boom');
|
|
return realInsertPlace(input);
|
|
});
|
|
await expect(svc.importNaverList(String(trip.id), FOLDER_URL)).rejects.toThrow('boom');
|
|
const count = ({ n: await countRows(orm, Places, { trip: trip.id }) }) as { n: number };
|
|
expect(count.n).toBe(0);
|
|
insertSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
// ── Free address backfill for list imports (#1954) ───────────────────────────
|
|
//
|
|
// A place pasted as a single Google Maps link has always been reverse geocoded;
|
|
// the same place arriving through a list import was not, so it stayed without an
|
|
// address forever. These cover the backfill that closes that gap.
|
|
|
|
describe('backfillMissingAddresses', () => {
|
|
function backfillSvc(reverseGeocode: MapsService['reverseGeocode']) {
|
|
return makePlacesService({ reverseGeocode } as unknown as MapsService);
|
|
}
|
|
|
|
it('PLACE-SVC-078 — fills the address of a place that has none', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const place = createPlace(testDb, trip.id, { name: 'Bar', lat: 48.85, lng: 2.35 }) as any;
|
|
|
|
const reverseGeocode = vi.fn(async () => ({ name: null, address: '1 Rue de Rivoli, Paris' }));
|
|
await (await backfillSvc(reverseGeocode)).backfillMissingAddresses(String(trip.id), [
|
|
{ id: place.id, name: 'Bar', lat: 48.85, lng: 2.35 },
|
|
]);
|
|
|
|
expect(reverseGeocode).toHaveBeenCalledWith('48.85', '2.35', undefined, { lane: 'background', timeoutMs: 10000 });
|
|
const row = await findRow(orm, Places, { id: place.id }) as { address: string };
|
|
expect(row.address).toBe('1 Rue de Rivoli, Paris');
|
|
});
|
|
|
|
it('PLACE-SVC-078b (PL47) — broadcasts place:updated with socketId: undefined, no exclusion', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const place = createPlace(testDb, trip.id, { name: 'Bar', lat: 48.85, lng: 2.35 });
|
|
const broadcastSpy = vi.spyOn(RealtimeService.prototype, 'broadcast');
|
|
|
|
const reverseGeocode = vi.fn(async () => ({ name: null, address: '1 Rue de Rivoli, Paris' }));
|
|
await (await backfillSvc(reverseGeocode)).backfillMissingAddresses(String(trip.id), [
|
|
{ id: place.id, name: 'Bar', lat: 48.85, lng: 2.35 },
|
|
]);
|
|
|
|
expect(broadcastSpy).toHaveBeenCalledTimes(1);
|
|
const [tripArg, event, payload, socketId] = broadcastSpy.mock.calls[0];
|
|
expect(tripArg).toBe(String(trip.id));
|
|
expect(event).toBe('place:updated');
|
|
// Task 5 review L7: the payload carries the just-filled address, not a
|
|
// stale pre-backfill snapshot (the qb read bypasses the identity map).
|
|
expect((payload as { place: { id: number; address: string | null } }).place.id).toBe(place.id);
|
|
expect((payload as { place: { address: string | null } }).place.address).toBe('1 Rue de Rivoli, Paris');
|
|
expect(socketId).toBeUndefined();
|
|
broadcastSpy.mockRestore();
|
|
});
|
|
|
|
it('PLACE-SVC-079 — never overwrites an address the import or the Google pass already wrote', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const place = createPlace(testDb, trip.id, { name: 'Bar', lat: 48.85, lng: 2.35 }) as any;
|
|
await updateRows(orm, Places, { id: place.id }, { address: 'Imported address' });
|
|
|
|
const reverseGeocode = vi.fn(async () => ({ name: null, address: 'Nominatim address' }));
|
|
await (await backfillSvc(reverseGeocode)).backfillMissingAddresses(String(trip.id), [
|
|
{ id: place.id, name: 'Bar', lat: 48.85, lng: 2.35, address: 'Imported address' },
|
|
]);
|
|
|
|
expect(reverseGeocode).not.toHaveBeenCalled();
|
|
const row = await findRow(orm, Places, { id: place.id }) as { address: string };
|
|
expect(row.address).toBe('Imported address');
|
|
});
|
|
|
|
it('PLACE-SVC-080 — a lookup that answers with nothing leaves the row untouched', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const place = createPlace(testDb, trip.id, { name: 'Bar', lat: 48.85, lng: 2.35 }) as any;
|
|
|
|
await (await backfillSvc(vi.fn(async () => ({ name: null, address: null })))).backfillMissingAddresses(String(trip.id), [
|
|
{ id: place.id, name: 'Bar', lat: 48.85, lng: 2.35 },
|
|
]);
|
|
|
|
const row = await findRow(orm, Places, { id: place.id }) as { address: string | null };
|
|
expect(row.address).toBeNull();
|
|
});
|
|
|
|
it('PLACE-SVC-081 — one failing lookup does not take down the rest of the batch', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const first = createPlace(testDb, trip.id, { name: 'A', lat: 1, lng: 2 }) as any;
|
|
const second = createPlace(testDb, trip.id, { name: 'B', lat: 3, lng: 4 }) as any;
|
|
|
|
const reverseGeocode = vi.fn()
|
|
.mockRejectedValueOnce(new Error('nominatim down'))
|
|
.mockResolvedValueOnce({ name: null, address: 'Second address' });
|
|
|
|
await expect(
|
|
(await backfillSvc(reverseGeocode as unknown as MapsService['reverseGeocode'])).backfillMissingAddresses(String(trip.id), [
|
|
{ id: first.id, name: 'A', lat: 1, lng: 2 },
|
|
{ id: second.id, name: 'B', lat: 3, lng: 4 },
|
|
]),
|
|
).resolves.toBeUndefined();
|
|
|
|
const rows = await findRows(orm, Places, { trip: trip.id }, { id: 'asc' }) as {
|
|
id: number; address: string | null;
|
|
}[];
|
|
expect(rows[0].address).toBeNull();
|
|
expect(rows[1].address).toBe('Second address');
|
|
});
|
|
|
|
it('PLACE-SVC-082 — an oversized batch is refused rather than queued for an hour', async () => {
|
|
const reverseGeocode = vi.fn();
|
|
const batch = Array.from({ length: ADDRESS_BACKFILL_MAX_PLACES + 1 }, (_, i) => ({
|
|
id: i + 1, name: `P${i}`, lat: 1, lng: 2,
|
|
}));
|
|
await (await backfillSvc(reverseGeocode as unknown as MapsService['reverseGeocode'])).backfillMissingAddresses('1', batch);
|
|
expect(reverseGeocode).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('PLACE-SVC-083 — a place without coordinates is skipped', async () => {
|
|
const reverseGeocode = vi.fn();
|
|
await (await backfillSvc(reverseGeocode as unknown as MapsService['reverseGeocode'])).backfillMissingAddresses('1', [
|
|
{ id: 1, name: 'A', lat: null as unknown as number, lng: null as unknown as number },
|
|
]);
|
|
expect(reverseGeocode).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
// ── findMatchingPlaceId ───────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* The public door onto the place-matching rule, for importers that need the
|
|
* matched row's id so they can link to it rather than merely knowing a duplicate
|
|
* exists. The rule itself lives in @trek/shared (place-match.ts); these cases pin
|
|
* that this service interprets it faithfully against real SQL.
|
|
*/
|
|
describe('findMatchingPlaceId', () => {
|
|
it('PLACES-SVC-008 — matches an existing place by name, ignoring case and surrounding space', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const place = createPlace(testDb, trip.id, { name: 'Eiffel Tower' });
|
|
|
|
expect(await svc.findMatchingPlaceId(String(trip.id), { name: ' eiffel tower ' })).toBe(place.id);
|
|
});
|
|
|
|
it('PLACES-SVC-009 — matches on a provider id even after the place was renamed (#1550)', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const place = createPlace(testDb, trip.id, { name: 'Original Name' });
|
|
await updateRows(orm, Places, { id: place.id }, { google_place_id: 'ChIJ_abc' });
|
|
|
|
expect(
|
|
await svc.findMatchingPlaceId(String(trip.id), { name: 'Renamed By User', google_place_id: 'ChIJ_abc' }),
|
|
).toBe(place.id);
|
|
});
|
|
|
|
it('PLACES-SVC-010 — does NOT match a NAMED candidate to a different place at the same coordinates', async () => {
|
|
// The restaurant and the bar in the same building are two places. This is the
|
|
// rule isPlaceDuplicate has always applied; the SQL copy used to disagree.
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
createPlace(testDb, trip.id, { name: 'Ground Floor Diner', lat: 52.52, lng: 13.405 });
|
|
|
|
expect(
|
|
await svc.findMatchingPlaceId(String(trip.id), { name: 'Rooftop Bar', lat: 52.52, lng: 13.405 }),
|
|
).toBeNull();
|
|
});
|
|
|
|
it('PLACES-SVC-011 — matches an UNNAMED candidate by coordinates within tolerance', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const place = createPlace(testDb, trip.id, { name: 'Anything', lat: 48.85, lng: 2.35 });
|
|
|
|
expect(
|
|
await svc.findMatchingPlaceId(String(trip.id), {
|
|
name: null,
|
|
lat: 48.85 + COORD_DEDUP_TOLERANCE / 2,
|
|
lng: 2.35,
|
|
}),
|
|
).toBe(place.id);
|
|
});
|
|
|
|
it('PLACES-SVC-012 — returns null when nothing recognises the candidate', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
createPlace(testDb, trip.id, { name: 'Somewhere Else' });
|
|
|
|
expect(await svc.findMatchingPlaceId(String(trip.id), { name: 'Unseen Place' })).toBeNull();
|
|
expect(await svc.findMatchingPlaceId(String(trip.id), { name: null, lat: null, lng: null })).toBeNull();
|
|
});
|
|
|
|
it('PLACES-SVC-013 — never matches a place belonging to another trip', async () => {
|
|
const { user } = createUser(testDb);
|
|
const mine = createTrip(testDb, user.id);
|
|
const theirs = createTrip(testDb, user.id);
|
|
createPlace(testDb, theirs.id, { name: 'Shared Name' });
|
|
|
|
expect(await svc.findMatchingPlaceId(String(mine.id), { name: 'Shared Name' })).toBeNull();
|
|
});
|
|
|
|
it('PLACES-SVC-014 — a provider id still wins over a name that points elsewhere', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
// The place the user renamed, carrying the id the importer knows it by.
|
|
const renamed = createPlace(testDb, trip.id, { name: 'Dinner Tuesday', lat: 41.88, lng: 12.47 });
|
|
await updateRows(orm, Places, { id: renamed.id }, { google_ftid: '0x1:0x2' });
|
|
// A different place that happens to carry the name the list still uses.
|
|
createPlace(testDb, trip.id, { name: 'Trattoria da Enzo', lat: 41.9, lng: 12.5 });
|
|
|
|
expect(
|
|
await svc.findMatchingPlaceId(String(trip.id), { name: 'Trattoria da Enzo', google_ftid: '0x1:0x2' }),
|
|
).toBe(renamed.id);
|
|
});
|
|
|
|
it('PLACES-SVC-015 — the name comparison is ASCII-only, so an accented capital does not match', async () => {
|
|
// Not a wish, a boundary: `lower(trim(name))` is SQLite's ASCII lowercase,
|
|
// while normalizePlaceName uses JavaScript's Unicode one. isPlaceDuplicate
|
|
// does match this pair in memory. Before the shared strategies, the
|
|
// coordinate fallback covered the gap for a named candidate — sometimes with
|
|
// the wrong row. This pins where the two halves still answer differently.
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
createPlace(testDb, trip.id, { name: 'CAFÉ CENTRAL', lat: 48.85, lng: 2.35 });
|
|
|
|
expect(await svc.findMatchingPlaceId(String(trip.id), { name: 'Café Central', lat: 48.85, lng: 2.35 })).toBeNull();
|
|
// The all-ASCII spelling of the same shape does match.
|
|
createPlace(testDb, trip.id, { name: 'CAFE CENTRAL', lat: 48.86, lng: 2.36 });
|
|
expect(await svc.findMatchingPlaceId(String(trip.id), { name: 'Cafe Central' })).not.toBeNull();
|
|
});
|
|
|
|
it('PLACES-SVC-016 — an unnamed candidate can match a NAMED row on coordinates', async () => {
|
|
// The other place the two halves differ: buildDedupSet collects coordinates
|
|
// only for unnamed rows, so isPlaceDuplicate would say no here. This is the
|
|
// answer findMatchingPlaceId wants — a booking with no place name should
|
|
// link to the hotel that has one — so it is pinned rather than removed.
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const hotel = createPlace(testDb, trip.id, { name: 'Hotel Lutetia', lat: 48.851, lng: 2.326 });
|
|
|
|
expect(await svc.findMatchingPlaceId(String(trip.id), { name: null, lat: 48.851, lng: 2.326 })).toBe(hotel.id);
|
|
});
|
|
|
|
it('PLACES-SVC-017 — the coordinate tolerance is a box roughly 11 m wide, and it closes', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const place = createPlace(testDb, trip.id, { name: null, lat: 48.85, lng: 2.35 });
|
|
|
|
// Pinned as a number, not against the constant: every other reference is
|
|
// relative to it, so widening it from 11 m to 111 m would keep them all green.
|
|
expect(COORD_DEDUP_TOLERANCE).toBe(0.0001);
|
|
|
|
const inside = { name: null, lat: 48.85 + COORD_DEDUP_TOLERANCE * 0.9, lng: 2.35 };
|
|
const outside = { name: null, lat: 48.85 + COORD_DEDUP_TOLERANCE * 1.5, lng: 2.35 };
|
|
expect(await svc.findMatchingPlaceId(String(trip.id), inside)).toBe(place.id);
|
|
expect(await svc.findMatchingPlaceId(String(trip.id), outside)).toBeNull();
|
|
// Exactly one tolerance away is NOT a match: 48.85 + 0.0001 lands on
|
|
// 48.850100000000004 in binary floating point, a hair over the bound. The
|
|
// edge is fuzzy by design of the arithmetic, so nothing should lean on it.
|
|
const edge = { name: null, lat: 48.85 + COORD_DEDUP_TOLERANCE, lng: 2.35 };
|
|
expect(await svc.findMatchingPlaceId(String(trip.id), edge)).toBeNull();
|
|
});
|
|
});
|