From f19face4059a37200d29987e7bec6ec2a58e7801 Mon Sep 17 00:00:00 2001 From: jubnl Date: Wed, 23 Sep 2026 08:53:54 +0200 Subject: [PATCH] refactor(places): import paths through repositories --- .../db/repositories/Categories.repository.ts | 12 + .../src/db/repositories/Places.repository.ts | 123 +++++++- server/src/nest/places/places.module.ts | 6 +- server/src/nest/places/places.service.ts | 240 +++++++++++----- server/tests/helpers/mcp-test-controllers.ts | 1 + server/tests/helpers/plugin-host.ts | 1 + .../Categories.repository.test.ts | 19 ++ .../db/repositories/Places.repository.test.ts | 262 ++++++++++++++++++ .../tests/unit/nest/places.directions.test.ts | 3 +- server/tests/unit/nest/places.service.test.ts | 46 ++- .../unit/nest/trip-read-model.service.test.ts | 3 +- server/tests/unit/nest/trips.service.test.ts | 3 +- .../unit/services/conflictUpdate.test.ts | 2 + 13 files changed, 644 insertions(+), 77 deletions(-) diff --git a/server/src/db/repositories/Categories.repository.ts b/server/src/db/repositories/Categories.repository.ts index 2f8506a26..13cc11377 100644 --- a/server/src/db/repositories/Categories.repository.ts +++ b/server/src/db/repositories/Categories.repository.ts @@ -21,6 +21,18 @@ export class CategoriesRepository extends TrekRepository { return categories.map((category) => toRow(category) as CategoryRow); } + /** + * PL33 (`PlacesService.importKmlPlaces`) — `SELECT id, name FROM + * categories`, the id/name-only projection the KML importer folds into + * `buildCategoryNameLookup` to resolve a folder name to a category id. No + * `ORDER BY` in the legacy statement — none added here. + */ + async listIdName(): Promise<{ id: number; name: string }[]> { + return this.qb('c') + .select(['c.id', 'c.name']) + .execute<{ id: number; name: string }[]>('all', false); + } + /** * `SELECT * FROM categories WHERE id = ?` — no owner filter. `user_id` * being nullable makes a row *look* ownable, but this legacy statement diff --git a/server/src/db/repositories/Places.repository.ts b/server/src/db/repositories/Places.repository.ts index eee29b869..eaf6b02a9 100644 --- a/server/src/db/repositories/Places.repository.ts +++ b/server/src/db/repositories/Places.repository.ts @@ -1,6 +1,6 @@ import type { Places } from '../entities/Places.entity'; import type { Tags } from '../entities/Tags.entity'; -import { absDifference, columnRef, currentTimestamp, lowerTrim } from '../dialect/sql-functions'; +import { absDifference, coalesceParam, columnRef, currentTimestamp, lowerTrim } from '../dialect/sql-functions'; import { type AssertRowKeys } from './_shared/rows'; import { TrekRepository } from './_shared/trek-repository'; @@ -594,4 +594,125 @@ export class PlacesRepository extends TrekRepository { return qb.execute('all', false); } + + // --------------------------------------------------------------------------- + // Plan 3c Task 5 (`PlacesService` import paths) — every method below is + // additive: `insertPlace`/`updatePlace`/`findWithTagsAndRatings`/the + // `findDuplicateBy*` trio above are Task 4's, unchanged and reused verbatim + // by the four importers (GPX/KML/Google/Naver) this task converts. + // --------------------------------------------------------------------------- + + /** + * PL36 (`colorizeImportedTracks`'s read half) — `SELECT DISTINCT + * route_color AS c FROM places WHERE trip_id = ? AND route_color IS NOT + * NULL`. `DISTINCT` via `.select([...], true)` (the same shape + * `listForTrip`/PL3 uses for its own `SELECT DISTINCT`), not `$ne: null` + * folded into a single-object filter, so the emitted `WHERE` clause reads + * in the legacy's own `trip_id = ? AND route_color IS NOT NULL` order. + * + * Called and its result acted on inside the SAME `uow.transactional(...)` + * block `setRouteColor` below writes in (the service's own PL36+PL37 + * pairing) — read and write share one transaction so two concurrent + * imports can never both observe the same set of free colours. + */ + async distinctRouteColors(trip_id: string | number): Promise { + const rows = await this.qb('p') + .select(['p.route_color as c'], true) + .where('p.trip_id = ? AND p.route_color IS NOT NULL', [trip_id]) + .execute<{ c: string }[]>('all', false); + return rows.map((r) => r.c); + } + + /** + * PL37 (`colorizeImportedTracks`'s write half) — `UPDATE places SET + * route_color = ? WHERE id = ?`. No `updated_at` stamp — the legacy + * statement never wrote one for this column (#776's own deliberate + * omission, restated in `PlacesService`'s class docstring), and this + * method reproduces exactly that, not the "every write stamps + * `updated_at`" shape `updatePlace`/`backfillFtid`/`fillIfEmpty` below + * follow. + */ + async setRouteColor(id: number, route_color: string): Promise { + await this.nativeUpdate({ id }, { route_color }); + } + + /** + * PL39 (`storeGooglePlaces`'s dedup-backfill half) — `UPDATE places SET + * google_ftid = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, unscoped + * by `trip_id` (matching the legacy statement exactly — the candidate id + * was already resolved against this trip by the `findDuplicatePlace` call + * immediately before it). + */ + async backfillFtid(id: number, google_ftid: string): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate({ id }, { google_ftid, updated_at: currentTimestamp(platform) }); + } + + /** + * PL43/PL44/PL46 (`enrichOne`'s two `UPDATE`s and + * `backfillMissingAddresses`'s one) — three legacy statements, one shape: + * `UPDATE places SET = COALESCE(, ?), ..., updated_at = + * CURRENT_TIMESTAMP WHERE id = ? AND trip_id = ?`. One `nativeUpdate` per + * CALL, built from whichever of the six columns the caller passes + * (`fields`'s keys are all optional — a key left OFF `fields` entirely is + * not touched at all, not even COALESCE'd against itself); every key + * that IS present renders `COALESCE(, ?)` through `coalesceParam` + * (never an inline `raw()`), so a column already holding a value is never + * overwritten and an empty one is filled from `fields`'s value. + * `updated_at` is stamped unconditionally — all three legacy statements + * stamp it, none of them conditionally. + * + * The three call shapes this serves (Task 5's "×3 shapes"): + * - PL43 (`enrichOne`'s main update) — `google_place_id`, `google_ftid`, + * `address`, `website`, `phone` all at once. + * - PL44 (`enrichOne`'s photo update) — `image_url` alone. + * - PL46 (`backfillMissingAddresses`) — `address` alone. + */ + async fillIfEmpty(id: number, trip_id: number, fields: { + google_place_id?: string | null; + google_ftid?: string | null; + address?: string | null; + website?: string | null; + phone?: string | null; + image_url?: string | null; + }): Promise { + const platform = this.getEntityManager().getPlatform(); + const data: { + google_place_id?: ReturnType; + google_ftid?: ReturnType; + address?: ReturnType; + website?: ReturnType; + phone?: ReturnType; + image_url?: ReturnType; + updated_at: ReturnType; + } = { updated_at: currentTimestamp(platform) }; + if (fields.google_place_id !== undefined) data.google_place_id = coalesceParam(platform, 'google_place_id', fields.google_place_id); + if (fields.google_ftid !== undefined) data.google_ftid = coalesceParam(platform, 'google_ftid', fields.google_ftid); + if (fields.address !== undefined) data.address = coalesceParam(platform, 'address', fields.address); + if (fields.website !== undefined) data.website = coalesceParam(platform, 'website', fields.website); + if (fields.phone !== undefined) data.phone = coalesceParam(platform, 'phone', fields.phone); + if (fields.image_url !== undefined) data.image_url = coalesceParam(platform, 'image_url', fields.image_url); + await this.nativeUpdate({ id, trip: trip_id }, data); + } + + // --------------------------------------------------------------------------- + // Plan 3c Task 7 (`trips.rpc.ts`) — additive (per `task-4-report.md`'s own + // note that this method "may not exist"): every column above is Task 4/5's, + // unchanged. + // --------------------------------------------------------------------------- + + /** + * RP2 (`trips.rpc.ts::getPlaces`) — `SELECT * FROM places WHERE trip_id = ? + * ORDER BY created_at DESC`. The trip's place POOL has no itinerary + * position of its own (`day_id`/`order_index` live on `day_assignments`), + * so this orders by `created_at` like the REST list does — the RPC + * method's own comment (`trips.rpc.ts`). + */ + async listForTripOrdered(trip_id: number): Promise { + return await this.qb('p') + .select(['p.*']) + .where({ trip: trip_id }) + .orderBy({ 'p.created_at': 'desc' }) + .execute('all', false); + } } diff --git a/server/src/nest/places/places.module.ts b/server/src/nest/places/places.module.ts index 62762b821..fc286b2ea 100644 --- a/server/src/nest/places/places.module.ts +++ b/server/src/nest/places/places.module.ts @@ -6,6 +6,7 @@ import { PlaceRatings } from '../../db/entities/PlaceRatings.entity'; import { TripMembers } from '../../db/entities/TripMembers.entity'; import { DayAssignments } from '../../db/entities/DayAssignments.entity'; import { Trips } from '../../db/entities/Trips.entity'; +import { Categories } from '../../db/entities/Categories.entity'; import { JourneyDomainModule } from '../journey/journey-domain.module'; import { PlacesController } from './places.controller'; import { PlacesService } from './places.service'; @@ -60,8 +61,9 @@ import { MAX_PLACE_IMAGE_SIZE } from '../common/place-image-upload'; // THIS module (not a sibling domain module's `exports`), matching // `AssignmentsDomainModule`'s own `MikroOrmModule.forFeature(...)` // precedent: a `forFeature` registration only reaches providers declared - // in the SAME module. - MikroOrmModule.forFeature([Places, Tags, PlaceRatings, TripMembers, DayAssignments, Trips]), + // in the SAME module. Plan 3c Task 5 adds `CategoriesRepository` for + // PL33's `importKmlPlaces` folder → category lookup. + MikroOrmModule.forFeature([Places, Tags, PlaceRatings, TripMembers, DayAssignments, Trips, Categories]), ], controllers: [PlacesController], providers: [PlacesService, PlacesMcp, PlacesRpc], diff --git a/server/src/nest/places/places.service.ts b/server/src/nest/places/places.service.ts index cbc2a5144..7e9e6d6ec 100644 --- a/server/src/nest/places/places.service.ts +++ b/server/src/nest/places/places.service.ts @@ -38,11 +38,13 @@ import { Tags } from '../../db/entities/Tags.entity'; import { PlaceRatings } from '../../db/entities/PlaceRatings.entity'; import { TripMembers } from '../../db/entities/TripMembers.entity'; import { DayAssignments } from '../../db/entities/DayAssignments.entity'; +import { Categories } from '../../db/entities/Categories.entity'; import type { PlacesRepository, PlaceWithCategoryRow } from '../../db/repositories/Places.repository'; import type { TagsRepository } from '../../db/repositories/Tags.repository'; import type { PlaceRatingsRepository } from '../../db/repositories/PlaceRatings.repository'; import type { TripMembersRepository } from '../../db/repositories/TripMembers.repository'; import type { DayAssignmentsRepository } from '../../db/repositories/DayAssignments.repository'; +import type { CategoriesRepository } from '../../db/repositories/Categories.repository'; /** Rows a place delete took down with the nights booked there, for the caller to announce. */ export interface CancelledStays { @@ -152,6 +154,7 @@ export class PlacesService { @InjectRepository(PlaceRatings) private readonly placeRatingsRepo: PlaceRatingsRepository, @InjectRepository(TripMembers) private readonly tripMembersRepo: TripMembersRepository, @InjectRepository(DayAssignments) private readonly dayAssignmentsRepo: DayAssignmentsRepository, + @InjectRepository(Categories) private readonly categoriesRepo: CategoriesRepository, ) {} async verifyTripAccess(tripId: string, userId: number) { @@ -812,20 +815,51 @@ export class PlacesService { if (waypoints.length === 0) return null; const dedup = await this.buildDedupSet(tripId); - const insertStmt = this.dbs.prepare(` - INSERT INTO places (trip_id, name, description, lat, lng, transport_mode, route_geometry) - VALUES (?, ?, ?, ?, ?, 'walking', ?) - `); const created: PlaceWithTags[] = []; let skipped = 0; + // PL31/PL32 — one `insertPlace` call per waypoint. The legacy statement's + // narrower 7-column INSERT (trip_id, name, description, lat, lng, + // transport_mode='walking' literal, route_geometry) is reproduced as a + // typed `insertPlace` partial: the literal becomes a value, and every + // column the legacy INSERT omitted takes the same value the DB column + // default would have produced (`duration_minutes: 60`, everything else + // `null`) — parity by stored row, not by SQL text. Re-selected via + // `findWithTagsAndRatings` INSIDE the same transaction the insert ran + // in, so the read sees the still-uncommitted row. await this.uow.transactional(async () => { for (const wp of waypoints) { if (isPlaceDuplicate({ name: wp.name, lat: wp.lat, lng: wp.lng }, dedup)) { skipped++; continue; } - const result = insertStmt.run(tripId, wp.name, wp.description, wp.lat, wp.lng, wp.routeGeometry || null); - const place = (await this.dbs.getPlaceWithTags(Number(result.lastInsertRowid)))!; + const placeId = await this.placesRepo.insertPlace({ + trip_id: Number(tripId), + name: wp.name, + description: wp.description, + lat: wp.lat, + lng: wp.lng, + 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: wp.routeGeometry || null, + route_color: null, + stop_type: null, + fill_percent: null, + }); + const place = (await this.placesRepo.findWithTagsAndRatings(placeId))!; created.push(place); trackInsertedInDedupSet({ name: wp.name, lat: wp.lat, lng: wp.lng }, dedup); } @@ -879,17 +913,13 @@ export class PlacesService { summary.warnings.push(decoded.warning); } - const categories = this.dbs.all<{ id: number; name: string }>('SELECT id, name FROM categories'); + // PL33 — `CategoriesRepository.listIdName` (Plan 3a's repository). + const categories = await this.categoriesRepo.listIdName(); const categoryLookup = buildCategoryNameLookup(categories); const dedup = await this.buildDedupSet(tripId); const created: PlaceWithTags[] = []; let dupCount = 0; - const insertStmt = this.dbs.prepare(` - INSERT INTO places (trip_id, name, description, lat, lng, category_id, transport_mode, route_geometry) - VALUES (?, ?, ?, ?, ?, ?, 'walking', ?) - `); - await this.uow.transactional(async () => { let fallbackIndex = 1; for (const node of placemarkNodes) { @@ -928,17 +958,40 @@ export class PlacesService { const categoryId = resolveCategoryIdForFolder(parsedPlacemark.folderName, categoryLookup); - const result = insertStmt.run( - tripId, + // PL34/PL35 — `insertPlace` with the legacy 8-column set (trip_id, + // name, description, lat, lng, category_id, transport_mode='walking' + // literal, route_geometry); every omitted column takes its DB-default + // value. Re-selected via `findWithTagsAndRatings` INSIDE the same + // transaction. + const placeId = await this.placesRepo.insertPlace({ + trip_id: Number(tripId), name, - parsedPlacemark.description, - parsedPlacemark.lat, - parsedPlacemark.lng, - categoryId, - parsedPlacemark.routeGeometry, - ); + description: parsedPlacemark.description, + lat: parsedPlacemark.lat, + lng: parsedPlacemark.lng, + address: null, + category_id: categoryId, + 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: parsedPlacemark.routeGeometry, + route_color: null, + stop_type: null, + fill_percent: null, + }); - const place = (await this.dbs.getPlaceWithTags(Number(result.lastInsertRowid)))!; + const place = (await this.placesRepo.findWithTagsAndRatings(placeId))!; created.push(place); trackInsertedInDedupSet({ name, lat: parsedPlacemark.lat, lng: parsedPlacemark.lng }, dedup); summary.createdCount += 1; @@ -981,22 +1034,19 @@ export class PlacesService { const tracks = result?.places?.filter((p) => p.route_geometry && !p.route_color) ?? []; if (tracks.length === 0) return; - // Read and write in one transaction so two concurrent imports cannot both - // read the same set of free colours. + // PL36+PL37 — read (`distinctRouteColors`) and write (`setRouteColor`) in + // ONE transaction so two concurrent imports cannot both read the same set + // of free colours. await this.uow.transactional(async () => { - const taken = new Set( - this.dbs.all<{ c: string }>( - 'SELECT DISTINCT route_color AS c FROM places WHERE trip_id = ? AND route_color IS NOT NULL', tripId, - ).map((r) => r.c), - ); + const taken = new Set(await this.placesRepo.distinctRouteColors(tripId)); const free = TRACK_COLORS.filter((c) => !taken.has(c)); - tracks.forEach((track, i) => { + for (const [i, track] of tracks.entries()) { // Free ones first, then wrap through the whole palette — never reuse a // free colour twice within the same import. const color = i < free.length ? free[i] : TRACK_COLORS[(i - free.length) % TRACK_COLORS.length]; - this.dbs.run('UPDATE places SET route_color = ? WHERE id = ?', color, track.id); + await this.placesRepo.setRouteColor(track.id, color); track.route_color = color; - }); + } }); } @@ -1145,11 +1195,6 @@ export class PlacesService { places: { name: string; lat: number; lng: number; notes: string | null; googleFtid: string | null }[], ): Promise<{ created: PlaceWithTags[]; skipped: number }> { const dedup = await this.buildDedupSet(tripId); - const insertStmt = this.dbs.prepare(` - INSERT INTO places (trip_id, name, lat, lng, notes, google_ftid, transport_mode) - VALUES (?, ?, ?, ?, ?, ?, 'walking') - `); - const updateGoogleFtidStmt = this.dbs.prepare('UPDATE places SET google_ftid = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?'); const created: PlaceWithTags[] = []; let skipped = 0; await this.uow.transactional(async () => { @@ -1162,14 +1207,47 @@ export class PlacesService { const candidate = { name: p.name, lat: p.lat, lng: p.lng, google_ftid: p.googleFtid }; if (isPlaceDuplicate(candidate, dedup)) { const duplicate = await this.findDuplicatePlace(tripId, candidate); + // PL39 — `backfillFtid`, unscoped by trip (matching the legacy + // statement — `duplicate` was already resolved against this trip). if (duplicate && !duplicate.google_ftid && p.googleFtid) { - updateGoogleFtidStmt.run(p.googleFtid, duplicate.id); + await this.placesRepo.backfillFtid(duplicate.id, p.googleFtid); } skipped++; continue; } - const result = insertStmt.run(tripId, p.name, p.lat, p.lng, p.notes, p.googleFtid); - const place = (await this.dbs.getPlaceWithTags(Number(result.lastInsertRowid)))!; + // PL38/PL40 — `insertPlace` with the legacy 7-column set (trip_id, + // name, lat, lng, notes, google_ftid, transport_mode='walking' + // literal); every omitted column takes its DB-default value. + // Re-selected via `findWithTagsAndRatings` INSIDE the same + // transaction. + const placeId = await this.placesRepo.insertPlace({ + trip_id: Number(tripId), + name: p.name, + description: null, + lat: p.lat, + lng: p.lng, + address: null, + category_id: null, + price: null, + currency: null, + place_time: null, + end_time: null, + duration_minutes: 60, + notes: p.notes, + image_url: null, + google_place_id: null, + google_ftid: p.googleFtid, + 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, + }); + const place = (await this.placesRepo.findWithTagsAndRatings(placeId))!; created.push(place); trackInsertedInDedupSet(candidate, dedup); } @@ -1396,20 +1474,46 @@ export class PlacesService { } const dedup = await this.buildDedupSet(tripId); - const insertStmt = this.dbs.prepare(` - INSERT INTO places (trip_id, name, lat, lng, address, notes, transport_mode) - VALUES (?, ?, ?, ?, ?, ?, 'walking') - `); const created: PlaceWithTags[] = []; let skipped = 0; + // PL41/PL42 — `insertPlace` with the legacy 7-column set (trip_id, name, + // lat, lng, address, notes, transport_mode='walking' literal); every + // omitted column takes its DB-default value. Re-selected via + // `findWithTagsAndRatings` INSIDE the same transaction. await this.uow.transactional(async () => { for (const p of places) { if (isPlaceDuplicate({ name: p.name, lat: p.lat, lng: p.lng }, dedup)) { skipped++; continue; } - const result = insertStmt.run(tripId, p.name, p.lat, p.lng, p.address, p.notes); - const place = (await this.dbs.getPlaceWithTags(Number(result.lastInsertRowid)))!; + const placeId = await this.placesRepo.insertPlace({ + trip_id: Number(tripId), + name: p.name, + description: null, + lat: p.lat, + lng: p.lng, + address: p.address, + category_id: null, + price: null, + currency: null, + place_time: null, + end_time: null, + duration_minutes: 60, + notes: p.notes, + 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, + }); + const place = (await this.placesRepo.findWithTagsAndRatings(placeId))!; created.push(place); trackInsertedInDedupSet({ name: p.name, lat: p.lat, lng: p.lng }, dedup); } @@ -1466,19 +1570,16 @@ export class PlacesService { if (!gpid) return; const gftid = trimOrNull(match.google_ftid); - // COALESCE so enrichment only fills empty columns — never overwrites data the - // import already captured (e.g. Naver's address) or anything the user edited. - this.dbs.run( - `UPDATE places - SET google_place_id = COALESCE(google_place_id, ?), - google_ftid = COALESCE(google_ftid, ?), - address = COALESCE(address, ?), - website = COALESCE(website, ?), - phone = COALESCE(phone, ?), - updated_at = CURRENT_TIMESTAMP - WHERE id = ? AND trip_id = ?`, - gpid, gftid, trimOrNull(match.address), trimOrNull(match.website), trimOrNull(match.phone), place.id, tripId, - ); + // PL43 — `fillIfEmpty`, COALESCE-per-column so enrichment only fills + // empty columns — never overwrites data the import already captured + // (e.g. Naver's address) or anything the user edited. + await this.placesRepo.fillIfEmpty(place.id, Number(tripId), { + google_place_id: gpid, + google_ftid: gftid, + address: trimOrNull(match.address), + website: trimOrNull(match.website), + phone: trimOrNull(match.phone), + }); // Photo is best-effort: Google often has none, in which case getPlacePhoto // resolves with photoUrl: null. A missing photo (or a provider outage, which @@ -1486,18 +1587,17 @@ export class PlacesService { try { const photo = await this.maps.getPlacePhoto(userId, gpid, place.lat, place.lng, place.name); if (photo?.photoUrl) { - this.dbs.run( - 'UPDATE places SET image_url = COALESCE(image_url, ?), updated_at = CURRENT_TIMESTAMP WHERE id = ? AND trip_id = ?', - photo.photoUrl, place.id, tripId, - ); + // PL44 — `fillIfEmpty`, the `image_url`-only shape. + await this.placesRepo.fillIfEmpty(place.id, Number(tripId), { image_url: photo.photoUrl }); } } catch { /* no photo — leave image_url as-is */ } - // Push the enriched row to every connected client (no socket exclusion: the - // importer's own client should also receive the late update). - const updated = await this.dbs.getPlaceWithTags(place.id); + // PL45 — push the enriched row to every connected client (no socket + // exclusion: the importer's own client should also receive the late + // update — `socketId: undefined`, no `X-Socket-Id` to exclude). + const updated = await this.placesRepo.findWithTagsAndRatings(place.id); if (updated) this.realtime.broadcast(tripId, 'place:updated', { place: updated }, undefined); } @@ -1564,11 +1664,11 @@ export class PlacesService { timeoutMs: 10000, }); if (!address) continue; - this.dbs.run( - 'UPDATE places SET address = COALESCE(address, ?), updated_at = CURRENT_TIMESTAMP WHERE id = ? AND trip_id = ?', - address, place.id, tripId, - ); - const updated = await this.dbs.getPlaceWithTags(place.id); + // PL46 — `fillIfEmpty`, the `address`-only shape. + await this.placesRepo.fillIfEmpty(place.id, Number(tripId), { address }); + // PL47 — same broadcast shape as PL45: `socketId: undefined`, no + // exclusion. + const updated = await this.placesRepo.findWithTagsAndRatings(place.id); if (updated) this.realtime.broadcast(tripId, 'place:updated', { place: updated }, undefined); } catch (err) { console.error(`[Places] address backfill failed for place ${place.id}:`, err instanceof Error ? err.message : err); diff --git a/server/tests/helpers/mcp-test-controllers.ts b/server/tests/helpers/mcp-test-controllers.ts index 09f5a4967..799c9f821 100644 --- a/server/tests/helpers/mcp-test-controllers.ts +++ b/server/tests/helpers/mcp-test-controllers.ts @@ -223,6 +223,7 @@ export async function createMcpTestRegistry(): Promise { await createTestPlaceRatingsRepo(dbService.connection), await createTestTripMembersRepo(dbService.connection), await createTestDayAssignmentsRepo(dbService.connection), + await createTestCategoriesRepo(dbService.connection), ); // Built after it: a hotel booking writes the stay's day stop through this one. const reservationsService = new ReservationsService(dbService, permissionsService, budgetService, realtimeService, notificationsStub(), new ReservationsReadRepository(dbService), accommodationsService, await createTestUnitOfWork(dbService.connection)); diff --git a/server/tests/helpers/plugin-host.ts b/server/tests/helpers/plugin-host.ts index a42967212..0b75b3372 100644 --- a/server/tests/helpers/plugin-host.ts +++ b/server/tests/helpers/plugin-host.ts @@ -145,6 +145,7 @@ export async function createPluginRpcHostFactory(dbs: DatabaseService): Promise< await createTestPlaceRatingsRepo(dbs.connection), await createTestTripMembersRepo(dbs.connection), await createTestDayAssignmentsRepo(dbs.connection), + await createTestCategoriesRepo(dbs.connection), ); // After accommodations: a hotel booking writes the stay's day stop through it. const reservations = new ReservationsService(dbs, permissions, budget, realtime, notificationsStub(), new ReservationsReadRepository(dbs), accommodations, await createTestUnitOfWork(dbs.connection)); diff --git a/server/tests/unit/db/repositories/Categories.repository.test.ts b/server/tests/unit/db/repositories/Categories.repository.test.ts index a707a472b..12338df59 100644 --- a/server/tests/unit/db/repositories/Categories.repository.test.ts +++ b/server/tests/unit/db/repositories/Categories.repository.test.ts @@ -224,4 +224,23 @@ describe('CategoriesRepository', () => { expect(await categories.findById(gone.id)).toBeNull(); }); }); + + describe('listIdName (Plan 3c Task 5, PL33 — PlacesService.importKmlPlaces\' folder → category lookup)', () => { + it('CATREPO-021: projects only id and name, no ORDER BY guaranteed, including a fresh row', async () => { + const created = createCategory(testDb, { name: 'RepoIdName' }); + const rows = await categories.listIdName(); + const match = rows.find((r) => r.id === created.id); + expect(match).toEqual({ id: created.id, name: 'RepoIdName' }); + // Only the two columns — no color/icon/user_id/created_at leaking through. + expect(match ? Object.keys(match).sort() : []).toEqual(['id', 'name']); + }); + + it('CATREPO-022: includes every category row, seeded defaults and user-created alike', async () => { + const before = await categories.listIdName(); + const created = createCategory(testDb, { name: 'RepoIdNameTwo' }); + const after = await categories.listIdName(); + expect(after.length).toBe(before.length + 1); + expect(after.map((r) => r.id)).toContain(created.id); + }); + }); }); diff --git a/server/tests/unit/db/repositories/Places.repository.test.ts b/server/tests/unit/db/repositories/Places.repository.test.ts index 5fc56735b..489e40791 100644 --- a/server/tests/unit/db/repositories/Places.repository.test.ts +++ b/server/tests/unit/db/repositories/Places.repository.test.ts @@ -6,6 +6,7 @@ * PRIM-GPWT-00x (empty tags/ratings only) nor any other suite exercises. */ import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { TRACK_COLORS } from '@trek/shared'; import { createSnapshotTestDb } from '../../../helpers/db-mock'; import { resetTestDb } from '../../../helpers/test-db'; import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; @@ -531,3 +532,264 @@ describe('PlacesRepository.listForTrip (PL3) — filter fragments', () => { expect(rows.map((r) => r.id)).toEqual([winner.id]); }); }); + +// --------------------------------------------------------------------------- +// Plan 3c Task 5 — the four importers' `insertPlace` column sets, the +// colorize read+write transaction, the ftid backfill, and the +// enrichment/backfill `fillIfEmpty` matrix. +// --------------------------------------------------------------------------- + +describe('PlacesRepository.insertPlace — the four importers\' narrower column sets (PL31/34/38/41), read back inside the same transaction (PL32/35/40/42)', () => { + const base = { + 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('PLACEREPO-033 (PL31/PL32, GPX): the 7-column GPX shape is stored, and the read inside the same transaction sees the uncommitted row', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + + const row = await t.em.transactional(async () => { + const id = await places.insertPlace({ + ...base, + trip_id: trip.id, name: 'GPX Waypoint', description: 'from a gpx file', + lat: 48.85, lng: 2.35, route_geometry: '[[48.85,2.35],[48.86,2.36]]', + }); + // Same transaction, no commit yet — this read must still see it. + return places.findWithTagsAndRatings(id); + }); + + expect(row).toMatchObject({ + trip_id: trip.id, name: 'GPX Waypoint', description: 'from a gpx file', + lat: 48.85, lng: 2.35, address: null, category_id: null, notes: null, + transport_mode: 'walking', route_geometry: '[[48.85,2.35],[48.86,2.36]]', + duration_minutes: 60, category: null, tags: [], ratings: [], + }); + // Committed for real, not just visible mid-transaction. + expect(testDb.prepare('SELECT name FROM places WHERE trip_id = ?').get(trip.id)).toMatchObject({ name: 'GPX Waypoint' }); + }); + + it('PLACEREPO-033b: a transaction that throws after the insert leaves no row behind — the insert really was uncommitted, not autocommitted ahead of the wrapper', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + + await expect(t.em.transactional(async () => { + const id = await places.insertPlace({ ...base, trip_id: trip.id, name: 'Rolled back', description: null, lat: 1, lng: 1 }); + // The uncommitted row is visible to a read inside the same transaction... + const seen = await places.findWithTagsAndRatings(id); + expect(seen?.name).toBe('Rolled back'); + throw new Error('force rollback'); + })).rejects.toThrow('force rollback'); + + // ...but never lands once the wrapping transaction rolls back. + expect(testDb.prepare('SELECT COUNT(*) as c FROM places WHERE trip_id = ?').get(trip.id)).toMatchObject({ c: 0 }); + }); + + it('PLACEREPO-034 (PL34/PL35, KML): the 8-column KML shape (adds category_id) is stored, read back in the same transaction', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const cat = createCategory(testDb, { name: 'Hiking' }); + + const row = await t.em.transactional(async () => { + const id = await places.insertPlace({ + ...base, + trip_id: trip.id, name: 'Placemark 1', description: 'a kml placemark', + lat: 35.0, lng: 139.0, category_id: cat.id, route_geometry: null, + }); + return places.findWithTagsAndRatings(id); + }); + + expect(row).toMatchObject({ + trip_id: trip.id, name: 'Placemark 1', description: 'a kml placemark', + lat: 35.0, lng: 139.0, category_id: cat.id, duration_minutes: 60, + transport_mode: 'walking', address: null, notes: null, + category: { id: cat.id, name: 'Hiking' }, + }); + }); + + it('PLACEREPO-035 (PL38/PL40, Google list): the 7-column Google shape (notes + google_ftid) is stored, read back in the same transaction', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + + const row = await t.em.transactional(async () => { + const id = await places.insertPlace({ + ...base, + trip_id: trip.id, name: 'Google Place', description: null, + lat: 40.7, lng: -74.0, notes: 'a note', google_ftid: '0x1:0x2', + }); + return places.findWithTagsAndRatings(id); + }); + + expect(row).toMatchObject({ + trip_id: trip.id, name: 'Google Place', notes: 'a note', google_ftid: '0x1:0x2', + lat: 40.7, lng: -74.0, address: null, category_id: null, duration_minutes: 60, + transport_mode: 'walking', + }); + }); + + it('PLACEREPO-036 (PL41/PL42, Naver list): the 7-column Naver shape (address + notes) is stored, read back in the same transaction', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + + const row = await t.em.transactional(async () => { + const id = await places.insertPlace({ + ...base, + trip_id: trip.id, name: 'Naver Spot', description: null, + lat: 37.5, lng: 127.0, address: '123 Some Street', notes: 'a naver note', + }); + return places.findWithTagsAndRatings(id); + }); + + expect(row).toMatchObject({ + trip_id: trip.id, name: 'Naver Spot', address: '123 Some Street', notes: 'a naver note', + lat: 37.5, lng: 127.0, category_id: null, duration_minutes: 60, transport_mode: 'walking', + }); + }); +}); + +describe('PlacesRepository.distinctRouteColors / setRouteColor (PL36/PL37)', () => { + it('PLACEREPO-037: distinctRouteColors returns only this trip\'s non-null colours, scoped by trip_id', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const other = createTrip(testDb, user.id); + testDb.prepare("INSERT INTO places (trip_id, name, lat, lng, route_geometry, route_color) VALUES (?, 'A', 1, 1, '[[1,1]]', ?)").run(trip.id, TRACK_COLORS[0]); + testDb.prepare("INSERT INTO places (trip_id, name, lat, lng, route_geometry, route_color) VALUES (?, 'B', 1, 1, '[[1,1]]', ?)").run(trip.id, TRACK_COLORS[1]); + testDb.prepare("INSERT INTO places (trip_id, name, lat, lng, route_geometry) VALUES (?, 'C', 1, 1, '[[1,1]]')").run(trip.id); + testDb.prepare("INSERT INTO places (trip_id, name, lat, lng, route_geometry, route_color) VALUES (?, 'D', 1, 1, '[[1,1]]', ?)").run(other.id, TRACK_COLORS[2]); + + const colors = await places.distinctRouteColors(trip.id); + expect(new Set(colors)).toEqual(new Set([TRACK_COLORS[0], TRACK_COLORS[1]])); + }); + + it('PLACEREPO-038: setRouteColor writes the column without stamping updated_at', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const place = createPlace(testDb, trip.id); + testDb.prepare('UPDATE places SET updated_at = ? WHERE id = ?').run('2020-01-01 00:00:00', place.id); + + await places.setRouteColor(place.id, TRACK_COLORS[3]); + + const row = testDb.prepare('SELECT route_color, updated_at FROM places WHERE id = ?').get(place.id) as { route_color: string; updated_at: string }; + expect(row.route_color).toBe(TRACK_COLORS[3]); + expect(row.updated_at).toBe('2020-01-01 00:00:00'); + }); + + it('PLACEREPO-039 (D6 single-connection race safety): two concurrent colour claims for the same trip never pick the same free colour', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const placeA = createPlace(testDb, trip.id, { name: 'Track A' }); + const placeB = createPlace(testDb, trip.id, { name: 'Track B' }); + testDb.prepare("UPDATE places SET route_geometry = '[[1,1]]' WHERE id IN (?, ?)").run(placeA.id, placeB.id); + + // Mirrors PlacesService.colorizeImportedTracks's own PL36+PL37 pairing + // EXACTLY: the read AND the write for one claim share ONE transaction — + // splitting them (read in one transaction, write in a second, separate + // one) is precisely the bug this pairing exists to prevent, and doing + // that here would make the test pass for the wrong reason. + const claimAndWrite = (placeId: number) => t.em.transactional(async () => { + const taken = new Set(await places.distinctRouteColors(trip.id)); + const free = TRACK_COLORS.find((c) => !taken.has(c)); + if (!free) throw new Error('palette exhausted'); + await places.setRouteColor(placeId, free); + return free; + }); + + const [colorA, colorB] = await Promise.all([ + claimAndWrite(placeA.id), + claimAndWrite(placeB.id), + ]); + + expect(colorA).not.toBe(colorB); + const rows = testDb.prepare('SELECT route_color FROM places WHERE id IN (?, ?)').all(placeA.id, placeB.id) as { route_color: string }[]; + expect(new Set(rows.map((r) => r.route_color)).size).toBe(2); + }); +}); + +describe('PlacesRepository.backfillFtid (PL39)', () => { + it('PLACEREPO-040: stamps google_ftid and updated_at, unscoped by trip (the candidate was already resolved against the trip by findDuplicatePlace)', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const place = createPlace(testDb, trip.id); + testDb.prepare('UPDATE places SET updated_at = ? WHERE id = ?').run('2020-01-01 00:00:00', place.id); + + await places.backfillFtid(place.id, '0x9:0x9'); + + const row = testDb.prepare('SELECT google_ftid, updated_at FROM places WHERE id = ?').get(place.id) as { google_ftid: string; updated_at: string }; + expect(row.google_ftid).toBe('0x9:0x9'); + expect(row.updated_at).not.toBe('2020-01-01 00:00:00'); + }); +}); + +describe('PlacesRepository.fillIfEmpty (PL43/PL44/PL46) — three call shapes over one COALESCE-per-column method', () => { + it('PLACEREPO-041 (PL43 shape): fills every empty column named in fields, stamps updated_at, and leaves an omitted column untouched', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const place = createPlace(testDb, trip.id); + testDb.prepare('UPDATE places SET phone = ?, updated_at = ? WHERE id = ?').run('+1 555', '2020-01-01 00:00:00', place.id); + + await places.fillIfEmpty(place.id, trip.id, { + google_place_id: 'gp1', google_ftid: 'gf1', address: 'addr1', website: 'https://x', phone: 'ignored', + }); + + const row = testDb.prepare('SELECT google_place_id, google_ftid, address, website, phone, updated_at FROM places WHERE id = ?').get(place.id) as Record; + expect(row.google_place_id).toBe('gp1'); + expect(row.google_ftid).toBe('gf1'); + expect(row.address).toBe('addr1'); + expect(row.website).toBe('https://x'); + // Already set — COALESCE keeps the existing value, not the caller's. + expect(row.phone).toBe('+1 555'); + expect(row.updated_at).not.toBe('2020-01-01 00:00:00'); + }); + + it('PLACEREPO-042: a column already set is NOT overwritten; an empty one is filled', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const place = createPlace(testDb, trip.id); + testDb.prepare('UPDATE places SET address = ? WHERE id = ?').run('Existing address', place.id); + + await places.fillIfEmpty(place.id, trip.id, { address: 'New address' }); + + expect((testDb.prepare('SELECT address FROM places WHERE id = ?').get(place.id) as { address: string }).address).toBe('Existing address'); + + const empty = createPlace(testDb, trip.id, { name: 'No address yet' }); + await places.fillIfEmpty(empty.id, trip.id, { address: 'First address' }); + expect((testDb.prepare('SELECT address FROM places WHERE id = ?').get(empty.id) as { address: string }).address).toBe('First address'); + }); + + it('PLACEREPO-043 (PL44 shape): image_url-only', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const place = createPlace(testDb, trip.id); + + await places.fillIfEmpty(place.id, trip.id, { image_url: '/api/maps/place-photo/x/bytes' }); + + const row = testDb.prepare('SELECT image_url, google_place_id FROM places WHERE id = ?').get(place.id) as { image_url: string; google_place_id: string | null }; + expect(row.image_url).toBe('/api/maps/place-photo/x/bytes'); + // Not touched — the key was never in `fields`, not even COALESCE'd against itself. + expect(row.google_place_id).toBeNull(); + }); + + it('PLACEREPO-044 (PL46 shape): address-only', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const place = createPlace(testDb, trip.id); + + await places.fillIfEmpty(place.id, trip.id, { address: '1 Rue de Rivoli' }); + + expect((testDb.prepare('SELECT address FROM places WHERE id = ?').get(place.id) as { address: string }).address).toBe('1 Rue de Rivoli'); + }); + + it('PLACEREPO-045: WHERE id = ? AND trip_id = ? scoping — a mismatched trip_id writes nothing', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const otherTrip = createTrip(testDb, user.id); + const place = createPlace(testDb, trip.id); + + await places.fillIfEmpty(place.id, otherTrip.id, { address: 'Should not land' }); + + expect((testDb.prepare('SELECT address FROM places WHERE id = ?').get(place.id) as { address: string | null }).address).toBeNull(); + }); +}); diff --git a/server/tests/unit/nest/places.directions.test.ts b/server/tests/unit/nest/places.directions.test.ts index a35876570..3601f3a2e 100644 --- a/server/tests/unit/nest/places.directions.test.ts +++ b/server/tests/unit/nest/places.directions.test.ts @@ -54,7 +54,7 @@ import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/plac import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service'; import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repository'; import { makeStorageFixture } from '../../helpers/storage-fixture'; -import { createTestUnitOfWork, createTestAppSettingsRepo, createTestUsersRepo, createTestTagsRepo, createTestPlaceRatingsRepo, createTestAssignmentParticipantsRepo, createTestPlacesRepo, createTestTripMembersRepo, createTestDayAssignmentsRepo, sharedTestOrm } from '../../helpers/test-uow'; +import { createTestUnitOfWork, createTestAppSettingsRepo, createTestUsersRepo, createTestTagsRepo, createTestPlaceRatingsRepo, createTestAssignmentParticipantsRepo, createTestPlacesRepo, createTestTripMembersRepo, createTestDayAssignmentsRepo, createTestCategoriesRepo, sharedTestOrm } from '../../helpers/test-uow'; const dbs = new DatabaseService(testDb); const photoCacheStub = { removeIfUnreferenced: vi.fn() } as unknown as PlacePhotoCacheService; @@ -97,6 +97,7 @@ async function svc(searchNominatim: MapsService['searchNominatim']): Promise { 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 }) as any; + 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'); + expect((payload as { place: { id: number } }).place.id).toBe(place.id); + 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); @@ -1718,6 +1742,26 @@ describe('backfillMissingAddresses', () => { 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 }) as any; + 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'); + expect((payload as { place: { id: number } }).place.id).toBe(place.id); + 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); diff --git a/server/tests/unit/nest/trip-read-model.service.test.ts b/server/tests/unit/nest/trip-read-model.service.test.ts index 8e7d914ce..9dc5e8ca3 100644 --- a/server/tests/unit/nest/trip-read-model.service.test.ts +++ b/server/tests/unit/nest/trip-read-model.service.test.ts @@ -83,7 +83,7 @@ import { createTestUnitOfWork, createTestAppSettingsRepo, createTestUsersRepo, sharedTestOrm, createTestDaysRepo, createTestDayAssignmentsRepo, createTestDayNotesRepo, createTestTripsRepo, createTestTripMembersRepo, createTestTagsRepo, createTestPlaceRatingsRepo, createTestAssignmentParticipantsRepo, - createTestGooglePlacePhotoMetaRepo, createTestPlacesRepo, + createTestGooglePlacePhotoMetaRepo, createTestPlacesRepo, createTestCategoriesRepo, } from '../../helpers/test-uow'; // Real sibling services over the same in-memory DB — the aggregation runs the @@ -136,6 +136,7 @@ beforeAll(async () => { await createTestPlaceRatingsRepo(dbs().connection), await createTestTripMembersRepo(dbs().connection), await createTestDayAssignmentsRepo(dbs().connection), + await createTestCategoriesRepo(dbs().connection), ); membersSvc = new TripMembersService(dbs(), budgetSvc, new UserCleanupService(dbs(), budgetSvc, await createTestUnitOfWork(dbs().connection), await createTestUsersRepo(dbs().connection)), new PermissionsService(await createTestAppSettingsRepo(dbs().connection), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub(), await createTestUnitOfWork(dbs().connection), await createTestTripsRepo(dbs().connection), await createTestTripMembersRepo(dbs().connection), await createTestUsersRepo(dbs().connection)); }); diff --git a/server/tests/unit/nest/trips.service.test.ts b/server/tests/unit/nest/trips.service.test.ts index c1dd2cbf8..3d341bc35 100644 --- a/server/tests/unit/nest/trips.service.test.ts +++ b/server/tests/unit/nest/trips.service.test.ts @@ -92,7 +92,7 @@ import { createTestUnitOfWork, createTestAppSettingsRepo, createTestUsersRepo, sharedTestOrm, createTestDaysRepo, createTestDayAssignmentsRepo, createTestDayNotesRepo, createTestTripsRepo, createTestTripMembersRepo, createTestTagsRepo, createTestPlaceRatingsRepo, createTestAssignmentParticipantsRepo, - createTestGooglePlacePhotoMetaRepo, createTestPlacesRepo, + createTestGooglePlacePhotoMetaRepo, createTestPlacesRepo, createTestCategoriesRepo, } from '../../helpers/test-uow'; // Real sibling services over the same in-memory DB — updateTrip's date-shift @@ -173,6 +173,7 @@ beforeAll(async () => { await createTestPlaceRatingsRepo(dbs().connection), await createTestTripMembersRepo(dbs().connection), await createTestDayAssignmentsRepo(dbs().connection), + await createTestCategoriesRepo(dbs().connection), ); svc = new TripsService( dbs(), diff --git a/server/tests/unit/services/conflictUpdate.test.ts b/server/tests/unit/services/conflictUpdate.test.ts index 70767ba14..c8d235d24 100644 --- a/server/tests/unit/services/conflictUpdate.test.ts +++ b/server/tests/unit/services/conflictUpdate.test.ts @@ -65,6 +65,7 @@ import { createTestPlacesRepo, createTestTripMembersRepo, createTestDayAssignmentsRepo, + createTestCategoriesRepo, sharedTestOrm, } from '../../helpers/test-uow'; @@ -112,6 +113,7 @@ beforeAll(async () => { await createTestPlaceRatingsRepo(dbs.connection), await createTestTripMembersRepo(dbs.connection), await createTestDayAssignmentsRepo(dbs.connection), + await createTestCategoriesRepo(dbs.connection), ); });