refactor(places): import paths through repositories

This commit is contained in:
jubnl
2026-09-23 08:53:54 +02:00
parent 955809f15f
commit f19face405
13 changed files with 644 additions and 77 deletions
@@ -21,6 +21,18 @@ export class CategoriesRepository extends TrekRepository<Categories> {
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
+122 -1
View File
@@ -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<Places> {
return qb.execute<PlaceWithCategoryRow[]>('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<string[]> {
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<void> {
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<void> {
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 <col> = COALESCE(<col>, ?), ..., 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(<col>, ?)` 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<void> {
const platform = this.getEntityManager().getPlatform();
const data: {
google_place_id?: ReturnType<typeof coalesceParam>;
google_ftid?: ReturnType<typeof coalesceParam>;
address?: ReturnType<typeof coalesceParam>;
website?: ReturnType<typeof coalesceParam>;
phone?: ReturnType<typeof coalesceParam>;
image_url?: ReturnType<typeof coalesceParam>;
updated_at: ReturnType<typeof currentTimestamp>;
} = { 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<PlaceRow[]> {
return await this.qb('p')
.select(['p.*'])
.where({ trip: trip_id })
.orderBy({ 'p.created_at': 'desc' })
.execute<PlaceRow[]>('all', false);
}
}
+4 -2
View File
@@ -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],
+170 -70
View File
@@ -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);
@@ -223,6 +223,7 @@ export async function createMcpTestRegistry(): Promise<McpRegistry> {
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));
+1
View File
@@ -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));
@@ -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);
});
});
});
@@ -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<string, string>;
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();
});
});
@@ -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<Pla
await createTestPlaceRatingsRepo(dbs.connection),
await createTestTripMembersRepo(dbs.connection),
await createTestDayAssignmentsRepo(dbs.connection),
await createTestCategoriesRepo(dbs.connection),
);
}
+45 -1
View File
@@ -78,7 +78,7 @@ import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpe
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';
import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository';
import type { UsersRepository } from '../../../src/db/repositories/Users.repository';
@@ -124,6 +124,7 @@ async function makePlacesService(
await createTestPlaceRatingsRepo(dbs.connection),
await createTestTripMembersRepo(dbs.connection),
await createTestDayAssignmentsRepo(dbs.connection),
await createTestCategoriesRepo(dbs.connection),
);
}
@@ -1420,6 +1421,29 @@ describe('enrichImportedPlaces', () => {
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);
@@ -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));
});
+2 -1
View File
@@ -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(),
@@ -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),
);
});