mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 06:56:35 +00:00
refactor(server): trip-membership, place-shadow, place-enrichment, place-photos and query helpers through repositories
This commit is contained in:
@@ -157,12 +157,36 @@ export function nowMinusDays(platform: Platform, days: number): RawQueryFragment
|
||||
return unsupported(platform);
|
||||
}
|
||||
|
||||
/** `TRIM(<col>)`. Pairs with `lower()` for the `LOWER(TRIM(name))` shape (`places.service.ts:642`). */
|
||||
/**
|
||||
* `TRIM(<col>)`, standalone. **Does NOT compose with `lower()`** to build
|
||||
* `LOWER(TRIM(name))` (Task 0b review M3): `lower()`'s `ref` argument goes
|
||||
* through `column()`, whose regex rejects anything but a bare
|
||||
* `alias.column` reference — `TRIM(name)` fails it — and even if it didn't,
|
||||
* `coalesce()`'s own docstring in this file documents that a
|
||||
* `RawQueryFragment` cannot be nested inside another one (`raw()`'s
|
||||
* `[Symbol.toPrimitive]` only implements the `'string'` coercion hint, which
|
||||
* is the object-key position, not template-literal interpolation). Use
|
||||
* `lowerTrim()` below for the composed shape.
|
||||
*/
|
||||
export function trim(platform: Platform, ref: string): RawQueryFragment {
|
||||
if (platform instanceof SqlitePlatform) return raw(`TRIM(${column(ref)})`);
|
||||
return unsupported(platform);
|
||||
}
|
||||
|
||||
/**
|
||||
* `LOWER(TRIM(<col>))` as one fragment — the shape `places.service.ts:642`
|
||||
* (`lower(trim(name))`, PL26) actually needs; `lower()`/`trim()` above
|
||||
* cannot be composed to build it (see `trim()`'s docstring, Task 0b review
|
||||
* M3). Typed `RawQueryFragment & symbol`, the same brand `lower()` carries,
|
||||
* for use as a filter KEY: `{ [lowerTrim(platform, 'name')]: lowerParam(...) }`.
|
||||
* SQLite's `LOWER()` is ASCII-only (program rule 18) — unchanged by the
|
||||
* `TRIM()`.
|
||||
*/
|
||||
export function lowerTrim(platform: Platform, ref: string): RawQueryFragment & symbol {
|
||||
if (platform instanceof SqlitePlatform) return raw(`LOWER(TRIM(${column(ref)}))`);
|
||||
return unsupported(platform);
|
||||
}
|
||||
|
||||
/**
|
||||
* `COALESCE(<col>, <fallbackCol>)` — both sides column references, for the
|
||||
* `COALESCE(a, b)` SELECT-projection shape (`days.service.ts:93-94`,
|
||||
@@ -209,3 +233,36 @@ export function absDifference(platform: Platform, ref: string, value: number): R
|
||||
return unsupported(platform);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plan 3c Task 1 — aggregate SELECT projections (`PlaceShadowPicksRepository
|
||||
// .totals`/`countBySource`/`countByLiveRank`, `place-shadow.service.ts:144,
|
||||
// 149, 157`). `alias` is a hand-written literal at every call site today
|
||||
// (never user input), validated the same way `column()` validates a column
|
||||
// reference, so a future caller cannot smuggle SQL through it either.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function alias(name: string): string {
|
||||
if (!/^[a-z_][a-z0-9_]*$/i.test(name)) {
|
||||
throw new Error(`sql-functions: not an alias: ${name}`);
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
/** `COUNT(*) as <alias>`, for a single-row totals read or a `GROUP BY` count column. */
|
||||
export function countAll(platform: Platform, aliasName: string): RawQueryFragment {
|
||||
if (platform instanceof SqlitePlatform) return raw(`COUNT(*) as ${alias(aliasName)}`);
|
||||
return unsupported(platform);
|
||||
}
|
||||
|
||||
/** `MIN(<col>) as <alias>`. */
|
||||
export function minOf(platform: Platform, ref: string, aliasName: string): RawQueryFragment {
|
||||
if (platform instanceof SqlitePlatform) return raw(`MIN(${column(ref)}) as ${alias(aliasName)}`);
|
||||
return unsupported(platform);
|
||||
}
|
||||
|
||||
/** `MAX(<col>) as <alias>`. */
|
||||
export function maxOf(platform: Platform, ref: string, aliasName: string): RawQueryFragment {
|
||||
if (platform instanceof SqlitePlatform) return raw(`MAX(${column(ref)}) as ${alias(aliasName)}`);
|
||||
return unsupported(platform);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,72 @@
|
||||
import type { GooglePlacePhotoMeta } from '../entities/GooglePlacePhotoMeta.entity';
|
||||
import { TrekRepository } from './_shared/trek-repository';
|
||||
|
||||
export class GooglePlacePhotoMetaRepository extends TrekRepository<GooglePlacePhotoMeta> {}
|
||||
export class GooglePlacePhotoMetaRepository extends TrekRepository<GooglePlacePhotoMeta> {
|
||||
/**
|
||||
* `SELECT attribution FROM google_place_photo_meta WHERE place_id = ? AND
|
||||
* error_at IS NULL` (`place-photo-cache.service.ts:76-79`, PP1). `place_id`
|
||||
* is a TEXT key — pseudo-ids like `coords:lat:lng` are legal values, not
|
||||
* only real Google place ids.
|
||||
*/
|
||||
async findLive(placeId: string): Promise<{ attribution: string | null } | null> {
|
||||
const row = await this.findOne({ place_id: placeId, error_at: null }, { fields: ['attribution'] });
|
||||
return row ? { attribution: row.attribution ?? null } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `DELETE FROM google_place_photo_meta WHERE place_id = ?` — ONE method for
|
||||
* the two call sites that share this exact statement: PP2
|
||||
* (`place-photo-cache.service.ts:87`, `get`'s self-healing delete after a
|
||||
* storage-object miss) and PP7 (`:219`, `deleteEntry`, after an explicit
|
||||
* storage delete).
|
||||
*/
|
||||
async deleteByPlaceId(placeId: string): Promise<void> {
|
||||
await this.nativeDelete({ place_id: placeId });
|
||||
}
|
||||
|
||||
/**
|
||||
* `SELECT error_at FROM google_place_photo_meta WHERE place_id = ? AND
|
||||
* error_at IS NOT NULL` (`place-photo-cache.service.ts:103-106`, PP3). The
|
||||
* `Date.now() - row.error_at < MISSING_TTL` comparison stays the caller's
|
||||
* (D4's defaults rule).
|
||||
*/
|
||||
async findErrored(placeId: string): Promise<{ error_at: number } | null> {
|
||||
const row = await this.findOne({ place_id: placeId, error_at: { $ne: null } }, { fields: ['error_at'] });
|
||||
return row && row.error_at != null ? { error_at: row.error_at } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `INSERT OR REPLACE INTO google_place_photo_meta (place_id, attribution,
|
||||
* fetched_at, error_at) VALUES (?, NULL, ?, ?)` (`place-photo-cache.service.ts:131-134`,
|
||||
* PP4, `markError`'s `'no-photo'` branch) — `attribution` forced `NULL` on
|
||||
* every call, both timestamps ms-epoch INTEGERs (never `currentTimestamp`,
|
||||
* which renders SQL text). `em.upsert` with `onConflictFields: ['place_id']`
|
||||
* (the table's real PK, `Migration20200101014600_…`) and `onConflictAction:
|
||||
* 'merge'` — `INSERT OR REPLACE` semantics for this single-column key.
|
||||
*/
|
||||
async upsertError(placeId: string, at: number): Promise<void> {
|
||||
await this.upsert(
|
||||
{ place_id: placeId, attribution: null, fetched_at: at, error_at: at },
|
||||
{ onConflictFields: ['place_id'], onConflictAction: 'merge' },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `INSERT OR REPLACE INTO google_place_photo_meta (place_id, attribution,
|
||||
* fetched_at, error_at) VALUES (?, ?, ?, NULL)` (`place-photo-cache.service.ts:161-164`,
|
||||
* PP5, `put`) — `error_at` forced `NULL` on every call, PP4's mirror image
|
||||
* on the same PK. Same `em.upsert` shape as `upsertError` above.
|
||||
*/
|
||||
async upsertPhoto(placeId: string, attribution: string | null, at: number): Promise<void> {
|
||||
await this.upsert(
|
||||
{ place_id: placeId, attribution, fetched_at: at, error_at: null },
|
||||
{ onConflictFields: ['place_id'], onConflictAction: 'merge' },
|
||||
);
|
||||
}
|
||||
|
||||
/** `SELECT place_id FROM google_place_photo_meta` (`place-photo-cache.service.ts:239`, PP8) — the nightly/boot sweep's full-table scan. */
|
||||
async listPlaceIds(): Promise<string[]> {
|
||||
const rows = await this.find({}, { fields: ['place_id'] });
|
||||
return rows.map((r) => r.place_id).filter((id): id is string => id != null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,35 @@
|
||||
import type { PlaceDetailsCache } from '../entities/PlaceDetailsCache.entity';
|
||||
import { TrekRepository } from './_shared/trek-repository';
|
||||
|
||||
export class PlaceDetailsCacheRepository extends TrekRepository<PlaceDetailsCache> {}
|
||||
export class PlaceDetailsCacheRepository extends TrekRepository<PlaceDetailsCache> {
|
||||
/**
|
||||
* `SELECT payload_json, fetched_at FROM place_details_cache WHERE place_id = ?
|
||||
* AND lang = ? AND expanded = ?` (`place-enrichment.service.ts:810-815`,
|
||||
* PE2). The caller passes `lang ?? ''` (D4's defaults rule — the column is
|
||||
* `NOT NULL DEFAULT ''`, never NULL) and `expanded` bound to the
|
||||
* `CACHE_KIND` constant.
|
||||
*/
|
||||
async findEntry(placeId: string, lang: string, kind: number): Promise<{ payload_json: string; fetched_at: number } | null> {
|
||||
const row = await this.findOne(
|
||||
{ place_id: placeId, lang, expanded: kind },
|
||||
{ fields: ['payload_json', 'fetched_at'] },
|
||||
);
|
||||
return row ? { payload_json: row.payload_json, fetched_at: row.fetched_at } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `INSERT OR REPLACE INTO place_details_cache (place_id, lang, expanded,
|
||||
* payload_json, fetched_at) VALUES (?, ?, ?, ?, ?)`
|
||||
* (`place-enrichment.service.ts:847-854`, PE3). `em.upsert` with
|
||||
* `onConflictFields: ['place_id', 'lang', 'expanded']` — the table's real
|
||||
* composite PRIMARY KEY (`Migration20200101014700_…`,
|
||||
* `PRIMARY KEY (place_id, lang, expanded)`), not just `place_id`: naming
|
||||
* only the single column here would let `em.upsert` infer the wrong
|
||||
* conflict target and silently insert duplicate rows for two different
|
||||
* `lang`/`expanded` values on the same place. `fetched_at` is ms-epoch,
|
||||
* never `currentTimestamp`.
|
||||
*/
|
||||
async upsertEntry(row: { place_id: string; lang: string; expanded: number; payload_json: string; fetched_at: number }): Promise<void> {
|
||||
await this.upsert(row, { onConflictFields: ['place_id', 'lang', 'expanded'], onConflictAction: 'merge' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,43 @@
|
||||
import type { PlaceRatings } from '../entities/PlaceRatings.entity';
|
||||
import { TrekRepository } from './_shared/trek-repository';
|
||||
|
||||
export class PlaceRatingsRepository extends TrekRepository<PlaceRatings> {}
|
||||
/** One rated place, from `QueryHelpersService.loadRatingsByPlaceIds` (QH2). */
|
||||
export interface PlaceRatingForPlaceRow {
|
||||
place_id: number;
|
||||
user_id: number;
|
||||
username: string;
|
||||
avatar: string | null;
|
||||
rating: number;
|
||||
}
|
||||
|
||||
export class PlaceRatingsRepository extends TrekRepository<PlaceRatings> {
|
||||
/**
|
||||
* `SELECT pr.place_id, pr.user_id, u.username, u.avatar, pr.rating
|
||||
* FROM place_ratings pr JOIN users u ON pr.user_id = u.id
|
||||
* WHERE pr.place_id IN (${…}) ORDER BY pr.created_at`
|
||||
* (`query-helpers.service.ts:65-70`, QH2) — the batch loader behind
|
||||
* `PlacesService.list`'s ratings column. Empty `placeIds` short-circuits
|
||||
* before any query, matching the legacy service method (which never
|
||||
* built the `IN (...)` placeholder list for an empty array either).
|
||||
*/
|
||||
async listForPlaces(placeIds: number[]): Promise<PlaceRatingForPlaceRow[]> {
|
||||
if (placeIds.length === 0) return [];
|
||||
// `pr.place` selects the plain FK scalar as `place_id` (that relation is
|
||||
// never separately joined here). `pr.user` IS separately joined (to `u`,
|
||||
// for the username/avatar), and selecting a relation property that is
|
||||
// ALSO an active join's target makes MikroORM alias the column off the
|
||||
// JOINED entity's own primary key (`u__id`) instead of the FK scalar —
|
||||
// verified directly, not assumed — so the user id is pulled explicitly
|
||||
// off the join instead (`u.id as user_id`), the same "physical alias"
|
||||
// shape `Places.repository.ts`'s `'c.name as category_name'` uses.
|
||||
// Selecting the `persist(false)` scalar mirror directly (`pr.user_id`)
|
||||
// was tried and silently drops the column from the result — also
|
||||
// verified directly, not assumed.
|
||||
return this.qb('pr')
|
||||
.join('pr.user', 'u')
|
||||
.select(['pr.place', 'u.id as user_id', 'u.username', 'u.avatar', 'pr.rating'])
|
||||
.where({ 'pr.place': { $in: placeIds } })
|
||||
.orderBy({ 'pr.created_at': 'asc' })
|
||||
.execute<PlaceRatingForPlaceRow[]>('all', false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,127 @@
|
||||
import type { PlaceShadowPicks } from '../entities/PlaceShadowPicks.entity';
|
||||
import { countAll, maxOf, minOf, nowMinusDays } from '../dialect/sql-functions';
|
||||
import { toRow, type AssertRowKeys } from './_shared/rows';
|
||||
import { TrekRepository } from './_shared/trek-repository';
|
||||
|
||||
export class PlaceShadowPicksRepository extends TrekRepository<PlaceShadowPicks> {}
|
||||
/** A `place_shadow_picks` row as the API emits it. */
|
||||
export interface PlaceShadowPickRow {
|
||||
id: number;
|
||||
created_at: string;
|
||||
query: string;
|
||||
lang: string | null;
|
||||
bias_lat: number | null;
|
||||
bias_lng: number | null;
|
||||
source: string;
|
||||
live_rank: number;
|
||||
live_count: number;
|
||||
picked_name: string;
|
||||
picked_lat: number;
|
||||
picked_lng: number;
|
||||
picked_place_id: string | null;
|
||||
}
|
||||
|
||||
const _placeShadowPickRowKeys: AssertRowKeys<PlaceShadowPickRow, PlaceShadowPicks> = true;
|
||||
|
||||
/**
|
||||
* The column set of the legacy `record()` INSERT
|
||||
* (`place-shadow.service.ts:97-113`, PS2): `query`, `source`, `live_rank`,
|
||||
* `live_count`, `picked_name`, `picked_lat`, `picked_lng` required; the rest
|
||||
* nullable. The `round()` rounding, the `?? null` defaulting and the
|
||||
* `liveRank >= liveCount` drop all stay in `PlaceShadowService` (D4's
|
||||
* defaults rule) — this writes exactly the row it is given.
|
||||
*/
|
||||
export interface NewPlaceShadowPickRow {
|
||||
query: string;
|
||||
lang: string | null;
|
||||
bias_lat: number | null;
|
||||
bias_lng: number | null;
|
||||
source: string;
|
||||
live_rank: number;
|
||||
live_count: number;
|
||||
picked_name: string;
|
||||
picked_lat: number;
|
||||
picked_lng: number;
|
||||
picked_place_id: string | null;
|
||||
}
|
||||
|
||||
export class PlaceShadowPicksRepository extends TrekRepository<PlaceShadowPicks> {
|
||||
/** `INSERT INTO place_shadow_picks (query, lang, bias_lat, bias_lng, source, live_rank, live_count, picked_name, picked_lat, picked_lng, picked_place_id) VALUES (?×11)` (PS2). `this.insert`, no read-back — the caller (`record()`) only needs to know the write happened. */
|
||||
async insertPick(row: NewPlaceShadowPickRow): Promise<void> {
|
||||
await this.insert({ ...row });
|
||||
}
|
||||
|
||||
/**
|
||||
* `SELECT * FROM place_shadow_picks WHERE id > ? ORDER BY id LIMIT ?`
|
||||
* (`place-shadow.service.ts:124-131`, PS3) — `size` is the PAGE size; this
|
||||
* fetches `size + 1` rows (the keyset-pagination probe row the caller uses
|
||||
* to compute `nextAfter`), matching the legacy statement's own `LIMIT
|
||||
* size + 1` exactly. Caller does the `after ?? 0` defaulting and the
|
||||
* `min(max(1, limit), EXPORT_PAGE_SIZE)` clamp (D4's defaults rule).
|
||||
*/
|
||||
async page(after: number, size: number): Promise<PlaceShadowPickRow[]> {
|
||||
const rows = await this.find({ id: { $gt: after } }, { orderBy: { id: 'asc' }, limit: size + 1 });
|
||||
return rows.map((r) => toRow(r) as PlaceShadowPickRow);
|
||||
}
|
||||
|
||||
/**
|
||||
* `SELECT COUNT(*) AS total, MIN(created_at) AS oldest, MAX(created_at) AS newest
|
||||
* FROM place_shadow_picks` (`place-shadow.service.ts:143-145`, PS4) — one
|
||||
* row, always (SQLite's bare aggregate SELECT never returns zero rows),
|
||||
* so `total` defaults to `0`/`oldest`/`newest` to `null` only for
|
||||
* type-safety against an `undefined` row, not because the query itself
|
||||
* can come back empty.
|
||||
*/
|
||||
async totals(): Promise<{ total: number; oldest: string | null; newest: string | null }> {
|
||||
const platform = this.getEntityManager().getPlatform();
|
||||
const row = await this.qb('p')
|
||||
.select([countAll(platform, 'total'), minOf(platform, 'p.created_at', 'oldest'), maxOf(platform, 'p.created_at', 'newest')])
|
||||
.execute<{ total: number; oldest: string | null; newest: string | null } | undefined>('get', false);
|
||||
return { total: row?.total ?? 0, oldest: row?.oldest ?? null, newest: row?.newest ?? null };
|
||||
}
|
||||
|
||||
/**
|
||||
* `SELECT source, COUNT(*) AS count FROM place_shadow_picks GROUP BY source
|
||||
* ORDER BY count DESC` (PS5). The `ORDER BY` is applied in JS, not SQL:
|
||||
* MikroORM's QueryBuilder cannot order by an ad-hoc raw-fragment alias
|
||||
* (`countAll`'s `RawQueryFragment` return type carries no alias literal for
|
||||
* `ExtractRawAliases` to pick up, so `.orderBy({ count: 'desc' })` throws
|
||||
* "not existing property" at runtime, verified directly) — `Array.prototype
|
||||
* .sort` is stable (ES2019+), so this produces the identical final row
|
||||
* order the legacy `ORDER BY count DESC` did.
|
||||
*/
|
||||
async countBySource(): Promise<{ source: string; count: number }[]> {
|
||||
const platform = this.getEntityManager().getPlatform();
|
||||
const rows = await this.qb('p')
|
||||
.select(['p.source', countAll(platform, 'count')])
|
||||
.groupBy('p.source')
|
||||
.execute<{ source: string; count: number }[]>('all', false);
|
||||
return [...rows].sort((a, b) => b.count - a.count);
|
||||
}
|
||||
|
||||
/** `SELECT live_rank, COUNT(*) AS count FROM place_shadow_picks GROUP BY live_rank` (PS6) — no ORDER BY, matching the legacy statement; the caller buckets the rows itself. */
|
||||
async countByLiveRank(): Promise<{ live_rank: number; count: number }[]> {
|
||||
const platform = this.getEntityManager().getPlatform();
|
||||
return this.qb('p')
|
||||
.select(['p.live_rank', countAll(platform, 'count')])
|
||||
.groupBy('p.live_rank')
|
||||
.execute<{ live_rank: number; count: number }[]>('all', false);
|
||||
}
|
||||
|
||||
/** `DELETE FROM place_shadow_picks` (PS7) — the admin wipe. Returns the affected-row count. */
|
||||
async deleteAll(): Promise<number> {
|
||||
return this.nativeDelete({});
|
||||
}
|
||||
|
||||
/**
|
||||
* `` DELETE FROM place_shadow_picks WHERE created_at < datetime('now', ?) ``
|
||||
* with `` `-${retentionDays} days` `` bound as a parameter
|
||||
* (`place-shadow.service.ts:190-193`, PS8) — `nowMinusDays` renders the
|
||||
* equivalent `datetime('now', '-N days')` fragment (it cannot bind the
|
||||
* modifier as a parameter the way the legacy statement did; see the
|
||||
* helper's own docstring). Returns the affected-row count.
|
||||
*/
|
||||
async purgeOlderThan(retentionDays: number): Promise<number> {
|
||||
const platform = this.getEntityManager().getPlatform();
|
||||
return this.nativeDelete({ created_at: { $lt: nowMinusDays(platform, retentionDays) } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Places } from '../entities/Places.entity';
|
||||
import type { Tags } from '../entities/Tags.entity';
|
||||
import { columnRef } from '../dialect/sql-functions';
|
||||
import { type AssertRowKeys } from './_shared/rows';
|
||||
import { TrekRepository } from './_shared/trek-repository';
|
||||
|
||||
@@ -121,17 +122,37 @@ export class PlacesRepository extends TrekRepository<Places> {
|
||||
.where('p.id = ?', [place_id])
|
||||
.execute<TagRow[]>('all', false);
|
||||
|
||||
// Task 0b review B1 (HIGH, live regression): `.select(['pr.user', ...])`
|
||||
// makes MikroORM resolve `pr.user` through the ACTIVE `join('pr.user',
|
||||
// 'u')` and emit the joined entity's own PK under its join alias
|
||||
// (`u__id`), not the FK scalar (`user_id`) — `ratings[].user_id` never
|
||||
// existed at runtime, silently breaking the client's
|
||||
// `ratings.find(r => r.user_id === currentUserId)` own-rating lookup.
|
||||
// `columnRef(...).as('user_id')` routes the FK column through the
|
||||
// dialect layer and aliases it back explicitly; `'pr.user_id'` (the
|
||||
// `persist(false)` scalar mirror) was tried and silently drops the
|
||||
// column, `'pr.user as user_id'` was tried and is still `u__id` — both
|
||||
// verified directly, not assumed (same finding independently reproduced
|
||||
// in `PlaceRatingsRepository.listForPlaces`'s docstring, Plan 3c Task 1).
|
||||
const platform = this.getEntityManager().getPlatform();
|
||||
const ratings = await this.qb('p')
|
||||
.join('p.place_ratings_collection', 'pr')
|
||||
.join('pr.user', 'u')
|
||||
.select(['pr.user', 'u.username', 'u.avatar', 'pr.rating'])
|
||||
.select([columnRef(platform, 'pr.user_id').as('user_id'), 'u.username', 'u.avatar', 'pr.rating'])
|
||||
.where('p.id = ?', [place_id])
|
||||
.orderBy({ 'pr.created_at': 'asc' })
|
||||
.execute<PlaceRatingRow[]>('all', false);
|
||||
|
||||
const { category_name, category_color, category_icon, ...placeRest } = place;
|
||||
// Task 0b review B2 (HIGH, live regression): the legacy `getPlaceWithTags`
|
||||
// returned `{ ...place, category: ... }` over the raw driver row of
|
||||
// `SELECT p.*, c.name as category_name, c.color as category_color, c.icon
|
||||
// as category_icon` — the three alias columns ride along on the wire
|
||||
// body (map popups, dashboard, shared-trip page all read them flat, per
|
||||
// the review's blast-radius list). Read them, don't strip them: spread
|
||||
// `place` whole, not `placeRest`.
|
||||
const { category_name, category_color, category_icon } = place;
|
||||
return {
|
||||
...placeRest,
|
||||
...place,
|
||||
category: place.category_id
|
||||
? { id: place.category_id, name: category_name!, color: category_color!, icon: category_icon! }
|
||||
: null,
|
||||
@@ -141,4 +162,23 @@ export class PlacesRepository extends TrekRepository<Places> {
|
||||
rating_count: ratings.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* `SELECT 1 FROM places WHERE google_place_id = ? OR image_url = ? LIMIT 1`
|
||||
* — the `places`-owned half of `PlacePhotoCacheService.isReferenced`
|
||||
* (`place-photo-cache.service.ts:205-211`, PP6), split per the Plan 3c
|
||||
* Task 1 PP6 ruling (option 2): the legacy single `UNION ALL … LIMIT 1`
|
||||
* statement spans this table AND `collection_places` (Plan 3h's), so the
|
||||
* service evaluates this half first and only runs the `collection_places`
|
||||
* half when this one is `false` — reproducing the legacy `UNION ALL …
|
||||
* LIMIT 1`'s short-circuit by evaluation order rather than by SQL.
|
||||
*/
|
||||
async existsByGoogleIdOrImageUrl(googlePlaceId: string, imageUrl: string): Promise<boolean> {
|
||||
const row = await this.qb('p')
|
||||
.select(['p.id'])
|
||||
.where({ $or: [{ google_place_id: googlePlaceId }, { image_url: imageUrl }] })
|
||||
.limit(1)
|
||||
.execute<{ id: number } | undefined>('get', false);
|
||||
return !!row;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,22 @@ export interface TagRow {
|
||||
|
||||
const _tagRowKeys: AssertRowKeys<TagRow, Tags> = true;
|
||||
|
||||
/**
|
||||
* One tag attached to one place, from `QueryHelpersService.loadTagsByPlaceIds`
|
||||
* (QH1). `user_id` is present only on the non-compact projection — `listForPlaces`
|
||||
* selects a narrower column set when `compact` is set, so a compact row never
|
||||
* carries the key at all (matching the legacy's destructuring, which drops it
|
||||
* rather than nulling it).
|
||||
*/
|
||||
export interface TagForPlaceRow {
|
||||
place_id: number;
|
||||
id: number;
|
||||
user_id?: number;
|
||||
name: string;
|
||||
color: string | null;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export class TagsRepository extends TrekRepository<Tags> {
|
||||
/**
|
||||
* `SELECT * FROM tags WHERE user_id = ? ORDER BY name ASC`, via the shared
|
||||
@@ -115,10 +131,36 @@ export class TagsRepository extends TrekRepository<Tags> {
|
||||
return this.nativeDelete({ id });
|
||||
}
|
||||
|
||||
// `loadForPlaces` (a batch `findByPlaceIds`-style tag loader) is reserved
|
||||
// here for Plan 3c, which moves QueryHelpersService's tag batch loader
|
||||
// (`query-helpers.service.ts:42`, `SELECT t.*, pt.place_id FROM tags t …`)
|
||||
// onto TagsRepository (spec D4). Not implemented in Plan 3a Task 3 — no
|
||||
// throwing stub, just this reservation, so `nest/places`'s later migration
|
||||
// doesn't duplicate the method name.
|
||||
/**
|
||||
* `SELECT t.*, pt.place_id FROM tags t JOIN place_tags pt ON t.id = pt.tag_id
|
||||
* WHERE pt.place_id IN (${…})` (`query-helpers.service.ts:41-45`, QH1) —
|
||||
* ONE method, TWO projections (Plan 3c Task 1 ruling): `compact` selects
|
||||
* `{id, name, color, created_at, place_id}`; the non-compact path selects
|
||||
* every `tags` column except the join key (`{id, user_id, name, color,
|
||||
* created_at, place_id}`), exactly the legacy's `t.*` minus `place_id`.
|
||||
*
|
||||
* Joined through `t.place_tags_inverse` (the inverse side of `Places.place_tags`,
|
||||
* a many-to-many over the `place_tags` pivot table): selecting the joined
|
||||
* place's own `id` as `place_id` is the same value the legacy's `pt.place_id`
|
||||
* read off the pivot row directly (the pivot's `place_id` column IS the
|
||||
* places row it points at), so this is not an approximation.
|
||||
*
|
||||
* Empty `placeIds` short-circuits before any query, matching the legacy
|
||||
* service method.
|
||||
*/
|
||||
async listForPlaces(placeIds: number[], options?: { compact?: boolean }): Promise<TagForPlaceRow[]> {
|
||||
if (placeIds.length === 0) return [];
|
||||
if (options?.compact) {
|
||||
return this.qb('t')
|
||||
.join('t.place_tags_inverse', 'p')
|
||||
.select(['t.id', 't.name', 't.color', 't.created_at', 'p.id as place_id'])
|
||||
.where({ 'p.id': { $in: placeIds } })
|
||||
.execute<TagForPlaceRow[]>('all', false);
|
||||
}
|
||||
return this.qb('t')
|
||||
.join('t.place_tags_inverse', 'p')
|
||||
.select(['t.id', 't.user', 't.name', 't.color', 't.created_at', 'p.id as place_id'])
|
||||
.where({ 'p.id': { $in: placeIds } })
|
||||
.execute<TagForPlaceRow[]>('all', false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,4 +42,45 @@ export class TripMembersRepository extends TrekRepository<TripMembers> {
|
||||
if (owner) ids.add(owner.user_id);
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* `SELECT user_id FROM trip_members WHERE trip_id = ? ORDER BY added_at ASC`
|
||||
* (`trip-membership.service.ts:29-31`, TB2) — the owner is excluded by
|
||||
* construction (the table has no row for the owner), matching the legacy
|
||||
* statement exactly. Raw-bind (D4's T5 escape hatch), the same
|
||||
* `number | string` seam `TripsRepository.findAccessible` documents.
|
||||
*/
|
||||
async listUserIdsByTrip(trip_id: number | string): Promise<number[]> {
|
||||
const rows = await this.qb('m')
|
||||
.select(['m.user'])
|
||||
.where('m.trip_id = ?', [trip_id])
|
||||
.orderBy({ 'm.added_at': 'asc' })
|
||||
.execute<{ user_id: number }[]>('all', false);
|
||||
return rows.map((r) => r.user_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* `SELECT id FROM trip_members WHERE trip_id = ? AND user_id = ?`
|
||||
* (`trip-membership.service.ts:70`, TB5) — `joinTripAsMember`'s
|
||||
* already-a-member check, the second leg of the non-transactional
|
||||
* check-then-act sequence (§18.4: unchanged by this conversion). Both
|
||||
* callers (`TripMembershipService.joinTripAsMember`) always pass real
|
||||
* `number`s, so no raw-bind seam here.
|
||||
*/
|
||||
async exists(trip_id: number, user_id: number): Promise<boolean> {
|
||||
const row = await this.qb('m')
|
||||
.select(['m.id'])
|
||||
.where({ trip: trip_id, user: user_id })
|
||||
.execute<{ id: number } | undefined>('get', false);
|
||||
return !!row;
|
||||
}
|
||||
|
||||
/**
|
||||
* `INSERT INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)`
|
||||
* (`trip-membership.service.ts:72`, TB6) — the third leg of the same
|
||||
* non-transactional sequence as `exists` above; `invitedBy` may be `null`.
|
||||
*/
|
||||
async addMember(trip_id: number, user_id: number, invitedBy: number | null): Promise<void> {
|
||||
await this.insert({ trip: trip_id, user: user_id, invited_by: invitedBy });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,25 @@ export interface TripAccess {
|
||||
}
|
||||
|
||||
export class TripsRepository extends TrekRepository<Trips> {
|
||||
/**
|
||||
* The `LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ?
|
||||
* WHERE (t.user_id = ? OR m.user_id IS NOT NULL)` half `findAccessible`
|
||||
* and `listAccessibleIds` (Plan 3c Task 1, TB3) share — one join builder,
|
||||
* per the task brief's ruling that the two must derive from the same
|
||||
* source rather than hand-keeping two copies in sync (the cross-plan note
|
||||
* in the inventory: TB3 and `TripsService.list`'s id half are "the same
|
||||
* query with different projections"). No explicit return-type annotation,
|
||||
* for the same inference reason `TrekRepository.kysely()` gives (the
|
||||
* QueryBuilder's fluent generic parameters do not survive being spelled
|
||||
* out via a separate type alias) — every caller chains `.select()`/
|
||||
* `.where()`/`.orderBy()` straight off this method's return value.
|
||||
*/
|
||||
private accessibleTripsQuery(user_id: number) {
|
||||
return this.qb('t')
|
||||
.leftJoin('t.trip_members_collection', 'm', { 'm.user': user_id })
|
||||
.andWhere({ $or: [{ 't.user': user_id }, { 'm.user': { $ne: null } }] });
|
||||
}
|
||||
|
||||
/**
|
||||
* The trip if the user owns it or is a member of it, else undefined.
|
||||
*
|
||||
@@ -30,9 +49,8 @@ export class TripsRepository extends TrekRepository<Trips> {
|
||||
* affinity comparison of the unconverted string never matches one).
|
||||
*/
|
||||
async findAccessible(trip_id: number | string, user_id: number): Promise<TripAccess | undefined> {
|
||||
const row = await this.qb('t')
|
||||
const row = await this.accessibleTripsQuery(user_id)
|
||||
.select(['t.id', 't.user', 't.currency'])
|
||||
.leftJoin('t.trip_members_collection', 'm', { 'm.user': user_id })
|
||||
// Raw condition (D4's T5 escape hatch), not `.where({ 't.id': trip_id })`:
|
||||
// MikroORM's typed filter rejects a `string` against `t.id`'s branded
|
||||
// `number` type, and coercing to `Number(trip_id)` first would be
|
||||
@@ -40,8 +58,7 @@ export class TripsRepository extends TrekRepository<Trips> {
|
||||
// `t.id`/`t.user_id` are the physical column names (not translated
|
||||
// through the entity's `trip`/`user` property aliasing the way a typed
|
||||
// filter object is), so the raw text names them directly.
|
||||
.where('t.id = ?', [trip_id])
|
||||
.andWhere({ $or: [{ 't.user': user_id }, { 'm.user': { $ne: null } }] })
|
||||
.andWhere('t.id = ?', [trip_id])
|
||||
// `mapResults: false` leaves the driver's row alone: the keys are the
|
||||
// column names, which is why the result type is spelled out here rather
|
||||
// than inferred from the entity's properties.
|
||||
@@ -69,4 +86,50 @@ export class TripsRepository extends TrekRepository<Trips> {
|
||||
.execute<{ id: number } | undefined>('get', false);
|
||||
return !!row;
|
||||
}
|
||||
|
||||
/**
|
||||
* `SELECT user_id FROM trips WHERE id = ?` (`trip-membership.service.ts:23`,
|
||||
* TB1) — `row ? row.user_id : null` stays the caller's decision to make
|
||||
* ("owner id for budget/MCP guards"); this returns exactly that shape.
|
||||
* Raw-bind (D4's T5 escape hatch), same `number | string` seam as
|
||||
* `findAccessible`/`isOwner` above.
|
||||
*/
|
||||
async getOwnerId(trip_id: number | string): Promise<number | null> {
|
||||
const row = await this.qb('t')
|
||||
.select(['t.user'])
|
||||
.where('t.id = ?', [trip_id])
|
||||
.execute<{ user_id: number } | undefined>('get', false);
|
||||
return row ? row.user_id : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `SELECT id, user_id FROM trips WHERE id = ?` (`trip-membership.service.ts:66`,
|
||||
* TB4) — `joinTripAsMember`'s first check-then-act read (§18.4: the
|
||||
* sequence stays non-transactional, unchanged by this conversion). The
|
||||
* only caller (`TripMembershipService.joinTripAsMember(tripId: number, …)`)
|
||||
* always passes a real `number`, so this takes one too — no raw-bind seam
|
||||
* to preserve here, unlike `findAccessible`/`isOwner`/`getOwnerId`.
|
||||
*/
|
||||
async findIdAndOwner(trip_id: number): Promise<{ id: number; user_id: number } | undefined> {
|
||||
return this.qb('t')
|
||||
.select(['t.id', 't.user'])
|
||||
.where({ id: trip_id })
|
||||
.execute<{ id: number; user_id: number } | undefined>('get', false);
|
||||
}
|
||||
|
||||
/**
|
||||
* `SELECT t.id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id
|
||||
* AND m.user_id = :userId WHERE (t.user_id = :userId OR m.user_id IS NOT NULL)
|
||||
* ORDER BY t.created_at DESC` (`trip-membership.service.ts:40-46`, TB3) —
|
||||
* the id half of `TripsService.list(userId, null)`, sharing `findAccessible`'s
|
||||
* join builder per the task brief's ruling (this class's `accessibleTripsQuery`
|
||||
* above).
|
||||
*/
|
||||
async listAccessibleIds(user_id: number): Promise<number[]> {
|
||||
const rows = await this.accessibleTripsQuery(user_id)
|
||||
.select(['t.id'])
|
||||
.orderBy({ 't.created_at': 'desc' })
|
||||
.execute<{ id: number }[]>('all', false);
|
||||
return rows.map((r) => r.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MikroOrmModule } from '@mikro-orm/nestjs';
|
||||
import { MapsModule } from '../maps/maps.module';
|
||||
import { PlacePhotosModule } from '../place-photos/place-photos.module';
|
||||
import { RateLimitModule } from '../common/rate-limit.module';
|
||||
import { PlaceEnrichmentController } from './place-enrichment.controller';
|
||||
import { PlaceEnrichmentService } from './place-enrichment.service';
|
||||
import { PlaceDetailsCache } from '../../db/entities/PlaceDetailsCache.entity';
|
||||
import { AppSettings } from '../../db/entities/AppSettings.entity';
|
||||
|
||||
/**
|
||||
* Place enrichment (L4 leaf module). Registered in AppModule.
|
||||
@@ -13,9 +16,13 @@ import { PlaceEnrichmentService } from './place-enrichment.service';
|
||||
* matters here: the photo cache's stampede guard and the maps concurrency limit
|
||||
* only hold if this module queues behind the same ones everything else uses.
|
||||
* Nothing outside the container consumes it, so there is no bridge.
|
||||
*
|
||||
* MikroOrmModule.forFeature registers PlaceDetailsCacheRepository/
|
||||
* AppSettingsRepository for PlaceEnrichmentService's @InjectRepository
|
||||
* constructor (Plan 3c Task 1).
|
||||
*/
|
||||
@Module({
|
||||
imports: [MapsModule, PlacePhotosModule, RateLimitModule],
|
||||
imports: [MapsModule, PlacePhotosModule, RateLimitModule, MikroOrmModule.forFeature([PlaceDetailsCache, AppSettings])],
|
||||
controllers: [PlaceEnrichmentController],
|
||||
providers: [PlaceEnrichmentService],
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { placeWebsiteSchema } from '@trek/shared';
|
||||
import type {
|
||||
MapsPlaceEnrichmentRequest,
|
||||
@@ -11,7 +12,10 @@ import type {
|
||||
PlaceRating,
|
||||
} from '@trek/shared';
|
||||
import { safeFetchFollow } from '../../utils/ssrfGuard';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { PlaceDetailsCache } from '../../db/entities/PlaceDetailsCache.entity';
|
||||
import type { PlaceDetailsCacheRepository } from '../../db/repositories/PlaceDetailsCache.repository';
|
||||
import { AppSettings } from '../../db/entities/AppSettings.entity';
|
||||
import type { AppSettingsRepository } from '../../db/repositories/AppSettings.repository';
|
||||
import {
|
||||
MapsService,
|
||||
isGoogleMapsHost,
|
||||
@@ -307,7 +311,8 @@ export function collectRating(details: Record<string, unknown> | null): PlaceRat
|
||||
@Injectable()
|
||||
export class PlaceEnrichmentService {
|
||||
constructor(
|
||||
private readonly database: DatabaseService,
|
||||
@InjectRepository(PlaceDetailsCache) private readonly cache: PlaceDetailsCacheRepository,
|
||||
@InjectRepository(AppSettings) private readonly appSettings: AppSettingsRepository,
|
||||
private readonly maps: MapsService,
|
||||
private readonly photoCache: PlacePhotoCacheService,
|
||||
) {}
|
||||
@@ -319,8 +324,8 @@ export class PlaceEnrichmentService {
|
||||
* working, and there is nothing here that warrants a migration.
|
||||
*/
|
||||
async enrichDisabled(): Promise<boolean> {
|
||||
const row = this.database.get<{ value: string }>('SELECT value FROM app_settings WHERE key = ?', 'places_enrich_enabled');
|
||||
return row?.value === 'false';
|
||||
const value = await this.appSettings.getValue('places_enrich_enabled');
|
||||
return value === 'false';
|
||||
}
|
||||
|
||||
async enrich(userId: number, req: MapsPlaceEnrichmentRequest): Promise<MapsPlaceEnrichmentResult> {
|
||||
@@ -807,12 +812,7 @@ export class PlaceEnrichmentService {
|
||||
|
||||
private async readCache(placeId: string, lang: string | undefined): Promise<CachedEnrichment | null> {
|
||||
try {
|
||||
const row = this.database.get<{ payload_json: string; fetched_at: number }>(
|
||||
'SELECT payload_json, fetched_at FROM place_details_cache WHERE place_id = ? AND lang = ? AND expanded = ?',
|
||||
placeId,
|
||||
lang ?? '',
|
||||
CACHE_KIND,
|
||||
);
|
||||
const row = await this.cache.findEntry(placeId, lang ?? '', CACHE_KIND);
|
||||
if (!row) return null;
|
||||
const parsed = JSON.parse(row.payload_json) as CachePayload;
|
||||
if (parsed.v !== CACHE_VERSION) return null;
|
||||
@@ -844,14 +844,13 @@ export class PlaceEnrichmentService {
|
||||
|
||||
private async writeCache(placeId: string, lang: string | undefined, value: CachedEnrichment): Promise<void> {
|
||||
try {
|
||||
this.database.run(
|
||||
'INSERT OR REPLACE INTO place_details_cache (place_id, lang, expanded, payload_json, fetched_at) VALUES (?, ?, ?, ?, ?)',
|
||||
placeId,
|
||||
lang ?? '',
|
||||
CACHE_KIND,
|
||||
JSON.stringify({ ...value, v: CACHE_VERSION } satisfies CachePayload),
|
||||
Date.now(),
|
||||
);
|
||||
await this.cache.upsertEntry({
|
||||
place_id: placeId,
|
||||
lang: lang ?? '',
|
||||
expanded: CACHE_KIND,
|
||||
payload_json: JSON.stringify({ ...value, v: CACHE_VERSION } satisfies CachePayload),
|
||||
fetched_at: Date.now(),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to cache place enrichment:', err);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { Jimp, JimpMime } from 'jimp';
|
||||
import crypto from 'node:crypto';
|
||||
import { Readable } from 'node:stream';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { StorageService } from '../storage/storage.service';
|
||||
import { GooglePlacePhotoMeta } from '../../db/entities/GooglePlacePhotoMeta.entity';
|
||||
import type { GooglePlacePhotoMetaRepository } from '../../db/repositories/GooglePlacePhotoMeta.repository';
|
||||
import { Places } from '../../db/entities/Places.entity';
|
||||
import type { PlacesRepository } from '../../db/repositories/Places.repository';
|
||||
|
||||
// How long a "no photo for this place" answer stays remembered. Nothing about it
|
||||
// changes until a photo appears upstream, so it is worth keeping: without it every
|
||||
@@ -46,6 +51,11 @@ interface CachedPhoto {
|
||||
* The registry's mode-aware prefix reproduces both TREK_PLACE_PHOTO_DIR
|
||||
* layouts (unset: uploads/photos/google; set: that dir, bare keys), so the
|
||||
* cache itself is mode-agnostic.
|
||||
*
|
||||
* `DatabaseService` stays injected (Plan 3c Task 1, PP6 ruling option 2)
|
||||
* purely for the `collection_places` half of `isReferenced` — that table is
|
||||
* Plan 3h's, so it stays a raw statement with a `// Plan 3h` comment until
|
||||
* then. Every other statement in this file is repository-backed.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PlacePhotoCacheService {
|
||||
@@ -59,6 +69,8 @@ export class PlacePhotoCacheService {
|
||||
constructor(
|
||||
private readonly db: DatabaseService,
|
||||
private readonly storage: StorageService,
|
||||
@InjectRepository(GooglePlacePhotoMeta) private readonly meta: GooglePlacePhotoMetaRepository,
|
||||
@InjectRepository(Places) private readonly places: PlacesRepository,
|
||||
) {}
|
||||
|
||||
private fileName(placeId: string): string {
|
||||
@@ -73,10 +85,7 @@ export class PlacePhotoCacheService {
|
||||
}
|
||||
|
||||
async get(placeId: string): Promise<CachedPhoto | null> {
|
||||
const row = this.db.get<{ attribution: string | null }>(
|
||||
'SELECT attribution FROM google_place_photo_meta WHERE place_id = ? AND error_at IS NULL',
|
||||
placeId,
|
||||
);
|
||||
const row = await this.meta.findLive(placeId);
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
@@ -84,7 +93,7 @@ export class PlacePhotoCacheService {
|
||||
// First time this placeId is checked this session — verify the object exists.
|
||||
// (Guards against volume wipes or manual deletion between server restarts.)
|
||||
if (!(await this.storage.exists('photos-google', this.fileName(placeId)))) {
|
||||
this.db.run('DELETE FROM google_place_photo_meta WHERE place_id = ?', placeId);
|
||||
await this.meta.deleteByPlaceId(placeId);
|
||||
return null;
|
||||
}
|
||||
this.knownOnDisk.add(placeId);
|
||||
@@ -100,10 +109,7 @@ export class PlacePhotoCacheService {
|
||||
this.recentFailures.delete(placeId);
|
||||
}
|
||||
|
||||
const row = this.db.get<{ error_at: number }>(
|
||||
'SELECT error_at FROM google_place_photo_meta WHERE place_id = ? AND error_at IS NOT NULL',
|
||||
placeId,
|
||||
);
|
||||
const row = await this.meta.findErrored(placeId);
|
||||
|
||||
if (!row) return false;
|
||||
return Date.now() - row.error_at < MISSING_TTL;
|
||||
@@ -128,10 +134,7 @@ export class PlacePhotoCacheService {
|
||||
|
||||
this.recentFailures.delete(placeId);
|
||||
this.knownOnDisk.delete(placeId);
|
||||
this.db.run(
|
||||
'INSERT OR REPLACE INTO google_place_photo_meta (place_id, attribution, fetched_at, error_at) VALUES (?, NULL, ?, ?)',
|
||||
placeId, Date.now(), Date.now(),
|
||||
);
|
||||
await this.meta.upsertError(placeId, Date.now());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -158,10 +161,7 @@ export class PlacePhotoCacheService {
|
||||
this.knownOnDisk.add(placeId);
|
||||
this.recentFailures.delete(placeId);
|
||||
|
||||
this.db.run(
|
||||
'INSERT OR REPLACE INTO google_place_photo_meta (place_id, attribution, fetched_at, error_at) VALUES (?, ?, ?, NULL)',
|
||||
placeId, attribution, Date.now(),
|
||||
);
|
||||
await this.meta.upsertPhoto(placeId, attribution, Date.now());
|
||||
|
||||
return { photoUrl: this.proxyUrl(placeId), attribution };
|
||||
}
|
||||
@@ -200,14 +200,20 @@ export class PlacePhotoCacheService {
|
||||
* the google_place_id itself, so collection_places must count as a referencing
|
||||
* table — otherwise the nightly sweep + trip-place delete would evict a photo
|
||||
* still shown on a collection thumbnail (#1081 photo-cache pitfall).
|
||||
*
|
||||
* Plan 3c Task 1 PP6 ruling (option 2): the legacy single `UNION ALL … LIMIT 1`
|
||||
* statement spans `places` (this plan's) and `collection_places` (Plan 3h's),
|
||||
* so it is split into two existence checks, evaluated in the SAME order the
|
||||
* legacy `UNION ALL … LIMIT 1` would short-circuit in — the `collection_places`
|
||||
* half only runs when the `places` half comes back false.
|
||||
*/
|
||||
private async isReferenced(placeId: string): Promise<boolean> {
|
||||
const proxyUrl = this.proxyUrl(placeId);
|
||||
if (await this.places.existsByGoogleIdOrImageUrl(placeId, proxyUrl)) return true;
|
||||
// Plan 3h: collection_places is not yet repository-backed.
|
||||
const row = this.db.get(
|
||||
`SELECT 1 FROM places WHERE google_place_id = ? OR image_url = ?
|
||||
UNION ALL
|
||||
SELECT 1 FROM collection_places WHERE google_place_id = ? OR image_url = ?
|
||||
LIMIT 1`,
|
||||
placeId, this.proxyUrl(placeId), placeId, this.proxyUrl(placeId),
|
||||
`SELECT 1 FROM collection_places WHERE google_place_id = ? OR image_url = ? LIMIT 1`,
|
||||
placeId, proxyUrl,
|
||||
);
|
||||
return !!row;
|
||||
}
|
||||
@@ -216,7 +222,7 @@ export class PlacePhotoCacheService {
|
||||
await this.storage.delete('photos-google', this.fileName(placeId)).catch(() => {
|
||||
/* already gone */
|
||||
});
|
||||
this.db.run('DELETE FROM google_place_photo_meta WHERE place_id = ?', placeId);
|
||||
await this.meta.deleteByPlaceId(placeId);
|
||||
this.knownOnDisk.delete(placeId);
|
||||
}
|
||||
|
||||
@@ -236,9 +242,9 @@ export class PlacePhotoCacheService {
|
||||
async sweepOrphans(): Promise<number> {
|
||||
let removed = 0;
|
||||
|
||||
const rows = this.db.all<{ place_id: string }>('SELECT place_id FROM google_place_photo_meta');
|
||||
const placeIds = await this.meta.listPlaceIds();
|
||||
const keepFiles = new Set<string>();
|
||||
for (const { place_id } of rows) {
|
||||
for (const place_id of placeIds) {
|
||||
if (await this.isReferenced(place_id)) {
|
||||
keepFiles.add(this.fileName(place_id));
|
||||
} else {
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MikroOrmModule } from '@mikro-orm/nestjs';
|
||||
import { PlacePhotoCacheService } from './place-photo-cache.service';
|
||||
import { PlacePhotoCacheJob } from './place-photo-cache.job';
|
||||
import { AppConfigModule } from '../app-config/app-config.module';
|
||||
import { SchedulingModule } from '../scheduling/scheduling.module';
|
||||
import { StorageModule } from '../storage/storage.module';
|
||||
import { GooglePlacePhotoMeta } from '../../db/entities/GooglePlacePhotoMeta.entity';
|
||||
import { Places } from '../../db/entities/Places.entity';
|
||||
|
||||
/** The marker-photo cache. No controller of its own — maps serves the bytes,
|
||||
* places and share read through it, and PlacePhotoCacheJob sweeps it nightly.
|
||||
*
|
||||
* Deliberately NOT @Global (permissions precedent), and AppConfigModule is
|
||||
* imported explicitly because @Global only reaches modules that are in the
|
||||
* graph — which a single-domain e2e TestingModule is not. */
|
||||
* graph — which a single-domain e2e TestingModule is not.
|
||||
*
|
||||
* MikroOrmModule.forFeature registers GooglePlacePhotoMetaRepository/
|
||||
* PlacesRepository for PlacePhotoCacheService's @InjectRepository
|
||||
* constructor (Plan 3c Task 1). */
|
||||
@Module({
|
||||
imports: [AppConfigModule, SchedulingModule, StorageModule],
|
||||
imports: [AppConfigModule, SchedulingModule, StorageModule, MikroOrmModule.forFeature([GooglePlacePhotoMeta, Places])],
|
||||
providers: [PlacePhotoCacheService, PlacePhotoCacheJob],
|
||||
exports: [PlacePhotoCacheService],
|
||||
})
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MikroOrmModule } from '@mikro-orm/nestjs';
|
||||
import { SchedulingModule } from '../scheduling/scheduling.module';
|
||||
import { PlaceShadowController } from './place-shadow.controller';
|
||||
import { PlaceShadowRetentionJob } from './place-shadow.job';
|
||||
import { PlaceShadowService } from './place-shadow.service';
|
||||
import { PlaceShadowPicks } from '../../db/entities/PlaceShadowPicks.entity';
|
||||
import { AppSettings } from '../../db/entities/AppSettings.entity';
|
||||
|
||||
/**
|
||||
* Place shadow log. Registered in AppModule; exports the service so the admin
|
||||
* surface can read the summary without going through HTTP.
|
||||
*
|
||||
* MikroOrmModule.forFeature registers PlaceShadowPicksRepository/
|
||||
* AppSettingsRepository for PlaceShadowService's @InjectRepository
|
||||
* constructor (Plan 3c Task 1).
|
||||
*/
|
||||
@Module({
|
||||
imports: [SchedulingModule],
|
||||
imports: [SchedulingModule, MikroOrmModule.forFeature([PlaceShadowPicks, AppSettings])],
|
||||
controllers: [PlaceShadowController],
|
||||
providers: [PlaceShadowService, PlaceShadowRetentionJob],
|
||||
exports: [PlaceShadowService],
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import type {
|
||||
PlaceShadowExportResult,
|
||||
PlaceShadowPickRequest,
|
||||
PlaceShadowRow,
|
||||
PlaceShadowSummaryResult,
|
||||
} from '@trek/shared';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { PlaceShadowPicks } from '../../db/entities/PlaceShadowPicks.entity';
|
||||
import type { PlaceShadowPicksRepository, PlaceShadowPickRow } from '../../db/repositories/PlaceShadowPicks.repository';
|
||||
import { AppSettings } from '../../db/entities/AppSettings.entity';
|
||||
import type { AppSettingsRepository } from '../../db/repositories/AppSettings.repository';
|
||||
|
||||
/** Rows older than this are removed nightly. */
|
||||
export const RETENTION_DAYS = 180;
|
||||
@@ -25,23 +29,7 @@ function round(value: number, decimals = COORD_DECIMALS): number {
|
||||
return Math.round(value * f) / f;
|
||||
}
|
||||
|
||||
interface DbRow {
|
||||
id: number;
|
||||
created_at: string;
|
||||
query: string;
|
||||
lang: string | null;
|
||||
bias_lat: number | null;
|
||||
bias_lng: number | null;
|
||||
source: string;
|
||||
live_rank: number;
|
||||
live_count: number;
|
||||
picked_name: string;
|
||||
picked_lat: number;
|
||||
picked_lng: number;
|
||||
picked_place_id: string | null;
|
||||
}
|
||||
|
||||
function toRow(r: DbRow): PlaceShadowRow {
|
||||
function toRow(r: PlaceShadowPickRow): PlaceShadowRow {
|
||||
return {
|
||||
id: r.id,
|
||||
createdAt: r.created_at,
|
||||
@@ -72,13 +60,14 @@ function toRow(r: DbRow): PlaceShadowRow {
|
||||
*/
|
||||
@Injectable()
|
||||
export class PlaceShadowService {
|
||||
constructor(private readonly db: DatabaseService) {}
|
||||
constructor(
|
||||
@InjectRepository(PlaceShadowPicks) private readonly picks: PlaceShadowPicksRepository,
|
||||
@InjectRepository(AppSettings) private readonly appSettings: AppSettingsRepository,
|
||||
) {}
|
||||
|
||||
async enabled(): Promise<boolean> {
|
||||
const row = this.db.get<{ value: string }>(
|
||||
"SELECT value FROM app_settings WHERE key = 'place_shadow_enabled'",
|
||||
);
|
||||
return row?.value === 'true';
|
||||
const value = await this.appSettings.getValue('place_shadow_enabled');
|
||||
return value === 'true';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -94,23 +83,19 @@ export class PlaceShadowService {
|
||||
// corpus exists to produce, so the row is dropped instead.
|
||||
if (pick.liveRank >= pick.liveCount) return false;
|
||||
|
||||
this.db.run(
|
||||
`INSERT INTO place_shadow_picks
|
||||
(query, lang, bias_lat, bias_lng, source, live_rank, live_count,
|
||||
picked_name, picked_lat, picked_lng, picked_place_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
pick.query,
|
||||
pick.lang ?? null,
|
||||
pick.biasLat === undefined ? null : round(pick.biasLat),
|
||||
pick.biasLng === undefined ? null : round(pick.biasLng),
|
||||
pick.source,
|
||||
pick.liveRank,
|
||||
pick.liveCount,
|
||||
pick.pickedName,
|
||||
round(pick.pickedLat),
|
||||
round(pick.pickedLng),
|
||||
pick.pickedPlaceId ?? null,
|
||||
);
|
||||
await this.picks.insertPick({
|
||||
query: pick.query,
|
||||
lang: pick.lang ?? null,
|
||||
bias_lat: pick.biasLat === undefined ? null : round(pick.biasLat),
|
||||
bias_lng: pick.biasLng === undefined ? null : round(pick.biasLng),
|
||||
source: pick.source,
|
||||
live_rank: pick.liveRank,
|
||||
live_count: pick.liveCount,
|
||||
picked_name: pick.pickedName,
|
||||
picked_lat: round(pick.pickedLat),
|
||||
picked_lng: round(pick.pickedLng),
|
||||
picked_place_id: pick.pickedPlaceId ?? null,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -121,14 +106,7 @@ export class PlaceShadowService {
|
||||
*/
|
||||
async export(after?: number, limit = EXPORT_PAGE_SIZE): Promise<PlaceShadowExportResult> {
|
||||
const size = Math.min(Math.max(1, limit), EXPORT_PAGE_SIZE);
|
||||
const rows = this.db.all<DbRow>(
|
||||
`SELECT * FROM place_shadow_picks
|
||||
WHERE id > ?
|
||||
ORDER BY id
|
||||
LIMIT ?`,
|
||||
after ?? 0,
|
||||
size + 1,
|
||||
);
|
||||
const rows = await this.picks.page(after ?? 0, size);
|
||||
const page = rows.slice(0, size);
|
||||
return {
|
||||
version: 1,
|
||||
@@ -140,22 +118,15 @@ export class PlaceShadowService {
|
||||
|
||||
async summary(): Promise<PlaceShadowSummaryResult> {
|
||||
const enabled = await this.enabled();
|
||||
const totals = this.db.get<{ total: number; oldest: string | null; newest: string | null }>(
|
||||
'SELECT COUNT(*) AS total, MIN(created_at) AS oldest, MAX(created_at) AS newest FROM place_shadow_picks',
|
||||
);
|
||||
const total = totals?.total ?? 0;
|
||||
const totals = await this.picks.totals();
|
||||
const total = totals.total;
|
||||
|
||||
const bySource = this.db.all<{ source: string; count: number }>(
|
||||
`SELECT source, COUNT(*) AS count FROM place_shadow_picks
|
||||
GROUP BY source ORDER BY count DESC`,
|
||||
);
|
||||
const bySource = await this.picks.countBySource();
|
||||
|
||||
// Buckets rather than a mean: the question is "did the user find it near
|
||||
// the top", and an average rank is dragged around by the rare query that
|
||||
// scrolled to result 40.
|
||||
const ranks = this.db.all<{ live_rank: number; count: number }>(
|
||||
'SELECT live_rank, COUNT(*) AS count FROM place_shadow_picks GROUP BY live_rank',
|
||||
);
|
||||
const ranks = await this.picks.countByLiveRank();
|
||||
const counted = (test: (rank: number) => boolean): number =>
|
||||
ranks.filter(r => test(r.live_rank)).reduce((sum, r) => sum + r.count, 0);
|
||||
|
||||
@@ -165,8 +136,8 @@ export class PlaceShadowService {
|
||||
return {
|
||||
enabled,
|
||||
total,
|
||||
oldest: totals?.oldest ?? null,
|
||||
newest: totals?.newest ?? null,
|
||||
oldest: totals.oldest,
|
||||
newest: totals.newest,
|
||||
retentionDays: RETENTION_DAYS,
|
||||
bySource,
|
||||
liveRankBuckets: [
|
||||
@@ -182,15 +153,11 @@ export class PlaceShadowService {
|
||||
|
||||
/** Admin wipe. Returns how many rows went. */
|
||||
async clear(): Promise<number> {
|
||||
return this.db.run('DELETE FROM place_shadow_picks').changes;
|
||||
return this.picks.deleteAll();
|
||||
}
|
||||
|
||||
/** Nightly retention. Returns how many rows went. */
|
||||
async purgeExpired(retentionDays = RETENTION_DAYS): Promise<number> {
|
||||
return this.db.run(
|
||||
`DELETE FROM place_shadow_picks
|
||||
WHERE created_at < datetime('now', ?)`,
|
||||
`-${retentionDays} days`,
|
||||
).changes;
|
||||
return this.picks.purgeOlderThan(retentionDays);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MikroOrmModule } from '@mikro-orm/nestjs';
|
||||
import { QueryHelpersService } from './query-helpers.service';
|
||||
import { Tags } from '../../db/entities/Tags.entity';
|
||||
import { PlaceRatings } from '../../db/entities/PlaceRatings.entity';
|
||||
import { AssignmentParticipants } from '../../db/entities/AssignmentParticipants.entity';
|
||||
|
||||
/** Shared batch loaders for the list endpoints. No controller or MCP surface of
|
||||
* its own — assignments, days, places and share import it for the loaders.
|
||||
* Deliberately NOT @Global so e2e TestingModules resolve it transitively
|
||||
* through each consumer's explicit import, same as PermissionsModule. */
|
||||
* through each consumer's explicit import, same as PermissionsModule.
|
||||
*
|
||||
* MikroOrmModule.forFeature registers TagsRepository/PlaceRatingsRepository/
|
||||
* AssignmentParticipantsRepository for QueryHelpersService's
|
||||
* @InjectRepository constructor (Plan 3c Task 1). */
|
||||
@Module({
|
||||
imports: [MikroOrmModule.forFeature([Tags, PlaceRatings, AssignmentParticipants])],
|
||||
providers: [QueryHelpersService],
|
||||
exports: [QueryHelpersService],
|
||||
})
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { Tags } from '../../db/entities/Tags.entity';
|
||||
import type { TagsRepository, TagForPlaceRow } from '../../db/repositories/Tags.repository';
|
||||
import { PlaceRatings } from '../../db/entities/PlaceRatings.entity';
|
||||
import type { PlaceRatingsRepository } from '../../db/repositories/PlaceRatings.repository';
|
||||
import { AssignmentParticipants } from '../../db/entities/AssignmentParticipants.entity';
|
||||
import type { AssignmentParticipantsRepository } from '../../db/repositories/AssignmentParticipants.repository';
|
||||
import type { Tag, Participant } from '../../types';
|
||||
|
||||
interface TagRow extends Tag {
|
||||
place_id: number;
|
||||
}
|
||||
|
||||
interface ParticipantRow {
|
||||
assignment_id: number;
|
||||
user_id: number;
|
||||
username: string;
|
||||
avatar: string | null;
|
||||
}
|
||||
|
||||
export interface PlaceRatingRow {
|
||||
user_id: number;
|
||||
username: string;
|
||||
@@ -22,8 +17,11 @@ export interface PlaceRatingRow {
|
||||
|
||||
/**
|
||||
* The batch loaders that keep the list endpoints off N+1 queries — one query per
|
||||
* collection instead of one per row. Moved 1:1 from services/queryHelpers.ts
|
||||
* (same SQL, same indexing) onto the injected connection.
|
||||
* collection instead of one per row. Plan 3c Task 1: onto `TagsRepository`
|
||||
* (QH1), `PlaceRatingsRepository` (QH2, a Plan 3c-owned table per the
|
||||
* inventory §14.6) and `AssignmentParticipantsRepository` (QH3) — this
|
||||
* service's own first repository test file, since it had none before
|
||||
* (inventory §15c).
|
||||
*
|
||||
* Only the loaders live here. The two pure reshaping functions that shipped in
|
||||
* the same legacy file touch no database and stayed free functions in
|
||||
@@ -31,29 +29,21 @@ export interface PlaceRatingRow {
|
||||
*/
|
||||
@Injectable()
|
||||
export class QueryHelpersService {
|
||||
constructor(private readonly db: DatabaseService) {}
|
||||
constructor(
|
||||
@InjectRepository(Tags) private readonly tags: TagsRepository,
|
||||
@InjectRepository(PlaceRatings) private readonly placeRatings: PlaceRatingsRepository,
|
||||
@InjectRepository(AssignmentParticipants) private readonly assignmentParticipants: AssignmentParticipantsRepository,
|
||||
) {}
|
||||
|
||||
/** Batch-load tags for multiple places in a single query, indexed by place ID. */
|
||||
async loadTagsByPlaceIds(placeIds: number[], { compact }: { compact?: boolean } = {}): Promise<Record<number, Partial<Tag>[]>> {
|
||||
const tagsByPlaceId: Record<number, Partial<Tag>[]> = {};
|
||||
if (placeIds.length > 0) {
|
||||
const placeholders = placeIds.map(() => '?').join(',');
|
||||
const allTags = this.db.all<TagRow>(`
|
||||
SELECT t.*, pt.place_id FROM tags t
|
||||
JOIN place_tags pt ON t.id = pt.tag_id
|
||||
WHERE pt.place_id IN (${placeholders})
|
||||
`, ...placeIds);
|
||||
|
||||
for (const tag of allTags) {
|
||||
const pid = tag.place_id;
|
||||
if (!tagsByPlaceId[pid]) tagsByPlaceId[pid] = [];
|
||||
if (compact) {
|
||||
tagsByPlaceId[pid].push({ id: tag.id, name: tag.name, color: tag.color, created_at: tag.created_at });
|
||||
} else {
|
||||
const { place_id, ...rest } = tag;
|
||||
tagsByPlaceId[pid].push(rest);
|
||||
}
|
||||
}
|
||||
const rows = await this.tags.listForPlaces(placeIds, { compact });
|
||||
for (const tag of rows as TagForPlaceRow[]) {
|
||||
const pid = tag.place_id;
|
||||
if (!tagsByPlaceId[pid]) tagsByPlaceId[pid] = [];
|
||||
const { place_id, ...rest } = tag;
|
||||
tagsByPlaceId[pid].push(rest);
|
||||
}
|
||||
return tagsByPlaceId;
|
||||
}
|
||||
@@ -61,17 +51,10 @@ export class QueryHelpersService {
|
||||
/** Batch-load collaborative ratings (#1435) for multiple places in one query, indexed by place ID. */
|
||||
async loadRatingsByPlaceIds(placeIds: number[]): Promise<Record<number, PlaceRatingRow[]>> {
|
||||
const ratingsByPlaceId: Record<number, PlaceRatingRow[]> = {};
|
||||
if (placeIds.length > 0) {
|
||||
const rows = this.db.all<PlaceRatingRow & { place_id: number }>(`
|
||||
SELECT pr.place_id, pr.user_id, u.username, u.avatar, pr.rating FROM place_ratings pr
|
||||
JOIN users u ON pr.user_id = u.id
|
||||
WHERE pr.place_id IN (${placeIds.map(() => '?').join(',')})
|
||||
ORDER BY pr.created_at
|
||||
`, ...placeIds);
|
||||
for (const { place_id, ...rest } of rows) {
|
||||
if (!ratingsByPlaceId[place_id]) ratingsByPlaceId[place_id] = [];
|
||||
ratingsByPlaceId[place_id].push(rest);
|
||||
}
|
||||
const rows = await this.placeRatings.listForPlaces(placeIds);
|
||||
for (const { place_id, ...rest } of rows) {
|
||||
if (!ratingsByPlaceId[place_id]) ratingsByPlaceId[place_id] = [];
|
||||
ratingsByPlaceId[place_id].push(rest);
|
||||
}
|
||||
return ratingsByPlaceId;
|
||||
}
|
||||
@@ -79,15 +62,10 @@ export class QueryHelpersService {
|
||||
/** Batch-load participants for multiple day-assignments in a single query, indexed by assignment ID. */
|
||||
async loadParticipantsByAssignmentIds(assignmentIds: number[]): Promise<Record<number, Participant[]>> {
|
||||
const participantsByAssignment: Record<number, Participant[]> = {};
|
||||
if (assignmentIds.length > 0) {
|
||||
const allParticipants = this.db.all<ParticipantRow>(
|
||||
`SELECT ap.assignment_id, ap.user_id, u.username, u.avatar FROM assignment_participants ap JOIN users u ON ap.user_id = u.id WHERE ap.assignment_id IN (${assignmentIds.map(() => '?').join(',')})`,
|
||||
...assignmentIds,
|
||||
);
|
||||
for (const p of allParticipants) {
|
||||
if (!participantsByAssignment[p.assignment_id]) participantsByAssignment[p.assignment_id] = [];
|
||||
participantsByAssignment[p.assignment_id].push({ user_id: p.user_id, username: p.username, avatar: p.avatar });
|
||||
}
|
||||
const rows = await this.assignmentParticipants.listForAssignments(assignmentIds);
|
||||
for (const p of rows) {
|
||||
if (!participantsByAssignment[p.assignment_id]) participantsByAssignment[p.assignment_id] = [];
|
||||
participantsByAssignment[p.assignment_id].push({ user_id: p.user_id, username: p.username, avatar: p.avatar });
|
||||
}
|
||||
return participantsByAssignment;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MikroOrmModule } from '@mikro-orm/nestjs';
|
||||
import { TripMembershipService } from './trip-membership.service';
|
||||
import { Trips } from '../../db/entities/Trips.entity';
|
||||
import { TripMembers } from '../../db/entities/TripMembers.entity';
|
||||
|
||||
/** Trip membership joins. No controller of its own — the HTTP surface lives with
|
||||
* auth, oidc and trip-invite, which all import this for the join itself. Kept
|
||||
* separate so those three (plus the plugin host) share one implementation
|
||||
* without AuthModule and TripInviteModule importing each other. */
|
||||
* without AuthModule and TripInviteModule importing each other.
|
||||
*
|
||||
* MikroOrmModule.forFeature([Trips, TripMembers]) only — NO service import
|
||||
* (Plan 3c Task 1 ruling): five modules import THIS module, so a new service
|
||||
* import here would close a cycle. `EntityManager`/repository resolution
|
||||
* comes entirely from the ORM's own `@Global()` core module + this
|
||||
* `forFeature` registration, keeping this module a leaf. */
|
||||
@Module({
|
||||
imports: [MikroOrmModule.forFeature([Trips, TripMembers])],
|
||||
providers: [TripMembershipService],
|
||||
exports: [TripMembershipService],
|
||||
})
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { Trips } from '../../db/entities/Trips.entity';
|
||||
import type { TripsRepository } from '../../db/repositories/Trips.repository';
|
||||
import { TripMembers } from '../../db/entities/TripMembers.entity';
|
||||
import type { TripMembersRepository } from '../../db/repositories/TripMembers.repository';
|
||||
|
||||
/**
|
||||
* Adding an existing user to a trip as a member, plus the leaf membership
|
||||
@@ -11,24 +15,26 @@ import { DatabaseService } from '../database/database.service';
|
||||
* this trip" / "which trips can this user see", but every service that owns
|
||||
* the hydrated answer (TripsService, TripMembersService, TripReadModelService)
|
||||
* lives in a module that imports the budget domain, so injecting one there
|
||||
* closes a real cycle. This module imports nothing, so it is the one place
|
||||
* those id-level reads can live — the fold that deleted trips.bridge.
|
||||
* closes a real cycle. This module imports nothing but `TripsRepository`/
|
||||
* `TripMembersRepository` (Plan 3c Task 1: `MikroOrmModule.forFeature`, no
|
||||
* service import — see `trip-membership.module.ts`), so it stays the one
|
||||
* place those id-level reads can live — the fold that deleted trips.bridge.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TripMembershipService {
|
||||
constructor(private readonly db: DatabaseService) {}
|
||||
constructor(
|
||||
@InjectRepository(Trips) private readonly trips: TripsRepository,
|
||||
@InjectRepository(TripMembers) private readonly tripMembers: TripMembersRepository,
|
||||
) {}
|
||||
|
||||
/** The trip owner's user id, or null when the trip does not exist. */
|
||||
async getOwnerId(tripId: string | number): Promise<number | null> {
|
||||
const row = this.db.get<{ user_id: number }>('SELECT user_id FROM trips WHERE id = ?', tripId);
|
||||
return row ? row.user_id : null;
|
||||
return this.trips.getOwnerId(tripId);
|
||||
}
|
||||
|
||||
/** Member user ids (owner excluded), in added_at order like listMembers. */
|
||||
async listMemberUserIds(tripId: string | number): Promise<number[]> {
|
||||
return this.db
|
||||
.all<{ user_id: number }>('SELECT user_id FROM trip_members WHERE trip_id = ? ORDER BY added_at ASC', tripId)
|
||||
.map((r) => r.user_id);
|
||||
return this.tripMembers.listUserIdsByTrip(tripId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -36,15 +42,7 @@ export class TripMembershipService {
|
||||
* half of TripsService.list(userId, null), same WHERE and ORDER BY.
|
||||
*/
|
||||
async listAccessibleTripIds(userId: number): Promise<number[]> {
|
||||
return this.db
|
||||
.prepare(`
|
||||
SELECT t.id FROM trips t
|
||||
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = :userId
|
||||
WHERE (t.user_id = :userId OR m.user_id IS NOT NULL)
|
||||
ORDER BY t.created_at DESC
|
||||
`)
|
||||
.all({ userId })
|
||||
.map((r) => (r as { id: number }).id);
|
||||
return this.trips.listAccessibleIds(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -57,19 +55,23 @@ export class TripMembershipService {
|
||||
* belongs to a real (non-guest) account.
|
||||
*
|
||||
* Returns whether a new membership row was actually created.
|
||||
*
|
||||
* Non-transactional check-then-act, unchanged (Plan 3c inventory §18.4,
|
||||
* program rule 11): two concurrent joins can both pass the "already a
|
||||
* member" check and both insert. Pinned by a concurrency test, not fixed.
|
||||
*/
|
||||
async joinTripAsMember(
|
||||
tripId: number,
|
||||
userId: number,
|
||||
invitedBy: number | null,
|
||||
): Promise<{ joined: boolean; tripId: number }> {
|
||||
const trip = this.db.get<{ id: number; user_id: number }>('SELECT id, user_id FROM trips WHERE id = ?', tripId);
|
||||
const trip = await this.trips.findIdAndOwner(tripId);
|
||||
if (!trip) return { joined: false, tripId };
|
||||
// The owner already has full access; never add them as a member.
|
||||
if (trip.user_id === userId) return { joined: false, tripId };
|
||||
const existing = this.db.get('SELECT id FROM trip_members WHERE trip_id = ? AND user_id = ?', tripId, userId);
|
||||
const existing = await this.tripMembers.exists(tripId, userId);
|
||||
if (existing) return { joined: false, tripId };
|
||||
this.db.run('INSERT INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)', tripId, userId, invitedBy);
|
||||
await this.tripMembers.addMember(tripId, userId, invitedBy);
|
||||
return { joined: true, tripId };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,7 +90,11 @@ describe('Budget e2e (real auth guard + temp SQLite, real budget SQL)', () => {
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
canAccessTrip.mockReturnValue({ id: tripId, user_id: 1, currency: 'EUR' });
|
||||
// 0b review L2 / security review F-B7: `canAccessTrip` is dead mock
|
||||
// scaffolding — `TripAccessGuard` reads `TripsRepository.findAccessible`
|
||||
// directly now (Plan 3c Task 0b), so `db/database`'s `canAccessTrip`
|
||||
// property is never imported by production code; this line used to be a
|
||||
// no-op that read as if it still controlled access.
|
||||
checkPermission.mockReturnValue(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -111,7 +111,8 @@ describe('Files + photos e2e (real auth guard + temp SQLite)', () => {
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
canAccessTrip.mockReturnValue({ id: 5, user_id: 1 });
|
||||
// 0b review L2 / security review F-B7: dead mock scaffolding — see
|
||||
// budget.e2e.test.ts's identical comment.
|
||||
checkPermission.mockReturnValue(true);
|
||||
helperSvc.canAccessTrekPhoto.mockReturnValue(true);
|
||||
});
|
||||
|
||||
@@ -31,7 +31,11 @@ const { db } = vi.hoisted(() => {
|
||||
route_geometry TEXT, route_color TEXT, stop_type TEXT, fill_percent INTEGER,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP);`);
|
||||
tmp.exec(`CREATE TABLE categories (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, color TEXT, icon TEXT);`);
|
||||
tmp.exec(`CREATE TABLE tags (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, color TEXT,
|
||||
// Plan 3c Task 1: TagsRepository.listForPlaces (QH1) selects `user_id`
|
||||
// explicitly (the legacy statement's `t.*` tolerated a narrower fixture
|
||||
// schema; a named column list does not) — added here, matching the real
|
||||
// `tags` table (`Migration20200101000000_baseline_schema.ts`).
|
||||
tmp.exec(`CREATE TABLE tags (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, name TEXT, color TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`);
|
||||
tmp.exec(`CREATE TABLE place_tags (place_id INTEGER NOT NULL, tag_id INTEGER NOT NULL,
|
||||
PRIMARY KEY (place_id, tag_id));`);
|
||||
@@ -137,7 +141,6 @@ describe('Places e2e (real auth guard + temp SQLite)', () => {
|
||||
// tests below re-seed their own data-bearing row with `INSERT OR
|
||||
// REPLACE` over this default.
|
||||
db.prepare("INSERT INTO trips (id, title, user_id) VALUES (5, 'Trip', 1)").run();
|
||||
canAccessTrip.mockReturnValue({ id: 5, user_id: 1 });
|
||||
checkPermission.mockReturnValue(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -103,7 +103,8 @@ describe('Trip invite-link e2e (real auth guard + temp SQLite)', () => {
|
||||
beforeEach(() => {
|
||||
db.prepare('DELETE FROM trip_invite_tokens').run();
|
||||
db.prepare('DELETE FROM trips').run();
|
||||
canAccessTrip.mockReturnValue({ user_id: 1 });
|
||||
// 0b review L2 / security review F-B7: dead mock scaffolding — see
|
||||
// budget.e2e.test.ts's identical comment.
|
||||
checkPermission.mockReturnValue(true);
|
||||
joinTripAsMember.mockReset();
|
||||
});
|
||||
|
||||
@@ -75,12 +75,23 @@ const { db } = vi.hoisted(() => {
|
||||
needs_review INTEGER DEFAULT 0, day_plan_position REAL, external_source TEXT, sync_enabled INTEGER,
|
||||
ingest_state TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`);
|
||||
tmp.exec('CREATE TABLE days (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL, day_number INTEGER, date TEXT);');
|
||||
// Task 2 review M1: DaysRepository.listByTrip (a plain `em.find(Days)`)
|
||||
// needs this table to exist — Days.entity.ts declares an inverse 1:1 to
|
||||
// it (`roadtrip_day_tracks: () => p.oneToOne(RoadtripDayTracks).ref()
|
||||
// .mappedBy('day')`), so MikroORM's find() resolves the relation's
|
||||
// presence against the real table even though the property is hidden.
|
||||
// Columns match the real migration
|
||||
// (`Migration20200101032900_which_imported_track_a_day_s_drive.ts`).
|
||||
tmp.exec(`CREATE TABLE roadtrip_day_tracks (day_id INTEGER PRIMARY KEY, place_id INTEGER NOT NULL,
|
||||
stray_km REAL, created_at TEXT DEFAULT CURRENT_TIMESTAMP);`);
|
||||
tmp.exec(`CREATE TABLE places (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL, name TEXT,
|
||||
image_url TEXT, address TEXT, lat REAL, lng REAL, category_id INTEGER, description TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`);
|
||||
// PlacesService.list joins categories and batch-loads tags/ratings.
|
||||
tmp.exec('CREATE TABLE categories (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, color TEXT, icon TEXT);');
|
||||
tmp.exec('CREATE TABLE tags (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, color TEXT, created_at DATETIME);');
|
||||
// Plan 3c Task 1: TagsRepository.listForPlaces (QH1) selects `user_id`
|
||||
// explicitly — see places.e2e.test.ts's identical comment.
|
||||
tmp.exec('CREATE TABLE tags (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, name TEXT, color TEXT, created_at DATETIME);');
|
||||
tmp.exec('CREATE TABLE place_tags (place_id INTEGER NOT NULL, tag_id INTEGER NOT NULL, PRIMARY KEY (place_id, tag_id));');
|
||||
tmp.exec('CREATE TABLE place_ratings (place_id INTEGER NOT NULL, user_id INTEGER NOT NULL, rating INTEGER, created_at DATETIME, UNIQUE(place_id, user_id));');
|
||||
tmp.exec(`CREATE TABLE day_accommodations (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL,
|
||||
@@ -162,7 +173,8 @@ describe('Trips e2e (real auth guard + temp SQLite)', () => {
|
||||
db.prepare('DELETE FROM trip_members').run();
|
||||
db.prepare('DELETE FROM days').run();
|
||||
db.prepare('DELETE FROM audit_log').run();
|
||||
canAccessTrip.mockReturnValue({ user_id: 1 });
|
||||
// 0b review L2 / security review F-B7: dead mock scaffolding — see
|
||||
// budget.e2e.test.ts's identical comment.
|
||||
checkPermission.mockReturnValue(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -37,6 +37,16 @@ import { DayAssignments } from '../../src/db/entities/DayAssignments.entity';
|
||||
import type { DayAssignmentsRepository } from '../../src/db/repositories/DayAssignments.repository';
|
||||
import { DayNotes } from '../../src/db/entities/DayNotes.entity';
|
||||
import type { DayNotesRepository } from '../../src/db/repositories/DayNotes.repository';
|
||||
import { PlaceRatings } from '../../src/db/entities/PlaceRatings.entity';
|
||||
import type { PlaceRatingsRepository } from '../../src/db/repositories/PlaceRatings.repository';
|
||||
import { AssignmentParticipants } from '../../src/db/entities/AssignmentParticipants.entity';
|
||||
import type { AssignmentParticipantsRepository } from '../../src/db/repositories/AssignmentParticipants.repository';
|
||||
import { PlaceShadowPicks } from '../../src/db/entities/PlaceShadowPicks.entity';
|
||||
import type { PlaceShadowPicksRepository } from '../../src/db/repositories/PlaceShadowPicks.repository';
|
||||
import { GooglePlacePhotoMeta } from '../../src/db/entities/GooglePlacePhotoMeta.entity';
|
||||
import type { GooglePlacePhotoMetaRepository } from '../../src/db/repositories/GooglePlacePhotoMeta.repository';
|
||||
import { PlaceDetailsCache } from '../../src/db/entities/PlaceDetailsCache.entity';
|
||||
import type { PlaceDetailsCacheRepository } from '../../src/db/repositories/PlaceDetailsCache.repository';
|
||||
|
||||
const perHandle = new WeakMap<Database.Database, Promise<UnitOfWork>>();
|
||||
const appSettingsPerHandle = new WeakMap<Database.Database, Promise<AppSettingsRepository>>();
|
||||
@@ -386,3 +396,66 @@ export function createTestDayNotesRepo(db: Database.Database): Promise<DayNotesR
|
||||
dayNotesRepoPerHandle.set(db, pending);
|
||||
return pending;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plan 3c Task 1 (`QueryHelpersService`/`TripMembershipService`/
|
||||
// `PlaceShadowService`/`PlaceEnrichmentService`/`PlacePhotoCacheService` onto
|
||||
// repositories) — appended at the end per the task's own file-ownership rule
|
||||
// (append only). Same memoisation-per-handle pattern as every helper above.
|
||||
// `TagsRepository` already has `createTestTagsRepo` (Plan 3a Task 3) — a
|
||||
// hand-constructed `QueryHelpersService` reuses that one rather than a second
|
||||
// copy. `TripsRepository`/`TripMembersRepository` already have
|
||||
// `createTestTripsRepo`/`createTestTripMembersRepo` (Plan 3c Task 0b) — a
|
||||
// hand-constructed `TripMembershipService` reuses those too.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const placeRatingsRepoPerHandle = new WeakMap<Database.Database, Promise<PlaceRatingsRepository>>();
|
||||
const assignmentParticipantsRepoPerHandle = new WeakMap<Database.Database, Promise<AssignmentParticipantsRepository>>();
|
||||
const placeShadowPicksRepoPerHandle = new WeakMap<Database.Database, Promise<PlaceShadowPicksRepository>>();
|
||||
const googlePlacePhotoMetaRepoPerHandle = new WeakMap<Database.Database, Promise<GooglePlacePhotoMetaRepository>>();
|
||||
const placeDetailsCacheRepoPerHandle = new WeakMap<Database.Database, Promise<PlaceDetailsCacheRepository>>();
|
||||
|
||||
/** The `PlaceRatingsRepository` a hand-constructed `QueryHelpersService` needs (QH2). */
|
||||
export function createTestPlaceRatingsRepo(db: Database.Database): Promise<PlaceRatingsRepository> {
|
||||
const existing = placeRatingsRepoPerHandle.get(db);
|
||||
if (existing !== undefined) return existing;
|
||||
const pending = sharedTestOrm(db).then((t) => t.repo(PlaceRatings));
|
||||
placeRatingsRepoPerHandle.set(db, pending);
|
||||
return pending;
|
||||
}
|
||||
|
||||
/** The `AssignmentParticipantsRepository` a hand-constructed `QueryHelpersService` needs (QH3). */
|
||||
export function createTestAssignmentParticipantsRepo(db: Database.Database): Promise<AssignmentParticipantsRepository> {
|
||||
const existing = assignmentParticipantsRepoPerHandle.get(db);
|
||||
if (existing !== undefined) return existing;
|
||||
const pending = sharedTestOrm(db).then((t) => t.repo(AssignmentParticipants));
|
||||
assignmentParticipantsRepoPerHandle.set(db, pending);
|
||||
return pending;
|
||||
}
|
||||
|
||||
/** The `PlaceShadowPicksRepository` a hand-constructed `PlaceShadowService` needs. */
|
||||
export function createTestPlaceShadowPicksRepo(db: Database.Database): Promise<PlaceShadowPicksRepository> {
|
||||
const existing = placeShadowPicksRepoPerHandle.get(db);
|
||||
if (existing !== undefined) return existing;
|
||||
const pending = sharedTestOrm(db).then((t) => t.repo(PlaceShadowPicks));
|
||||
placeShadowPicksRepoPerHandle.set(db, pending);
|
||||
return pending;
|
||||
}
|
||||
|
||||
/** The `GooglePlacePhotoMetaRepository` a hand-constructed `PlacePhotoCacheService` needs. */
|
||||
export function createTestGooglePlacePhotoMetaRepo(db: Database.Database): Promise<GooglePlacePhotoMetaRepository> {
|
||||
const existing = googlePlacePhotoMetaRepoPerHandle.get(db);
|
||||
if (existing !== undefined) return existing;
|
||||
const pending = sharedTestOrm(db).then((t) => t.repo(GooglePlacePhotoMeta));
|
||||
googlePlacePhotoMetaRepoPerHandle.set(db, pending);
|
||||
return pending;
|
||||
}
|
||||
|
||||
/** The `PlaceDetailsCacheRepository` a hand-constructed `PlaceEnrichmentService` needs. */
|
||||
export function createTestPlaceDetailsCacheRepo(db: Database.Database): Promise<PlaceDetailsCacheRepository> {
|
||||
const existing = placeDetailsCacheRepoPerHandle.get(db);
|
||||
if (existing !== undefined) return existing;
|
||||
const pending = sharedTestOrm(db).then((t) => t.repo(PlaceDetailsCache));
|
||||
placeDetailsCacheRepoPerHandle.set(db, pending);
|
||||
return pending;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import { QueryHelpersService } from '../../src/nest/query-helpers/query-helpers.
|
||||
import {
|
||||
createTestUnitOfWork, createTestAppSettingsRepo,
|
||||
createTestDaysRepo, createTestDayAssignmentsRepo, createTestDayNotesRepo, createTestTripsRepo,
|
||||
createTestTagsRepo, createTestPlaceRatingsRepo, createTestAssignmentParticipantsRepo,
|
||||
} from '../helpers/test-uow';
|
||||
|
||||
let svc: DaysService;
|
||||
@@ -36,7 +37,7 @@ beforeAll(async () => {
|
||||
new DatabaseService(testDb),
|
||||
new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)),
|
||||
new RealtimeService(),
|
||||
new QueryHelpersService(new DatabaseService(testDb)),
|
||||
new QueryHelpersService(await createTestTagsRepo(testDb), await createTestPlaceRatingsRepo(testDb), await createTestAssignmentParticipantsRepo(testDb)),
|
||||
await createTestUnitOfWork(testDb),
|
||||
await createTestDaysRepo(testDb),
|
||||
await createTestDayAssignmentsRepo(testDb),
|
||||
|
||||
@@ -16,14 +16,16 @@
|
||||
* conversion never meets a second C1 (task-6-review-parity.md's boot-sweep
|
||||
* finding).
|
||||
*
|
||||
* Deliberately does NOT `vi.mock('../../src/db/database', ...)` the way
|
||||
* most integration suites do: `buildDbMock`'s `runDemoSeed` is a
|
||||
* deliberate no-op stub (tests/helpers/db-mock.ts) so that suite's app
|
||||
* boots don't carry demo-seed side effects — which would silence the very
|
||||
* thing this file exists to exercise. `NODE_ENV=test` already gives
|
||||
* `db/database.ts`'s own singleton an isolated, per-worker `:memory:` copy
|
||||
* of the migrated schema snapshot (`readSchemaSnapshot()`), so no mock is
|
||||
* needed here at all.
|
||||
* Does NOT use `buildDbMock` the way most integration suites do — its
|
||||
* `runDemoSeed` is a deliberate no-op stub (tests/helpers/db-mock.ts) so
|
||||
* that suite's app boots don't carry demo-seed side effects, which would
|
||||
* silence the very thing this file exists to exercise. `NODE_ENV=test`
|
||||
* already gives `db/database.ts`'s own singleton an isolated, per-worker
|
||||
* `:memory:` copy of the migrated schema snapshot (`readSchemaSnapshot()`),
|
||||
* so nothing needs stubbing for the boot itself — the `vi.mock` below (Task
|
||||
* 0b review M2) exists only to wrap `runDemoSeed` with a context-recording
|
||||
* spy, not to replace its behaviour: `importOriginal` re-exports everything
|
||||
* else from the real module unchanged.
|
||||
*
|
||||
* `seedDemoData`'s own `require('../demo/demo-seed')` (a deliberate dynamic
|
||||
* require — see that file's docstring) cannot resolve under vitest's SWC
|
||||
@@ -41,6 +43,43 @@
|
||||
*/
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import { RequestContext } from '@mikro-orm/core';
|
||||
|
||||
/**
|
||||
* Task 0b review M2: the pre-fix version of this file only asserted "no
|
||||
* `cannotUseGlobalContext`/`global EntityManager` line reached the log",
|
||||
* which cannot distinguish a wrapped call from an unwrapped one — `runDemoSeed`
|
||||
* is raw `better-sqlite3` and never touches the ORM, so it produces neither
|
||||
* line either way (verified: deleting the `withRequestContext` wrap in
|
||||
* `db/orm.ts:59` left this ratchet green, 1/1). Fixed by asserting the
|
||||
* context POSITIVELY: a thin spy around the real `runDemoSeed`, preserving
|
||||
* everything else `../../src/db/database` exports (`importOriginal`),
|
||||
* records whether `RequestContext.currentRequestContext()` is defined at the
|
||||
* instant `runDemoSeed` is entered — this is exactly the ratchet MikroORM's
|
||||
* own `allowGlobalContext: false` gate reads at query time, so it is the
|
||||
* real signal, not a log-line proxy for it. Deleting the `withRequestContext`
|
||||
* wrap now fails this test directly (verified).
|
||||
*/
|
||||
// `vi.hoisted`, not a plain top-level `const`: `vi.mock`'s factory is
|
||||
// invoked when something up the import chain first resolves
|
||||
// `../../src/db/database` (during `buildApp`'s own module graph, not at
|
||||
// this file's textual position), and vitest hoists the `vi.mock` call
|
||||
// itself above every import — a plain `const` here would still be in its
|
||||
// temporal dead zone when the factory closes over it. Same hazard the
|
||||
// legacy `trip-membership.service.test.ts` guarded against with its own
|
||||
// `vi.hoisted(() => ({ testDb, dbMock }))`.
|
||||
const { contextSeenOnEntry } = vi.hoisted(() => ({ contextSeenOnEntry: [] as boolean[] }));
|
||||
vi.mock('../../src/db/database', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../src/db/database')>();
|
||||
return {
|
||||
...actual,
|
||||
runDemoSeed: (): void => {
|
||||
contextSeenOnEntry.push(RequestContext.currentRequestContext() !== undefined);
|
||||
return actual.runDemoSeed();
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
|
||||
describe('runDemoSeed runs inside a request context (Plan 3c Task 0b)', () => {
|
||||
@@ -50,6 +89,7 @@ describe('runDemoSeed runs inside a request context (Plan 3c Task 0b)', () => {
|
||||
afterEach(async () => {
|
||||
await app?.close();
|
||||
app = undefined;
|
||||
contextSeenOnEntry.length = 0;
|
||||
if (prevDemo === undefined) delete process.env.DEMO_MODE;
|
||||
else process.env.DEMO_MODE = prevDemo;
|
||||
});
|
||||
@@ -69,6 +109,12 @@ describe('runDemoSeed runs inside a request context (Plan 3c Task 0b)', () => {
|
||||
// The one thing this task could regress: a missing request context.
|
||||
const suspicious = demoLines.filter((line) => /cannotUseGlobalContext|global EntityManager/i.test(line));
|
||||
expect(suspicious).toEqual([]);
|
||||
|
||||
// DEMO-SEED-002 (0b review M2): the positive ratchet. runDemoSeed was
|
||||
// actually entered (the spy fired) AND every entry saw a defined
|
||||
// request context.
|
||||
expect(contextSeenOnEntry.length).toBeGreaterThan(0);
|
||||
expect(contextSeenOnEntry.every(Boolean)).toBe(true);
|
||||
} finally {
|
||||
errSpy.mockRestore();
|
||||
}
|
||||
|
||||
@@ -147,7 +147,17 @@ describe('ORM request-context seams populated in production', () => {
|
||||
* per-job pattern for a register-only job.
|
||||
*/
|
||||
it('SEAM-003: PlaceShadowRetentionJob registers against the real CronRegistrarService, and firing its tick runs inside the same request context with no missing-context error', async () => {
|
||||
const orm = app.get(MikroORM);
|
||||
const registrar = app.get(CronRegistrarService);
|
||||
// 0b security review F-B4: a structural identity proof, matching
|
||||
// SEAM-001/002's shape, not just the behavioural one below — a future
|
||||
// refactor that quietly drops CronRegistrarService's ORM constructor arg
|
||||
// (but leaves SOME orm-shaped stub wired) would still pass the
|
||||
// behavioural half if that stub happened to work; this catches it
|
||||
// directly, the same way SEAM-001 already does for the OTHER three
|
||||
// choke points.
|
||||
expect((registrar as unknown as { orm?: MikroORM }).orm).toBe(orm);
|
||||
|
||||
const isEnabledSpy = vi.spyOn(registrar, 'isEnabled').mockReturnValue(true);
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
try {
|
||||
@@ -156,15 +166,39 @@ describe('ORM request-context seams populated in production', () => {
|
||||
VALUES (datetime('now', '-999 days'), 'seam-003', 'nominatim', 1, 1, 'expired pick', 0, 0)
|
||||
`).run();
|
||||
|
||||
app.get(PlaceShadowRetentionJob).onApplicationBootstrap();
|
||||
// Real bug found converting `purgeExpired` to a genuinely async
|
||||
// repository call (0b security review F-B4): the `cron` package's
|
||||
// `CronJob.fireOnTick()` only awaits its callback when the job is
|
||||
// constructed with `waitForCompletion: true`
|
||||
// (`node_modules/cron/dist/job.js`'s `fireOnTick`) — `cron-registrar
|
||||
// .service.ts`'s `CronJob.from({...})` never sets that option, so
|
||||
// `await job.fireOnTick()` resolves as soon as the SYNCHRONOUS part of
|
||||
// `wrappedTick` returns, before the awaited `withRequestContext(orm,
|
||||
// () => onTick())` promise (and therefore the repository delete
|
||||
// inside it) has actually settled. Verified directly: without the
|
||||
// spy below, `remaining.n` read `1`, not `0` — a race, not a context
|
||||
// failure (the previous, all-synchronous `better-sqlite3` DELETE never
|
||||
// exposed this gap). Spying on the job's own `tick()` and awaiting the
|
||||
// promise IT returns is the fix — the same promise `wrappedTick`
|
||||
// itself awaits, just observed from outside.
|
||||
const jobInstance = app.get(PlaceShadowRetentionJob);
|
||||
const tickSpy = vi.spyOn(jobInstance, 'tick');
|
||||
jobInstance.onApplicationBootstrap();
|
||||
const job = app.get(SchedulerRegistry).getCronJob('place-shadow-retention');
|
||||
await job.fireOnTick();
|
||||
await tickSpy.mock.results[0]?.value;
|
||||
|
||||
const suspicious = errSpy.mock.calls
|
||||
.map((args) => args.map(String).join(' '))
|
||||
.filter((line) => /cannotUseGlobalContext|global EntityManager/i.test(line));
|
||||
expect(suspicious).toEqual([]);
|
||||
|
||||
// Plan 3c Task 1: `purgeExpired` is now `PlaceShadowPicksRepository`-backed
|
||||
// (a real ORM call, not raw `better-sqlite3`), so this outcome is now a
|
||||
// load-bearing mutation ratchet on its own — removing the
|
||||
// `withRequestContext` wrap in `cron-registrar.service.ts`'s `register()`
|
||||
// makes the repository call throw inside `PlaceShadowRetentionJob.tick`'s
|
||||
// own try/catch, and the row survives (verified by hand, reverted).
|
||||
const remaining = testDb.prepare("SELECT COUNT(*) as n FROM place_shadow_picks WHERE query = 'seam-003'").get() as { n: number };
|
||||
expect(remaining.n).toBe(0);
|
||||
} finally {
|
||||
|
||||
@@ -34,6 +34,9 @@ import { LocalDriver } from '../../src/nest/storage/drivers/local.driver';
|
||||
import { StorageService } from '../../src/nest/storage/storage.service';
|
||||
import type { StorageRegistryService, ResolvedCategory } from '../../src/nest/storage/storage-registry.service';
|
||||
import { DEFAULT_UPLOADS_ROOT, GLOBAL_TEMP_DIR } from '../../src/nest/storage/storage-paths';
|
||||
import { createTestOrm } from '../helpers/test-orm';
|
||||
import { GooglePlacePhotoMeta } from '../../src/db/entities/GooglePlacePhotoMeta.entity';
|
||||
import { Places } from '../../src/db/entities/Places.entity';
|
||||
|
||||
// A real instance over the same connection the app uses — these cases write a
|
||||
// cache entry and then read it back through the HTTP route, so the stub
|
||||
@@ -46,7 +49,12 @@ const testStorage = new StorageService({
|
||||
tempDir: () => GLOBAL_TEMP_DIR,
|
||||
replicaFailures: () => [],
|
||||
} as unknown as StorageRegistryService);
|
||||
const placePhotoCache = new PlacePhotoCacheService(new DatabaseService(sharedDb), testStorage);
|
||||
// Plan 3c Task 1: PlacePhotoCacheService now needs two repositories — a
|
||||
// second, throwaway MikroORM bound to the SAME `sharedDb` connection
|
||||
// (`allowGlobalContext: true`, the same shape `place-photo-cache.service
|
||||
// .test.ts` uses), since this helper's two call sites below run outside any
|
||||
// HTTP request the app's own `withRequestContext` would wrap.
|
||||
let placePhotoCache: PlacePhotoCacheService;
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
@@ -57,6 +65,8 @@ let app: Application;
|
||||
beforeAll(async () => {
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
const t = await createTestOrm(sharedDb, { allowGlobalContext: true });
|
||||
placePhotoCache = new PlacePhotoCacheService(new DatabaseService(sharedDb), testStorage, t.repo(GooglePlacePhotoMeta), t.repo(Places));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -11,11 +11,15 @@ import {
|
||||
coalesceParam,
|
||||
columnIncrementedBy,
|
||||
columnRef,
|
||||
countAll,
|
||||
currentTimestamp,
|
||||
dateAdd,
|
||||
dateOf,
|
||||
lower,
|
||||
lowerParam,
|
||||
lowerTrim,
|
||||
maxOf,
|
||||
minOf,
|
||||
nowMinusDays,
|
||||
trim,
|
||||
} from '../../../../src/db/dialect/sql-functions';
|
||||
@@ -326,4 +330,102 @@ describe('sql-functions (sqlite)', () => {
|
||||
expect(() => coalesceParam(foreign, 'u.a', 'x')).toThrow(/no implementation for platform FakePlatform/);
|
||||
expect(() => absDifference(foreign, 'u.lat', 1)).toThrow(/no implementation for platform FakePlatform/);
|
||||
});
|
||||
|
||||
// Plan 3c Task 1 (§17a: COUNT(*)/MIN/MAX aggregates — `PlaceShadowPicksRepository
|
||||
// .totals`/`countBySource`/`countByLiveRank`). ESLint bans `raw()` inside
|
||||
// src/db/repositories/**, so these three aliased-aggregate helpers exist
|
||||
// for repositories that need a COUNT/MIN/MAX in a SELECT projection.
|
||||
|
||||
it('SQLF-023: countAll/minOf/maxOf render one aliased aggregate row, matching a hand-written statement', async () => {
|
||||
const { user: a } = createUser(testDb);
|
||||
testDb.prepare("UPDATE users SET created_at = '2026-01-01 00:00:00' WHERE id = ?").run(a.id);
|
||||
const { user: b } = createUser(testDb);
|
||||
testDb.prepare("UPDATE users SET created_at = '2026-06-15 00:00:00' WHERE id = ?").run(b.id);
|
||||
const platform = t.em.getPlatform();
|
||||
|
||||
const row = await t.em.createQueryBuilder(Users, 'u')
|
||||
.select([countAll(platform, 'total'), minOf(platform, 'u.created_at', 'oldest'), maxOf(platform, 'u.created_at', 'newest')])
|
||||
.where({ id: { $in: [a.id, b.id] } })
|
||||
.execute('get', false);
|
||||
expect(row).toEqual({ total: 2, oldest: '2026-01-01 00:00:00', newest: '2026-06-15 00:00:00' });
|
||||
|
||||
const expected = testDb
|
||||
.prepare('SELECT COUNT(*) as total, MIN(created_at) as oldest, MAX(created_at) as newest FROM users WHERE id IN (?, ?)')
|
||||
.get(a.id, b.id);
|
||||
expect(row).toEqual(expected);
|
||||
});
|
||||
|
||||
// No `.orderBy({ count: 'desc' })` here: MikroORM's QueryBuilder cannot
|
||||
// order by an ad-hoc raw-fragment alias (verified directly — it throws
|
||||
// "Trying to query by not existing property" at runtime, since `raw()`'s
|
||||
// alias carries no literal type for `ExtractRawAliases` to register). The
|
||||
// repository callers that need "ORDER BY <aliased count> DESC"
|
||||
// (`PlaceShadowPicksRepository.countBySource`) sort the fetched rows in JS
|
||||
// instead — `Array.prototype.sort` is stable (ES2019+), so the resulting
|
||||
// order matches a real `ORDER BY count DESC` byte for byte.
|
||||
it('SQLF-024: countAll composes with GROUP BY, matching a hand-written GROUP BY statement', async () => {
|
||||
createUser(testDb, { role: 'admin' });
|
||||
createUser(testDb, { role: 'admin' });
|
||||
createUser(testDb, { role: 'user' });
|
||||
const platform = t.em.getPlatform();
|
||||
|
||||
const rows = await t.em.createQueryBuilder(Users, 'u')
|
||||
.select(['u.role', countAll(platform, 'count')])
|
||||
.groupBy('u.role')
|
||||
.execute('all', false);
|
||||
|
||||
const expected = testDb.prepare('SELECT role, COUNT(*) as count FROM users GROUP BY role').all();
|
||||
expect([...(rows as { role: string; count: number }[])].sort((a, b) => a.role.localeCompare(b.role)))
|
||||
.toEqual([...(expected as { role: string; count: number }[])].sort((a, b) => a.role.localeCompare(b.role)));
|
||||
});
|
||||
|
||||
it('SQLF-025: countAll/minOf/maxOf reject an alias that is not a plain identifier', () => {
|
||||
const platform = t.em.getPlatform();
|
||||
expect(() => countAll(platform, 'x; DROP TABLE users; --')).toThrow(/not an alias/);
|
||||
expect(() => minOf(platform, 'u.created_at', '1bad')).toThrow(/not an alias/);
|
||||
expect(() => maxOf(platform, 'u.created_at', '')).toThrow(/not an alias/);
|
||||
});
|
||||
|
||||
it('SQLF-026: an unknown platform fails closed for countAll/minOf/maxOf', () => {
|
||||
class FakePlatform extends Platform {}
|
||||
const foreign = new FakePlatform();
|
||||
expect(() => countAll(foreign, 'total')).toThrow(/no implementation for platform FakePlatform/);
|
||||
expect(() => minOf(foreign, 'u.created_at', 'oldest')).toThrow(/no implementation for platform FakePlatform/);
|
||||
expect(() => maxOf(foreign, 'u.created_at', 'newest')).toThrow(/no implementation for platform FakePlatform/);
|
||||
});
|
||||
|
||||
// Task 0b review M3 (blocks Task 4's PL26, places.service.ts:642
|
||||
// `lower(trim(name))`): `lower()`/`trim()` cannot compose to build
|
||||
// `LOWER(TRIM(name))` — `lowerTrim()` is the one fragment for that shape.
|
||||
it('SQLF-027: lowerTrim renders LOWER(TRIM(<col>)) as one fragment, matching a hand-written statement', async () => {
|
||||
const { user } = createUser(testDb, { username: ' Padded Name ' });
|
||||
const platform = t.em.getPlatform();
|
||||
const row = await t.em.createQueryBuilder(Users, 'u')
|
||||
.select([lowerTrim(platform, 'u.username').as('n')])
|
||||
.where({ id: user.id })
|
||||
.execute('get', false);
|
||||
expect((row as { n: string }).n).toBe('padded name');
|
||||
|
||||
const expected = testDb.prepare('SELECT LOWER(TRIM(username)) as n FROM users WHERE id = ?').get(user.id) as { n: string };
|
||||
expect((row as { n: string }).n).toBe(expected.n);
|
||||
});
|
||||
|
||||
it('SQLF-028: lowerTrim is usable as a find() filter key, and SQLite LOWER() stays ASCII-only through the TRIM', async () => {
|
||||
const { user } = createUser(testDb, { username: ' JOSÉ ' });
|
||||
const platform = t.em.getPlatform();
|
||||
const rows = await t.em.find(Users, { [lowerTrim(platform, 'username')]: 'josé'.toLowerCase() });
|
||||
// toLowerCase() is full-Unicode ('é' stays 'é'), but SQLite's LOWER() is
|
||||
// ASCII-only and leaves 'É' untouched — so the JS-lowered bind does NOT
|
||||
// match the SQL-lowered, SQL-trimmed column (program rule 18: mixing
|
||||
// engines is the bug, not the fix).
|
||||
expect(rows).toEqual([]);
|
||||
const matching = await t.em.find(Users, { [lowerTrim(platform, 'username')]: 'josÉ' });
|
||||
expect(matching.map((r) => r.id)).toEqual([user.id]);
|
||||
});
|
||||
|
||||
it('SQLF-029: an unknown platform fails closed for lowerTrim', () => {
|
||||
class FakePlatform extends Platform {}
|
||||
const foreign = new FakePlatform();
|
||||
expect(() => lowerTrim(foreign, 'u.name')).toThrow(/no implementation for platform FakePlatform/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* GooglePlacePhotoMetaRepository (Plan 3c Task 1, PP1–PP5/PP7/PP8): every
|
||||
* statement `PlacePhotoCacheService` used to issue raw, on real rows.
|
||||
* `place_id` is a TEXT key — pseudo-ids like `coords:lat:lng` are legal
|
||||
* values, exercised below alongside real Google place ids.
|
||||
*/
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createSnapshotTestDb } from '../../../helpers/db-mock';
|
||||
import { resetTestDb } from '../../../helpers/test-db';
|
||||
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
|
||||
import { GooglePlacePhotoMeta } from '../../../../src/db/entities/GooglePlacePhotoMeta.entity';
|
||||
import type { GooglePlacePhotoMetaRepository } from '../../../../src/db/repositories/GooglePlacePhotoMeta.repository';
|
||||
|
||||
const testDb = createSnapshotTestDb();
|
||||
let t: TestOrm;
|
||||
let meta: GooglePlacePhotoMetaRepository;
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestOrm(testDb);
|
||||
meta = t.repo(GooglePlacePhotoMeta);
|
||||
});
|
||||
beforeEach(() => { resetTestDb(testDb); t.clear(); });
|
||||
afterAll(async () => { await t.close(); testDb.close(); });
|
||||
|
||||
function rawRow(placeId: string): unknown {
|
||||
return testDb.prepare('SELECT * FROM google_place_photo_meta WHERE place_id = ?').get(placeId);
|
||||
}
|
||||
|
||||
describe('GooglePlacePhotoMetaRepository.findLive / findErrored', () => {
|
||||
it('GPPMREPO-001: findLive returns the attribution only when error_at is NULL', async () => {
|
||||
testDb.prepare('INSERT INTO google_place_photo_meta (place_id, attribution, fetched_at, error_at) VALUES (?, ?, ?, NULL)').run('p1', 'Some Author', 1000);
|
||||
expect(await meta.findLive('p1')).toEqual({ attribution: 'Some Author' });
|
||||
});
|
||||
|
||||
it('GPPMREPO-002: findLive returns null when error_at is set, even with an attribution present', async () => {
|
||||
testDb.prepare('INSERT INTO google_place_photo_meta (place_id, attribution, fetched_at, error_at) VALUES (?, NULL, ?, ?)').run('p1', 1000, 2000);
|
||||
expect(await meta.findLive('p1')).toBeNull();
|
||||
});
|
||||
|
||||
it('GPPMREPO-003: findLive returns null for a missing row', async () => {
|
||||
expect(await meta.findLive('missing')).toBeNull();
|
||||
});
|
||||
|
||||
it('GPPMREPO-004: works with coords:lat:lng pseudo-ids (TEXT key, colons and dots included)', async () => {
|
||||
const pseudoId = 'coords:48.8566:2.3522';
|
||||
testDb.prepare('INSERT INTO google_place_photo_meta (place_id, attribution, fetched_at, error_at) VALUES (?, NULL, ?, NULL)').run(pseudoId, 1000);
|
||||
expect(await meta.findLive(pseudoId)).toEqual({ attribution: null });
|
||||
});
|
||||
|
||||
it('GPPMREPO-005: findErrored returns {error_at} only when error_at IS NOT NULL', async () => {
|
||||
testDb.prepare('INSERT INTO google_place_photo_meta (place_id, attribution, fetched_at, error_at) VALUES (?, NULL, ?, ?)').run('p1', 1000, 5000);
|
||||
expect(await meta.findErrored('p1')).toEqual({ error_at: 5000 });
|
||||
});
|
||||
|
||||
it('GPPMREPO-006: findErrored returns null when error_at is NULL', async () => {
|
||||
testDb.prepare('INSERT INTO google_place_photo_meta (place_id, attribution, fetched_at, error_at) VALUES (?, ?, ?, NULL)').run('p1', 'A', 1000);
|
||||
expect(await meta.findErrored('p1')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GooglePlacePhotoMetaRepository.deleteByPlaceId', () => {
|
||||
it('GPPMREPO-007: deletes the row for the given place_id', async () => {
|
||||
testDb.prepare('INSERT INTO google_place_photo_meta (place_id, attribution, fetched_at, error_at) VALUES (?, NULL, ?, NULL)').run('p1', 1000);
|
||||
await meta.deleteByPlaceId('p1');
|
||||
expect(rawRow('p1')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('GPPMREPO-008: deleting a missing place_id does not throw', async () => {
|
||||
await expect(meta.deleteByPlaceId('never-existed')).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GooglePlacePhotoMetaRepository.upsertError / upsertPhoto — INSERT OR REPLACE parity', () => {
|
||||
it('GPPMREPO-009: upsertError writes NULL attribution and matching fetched_at/error_at on a fresh row', async () => {
|
||||
await meta.upsertError('p1', 12345);
|
||||
expect(rawRow('p1')).toMatchObject({ place_id: 'p1', attribution: null, fetched_at: 12345, error_at: 12345 });
|
||||
});
|
||||
|
||||
it('GPPMREPO-010: upsertPhoto writes the attribution with a NULL error_at on a fresh row', async () => {
|
||||
await meta.upsertPhoto('p1', 'Some Author · CC BY 2.0', 999);
|
||||
expect(rawRow('p1')).toMatchObject({ place_id: 'p1', attribution: 'Some Author · CC BY 2.0', fetched_at: 999, error_at: null });
|
||||
});
|
||||
|
||||
// PP4 forces attribution NULL; PP5 forces error_at NULL — same PK, mirror
|
||||
// images. Writes PE3-style twice then a third time, asserting the exact
|
||||
// row after each (Task 1 brief's explicit test requirement).
|
||||
it('GPPMREPO-011: put then markError then put again — each call overwrites the row exactly (INSERT OR REPLACE semantics via em.upsert merge)', async () => {
|
||||
await meta.upsertPhoto('p1', 'Author A', 100);
|
||||
expect(rawRow('p1')).toMatchObject({ attribution: 'Author A', fetched_at: 100, error_at: null });
|
||||
|
||||
await meta.upsertError('p1', 200);
|
||||
expect(rawRow('p1')).toMatchObject({ attribution: null, fetched_at: 200, error_at: 200 });
|
||||
|
||||
await meta.upsertPhoto('p1', 'Author B', 300);
|
||||
expect(rawRow('p1')).toMatchObject({ attribution: 'Author B', fetched_at: 300, error_at: null });
|
||||
|
||||
// Only one row exists for this PK throughout — no duplicate insert.
|
||||
expect(testDb.prepare('SELECT COUNT(*) as c FROM google_place_photo_meta WHERE place_id = ?').get('p1')).toEqual({ c: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('GooglePlacePhotoMetaRepository.listPlaceIds', () => {
|
||||
it('GPPMREPO-012: lists every place_id, full table scan', async () => {
|
||||
testDb.prepare('INSERT INTO google_place_photo_meta (place_id, fetched_at) VALUES (?, ?)').run('p1', 1);
|
||||
testDb.prepare('INSERT INTO google_place_photo_meta (place_id, fetched_at) VALUES (?, ?)').run('p2', 2);
|
||||
expect((await meta.listPlaceIds()).sort()).toEqual(['p1', 'p2']);
|
||||
});
|
||||
|
||||
it('GPPMREPO-013: an empty table yields an empty array', async () => {
|
||||
expect(await meta.listPlaceIds()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// D-shape (Task 1 brief item): findLive/findErrored are `findOne`-based
|
||||
// (`TrekRepository`'s `disableIdentityMap: true` default), the class of read
|
||||
// program rule 14 targets directly.
|
||||
describe('GooglePlacePhotoMetaRepository — D-shape', () => {
|
||||
it('GPPMREPO-014: a raw write after findLive is visible in the next findLive call, in one query (disableIdentityMap regression)', async () => {
|
||||
testDb.prepare('INSERT INTO google_place_photo_meta (place_id, attribution, fetched_at, error_at) VALUES (?, ?, ?, NULL)').run('p1', 'Old', 1000);
|
||||
expect(await meta.findLive('p1')).toEqual({ attribution: 'Old' }); // populate the identity map
|
||||
testDb.prepare('UPDATE google_place_photo_meta SET attribution = ? WHERE place_id = ?').run('New', 'p1');
|
||||
|
||||
const connection = t.orm.em.getConnection();
|
||||
const spy = vi.spyOn(connection, 'execute');
|
||||
try {
|
||||
expect(await meta.findLive('p1')).toEqual({ attribution: 'New' });
|
||||
expect(spy.mock.calls.length).toBe(1);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* PlaceDetailsCacheRepository (Plan 3c Task 1, PE2/PE3): the two statements
|
||||
* `PlaceEnrichmentService` used to issue raw, on real rows. The table's real
|
||||
* PRIMARY KEY is the composite `(place_id, lang, expanded)` — the parity
|
||||
* proof below writes the same triple twice and asserts exactly one row.
|
||||
*/
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createSnapshotTestDb } from '../../../helpers/db-mock';
|
||||
import { resetTestDb } from '../../../helpers/test-db';
|
||||
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
|
||||
import { PlaceDetailsCache } from '../../../../src/db/entities/PlaceDetailsCache.entity';
|
||||
import type { PlaceDetailsCacheRepository } from '../../../../src/db/repositories/PlaceDetailsCache.repository';
|
||||
|
||||
const testDb = createSnapshotTestDb();
|
||||
let t: TestOrm;
|
||||
let cache: PlaceDetailsCacheRepository;
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestOrm(testDb);
|
||||
cache = t.repo(PlaceDetailsCache);
|
||||
});
|
||||
beforeEach(() => { resetTestDb(testDb); t.clear(); });
|
||||
afterAll(async () => { await t.close(); testDb.close(); });
|
||||
|
||||
function rawRow(placeId: string, lang: string, expanded: number): unknown {
|
||||
return testDb.prepare('SELECT * FROM place_details_cache WHERE place_id = ? AND lang = ? AND expanded = ?').get(placeId, lang, expanded);
|
||||
}
|
||||
|
||||
describe('PlaceDetailsCacheRepository.findEntry', () => {
|
||||
it('PDCREPO-001: finds the entry for the exact (place_id, lang, expanded) triple', async () => {
|
||||
testDb.prepare('INSERT INTO place_details_cache (place_id, lang, expanded, payload_json, fetched_at) VALUES (?, ?, ?, ?, ?)')
|
||||
.run('p1', 'en', 0, '{"a":1}', 1000);
|
||||
expect(await cache.findEntry('p1', 'en', 0)).toEqual({ payload_json: '{"a":1}', fetched_at: 1000 });
|
||||
});
|
||||
|
||||
it('PDCREPO-002: a different lang or expanded value is a miss, not a fuzzy match', async () => {
|
||||
testDb.prepare('INSERT INTO place_details_cache (place_id, lang, expanded, payload_json, fetched_at) VALUES (?, ?, ?, ?, ?)')
|
||||
.run('p1', 'en', 0, '{"a":1}', 1000);
|
||||
expect(await cache.findEntry('p1', 'fr', 0)).toBeNull();
|
||||
expect(await cache.findEntry('p1', 'en', 2)).toBeNull();
|
||||
});
|
||||
|
||||
it('PDCREPO-003: an empty-string lang (the "no lang requested" default) is its own distinct row', async () => {
|
||||
testDb.prepare('INSERT INTO place_details_cache (place_id, lang, expanded, payload_json, fetched_at) VALUES (?, ?, ?, ?, ?)')
|
||||
.run('p1', '', 2, '{"b":2}', 2000);
|
||||
expect(await cache.findEntry('p1', '', 2)).toEqual({ payload_json: '{"b":2}', fetched_at: 2000 });
|
||||
expect(await cache.findEntry('p1', 'en', 2)).toBeNull();
|
||||
});
|
||||
|
||||
it('PDCREPO-004: a missing entry resolves to null', async () => {
|
||||
expect(await cache.findEntry('missing', '', 0)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PlaceDetailsCacheRepository.upsertEntry — composite-key INSERT OR REPLACE parity', () => {
|
||||
it('PDCREPO-005: writes a fresh row for a new (place_id, lang, expanded) triple', async () => {
|
||||
await cache.upsertEntry({ place_id: 'p1', lang: '', expanded: 2, payload_json: '{"v":1}', fetched_at: 100 });
|
||||
expect(rawRow('p1', '', 2)).toMatchObject({ payload_json: '{"v":1}', fetched_at: 100 });
|
||||
});
|
||||
|
||||
// The composite-key proof the Task 1 brief requires: writes the SAME
|
||||
// triple twice and asserts exactly ONE row survives (proving
|
||||
// `onConflictFields: ['place_id', 'lang', 'expanded']` is the real
|
||||
// conflict target, not an inferred single-column PK that would silently
|
||||
// insert a duplicate).
|
||||
it('PDCREPO-006: writing the same (place_id, lang, expanded) triple twice leaves exactly one row, with the second payload', async () => {
|
||||
await cache.upsertEntry({ place_id: 'p1', lang: 'en', expanded: 2, payload_json: '{"v":1}', fetched_at: 100 });
|
||||
await cache.upsertEntry({ place_id: 'p1', lang: 'en', expanded: 2, payload_json: '{"v":2}', fetched_at: 200 });
|
||||
|
||||
const rows = testDb.prepare('SELECT * FROM place_details_cache WHERE place_id = ? AND lang = ? AND expanded = ?').all('p1', 'en', 2);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rawRow('p1', 'en', 2)).toMatchObject({ payload_json: '{"v":2}', fetched_at: 200 });
|
||||
});
|
||||
|
||||
it('PDCREPO-007: a different lang or expanded value for the same place_id is a SEPARATE row, not overwritten', async () => {
|
||||
await cache.upsertEntry({ place_id: 'p1', lang: 'en', expanded: 0, payload_json: '{"kind":"details"}', fetched_at: 100 });
|
||||
await cache.upsertEntry({ place_id: 'p1', lang: 'en', expanded: 2, payload_json: '{"kind":"enrichment"}', fetched_at: 200 });
|
||||
await cache.upsertEntry({ place_id: 'p1', lang: 'fr', expanded: 2, payload_json: '{"kind":"enrichment-fr"}', fetched_at: 300 });
|
||||
|
||||
const rows = testDb.prepare('SELECT lang, expanded, payload_json FROM place_details_cache WHERE place_id = ? ORDER BY lang, expanded').all('p1');
|
||||
expect(rows).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
// D-shape (Task 1 brief item): `findEntry` is `findOne`-based
|
||||
// (`TrekRepository`'s `disableIdentityMap: true` default).
|
||||
describe('PlaceDetailsCacheRepository — D-shape', () => {
|
||||
it('PDCREPO-008: a raw write after findEntry is visible in the next findEntry call, in one query (disableIdentityMap regression)', async () => {
|
||||
testDb.prepare('INSERT INTO place_details_cache (place_id, lang, expanded, payload_json, fetched_at) VALUES (?, ?, ?, ?, ?)')
|
||||
.run('p1', 'en', 2, '{"v":"old"}', 100);
|
||||
expect(await cache.findEntry('p1', 'en', 2)).toEqual({ payload_json: '{"v":"old"}', fetched_at: 100 }); // populate the identity map
|
||||
testDb.prepare('UPDATE place_details_cache SET payload_json = ?, fetched_at = ? WHERE place_id = ? AND lang = ? AND expanded = ?')
|
||||
.run('{"v":"new"}', 200, 'p1', 'en', 2);
|
||||
|
||||
const connection = t.orm.em.getConnection();
|
||||
const spy = vi.spyOn(connection, 'execute');
|
||||
try {
|
||||
expect(await cache.findEntry('p1', 'en', 2)).toEqual({ payload_json: '{"v":"new"}', fetched_at: 200 });
|
||||
expect(spy.mock.calls.length).toBe(1);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* PlaceRatingsRepository.listForPlaces (Plan 3c Task 1, QH2): the batch
|
||||
* ratings-by-place loader behind `QueryHelpersService.loadRatingsByPlaceIds`,
|
||||
* moved off `query-helpers.service.ts`. `place_ratings` is a Plan 3c-owned
|
||||
* table (inventory §14.6) with no repository test file before this one.
|
||||
*/
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createSnapshotTestDb } from '../../../helpers/db-mock';
|
||||
import { resetTestDb } from '../../../helpers/test-db';
|
||||
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
|
||||
import { createPlace, createTrip, createUser } from '../../../helpers/factories';
|
||||
import { PlaceRatings } from '../../../../src/db/entities/PlaceRatings.entity';
|
||||
import type { PlaceRatingsRepository } from '../../../../src/db/repositories/PlaceRatings.repository';
|
||||
|
||||
const testDb = createSnapshotTestDb();
|
||||
let t: TestOrm;
|
||||
let placeRatings: PlaceRatingsRepository;
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestOrm(testDb);
|
||||
placeRatings = t.repo(PlaceRatings);
|
||||
});
|
||||
beforeEach(() => { resetTestDb(testDb); t.clear(); });
|
||||
afterAll(async () => { await t.close(); testDb.close(); });
|
||||
|
||||
function rate(placeId: number, userId: number, rating: number, createdAt: string): void {
|
||||
testDb.prepare('INSERT INTO place_ratings (place_id, user_id, rating, created_at) VALUES (?, ?, ?, ?)').run(placeId, userId, rating, createdAt);
|
||||
}
|
||||
|
||||
async function withQueryCount<T>(fn: () => Promise<T>): Promise<{ value: T; queries: number }> {
|
||||
const connection = t.orm.em.getConnection();
|
||||
const spy = vi.spyOn(connection, 'execute');
|
||||
try {
|
||||
const value = await fn();
|
||||
return { value, queries: spy.mock.calls.length };
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
}
|
||||
|
||||
describe('PlaceRatingsRepository.listForPlaces', () => {
|
||||
it('PLACERATINGSREPO-001: an empty placeIds array short-circuits to [] without querying', async () => {
|
||||
const { value, queries } = await withQueryCount(() => placeRatings.listForPlaces([]));
|
||||
expect(value).toEqual([]);
|
||||
expect(queries).toBe(0);
|
||||
});
|
||||
|
||||
it('PLACERATINGSREPO-002: joins the voter\'s username/avatar, ordered by created_at', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: voterA } = createUser(testDb, { username: 'alpha' });
|
||||
const { user: voterB } = createUser(testDb, { username: 'beta' });
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
rate(place.id, voterA.id, 4, '2026-01-01 00:00:00');
|
||||
rate(place.id, voterB.id, 2, '2026-01-02 00:00:00');
|
||||
|
||||
const rows = await placeRatings.listForPlaces([place.id]);
|
||||
expect(rows).toEqual([
|
||||
{ place_id: place.id, user_id: voterA.id, username: 'alpha', avatar: null, rating: 4 },
|
||||
{ place_id: place.id, user_id: voterB.id, username: 'beta', avatar: null, rating: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('PLACERATINGSREPO-003: batches across several places, indexed by place_id in the caller', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: voter } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
const placeA = createPlace(testDb, trip.id, { name: 'A' });
|
||||
const placeB = createPlace(testDb, trip.id, { name: 'B' });
|
||||
rate(placeA.id, voter.id, 5, '2026-01-01 00:00:00');
|
||||
rate(placeB.id, voter.id, 1, '2026-01-01 00:00:00');
|
||||
|
||||
const rows = await placeRatings.listForPlaces([placeA.id, placeB.id]);
|
||||
expect(rows.map((r) => r.place_id).sort()).toEqual([placeA.id, placeB.id].sort());
|
||||
});
|
||||
|
||||
it('PLACERATINGSREPO-004: a place with no ratings contributes no rows', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
expect(await placeRatings.listForPlaces([place.id])).toEqual([]);
|
||||
});
|
||||
|
||||
// D-shape (Task 1 brief item): `listForPlaces` is a `qb().execute('all',
|
||||
// false)` projection, which never hydrates an entity into the identity
|
||||
// map by construction (unlike `find`/`findOne`), so there is no staleness
|
||||
// hazard here to reproduce — this proves it anyway, the same honest
|
||||
// "not required, proven regardless" shape `Tags.repository.test.ts`'s
|
||||
// TAGREPO comments already use for a method that IS at risk.
|
||||
it('PLACERATINGSREPO-005 (D-shape): a rating written after an unrelated identity-map read is visible in the FIRST wider projection', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: voter } = createUser(testDb, { username: 'fresh-voter' });
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
await t.repo(PlaceRatings).find({}); // populate the identity map with an unrelated (empty) read
|
||||
rate(place.id, voter.id, 3, '2026-01-01 00:00:00');
|
||||
const rows = await placeRatings.listForPlaces([place.id]);
|
||||
expect(rows).toEqual([{ place_id: place.id, user_id: voter.id, username: 'fresh-voter', avatar: null, rating: 3 }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* PlaceShadowPicksRepository (Plan 3c Task 1, PS2–PS8): every statement
|
||||
* `PlaceShadowService` used to issue raw, on real rows. `place_shadow_picks`
|
||||
* has no other repository test file before this one.
|
||||
*/
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createSnapshotTestDb } from '../../../helpers/db-mock';
|
||||
import { resetTestDb } from '../../../helpers/test-db';
|
||||
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
|
||||
import { PlaceShadowPicks } from '../../../../src/db/entities/PlaceShadowPicks.entity';
|
||||
import type { PlaceShadowPicksRepository, NewPlaceShadowPickRow } from '../../../../src/db/repositories/PlaceShadowPicks.repository';
|
||||
|
||||
const testDb = createSnapshotTestDb();
|
||||
let t: TestOrm;
|
||||
let picks: PlaceShadowPicksRepository;
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestOrm(testDb);
|
||||
picks = t.repo(PlaceShadowPicks);
|
||||
});
|
||||
beforeEach(() => { resetTestDb(testDb); t.clear(); });
|
||||
afterAll(async () => { await t.close(); testDb.close(); });
|
||||
|
||||
function row(overrides: Partial<NewPlaceShadowPickRow> = {}): NewPlaceShadowPickRow {
|
||||
return {
|
||||
query: 'louvre',
|
||||
lang: null,
|
||||
bias_lat: null,
|
||||
bias_lng: null,
|
||||
source: 'nominatim',
|
||||
live_rank: 0,
|
||||
live_count: 5,
|
||||
picked_name: 'Louvre Museum',
|
||||
picked_lat: 48.8606,
|
||||
picked_lng: 2.3376,
|
||||
picked_place_id: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function rawRow(id: number): unknown {
|
||||
return testDb.prepare('SELECT * FROM place_shadow_picks WHERE id = ?').get(id);
|
||||
}
|
||||
|
||||
describe('PlaceShadowPicksRepository.insertPick', () => {
|
||||
it('PSPICKREPO-001: inserts exactly the given columns, nullable ones included', async () => {
|
||||
await picks.insertPick(row());
|
||||
const stored = testDb.prepare('SELECT * FROM place_shadow_picks').get() as { id: number };
|
||||
expect(rawRow(stored.id)).toMatchObject({ query: 'louvre', source: 'nominatim', live_rank: 0, live_count: 5, lang: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('PlaceShadowPicksRepository.page', () => {
|
||||
it('PSPICKREPO-002: pages by id, oldest first, fetching size + 1 rows for the probe', async () => {
|
||||
await picks.insertPick(row({ query: 'a' }));
|
||||
await picks.insertPick(row({ query: 'b' }));
|
||||
await picks.insertPick(row({ query: 'c' }));
|
||||
|
||||
const page = await picks.page(0, 2);
|
||||
expect(page).toHaveLength(3); // size + 1 probe row
|
||||
expect(page.map((r) => r.query)).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('PSPICKREPO-003: after excludes everything at or below that id', async () => {
|
||||
await picks.insertPick(row({ query: 'a' }));
|
||||
const firstId = (testDb.prepare('SELECT id FROM place_shadow_picks').get() as { id: number }).id;
|
||||
await picks.insertPick(row({ query: 'b' }));
|
||||
const page = await picks.page(firstId, 5);
|
||||
expect(page.map((r) => r.query)).toEqual(['b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PlaceShadowPicksRepository.totals / countBySource / countByLiveRank', () => {
|
||||
it('PSPICKREPO-004: totals on an empty table reports 0/null/null', async () => {
|
||||
expect(await picks.totals()).toEqual({ total: 0, oldest: null, newest: null });
|
||||
});
|
||||
|
||||
it('PSPICKREPO-005: totals reports COUNT/MIN/MAX across every row', async () => {
|
||||
await picks.insertPick(row());
|
||||
await picks.insertPick(row());
|
||||
testDb.prepare("UPDATE place_shadow_picks SET created_at = '2026-01-01 00:00:00' WHERE id = (SELECT MIN(id) FROM place_shadow_picks)").run();
|
||||
testDb.prepare("UPDATE place_shadow_picks SET created_at = '2026-06-01 00:00:00' WHERE id = (SELECT MAX(id) FROM place_shadow_picks)").run();
|
||||
const totals = await picks.totals();
|
||||
expect(totals).toEqual({ total: 2, oldest: '2026-01-01 00:00:00', newest: '2026-06-01 00:00:00' });
|
||||
});
|
||||
|
||||
it('PSPICKREPO-006: countBySource groups and orders by count DESC', async () => {
|
||||
await picks.insertPick(row({ source: 'nominatim' }));
|
||||
await picks.insertPick(row({ source: 'nominatim' }));
|
||||
await picks.insertPick(row({ source: 'google' }));
|
||||
expect(await picks.countBySource()).toEqual([
|
||||
{ source: 'nominatim', count: 2 },
|
||||
{ source: 'google', count: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('PSPICKREPO-007: countByLiveRank groups with no ORDER BY (caller buckets)', async () => {
|
||||
await picks.insertPick(row({ live_rank: 0 }));
|
||||
await picks.insertPick(row({ live_rank: 0 }));
|
||||
await picks.insertPick(row({ live_rank: 3 }));
|
||||
const rows = await picks.countByLiveRank();
|
||||
expect(rows).toEqual(expect.arrayContaining([{ live_rank: 0, count: 2 }, { live_rank: 3, count: 1 }]));
|
||||
expect(rows).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PlaceShadowPicksRepository.deleteAll / purgeOlderThan', () => {
|
||||
it('PSPICKREPO-008: deleteAll wipes every row and returns the affected count', async () => {
|
||||
await picks.insertPick(row());
|
||||
await picks.insertPick(row());
|
||||
expect(await picks.deleteAll()).toBe(2);
|
||||
expect(await picks.totals()).toEqual({ total: 0, oldest: null, newest: null });
|
||||
});
|
||||
|
||||
it('PSPICKREPO-009: purgeOlderThan removes exactly the rows older than the cutoff, proving the deleted set on a seeded table', async () => {
|
||||
await picks.insertPick(row({ query: 'old' }));
|
||||
await picks.insertPick(row({ query: 'new' }));
|
||||
const [oldRow, newRow] = testDb.prepare('SELECT id, query FROM place_shadow_picks ORDER BY id ASC').all() as { id: number; query: string }[];
|
||||
testDb.prepare("UPDATE place_shadow_picks SET created_at = datetime('now', '-200 days') WHERE id = ?").run(oldRow.id);
|
||||
testDb.prepare("UPDATE place_shadow_picks SET created_at = datetime('now', '-1 days') WHERE id = ?").run(newRow.id);
|
||||
|
||||
const removed = await picks.purgeOlderThan(180);
|
||||
expect(removed).toBe(1);
|
||||
const survivors = testDb.prepare('SELECT query FROM place_shadow_picks').all() as { query: string }[];
|
||||
expect(survivors.map((r) => r.query)).toEqual(['new']);
|
||||
});
|
||||
|
||||
it('PSPICKREPO-010: purgeOlderThan(0) removes rows created before right now, leaving nothing newer than the boundary', async () => {
|
||||
await picks.insertPick(row());
|
||||
const { id } = testDb.prepare('SELECT id FROM place_shadow_picks').get() as { id: number };
|
||||
testDb.prepare("UPDATE place_shadow_picks SET created_at = datetime('now', '-1 seconds') WHERE id = ?").run(id);
|
||||
expect(await picks.purgeOlderThan(0)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// D-shape (Task 1 brief item): `page`/`totals`/`countBySource`/`countByLiveRank`
|
||||
// all go through `find`/`qb().execute()`, neither of which hydrates a
|
||||
// long-lived entity here — proven anyway for `page` (the one `find()`-based
|
||||
// read), the honest "not required, proven regardless" shape.
|
||||
describe('PlaceShadowPicksRepository — D-shape', () => {
|
||||
it('PSPICKREPO-011: a row inserted after an unrelated identity-map read is visible in the FIRST wider projection (page)', async () => {
|
||||
await t.repo(PlaceShadowPicks).find({}); // populate the identity map with an unrelated (empty) read
|
||||
await picks.insertPick(row({ query: 'fresh' }));
|
||||
const page = await picks.page(0, 10);
|
||||
expect(page.map((r) => r.query)).toEqual(['fresh']);
|
||||
});
|
||||
});
|
||||
@@ -24,7 +24,69 @@ beforeAll(async () => {
|
||||
beforeEach(() => { resetTestDb(testDb); t.clear(); });
|
||||
afterAll(async () => { await t.close(); testDb.close(); });
|
||||
|
||||
/**
|
||||
* The legacy `getPlaceWithTags` (`db/database.ts:146-197` at base), run raw
|
||||
* on the same rows — 0b security review F-B6: a `toMatchObject`/partial
|
||||
* assertion cannot see a renamed key (B1's `u__id`) or a dropped one (B2's
|
||||
* flat `category_name`/`category_color`/`category_icon`); this reproduces
|
||||
* the legacy function's exact three-statement composition and the test below
|
||||
* does a full `toEqual` against it, key for key.
|
||||
*/
|
||||
function legacyGetPlaceWithTags(placeId: number): unknown {
|
||||
const place = testDb
|
||||
.prepare('SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?')
|
||||
.get(placeId) as (Record<string, unknown> & { category_id: number | null; category_name: string | null; category_color: string | null; category_icon: string | null }) | undefined;
|
||||
if (!place) return null;
|
||||
const tags = testDb.prepare('SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?').all(placeId);
|
||||
const ratings = testDb
|
||||
.prepare('SELECT pr.user_id, u.username, u.avatar, pr.rating FROM place_ratings pr JOIN users u ON pr.user_id = u.id WHERE pr.place_id = ? ORDER BY pr.created_at')
|
||||
.all(placeId) as { user_id: number; username: string; avatar: string | null; rating: number }[];
|
||||
return {
|
||||
...place,
|
||||
category: place.category_id
|
||||
? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon }
|
||||
: null,
|
||||
tags,
|
||||
ratings,
|
||||
rating_avg: ratings.length > 0 ? ratings.reduce((s, r) => s + r.rating, 0) / ratings.length : null,
|
||||
rating_count: ratings.length,
|
||||
};
|
||||
}
|
||||
|
||||
describe('PlacesRepository.findWithTagsAndRatings — parity with the legacy getPlaceWithTags', () => {
|
||||
it('PLACEREPO-011 (0b security review F-B6): full key-for-key parity with the legacy statement, on a place with a category, two tags and two ratings', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: voterA } = createUser(testDb, { username: 'parity_voter_a' });
|
||||
const { user: voterB } = createUser(testDb, { username: 'parity_voter_b' });
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
const category = createCategory(testDb, { name: 'Parks', color: '#00aa00', icon: '🌳' });
|
||||
const place = createPlace(testDb, trip.id, { category_id: category.id, name: 'Parity Park' });
|
||||
const tagA = createTag(testDb, owner.id, { name: 'Green' });
|
||||
const tagB = createTag(testDb, owner.id, { name: 'Quiet' });
|
||||
testDb.prepare('INSERT INTO place_tags (place_id, tag_id) VALUES (?, ?)').run(place.id, tagA.id);
|
||||
testDb.prepare('INSERT INTO place_tags (place_id, tag_id) VALUES (?, ?)').run(place.id, tagB.id);
|
||||
testDb.prepare("INSERT INTO place_ratings (place_id, user_id, rating, created_at) VALUES (?, ?, ?, datetime('now', '-2 minutes'))").run(place.id, voterA.id, 5);
|
||||
testDb.prepare("INSERT INTO place_ratings (place_id, user_id, rating, created_at) VALUES (?, ?, ?, datetime('now', '-1 minutes'))").run(place.id, voterB.id, 3);
|
||||
|
||||
const result = await places.findWithTagsAndRatings(place.id);
|
||||
const legacy = legacyGetPlaceWithTags(place.id) as Record<string, unknown>;
|
||||
|
||||
// Sort both sides' tags/ratings arrays the same way before the full
|
||||
// toEqual — the repository's join order and the legacy statement's join
|
||||
// order are not contractually identical for the tags half (no ORDER BY
|
||||
// in either), only the ratings half is `ORDER BY pr.created_at` on both.
|
||||
const sortByField = <T extends Record<string, unknown>>(arr: T[], field: string): T[] =>
|
||||
[...arr].sort((a, b) => String(a[field]).localeCompare(String(b[field])));
|
||||
const normalize = (row: Record<string, unknown>): Record<string, unknown> => ({
|
||||
...row,
|
||||
tags: sortByField(row.tags as Record<string, unknown>[], 'id'),
|
||||
});
|
||||
|
||||
expect(normalize(result as unknown as Record<string, unknown>)).toEqual(normalize(legacy));
|
||||
});
|
||||
});
|
||||
|
||||
describe('PlacesRepository.findWithTagsAndRatings — parity with the legacy getPlaceWithTags (per-case)', () => {
|
||||
it('PLACEREPO-001: no category, no tags, no ratings — null category, empty arrays, null average', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
@@ -77,22 +139,93 @@ describe('PlacesRepository.findWithTagsAndRatings — parity with the legacy get
|
||||
testDb
|
||||
.prepare("INSERT INTO place_ratings (place_id, user_id, rating, created_at) VALUES (?, ?, ?, datetime('now', '-2 minutes'))")
|
||||
.run(place.id, voterA.id, 4);
|
||||
// Task 0b review M1: 4-and-2 averages to 3, which `Math.round(3)` also
|
||||
// equals — the brief's own named mutation (wrapping the average in
|
||||
// `Math.round(...)`) survived undetected. 4-and-3 averages to 3.5,
|
||||
// which `Math.round` would corrupt to 3 or 4 — mutation-proved.
|
||||
testDb
|
||||
.prepare("INSERT INTO place_ratings (place_id, user_id, rating, created_at) VALUES (?, ?, ?, datetime('now', '-1 minutes'))")
|
||||
.run(place.id, voterB.id, 2);
|
||||
.run(place.id, voterB.id, 3);
|
||||
|
||||
const result = await places.findWithTagsAndRatings(place.id);
|
||||
expect(result?.ratings).toHaveLength(2);
|
||||
expect(result?.ratings.map((r) => r.username)).toEqual(['voter_a', 'voter_b']);
|
||||
expect(result?.rating_avg).toBe(3);
|
||||
expect(result?.rating_avg).toBe(3.5);
|
||||
expect(result?.rating_count).toBe(2);
|
||||
});
|
||||
|
||||
it('PLACEREPO-005: a missing place id resolves to null, not a throw', async () => {
|
||||
// Task 0b review B1 (HIGH, live regression): `ratings[].user_id` was
|
||||
// emitted as `u__id` at runtime (a `join('pr.user','u')` + `.select(['pr.user',
|
||||
// …])` interaction — TypeScript's declared `PlaceRatingRow.user_id` never
|
||||
// existed on the actual row). The client's `StarRating.tsx` keys off
|
||||
// `ratings.find(r => r.user_id === currentUserId)`, so this is the direct
|
||||
// regression proof neither PLACEREPO-004 (keys off `username`) nor any
|
||||
// other suite caught.
|
||||
it('PLACEREPO-007 (0b review B1): a rating row carries a real user_id key, not u__id', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: voter } = createUser(testDb, { username: 'keyed_voter' });
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
testDb.prepare('INSERT INTO place_ratings (place_id, user_id, rating) VALUES (?, ?, ?)').run(place.id, voter.id, 5);
|
||||
|
||||
const result = await places.findWithTagsAndRatings(place.id);
|
||||
expect(result?.ratings[0]).toEqual({ user_id: voter.id, username: 'keyed_voter', avatar: null, rating: 5 });
|
||||
expect(result?.ratings[0]).not.toHaveProperty('u__id');
|
||||
});
|
||||
|
||||
// Task 0b review B2 (HIGH, live regression): `category_name`/
|
||||
// `category_color`/`category_icon` rode along on the legacy
|
||||
// `getPlaceWithTags`'s runtime object (`{...place, category: ...}` over
|
||||
// the raw `SELECT p.*, c.name as category_name, …` row) even though the
|
||||
// declared `PlaceWithTags` interface never mentioned them — several live
|
||||
// client call sites read them flat (map popups, dashboard, shared-trip
|
||||
// page), so a repository that strips them via `const { category_name,
|
||||
// ..., ...placeRest } = place` silently breaks those, even though every
|
||||
// OTHER assertion in this file (which reads through `result?.category`)
|
||||
// stays green.
|
||||
it('PLACEREPO-008 (0b review B2): the flat category_name/category_color/category_icon columns ride along on the row, alongside the nested category object', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
const category = createCategory(testDb, { name: 'Cafes', color: '#654321', icon: '☕' });
|
||||
const place = createPlace(testDb, trip.id, { category_id: category.id });
|
||||
|
||||
const result = await places.findWithTagsAndRatings(place.id) as unknown as Record<string, unknown>;
|
||||
expect(result.category_name).toBe('Cafes');
|
||||
expect(result.category_color).toBe('#654321');
|
||||
expect(result.category_icon).toBe('☕');
|
||||
expect(result.category).toEqual({ id: category.id, name: 'Cafes', color: '#654321', icon: '☕' });
|
||||
});
|
||||
|
||||
it('PLACEREPO-009: a missing place id resolves to null, not a throw', async () => {
|
||||
expect(await places.findWithTagsAndRatings(999999)).toBeNull();
|
||||
});
|
||||
|
||||
it('PLACEREPO-006: a non-numeric-looking id resolves to null, the same raw-bind seam TripsRepository documents', async () => {
|
||||
it('PLACEREPO-010: a non-numeric-looking id resolves to null, the same raw-bind seam TripsRepository documents', async () => {
|
||||
expect(await places.findWithTagsAndRatings('abc')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// Plan 3c Task 1, PP6 ruling (option 2): the `places`-owned half of
|
||||
// `PlacePhotoCacheService.isReferenced`'s legacy `UNION ALL … LIMIT 1`.
|
||||
describe('PlacesRepository.existsByGoogleIdOrImageUrl', () => {
|
||||
it('EXISTSGOOGLEORIMAGE-001: true when a place carries the google_place_id', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
testDb.prepare('UPDATE places SET google_place_id = ? WHERE id = ?').run('ChIJ-gpid', place.id);
|
||||
expect(await places.existsByGoogleIdOrImageUrl('ChIJ-gpid', '/api/maps/place-photo/ChIJ-gpid/bytes')).toBe(true);
|
||||
});
|
||||
|
||||
it('EXISTSGOOGLEORIMAGE-002: true when a place carries the proxy image_url instead (coords: pseudo-ids never have a google_place_id)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
const proxyUrl = '/api/maps/place-photo/coords%3A1%3A2/bytes';
|
||||
testDb.prepare('UPDATE places SET image_url = ? WHERE id = ?').run(proxyUrl, place.id);
|
||||
expect(await places.existsByGoogleIdOrImageUrl('coords:1:2', proxyUrl)).toBe(true);
|
||||
});
|
||||
|
||||
it('EXISTSGOOGLEORIMAGE-003: false when nothing references either value', async () => {
|
||||
expect(await places.existsByGoogleIdOrImageUrl('never-seen', '/api/maps/place-photo/never-seen/bytes')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vites
|
||||
import { createSnapshotTestDb } from '../../../helpers/db-mock';
|
||||
import { resetTestDb } from '../../../helpers/test-db';
|
||||
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
|
||||
import { createTag, createUser } from '../../../helpers/factories';
|
||||
import { createPlace, createTag, createTrip, createUser } from '../../../helpers/factories';
|
||||
import { Tags } from '../../../../src/db/entities/Tags.entity';
|
||||
import type { TagsRepository } from '../../../../src/db/repositories/Tags.repository';
|
||||
|
||||
@@ -254,4 +254,76 @@ describe('TagsRepository', () => {
|
||||
expect(remaining[0].id).toBe(keep.id);
|
||||
});
|
||||
});
|
||||
|
||||
// Plan 3c Task 1 (QH1): the batch tag-by-place loader behind
|
||||
// `QueryHelpersService.loadTagsByPlaceIds`, moved off `query-helpers.service.ts`.
|
||||
describe('listForPlaces', () => {
|
||||
function attach(tagId: number, placeId: number): void {
|
||||
testDb.prepare('INSERT INTO place_tags (tag_id, place_id) VALUES (?, ?)').run(tagId, placeId);
|
||||
}
|
||||
|
||||
it('LISTFORPLACESREPO-001: an empty placeIds array short-circuits to [] without querying', async () => {
|
||||
const { value, queries } = await withQueryCount(() => tags.listForPlaces([]));
|
||||
expect(value).toEqual([]);
|
||||
expect(queries).toBe(0);
|
||||
});
|
||||
|
||||
it('LISTFORPLACESREPO-002: non-compact returns every tags column except the join key, plus place_id', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
const tag = createTag(testDb, user.id, { name: 'Beach', color: '#ff0000' });
|
||||
attach(tag.id, place.id);
|
||||
|
||||
const rows = await tags.listForPlaces([place.id]);
|
||||
expect(rows).toEqual([
|
||||
{ id: tag.id, user_id: user.id, name: 'Beach', color: '#ff0000', created_at: (rawTag(tag.id) as { created_at: string }).created_at, place_id: place.id },
|
||||
]);
|
||||
});
|
||||
|
||||
it('LISTFORPLACESREPO-003: compact returns {id, name, color, created_at, place_id} — no user_id', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
const tag = createTag(testDb, user.id, { name: 'Compact', color: '#00ff00' });
|
||||
attach(tag.id, place.id);
|
||||
|
||||
const rows = await tags.listForPlaces([place.id], { compact: true });
|
||||
expect(rows).toEqual([{ id: tag.id, name: 'Compact', color: '#00ff00', created_at: (rawTag(tag.id) as { created_at: string }).created_at, place_id: place.id }]);
|
||||
expect(rows[0]).not.toHaveProperty('user_id');
|
||||
});
|
||||
|
||||
it('LISTFORPLACESREPO-004: one row per (tag, place) pair — the same tag on two places yields two rows', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const placeA = createPlace(testDb, trip.id, { name: 'A' });
|
||||
const placeB = createPlace(testDb, trip.id, { name: 'B' });
|
||||
const tag = createTag(testDb, user.id);
|
||||
attach(tag.id, placeA.id);
|
||||
attach(tag.id, placeB.id);
|
||||
|
||||
const rows = await tags.listForPlaces([placeA.id, placeB.id]);
|
||||
expect(rows.map((r) => r.place_id).sort()).toEqual([placeA.id, placeB.id].sort());
|
||||
});
|
||||
|
||||
it('LISTFORPLACESREPO-005: a place with no tags contributes no rows', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
expect(await tags.listForPlaces([place.id])).toEqual([]);
|
||||
});
|
||||
|
||||
it('LISTFORPLACESREPO-006 (D-shape): a tag written via place_tags is visible in the FIRST wider (non-compact) projection, bypassing the identity map', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
const tag = createTag(testDb, user.id, { name: 'Fresh' });
|
||||
// Populate the identity map with a narrower read first.
|
||||
await tags.findByIdAndUser(tag.id, user.id);
|
||||
attach(tag.id, place.id);
|
||||
testDb.prepare('UPDATE tags SET name = ? WHERE id = ?').run('Renamed', tag.id);
|
||||
const rows = await tags.listForPlaces([place.id]);
|
||||
expect(rows[0]?.name).toBe('Renamed');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -82,3 +82,55 @@ describe('TripMembersRepository.rosterUserIds — parity with the legacy UNION s
|
||||
expect(await tripMembers.rosterUserIds('abc')).toEqual(new Set());
|
||||
});
|
||||
});
|
||||
|
||||
// Plan 3c Task 1 (TB2/TB5/TB6): TripMembershipService.listMemberUserIds/
|
||||
// joinTripAsMember's own check-then-act sequence.
|
||||
describe('TripMembersRepository — listUserIdsByTrip / exists / addMember (Plan 3c Task 1)', () => {
|
||||
it('TMEMREPO-007: listUserIdsByTrip excludes the owner and orders by added_at ASC', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: first } = createUser(testDb);
|
||||
const { user: second } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addTripMember(testDb, trip.id, first.id);
|
||||
testDb.prepare('UPDATE trip_members SET added_at = ? WHERE trip_id = ? AND user_id = ?').run('2026-01-01 00:00:00', trip.id, first.id);
|
||||
addTripMember(testDb, trip.id, second.id);
|
||||
testDb.prepare('UPDATE trip_members SET added_at = ? WHERE trip_id = ? AND user_id = ?').run('2026-02-01 00:00:00', trip.id, second.id);
|
||||
|
||||
expect(await tripMembers.listUserIdsByTrip(trip.id)).toEqual([first.id, second.id]);
|
||||
});
|
||||
|
||||
it('TMEMREPO-008: listUserIdsByTrip returns an empty array for an owner-only trip', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
expect(await tripMembers.listUserIdsByTrip(trip.id)).toEqual([]);
|
||||
});
|
||||
|
||||
it('TMEMREPO-009: exists is true only for an actual (trip, user) membership row', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const { user: stranger } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
|
||||
expect(await tripMembers.exists(trip.id, member.id)).toBe(true);
|
||||
expect(await tripMembers.exists(trip.id, stranger.id)).toBe(false);
|
||||
// The owner has no trip_members row unless one was explicitly added.
|
||||
expect(await tripMembers.exists(trip.id, owner.id)).toBe(false);
|
||||
});
|
||||
|
||||
it('TMEMREPO-010: addMember inserts a row with the given invited_by, nullable', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: inviter } = createUser(testDb);
|
||||
const { user: joiner } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
|
||||
await tripMembers.addMember(trip.id, joiner.id, inviter.id);
|
||||
const row = testDb.prepare('SELECT trip_id, user_id, invited_by FROM trip_members WHERE trip_id = ? AND user_id = ?').get(trip.id, joiner.id);
|
||||
expect(row).toEqual({ trip_id: trip.id, user_id: joiner.id, invited_by: inviter.id });
|
||||
|
||||
const { user: joiner2 } = createUser(testDb);
|
||||
await tripMembers.addMember(trip.id, joiner2.id, null);
|
||||
const row2 = testDb.prepare('SELECT invited_by FROM trip_members WHERE trip_id = ? AND user_id = ?').get(trip.id, joiner2.id);
|
||||
expect(row2).toEqual({ invited_by: null });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,6 +61,13 @@ describe('TripsRepository.findAccessible — parity with canAccessTrip', () => {
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
expect(await trips.isOwner(trip.id, owner.id)).toBe(true);
|
||||
expect(await trips.isOwner(trip.id, member.id)).toBe(false);
|
||||
|
||||
// 0b security review F-B6: full parity against the legacy statement run
|
||||
// raw on the same rows, not just the boolean the method returns.
|
||||
const legacyIsOwner = (tripId: number, userId: number): boolean =>
|
||||
!!testDb.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId);
|
||||
expect(await trips.isOwner(trip.id, owner.id)).toEqual(legacyIsOwner(trip.id, owner.id));
|
||||
expect(await trips.isOwner(trip.id, member.id)).toEqual(legacyIsOwner(trip.id, member.id));
|
||||
});
|
||||
|
||||
it('TRIPREPO-007: a missing trip id resolves to undefined, not a throw, for both findAccessible and isOwner', async () => {
|
||||
@@ -81,6 +88,56 @@ describe('TripsRepository.findAccessible — parity with canAccessTrip', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Plan 3c Task 1 (TB1/TB3/TB4): the trip-membership primitives, added onto
|
||||
// the same repository so `TripMembershipService` never touches raw SQL.
|
||||
describe('TripsRepository — getOwnerId / findIdAndOwner / listAccessibleIds (Plan 3c Task 1)', () => {
|
||||
it('TRIPREPO-008: getOwnerId returns the owner id, null for a missing trip', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
expect(await trips.getOwnerId(trip.id)).toBe(user.id);
|
||||
expect(await trips.getOwnerId(999999)).toBeNull();
|
||||
});
|
||||
|
||||
it('TRIPREPO-009: getOwnerId binds a string id raw, matching the `0x10`/`007`-shaped id parity seam', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
// A numeric-looking string binds fine under SQLite's affinity comparison.
|
||||
expect(await trips.getOwnerId(String(trip.id))).toBe(user.id);
|
||||
// A non-numeric string never matches any row — no throw, no coercion.
|
||||
expect(await trips.getOwnerId('not-a-number')).toBeNull();
|
||||
});
|
||||
|
||||
it('TRIPREPO-010: findIdAndOwner returns {id, user_id} for a real trip, undefined for a missing one', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
expect(await trips.findIdAndOwner(trip.id)).toEqual({ id: trip.id, user_id: user.id });
|
||||
expect(await trips.findIdAndOwner(999999)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('TRIPREPO-011: listAccessibleIds returns owned + member trips, newest first, excluding a stranger\'s trips', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const { user: stranger } = createUser(testDb);
|
||||
const a = createTrip(testDb, owner.id);
|
||||
testDb.prepare('UPDATE trips SET created_at = ? WHERE id = ?').run('2026-01-01 00:00:00', a.id);
|
||||
const b = createTrip(testDb, owner.id);
|
||||
testDb.prepare('UPDATE trips SET created_at = ? WHERE id = ?').run('2026-02-01 00:00:00', b.id);
|
||||
const c = createTrip(testDb, stranger.id);
|
||||
addTripMember(testDb, c.id, member.id);
|
||||
|
||||
expect(await trips.listAccessibleIds(owner.id)).toEqual([b.id, a.id]);
|
||||
expect(await trips.listAccessibleIds(member.id)).toEqual([c.id]);
|
||||
expect(await trips.listAccessibleIds(stranger.id)).toEqual([c.id]);
|
||||
});
|
||||
|
||||
it('TRIPREPO-012: listAccessibleIds never double-counts a trip where the caller is both owner and, somehow, member-listed', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addTripMember(testDb, trip.id, owner.id);
|
||||
expect(await trips.listAccessibleIds(owner.id)).toEqual([trip.id]);
|
||||
});
|
||||
});
|
||||
|
||||
// Plan 3c Task 2 (DY35): `days.service.ts::insert`'s dated path extends the
|
||||
// trip by one day. Owned exclusively by Task 2 per the plan's file
|
||||
// ownership (`Trips.repository.ts` ONLY for `setEndDate`) — appended here
|
||||
|
||||
@@ -87,7 +87,7 @@ import type { TodoService } from '../../../src/nest/todo/todo.service';
|
||||
import type { CollabService } from '../../../src/nest/collab/collab.service';
|
||||
import { AddonsService } from '../../../src/nest/addons/addons.service';
|
||||
import { notificationsStub } from '../../helpers/notifications';
|
||||
import { createTestUnitOfWork, createTestAppSettingsRepo, sharedTestOrm } from '../../helpers/test-uow';
|
||||
import { createTestUnitOfWork, createTestAppSettingsRepo, createTestTripsRepo, createTestTripMembersRepo, sharedTestOrm } from '../../helpers/test-uow';
|
||||
import type { EntityManager } from '@mikro-orm/core';
|
||||
|
||||
// The trip-summary prompt moved to the DI-discovered TripsMcp — its cases below
|
||||
@@ -148,7 +148,7 @@ beforeAll(async () => {
|
||||
new ExchangeRatesService(),
|
||||
promptDbs(),
|
||||
new RuntimeEnvService(),
|
||||
new TripMembershipService(promptDbs()),
|
||||
new TripMembershipService(await createTestTripsRepo(promptDbs().connection), await createTestTripMembersRepo(promptDbs().connection)),
|
||||
addonsStub,
|
||||
promptGuards,
|
||||
await createTestUnitOfWork(promptDbs().connection),
|
||||
|
||||
@@ -38,6 +38,7 @@ import type { PhotoProviderFieldsRepository } from '../../../src/db/repositories
|
||||
import { AppSettings } from '../../../src/db/entities/AppSettings.entity';
|
||||
import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository';
|
||||
import { Users } from '../../../src/db/entities/Users.entity';
|
||||
import { PlaceShadowPicks } from '../../../src/db/entities/PlaceShadowPicks.entity';
|
||||
import type { UsersRepository } from '../../../src/db/repositories/Users.repository';
|
||||
|
||||
const { getPhotoProviderConfig } = vi.hoisted(() => ({ getPhotoProviderConfig: vi.fn(() => ({})) }));
|
||||
@@ -587,7 +588,7 @@ describe('AddonsService place shadow flag', () => {
|
||||
});
|
||||
|
||||
it('ADDONS-SVC-093 answers the same as PlaceShadowService.enabled() for every stored value', async () => {
|
||||
const shadow = new PlaceShadowService(new DatabaseService(testDb));
|
||||
const shadow = new PlaceShadowService(t.repo(PlaceShadowPicks), appSettingsRepo);
|
||||
const cases: Array<[string | undefined, boolean]> = [
|
||||
[undefined, false],
|
||||
['true', true],
|
||||
|
||||
@@ -92,6 +92,8 @@ import {
|
||||
createTestMcpTokensRepo,
|
||||
createTestOauthTokensRepo,
|
||||
createTestPasswordResetTokensRepo,
|
||||
createTestTripsRepo,
|
||||
createTestTripMembersRepo,
|
||||
} from '../../helpers/test-uow';
|
||||
|
||||
const dbs = new DatabaseService(testDb);
|
||||
@@ -113,7 +115,7 @@ beforeAll(async () => {
|
||||
permissions = new PermissionsService(await createTestAppSettingsRepo(dbs.connection), await createTestUnitOfWork(dbs.connection));
|
||||
userCleanup = new UserCleanupService(dbs, new BudgetService(dbs, permissions, new ExchangeRatesService(), realtime, await createTestUnitOfWork(dbs.connection)), await createTestUnitOfWork(dbs.connection), await createTestUsersRepo(dbs.connection));
|
||||
auth = new AuthService(
|
||||
permissions, new TripMembershipService(dbs), webauthn, userCleanup, new MailerService(dbs), new EphemeralTokenService(), new AllowedFileTypesService(dbs), await createTestUnitOfWork(dbs.connection),
|
||||
permissions, new TripMembershipService(await createTestTripsRepo(dbs.connection), await createTestTripMembersRepo(dbs.connection)), webauthn, userCleanup, new MailerService(dbs), new EphemeralTokenService(), new AllowedFileTypesService(dbs), await createTestUnitOfWork(dbs.connection),
|
||||
await createTestAppSettingsRepo(dbs.connection), await createTestUsersRepo(dbs.connection), await createTestInviteTokensRepo(dbs.connection), await createTestMcpTokensRepo(dbs.connection),
|
||||
await createTestOauthTokensRepo(dbs.connection), await createTestWebauthnCredentialsRepo(dbs.connection), await createTestPasswordResetTokensRepo(dbs.connection),
|
||||
);
|
||||
|
||||
@@ -70,6 +70,8 @@ import {
|
||||
createTestMcpTokensRepo,
|
||||
createTestOauthTokensRepo,
|
||||
createTestPasswordResetTokensRepo,
|
||||
createTestTripsRepo,
|
||||
createTestTripMembersRepo,
|
||||
} from '../../helpers/test-uow';
|
||||
|
||||
const dbs = new DatabaseService(testDb);
|
||||
@@ -91,7 +93,7 @@ beforeAll(async () => {
|
||||
permissions = new PermissionsService(await createTestAppSettingsRepo(dbs.connection), await createTestUnitOfWork(dbs.connection));
|
||||
userCleanup = new UserCleanupService(dbs, new BudgetService(dbs, permissions, new ExchangeRatesService(), realtime, await createTestUnitOfWork(dbs.connection)), await createTestUnitOfWork(dbs.connection), await createTestUsersRepo(dbs.connection));
|
||||
auth = new AuthService(
|
||||
permissions, new TripMembershipService(dbs), webauthn, userCleanup, new MailerService(dbs), new EphemeralTokenService(), new AllowedFileTypesService(dbs), await createTestUnitOfWork(dbs.connection),
|
||||
permissions, new TripMembershipService(await createTestTripsRepo(dbs.connection), await createTestTripMembersRepo(dbs.connection)), webauthn, userCleanup, new MailerService(dbs), new EphemeralTokenService(), new AllowedFileTypesService(dbs), await createTestUnitOfWork(dbs.connection),
|
||||
await createTestAppSettingsRepo(dbs.connection), await createTestUsersRepo(dbs.connection), await createTestInviteTokensRepo(dbs.connection), await createTestMcpTokensRepo(dbs.connection),
|
||||
await createTestOauthTokensRepo(dbs.connection), await createTestWebauthnCredentialsRepo(dbs.connection), await createTestPasswordResetTokensRepo(dbs.connection),
|
||||
);
|
||||
|
||||
@@ -55,17 +55,25 @@ import { CollectionsService } from '../../../src/nest/collections/collections.se
|
||||
import { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service';
|
||||
import { makeStorageFixture } from '../../helpers/storage-fixture';
|
||||
import { notificationsStub } from '../../helpers/notifications';
|
||||
import { createTestUnitOfWork, createTestAppSettingsRepo, createTestDatabaseService } from '../../helpers/test-uow';
|
||||
import { createTestUnitOfWork, createTestAppSettingsRepo, createTestDatabaseService, createTestGooglePlacePhotoMetaRepo, createTestPlacesRepo } from '../../helpers/test-uow';
|
||||
|
||||
const storageFx = makeStorageFixture('');
|
||||
let svc: CollectionsService;
|
||||
beforeAll(async () => {
|
||||
svc = new CollectionsService(await createTestDatabaseService(testDb), new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), new RealtimeService(), notificationsStub(notifSend), storageFx.storage, await createTestUnitOfWork(testDb));
|
||||
});
|
||||
// The real cache: these cases assert what removeIfUnreferenced actually does
|
||||
// about collection_places (#1081), so a stub would assert nothing.
|
||||
const photoCache = new PlacePhotoCacheService(new DatabaseService(testDb), makeStorageFixture('photos/google/').storage);
|
||||
// Plan 3c Task 1: built inside the async `beforeAll` below now — the
|
||||
// constructor needs two repositories, resolved through `createTestOrm`.
|
||||
let photoCache: PlacePhotoCacheService;
|
||||
const removeIfUnreferenced = (id: string) => photoCache.removeIfUnreferenced(id);
|
||||
beforeAll(async () => {
|
||||
svc = new CollectionsService(await createTestDatabaseService(testDb), new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), new RealtimeService(), notificationsStub(notifSend), storageFx.storage, await createTestUnitOfWork(testDb));
|
||||
photoCache = new PlacePhotoCacheService(
|
||||
new DatabaseService(testDb),
|
||||
makeStorageFixture('photos/google/').storage,
|
||||
await createTestGooglePlacePhotoMetaRepo(testDb),
|
||||
await createTestPlacesRepo(testDb),
|
||||
);
|
||||
});
|
||||
|
||||
function clearCollections() {
|
||||
testDb.exec(`
|
||||
|
||||
@@ -67,6 +67,7 @@ import type { Day } from '../../../src/types';
|
||||
import {
|
||||
createTestUnitOfWork, createTestAppSettingsRepo, createTestDatabaseService,
|
||||
createTestDaysRepo, createTestDayAssignmentsRepo, createTestDayNotesRepo, createTestTripsRepo,
|
||||
createTestTagsRepo, createTestPlaceRatingsRepo, createTestAssignmentParticipantsRepo,
|
||||
} from '../../helpers/test-uow';
|
||||
|
||||
let svc: DaysService;
|
||||
@@ -76,7 +77,7 @@ beforeAll(async () => {
|
||||
dbs,
|
||||
new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)),
|
||||
new RealtimeService(),
|
||||
new QueryHelpersService(dbs),
|
||||
new QueryHelpersService(await createTestTagsRepo(testDb), await createTestPlaceRatingsRepo(testDb), await createTestAssignmentParticipantsRepo(testDb)),
|
||||
await createTestUnitOfWork(testDb),
|
||||
await createTestDaysRepo(testDb),
|
||||
await createTestDayAssignmentsRepo(testDb),
|
||||
@@ -832,7 +833,7 @@ describe('DaysService.canEdit', () => {
|
||||
new DatabaseService(testDb),
|
||||
permissions,
|
||||
new RealtimeService(),
|
||||
new QueryHelpersService(new DatabaseService(testDb)),
|
||||
new QueryHelpersService(await createTestTagsRepo(testDb), await createTestPlaceRatingsRepo(testDb), await createTestAssignmentParticipantsRepo(testDb)),
|
||||
await createTestUnitOfWork(testDb),
|
||||
await createTestDaysRepo(testDb),
|
||||
await createTestDayAssignmentsRepo(testDb),
|
||||
|
||||
@@ -115,21 +115,24 @@ import {
|
||||
createTestOauthTokensRepo,
|
||||
createTestWebauthnCredentialsRepo,
|
||||
createTestPasswordResetTokensRepo,
|
||||
createTestTripsRepo,
|
||||
createTestTripMembersRepo,
|
||||
} from '../../helpers/test-uow';
|
||||
|
||||
// MailerService is injected since the notifications fold — a stub instead of a
|
||||
// module mock. sendPasswordResetEmail is the only thing auth reaches for.
|
||||
const mailerStub = { sendPasswordResetEmail: vi.fn() } as unknown as MailerService;
|
||||
|
||||
const membership = new TripMembershipService(new DatabaseService(testDb));
|
||||
// Positional, and previously shifted by one: an AtlasService sat in the membership
|
||||
// slot, which AuthService no longer takes at all, so webauthn/userCleanup/mailer
|
||||
// each landed one place too late and the EphemeralTokenService was missing
|
||||
// entirely. Nothing failed, because no case below reaches those collaborators.
|
||||
|
||||
let membership: TripMembershipService;
|
||||
let auth: AuthService;
|
||||
let svc: OidcService;
|
||||
beforeAll(async () => {
|
||||
membership = new TripMembershipService(await createTestTripsRepo(testDb), await createTestTripMembersRepo(testDb));
|
||||
auth = new AuthService(
|
||||
new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)),
|
||||
membership,
|
||||
|
||||
@@ -99,6 +99,8 @@ import {
|
||||
createTestMcpTokensRepo,
|
||||
createTestOauthTokensRepo,
|
||||
createTestPasswordResetTokensRepo,
|
||||
createTestTripsRepo,
|
||||
createTestTripMembersRepo,
|
||||
} from '../../helpers/test-uow';
|
||||
|
||||
// MailerService is injected since the notifications fold — a stub instead of a
|
||||
@@ -126,7 +128,7 @@ let svc: PasskeyService;
|
||||
beforeAll(async () => {
|
||||
auth = new AuthService(
|
||||
new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)),
|
||||
new TripMembershipService(new DatabaseService(testDb)),
|
||||
new TripMembershipService(await createTestTripsRepo(testDb), await createTestTripMembersRepo(testDb)),
|
||||
new WebauthnConfigService(await createTestAppSettingsRepo(testDb)),
|
||||
new UserCleanupService(new DatabaseService(testDb), new BudgetService(new DatabaseService(testDb), new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), new ExchangeRatesService(), new RealtimeService(), await createTestUnitOfWork(testDb)), await createTestUnitOfWork(testDb), await createTestUsersRepo(testDb)),
|
||||
mailerStub,
|
||||
|
||||
@@ -11,21 +11,14 @@
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const { mockDbGet, mockDbRun } = vi.hoisted(() => ({
|
||||
// Declared with the shape the fake statement below calls it with (the SQL plus
|
||||
// whatever the caller bound), otherwise the spread has no rest parameter to land in.
|
||||
mockDbGet: vi.fn((_sql: string, ..._params: unknown[]): unknown => undefined),
|
||||
mockDbRun: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/db/database', () => ({
|
||||
db: {
|
||||
prepare: (sql: string) => ({
|
||||
get: (...params: unknown[]) => mockDbGet(sql, ...params),
|
||||
run: (...params: unknown[]) => mockDbRun(sql, ...params),
|
||||
all: () => [],
|
||||
}),
|
||||
},
|
||||
// Rebuilt on PlaceDetailsCacheRepository/AppSettingsRepository (Plan 3c Task
|
||||
// 1, R8's rewrite list) — see place-enrichment.service.test.ts's header for
|
||||
// why the legacy SQL-text-keyed `db.prepare` stub cannot survive the service
|
||||
// calling two named repository methods instead.
|
||||
const { mockGetValue, mockFindEntry, mockUpsertEntry } = vi.hoisted(() => ({
|
||||
mockGetValue: vi.fn(async (_key: string): Promise<string | null> => null),
|
||||
mockFindEntry: vi.fn(async (..._args: unknown[]): Promise<{ payload_json: string; fetched_at: number } | null> => null),
|
||||
mockUpsertEntry: vi.fn(async (_row: { place_id: string; lang: string; expanded: number; payload_json: string; fetched_at: number }): Promise<void> => {}),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/utils/ssrfGuard', () => ({
|
||||
@@ -36,12 +29,12 @@ vi.mock('../../../src/utils/ssrfGuard', () => ({
|
||||
|
||||
vi.mock('../../../src/config', () => ({ JWT_SECRET: 'test-secret', ENCRYPTION_KEY: '0'.repeat(64) }));
|
||||
|
||||
import { db } from '../../../src/db/database';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import { PlaceEnrichmentService } from '../../../src/nest/place-enrichment/place-enrichment.service';
|
||||
import { readBrandIdentity } from '../../../src/nest/maps/maps.service';
|
||||
import type { MapsService } from '../../../src/nest/maps/maps.service';
|
||||
import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service';
|
||||
import type { PlaceDetailsCacheRepository } from '../../../src/db/repositories/PlaceDetailsCache.repository';
|
||||
import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository';
|
||||
|
||||
const REQ = { lat: 54.088, lng: 12.1409, name: "L'Osteria Rostock", placeId: 'ChIJosteria', lang: 'de' };
|
||||
|
||||
@@ -83,7 +76,15 @@ function mapsStub(over: Partial<Record<keyof MapsService, unknown>> = {}) {
|
||||
const cacheStub = () =>
|
||||
({ get: vi.fn(() => null), put: vi.fn(async () => ({ photoUrl: '/x', filePath: '/x', attribution: null })) }) as unknown as PlacePhotoCacheService;
|
||||
|
||||
const make = (maps: MapsService) => new PlaceEnrichmentService(new DatabaseService(db as never), maps, cacheStub());
|
||||
function appSettingsStub(): AppSettingsRepository {
|
||||
return { getValue: mockGetValue } as unknown as AppSettingsRepository;
|
||||
}
|
||||
|
||||
function cacheRepoStub(): PlaceDetailsCacheRepository {
|
||||
return { findEntry: mockFindEntry, upsertEntry: mockUpsertEntry } as unknown as PlaceDetailsCacheRepository;
|
||||
}
|
||||
|
||||
const make = (maps: MapsService) => new PlaceEnrichmentService(cacheRepoStub(), appSettingsStub(), maps, cacheStub());
|
||||
|
||||
/** A place whose own identity is empty but that belongs to a chain. */
|
||||
const branchOfAChain = (over: Partial<Record<keyof MapsService, unknown>> = {}) =>
|
||||
@@ -93,9 +94,12 @@ const branchOfAChain = (over: Partial<Record<keyof MapsService, unknown>> = {})
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockDbGet.mockReset();
|
||||
mockDbGet.mockReturnValue(undefined);
|
||||
mockDbRun.mockReset();
|
||||
mockGetValue.mockReset();
|
||||
mockGetValue.mockResolvedValue(null);
|
||||
mockFindEntry.mockReset();
|
||||
mockFindEntry.mockResolvedValue(null);
|
||||
mockUpsertEntry.mockReset();
|
||||
mockUpsertEntry.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe('readBrandIdentity', () => {
|
||||
|
||||
@@ -9,22 +9,14 @@
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const { mockDbGet, mockDbRun } = vi.hoisted(() => ({
|
||||
// The prepare() stub below forwards the SQL and then the bound values, so the
|
||||
// spy has to declare that shape. Inferred from a zero-arg implementation it
|
||||
// would reject the spread at the call site.
|
||||
mockDbGet: vi.fn((_sql: string, ..._params: unknown[]): unknown => undefined),
|
||||
mockDbRun: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/db/database', () => ({
|
||||
db: {
|
||||
prepare: (sql: string) => ({
|
||||
get: (...params: unknown[]) => mockDbGet(sql, ...params),
|
||||
run: (...params: unknown[]) => mockDbRun(sql, ...params),
|
||||
all: () => [],
|
||||
}),
|
||||
},
|
||||
// Rebuilt on PlaceDetailsCacheRepository/AppSettingsRepository (Plan 3c Task
|
||||
// 1, R8's rewrite list) — see place-enrichment.service.test.ts's header for
|
||||
// why the legacy SQL-text-keyed `db.prepare` stub cannot survive the service
|
||||
// calling two named repository methods instead.
|
||||
const { mockGetValue, mockFindEntry, mockUpsertEntry } = vi.hoisted(() => ({
|
||||
mockGetValue: vi.fn(async (_key: string): Promise<string | null> => null),
|
||||
mockFindEntry: vi.fn(async (..._args: unknown[]): Promise<{ payload_json: string; fetched_at: number } | null> => null),
|
||||
mockUpsertEntry: vi.fn(async (_row: { place_id: string; lang: string; expanded: number; payload_json: string; fetched_at: number }): Promise<void> => {}),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/utils/ssrfGuard', () => ({
|
||||
@@ -42,11 +34,11 @@ vi.mock('../../../src/nest/maps/trek-places.client', () => ({
|
||||
trekPlacesById: mockTrekPlacesById,
|
||||
}));
|
||||
|
||||
import { db } from '../../../src/db/database';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import { PlaceEnrichmentService } from '../../../src/nest/place-enrichment/place-enrichment.service';
|
||||
import type { MapsService } from '../../../src/nest/maps/maps.service';
|
||||
import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service';
|
||||
import type { PlaceDetailsCacheRepository } from '../../../src/db/repositories/PlaceDetailsCache.repository';
|
||||
import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository';
|
||||
|
||||
const GOOGLE_REQ = { lat: 53.6323, lng: 10.0067, name: 'Hamburg Airport', placeId: 'ChIJham', lang: 'de' };
|
||||
const OSM_REQ = { lat: 52.525, lng: 13.3694, name: 'Berlin Hauptbahnhof', placeId: 'relation:3600565', lang: 'de' };
|
||||
@@ -83,12 +75,23 @@ function mapsStub(over: Partial<Record<keyof MapsService, unknown>> = {}) {
|
||||
const cacheStub = () =>
|
||||
({ get: vi.fn(() => null), put: vi.fn(async () => ({ photoUrl: '/x', filePath: '/x', attribution: null })) }) as unknown as PlacePhotoCacheService;
|
||||
|
||||
const make = (maps: MapsService) => new PlaceEnrichmentService(new DatabaseService(db as never), maps, cacheStub());
|
||||
function appSettingsStub(): AppSettingsRepository {
|
||||
return { getValue: mockGetValue } as unknown as AppSettingsRepository;
|
||||
}
|
||||
|
||||
function cacheRepoStub(): PlaceDetailsCacheRepository {
|
||||
return { findEntry: mockFindEntry, upsertEntry: mockUpsertEntry } as unknown as PlaceDetailsCacheRepository;
|
||||
}
|
||||
|
||||
const make = (maps: MapsService) => new PlaceEnrichmentService(cacheRepoStub(), appSettingsStub(), maps, cacheStub());
|
||||
|
||||
beforeEach(() => {
|
||||
mockDbGet.mockReset();
|
||||
mockDbGet.mockReturnValue(undefined);
|
||||
mockDbRun.mockReset();
|
||||
mockGetValue.mockReset();
|
||||
mockGetValue.mockResolvedValue(null);
|
||||
mockFindEntry.mockReset();
|
||||
mockFindEntry.mockResolvedValue(null);
|
||||
mockUpsertEntry.mockReset();
|
||||
mockUpsertEntry.mockResolvedValue(undefined);
|
||||
mockTrekPlacesById.mockReset();
|
||||
mockTrekPlacesById.mockResolvedValue(null);
|
||||
});
|
||||
@@ -417,9 +420,10 @@ describe("the description on the place's own website", () => {
|
||||
source: 'website',
|
||||
sourceUrl: 'https://losteria.net/rostock',
|
||||
});
|
||||
const written = mockDbRun.mock.calls.find(([sql]) => String(sql).includes('INSERT OR REPLACE INTO place_details_cache'));
|
||||
expect(mockUpsertEntry).toHaveBeenCalledTimes(1);
|
||||
const [written] = mockUpsertEntry.mock.calls[0];
|
||||
expect(written).toBeTruthy();
|
||||
expect(String(written![4])).not.toContain('phish.example');
|
||||
expect(written.payload_json).not.toContain('phish.example');
|
||||
});
|
||||
|
||||
it('ENRICH-101: asks when the caller passed no details, all the same', async () => {
|
||||
@@ -468,8 +472,8 @@ describe("the description on the place's own website", () => {
|
||||
});
|
||||
|
||||
expect(out.description).toBeNull();
|
||||
for (const [sql, ...params] of mockDbRun.mock.calls) {
|
||||
expect(JSON.stringify(params), String(sql)).not.toContain('phish.example');
|
||||
for (const [row] of mockUpsertEntry.mock.calls) {
|
||||
expect(JSON.stringify(row)).not.toContain('phish.example');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -490,7 +494,7 @@ describe("the description on the place's own website", () => {
|
||||
const out = await make(mapsStub()).enrich(1, carried);
|
||||
|
||||
expect(out.description).toMatchObject({ source: 'osm', text: 'Buchung nur noch über https://phish.example' });
|
||||
expect(mockDbRun.mock.calls.some(([sql]) => String(sql).includes('place_details_cache'))).toBe(false);
|
||||
expect(mockUpsertEntry).not.toHaveBeenCalled();
|
||||
|
||||
// The same summary from the service's own lookup is the map's, and keeps.
|
||||
const maps = mapsStub({
|
||||
@@ -498,10 +502,10 @@ describe("the description on the place's own website", () => {
|
||||
place: { source: 'openstreetmap', summary: 'Größter Kreuzungsbahnhof Europas.', osm_url: 'https://www.openstreetmap.org/relation/3600565' },
|
||||
})),
|
||||
});
|
||||
mockDbRun.mockClear();
|
||||
mockUpsertEntry.mockClear();
|
||||
const own = await make(maps).enrich(1, OSM_REQ);
|
||||
expect(own.description).toMatchObject({ source: 'osm', text: 'Größter Kreuzungsbahnhof Europas.' });
|
||||
expect(mockDbRun.mock.calls.some(([sql]) => String(sql).includes('INSERT OR REPLACE INTO place_details_cache'))).toBe(true);
|
||||
expect(mockUpsertEntry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('ENRICH-121: a menu link the request carried is answered, but not cached for everyone', async () => {
|
||||
@@ -515,16 +519,16 @@ describe("the description on the place's own website", () => {
|
||||
});
|
||||
|
||||
expect(out.facts).toContainEqual({ kind: 'menu', value: null, url: 'https://phish.example/menu' });
|
||||
expect(mockDbRun.mock.calls.some(([sql]) => String(sql).includes('place_details_cache'))).toBe(false);
|
||||
expect(mockUpsertEntry).not.toHaveBeenCalled();
|
||||
|
||||
// The same link from the service's own lookup is the map's, and keeps.
|
||||
const maps = mapsStub({
|
||||
details: vi.fn(async () => ({ place: { source: 'openstreetmap', menu_url: 'https://example.org/karte' } })),
|
||||
});
|
||||
mockDbRun.mockClear();
|
||||
mockUpsertEntry.mockClear();
|
||||
const own = await make(maps).enrich(1, OSM_REQ);
|
||||
expect(own.facts).toContainEqual({ kind: 'menu', value: null, url: 'https://example.org/karte' });
|
||||
expect(mockDbRun.mock.calls.some(([sql]) => String(sql).includes('INSERT OR REPLACE INTO place_details_cache'))).toBe(true);
|
||||
expect(mockUpsertEntry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('ENRICH-119: with the index switched off, no lookup leaves for the description', async () => {
|
||||
|
||||
@@ -4,34 +4,24 @@
|
||||
* The service is a fan-out over MapsService primitives plus the shared photo
|
||||
* cache, so both are injected as stubs and every provider branch is driven from
|
||||
* the test. The SSRF guard is mocked the same way maps.service.test.ts mocks it.
|
||||
*
|
||||
* Rebuilt on PlaceDetailsCacheRepository/AppSettingsRepository (Plan 3c Task
|
||||
* 1, R8's rewrite list): the legacy version stubbed `db.prepare(sql).{get,run}`
|
||||
* and branched on whether the SQL text contained `'place_details_cache'` to
|
||||
* tell the settings read (PE1) apart from the cache read (PE2)/write (PE3).
|
||||
* That coupling cannot survive the service calling two named repository
|
||||
* methods instead of one shared connection — each repository method now has
|
||||
* its own controllable fake, with no SQL-text branching anywhere.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
/** Whatever the statement under test selects: a settings row, a cache row, or nothing. */
|
||||
type DbRow = Record<string, unknown> | undefined;
|
||||
|
||||
const { mockDbGet, mockDbRun, mockSafeFetchFollow } = vi.hoisted(() => ({
|
||||
// Signature taken from the seam below rather than from this default: the
|
||||
// statement text is folded in ahead of the params, so a no-argument spy
|
||||
// would reject both the call site and every mockImplementation here.
|
||||
mockDbGet: vi.fn((_sql: string, ..._params: unknown[]): DbRow => undefined),
|
||||
mockDbRun: vi.fn((_sql: string, ..._params: unknown[]) => {}),
|
||||
const { mockGetValue, mockFindEntry, mockUpsertEntry, mockSafeFetchFollow } = vi.hoisted(() => ({
|
||||
mockGetValue: vi.fn(async (_key: string): Promise<string | null> => null),
|
||||
mockFindEntry: vi.fn(async (..._args: unknown[]): Promise<{ payload_json: string; fetched_at: number } | null> => null),
|
||||
mockUpsertEntry: vi.fn(async (..._args: unknown[]): Promise<void> => {}),
|
||||
mockSafeFetchFollow: vi.fn(async () => ({ ok: true, arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer })),
|
||||
}));
|
||||
|
||||
// prepare() takes the SQL and the statement takes the params, so the spies get
|
||||
// the statement text folded back in as their first argument — the assertions
|
||||
// below distinguish the settings read from the cache read by exactly that.
|
||||
vi.mock('../../../src/db/database', () => ({
|
||||
db: {
|
||||
prepare: (sql: string) => ({
|
||||
get: (...params: unknown[]) => mockDbGet(sql, ...params),
|
||||
run: (...params: unknown[]) => mockDbRun(sql, ...params),
|
||||
all: () => [],
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/utils/ssrfGuard', () => ({
|
||||
safeFetchFollow: mockSafeFetchFollow,
|
||||
checkSsrf: vi.fn(async () => ({ allowed: true })),
|
||||
@@ -51,8 +41,6 @@ vi.mock('../../../src/nest/maps/trek-places.client', async (importOriginal) => (
|
||||
trekPlacesById: mockTrekPlacesById,
|
||||
}));
|
||||
|
||||
import { db } from '../../../src/db/database';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import {
|
||||
candidateKey,
|
||||
PlaceEnrichmentService,
|
||||
@@ -64,6 +52,16 @@ import {
|
||||
} from '../../../src/nest/place-enrichment/place-enrichment.service';
|
||||
import type { MapsService } from '../../../src/nest/maps/maps.service';
|
||||
import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service';
|
||||
import type { PlaceDetailsCacheRepository } from '../../../src/db/repositories/PlaceDetailsCache.repository';
|
||||
import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository';
|
||||
|
||||
function appSettingsStub(): AppSettingsRepository {
|
||||
return { getValue: mockGetValue } as unknown as AppSettingsRepository;
|
||||
}
|
||||
|
||||
function cacheRepoStub(): PlaceDetailsCacheRepository {
|
||||
return { findEntry: mockFindEntry, upsertEntry: mockUpsertEntry } as unknown as PlaceDetailsCacheRepository;
|
||||
}
|
||||
|
||||
const REQ = { lat: 50.9, lng: 6.96, name: 'Museum Ludwig', placeId: 'ChIJmuseum' };
|
||||
const OSM_REQ = { ...REQ, placeId: 'way:12345' };
|
||||
@@ -105,7 +103,7 @@ function cacheStub(over: Record<string, unknown> = {}) {
|
||||
}
|
||||
|
||||
function make(maps: MapsService, cache: PlacePhotoCacheService) {
|
||||
return new PlaceEnrichmentService(new DatabaseService(db as never), maps, cache);
|
||||
return new PlaceEnrichmentService(cacheRepoStub(), appSettingsStub(), maps, cache);
|
||||
}
|
||||
|
||||
let candidateSeq = 0;
|
||||
@@ -133,9 +131,12 @@ beforeEach(() => {
|
||||
// Per test, so a fixture's page id is a property of the case rather than of
|
||||
// where the case sits in the file.
|
||||
candidateSeq = 0;
|
||||
mockDbGet.mockReset();
|
||||
mockDbGet.mockReturnValue(undefined);
|
||||
mockDbRun.mockReset();
|
||||
mockGetValue.mockReset();
|
||||
mockGetValue.mockResolvedValue(null);
|
||||
mockFindEntry.mockReset();
|
||||
mockFindEntry.mockResolvedValue(null);
|
||||
mockUpsertEntry.mockReset();
|
||||
mockUpsertEntry.mockResolvedValue(undefined);
|
||||
mockSafeFetchFollow.mockReset();
|
||||
mockSafeFetchFollow.mockResolvedValue({ ok: true, arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer });
|
||||
mockTrekPlacesById.mockReset();
|
||||
@@ -148,19 +149,19 @@ describe('enrichDisabled', () => {
|
||||
it('ENRICH-001: is off only when the setting is literally "false"', async () => {
|
||||
const svc = make(mapsStub(), cacheStub());
|
||||
|
||||
mockDbGet.mockReturnValue(undefined);
|
||||
mockGetValue.mockResolvedValue(null);
|
||||
expect(await svc.enrichDisabled()).toBe(false); // never configured — fail open
|
||||
|
||||
mockDbGet.mockReturnValue({ value: 'true' });
|
||||
mockGetValue.mockResolvedValue('true');
|
||||
expect(await svc.enrichDisabled()).toBe(false);
|
||||
|
||||
mockDbGet.mockReturnValue({ value: 'false' });
|
||||
mockGetValue.mockResolvedValue('false');
|
||||
expect(await svc.enrichDisabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('ENRICH-002: answers the disabled envelope without touching a provider', async () => {
|
||||
const maps = mapsStub();
|
||||
mockDbGet.mockReturnValue({ value: 'false' });
|
||||
mockGetValue.mockResolvedValue('false');
|
||||
|
||||
const out = await make(maps, cacheStub()).enrich(1, REQ);
|
||||
|
||||
@@ -547,10 +548,8 @@ describe('result cache', () => {
|
||||
it('ENRICH-021: serves a fresh entry without calling any provider', async () => {
|
||||
const maps = mapsStub();
|
||||
const cache = cacheStub({ get: vi.fn(() => ({ photoUrl: '/x', filePath: '/tmp/x', attribution: null })) });
|
||||
mockDbGet.mockImplementation((sql: string) =>
|
||||
String(sql).includes('place_details_cache')
|
||||
? cachedRow([{ key: 'ChIJmuseum~p0', url: '/x', attribution: null, license: null, licenseUrl: null, sourceUrl: null, source: 'wikimedia' }])
|
||||
: undefined,
|
||||
mockFindEntry.mockResolvedValue(
|
||||
cachedRow([{ key: 'ChIJmuseum~p0', url: '/x', attribution: null, license: null, licenseUrl: null, sourceUrl: null, source: 'wikimedia' }]),
|
||||
);
|
||||
|
||||
const out = await make(maps, cache).enrich(1, REQ);
|
||||
@@ -562,9 +561,7 @@ describe('result cache', () => {
|
||||
it('ENRICH-022: refetches once the entry is older than a week', async () => {
|
||||
const maps = mapsStub({ fetchCommonsCandidates: vi.fn(async () => [commonsCandidate()]) });
|
||||
const eightDaysAgo = Date.now() - 8 * 24 * 60 * 60 * 1000;
|
||||
mockDbGet.mockImplementation((sql: string) =>
|
||||
String(sql).includes('place_details_cache') ? cachedRow([], eightDaysAgo) : undefined,
|
||||
);
|
||||
mockFindEntry.mockResolvedValue(cachedRow([], eightDaysAgo));
|
||||
|
||||
await make(maps, cacheStub()).enrich(1, REQ);
|
||||
|
||||
@@ -584,10 +581,8 @@ describe('result cache', () => {
|
||||
return { photoUrl: `/api/maps/place-photo/${key}/bytes`, filePath: '/tmp/x', attribution: null };
|
||||
}),
|
||||
});
|
||||
mockDbGet.mockImplementation((sql: string) =>
|
||||
String(sql).includes('place_details_cache')
|
||||
? cachedRow([{ key: 'ChIJmuseum~p0', url: '/x', attribution: null, license: null, licenseUrl: null, sourceUrl: null, source: 'wikimedia' }])
|
||||
: undefined,
|
||||
mockFindEntry.mockResolvedValue(
|
||||
cachedRow([{ key: 'ChIJmuseum~p0', url: '/x', attribution: null, license: null, licenseUrl: null, sourceUrl: null, source: 'wikimedia' }]),
|
||||
);
|
||||
|
||||
const out = await make(maps, cache).enrich(1, REQ);
|
||||
@@ -599,10 +594,8 @@ describe('result cache', () => {
|
||||
it('ENRICH-023b: keeps serving a cached entry while all its bytes are still there', async () => {
|
||||
const maps = mapsStub();
|
||||
const cache = cacheStub({ get: vi.fn(() => ({ photoUrl: '/x', filePath: '/tmp/x', attribution: null })) });
|
||||
mockDbGet.mockImplementation((sql: string) =>
|
||||
String(sql).includes('place_details_cache')
|
||||
? cachedRow([{ key: 'ChIJmuseum~p0', url: '/x', attribution: null, license: null, licenseUrl: null, sourceUrl: null, source: 'wikimedia' }])
|
||||
: undefined,
|
||||
mockFindEntry.mockResolvedValue(
|
||||
cachedRow([{ key: 'ChIJmuseum~p0', url: '/x', attribution: null, license: null, licenseUrl: null, sourceUrl: null, source: 'wikimedia' }]),
|
||||
);
|
||||
|
||||
const out = await make(maps, cache).enrich(1, REQ);
|
||||
@@ -614,9 +607,7 @@ describe('result cache', () => {
|
||||
it('ENRICH-023c: discards an entry written by an older format version', async () => {
|
||||
// Otherwise a release that adds a field keeps serving last week's answers.
|
||||
const maps = mapsStub({ fetchCommonsCandidates: vi.fn(async () => [commonsCandidate()]) });
|
||||
mockDbGet.mockImplementation((sql: string) =>
|
||||
String(sql).includes('place_details_cache') ? cachedRow([], Date.now(), 1) : undefined,
|
||||
);
|
||||
mockFindEntry.mockResolvedValue(cachedRow([], Date.now(), 1));
|
||||
|
||||
await make(maps, cacheStub()).enrich(1, REQ);
|
||||
|
||||
@@ -625,9 +616,7 @@ describe('result cache', () => {
|
||||
|
||||
it('ENRICH-024: ignores an unreadable cache row and rebuilds', async () => {
|
||||
const maps = mapsStub({ fetchCommonsCandidates: vi.fn(async () => [commonsCandidate()]) });
|
||||
mockDbGet.mockImplementation((sql: string) =>
|
||||
String(sql).includes('place_details_cache') ? { payload_json: '{not json', fetched_at: Date.now() } : undefined,
|
||||
);
|
||||
mockFindEntry.mockResolvedValue({ payload_json: '{not json', fetched_at: Date.now() });
|
||||
|
||||
const out = await make(maps, cacheStub()).enrich(1, REQ);
|
||||
|
||||
@@ -639,17 +628,17 @@ describe('result cache', () => {
|
||||
|
||||
await make(maps, cacheStub()).enrich(1, { ...REQ, lang: 'de' });
|
||||
|
||||
const write = mockDbRun.mock.calls.find((c) => String(c[0]).includes('INSERT OR REPLACE INTO place_details_cache'));
|
||||
expect(write).toBeDefined();
|
||||
expect(write![1]).toBe('ChIJmuseum');
|
||||
expect(write![2]).toBe('de');
|
||||
expect(mockUpsertEntry).toHaveBeenCalledTimes(1);
|
||||
const [written] = mockUpsertEntry.mock.calls[0] as [{ place_id: string; lang: string; expanded: number; payload_json: string; fetched_at: number }];
|
||||
expect(written.place_id).toBe('ChIJmuseum');
|
||||
expect(written.lang).toBe('de');
|
||||
// expanded = 2 — the plain (0) and reviews (1) caches keep their rows.
|
||||
expect(write![3]).toBe(2);
|
||||
expect(written.expanded).toBe(2);
|
||||
});
|
||||
|
||||
it('ENRICH-026: keeps working when the cache write fails', async () => {
|
||||
const maps = mapsStub({ fetchCommonsCandidates: vi.fn(async () => [commonsCandidate()]) });
|
||||
mockDbRun.mockImplementation(() => { throw new Error('db locked'); });
|
||||
mockUpsertEntry.mockRejectedValue(new Error('db locked'));
|
||||
const err = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
const out = await make(maps, cacheStub()).enrich(1, REQ);
|
||||
|
||||
@@ -10,6 +10,16 @@
|
||||
* bare keys) — via the shared stub-registry fixture. The real mode flip is
|
||||
* pinned by the storage-registry tests; here the two prefixes prove the cache
|
||||
* itself is mode-agnostic.
|
||||
*
|
||||
* Rebuilt on GooglePlacePhotoMetaRepository/PlacesRepository (Plan 3c Task 1):
|
||||
* the legacy version cast a hand-rolled 3-table SQLite fixture to
|
||||
* `DatabaseService`; that cannot survive the service now calling two
|
||||
* repositories for its owned reads/writes. The same minimal 3-table fixture
|
||||
* stays (every column these repositories touch is already in it — the R8
|
||||
* `createSnapshotTestDb()` migration only applies once a converted read
|
||||
* needs a column the hand-rolled DDL omits, which is not the case here), but
|
||||
* it is now wired through `createTestOrm()` and `DatabaseService` is kept
|
||||
* ONLY for the one raw `collection_places` statement (PP6's Plan 3h half).
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from 'vitest';
|
||||
import path from 'node:path';
|
||||
@@ -48,6 +58,11 @@ vi.mock('../../../../src/db/database', () => ({ db: testDb }));
|
||||
|
||||
import { PlacePhotoCacheService } from '../../../../src/nest/place-photos/place-photo-cache.service';
|
||||
import { DatabaseService } from '../../../../src/nest/database/database.service';
|
||||
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
|
||||
import { GooglePlacePhotoMeta } from '../../../../src/db/entities/GooglePlacePhotoMeta.entity';
|
||||
import type { GooglePlacePhotoMetaRepository } from '../../../../src/db/repositories/GooglePlacePhotoMeta.repository';
|
||||
import { Places } from '../../../../src/db/entities/Places.entity';
|
||||
import type { PlacesRepository } from '../../../../src/db/repositories/Places.repository';
|
||||
import { makeStorageFixture, type StorageFixture } from '../../../helpers/storage-fixture';
|
||||
|
||||
async function makeJpeg(width: number, height: number): Promise<Buffer> {
|
||||
@@ -59,6 +74,17 @@ function nameFor(placeId: string): string {
|
||||
return `${crypto.createHash('sha1').update(placeId).digest('hex')}.jpg`;
|
||||
}
|
||||
|
||||
let t: TestOrm;
|
||||
let metaRepo: GooglePlacePhotoMetaRepository;
|
||||
let placesRepo: PlacesRepository;
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestOrm(testDb, { allowGlobalContext: true });
|
||||
metaRepo = t.repo(GooglePlacePhotoMeta);
|
||||
placesRepo = t.repo(Places);
|
||||
});
|
||||
afterAll(async () => { await t.close(); });
|
||||
|
||||
describe.each([
|
||||
['mode A (photos/google/ prefix)', 'photos/google/'],
|
||||
['mode B (bare keys)', ''],
|
||||
@@ -75,11 +101,12 @@ describe.each([
|
||||
|
||||
beforeAll(() => {
|
||||
fx = makeStorageFixture(keyPrefix);
|
||||
cache = new PlacePhotoCacheService(new DatabaseService(testDb as never), fx.storage);
|
||||
cache = new PlacePhotoCacheService(new DatabaseService(testDb as never), fx.storage, metaRepo, placesRepo);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
testDb.exec('DELETE FROM places; DELETE FROM collection_places; DELETE FROM google_place_photo_meta;');
|
||||
t.clear();
|
||||
for (const f of fs.readdirSync(fx.root)) {
|
||||
if (f === '.tmp') continue;
|
||||
fs.rmSync(path.join(fx.root, f), { recursive: true, force: true });
|
||||
@@ -180,6 +207,20 @@ describe.each([
|
||||
|
||||
expect(fs.existsSync(filePathFor(id))).toBe(true);
|
||||
});
|
||||
|
||||
// Plan 3c Task 1 PP6 ruling: the `collection_places` half stays raw
|
||||
// (Plan 3h owns that table) and only runs when the `places` half comes
|
||||
// back false — this is the direct proof that half is still wired,
|
||||
// through the SAME `isReferenced` call as the two above.
|
||||
it('PPC-016: keeps an entry referenced only through collection_places, proving the Plan 3h raw fallback still fires', async () => {
|
||||
const id = 'coll-only';
|
||||
await cache.put(id, await makeJpeg(50, 50), null);
|
||||
testDb.prepare('INSERT INTO collection_places (google_place_id) VALUES (?)').run(id);
|
||||
|
||||
await cache.removeIfUnreferenced(id);
|
||||
|
||||
expect(fs.existsSync(filePathFor(id))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sweepOrphans()', () => {
|
||||
|
||||
@@ -1,49 +1,28 @@
|
||||
/**
|
||||
* PlaceShadowService against a real in-memory database.
|
||||
* PlaceShadowService against real rows (Plan 3c Task 1).
|
||||
*
|
||||
* The interesting parts of this service ARE the SQL — the id-paged export, the
|
||||
* rank bucketing, the age-based retention — so a mocked DatabaseService would
|
||||
* assert that the strings were passed along and prove nothing about what they
|
||||
* do. The table is created here rather than by running the migration array, so
|
||||
* appending the next migration cannot drag this file along.
|
||||
* rank bucketing, the age-based retention — so a mocked repository would
|
||||
* assert that values were passed along and prove nothing about what they do.
|
||||
* Rebuilt off the real migrated schema snapshot (`createSnapshotTestDb()` +
|
||||
* `createTestOrm()`, the harness every other converted repository test uses)
|
||||
* rather than the legacy hand-written `SCHEMA`/`dbFacade` — R8's rewrite list.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import Database from 'better-sqlite3';
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import { createSnapshotTestDb } from '../../helpers/db-mock';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
|
||||
import { PlaceShadowService, RETENTION_DAYS } from '../../../src/nest/place-shadow/place-shadow.service';
|
||||
import type { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import { PlaceShadowPicks } from '../../../src/db/entities/PlaceShadowPicks.entity';
|
||||
import type { PlaceShadowPicksRepository } from '../../../src/db/repositories/PlaceShadowPicks.repository';
|
||||
import { AppSettings } from '../../../src/db/entities/AppSettings.entity';
|
||||
import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository';
|
||||
import type { PlaceShadowPickRequest } from '@trek/shared';
|
||||
|
||||
const SCHEMA = `
|
||||
CREATE TABLE app_settings (key TEXT PRIMARY KEY, value TEXT);
|
||||
CREATE TABLE place_shadow_picks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
query TEXT NOT NULL,
|
||||
lang TEXT,
|
||||
bias_lat REAL,
|
||||
bias_lng REAL,
|
||||
source TEXT NOT NULL,
|
||||
live_rank INTEGER NOT NULL,
|
||||
live_count INTEGER NOT NULL,
|
||||
picked_name TEXT NOT NULL,
|
||||
picked_lat REAL NOT NULL,
|
||||
picked_lng REAL NOT NULL,
|
||||
picked_place_id TEXT
|
||||
);
|
||||
`;
|
||||
|
||||
let conn: Database.Database;
|
||||
const testDb = createSnapshotTestDb();
|
||||
let t: TestOrm;
|
||||
let svc: PlaceShadowService;
|
||||
|
||||
/** Just the three methods the service uses, backed by the real connection. */
|
||||
function dbFacade(c: Database.Database): DatabaseService {
|
||||
return {
|
||||
get: <T>(sql: string, ...p: unknown[]) => c.prepare(sql).get(...p) as T | undefined,
|
||||
all: <T>(sql: string, ...p: unknown[]) => c.prepare(sql).all(...p) as T[],
|
||||
run: (sql: string, ...p: unknown[]) => c.prepare(sql).run(...p),
|
||||
} as unknown as DatabaseService;
|
||||
}
|
||||
|
||||
const PICK: PlaceShadowPickRequest = {
|
||||
query: 'losteria rostock',
|
||||
lang: 'de',
|
||||
@@ -59,27 +38,30 @@ const PICK: PlaceShadowPickRequest = {
|
||||
};
|
||||
|
||||
function enable(on = true) {
|
||||
conn.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)')
|
||||
testDb.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)')
|
||||
.run('place_shadow_enabled', on ? 'true' : 'false');
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
conn = new Database(':memory:');
|
||||
conn.exec(SCHEMA);
|
||||
svc = new PlaceShadowService(dbFacade(conn));
|
||||
beforeAll(async () => {
|
||||
t = await createTestOrm(testDb);
|
||||
const picks: PlaceShadowPicksRepository = t.repo(PlaceShadowPicks);
|
||||
const appSettings: AppSettingsRepository = t.repo(AppSettings);
|
||||
svc = new PlaceShadowService(picks, appSettings);
|
||||
});
|
||||
beforeEach(() => { resetTestDb(testDb); t.clear(); });
|
||||
afterAll(async () => { await t.close(); testDb.close(); });
|
||||
|
||||
describe('PlaceShadowService', () => {
|
||||
describe('the switch', () => {
|
||||
it('is off when the setting row is absent', async () => {
|
||||
expect(await svc.enabled()).toBe(false);
|
||||
expect(await svc.record(PICK)).toBe(false);
|
||||
expect(conn.prepare('SELECT COUNT(*) AS n FROM place_shadow_picks').get()).toEqual({ n: 0 });
|
||||
expect(testDb.prepare('SELECT COUNT(*) AS n FROM place_shadow_picks').get()).toEqual({ n: 0 });
|
||||
});
|
||||
|
||||
it('is off for any value that is not exactly "true"', async () => {
|
||||
for (const value of ['false', '1', 'yes', 'TRUE', '']) {
|
||||
conn.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)')
|
||||
testDb.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)')
|
||||
.run('place_shadow_enabled', value);
|
||||
expect(await svc.enabled(), `value ${JSON.stringify(value)}`).toBe(false);
|
||||
}
|
||||
@@ -97,7 +79,7 @@ describe('PlaceShadowService', () => {
|
||||
|
||||
it('rounds every coordinate to three decimals', async () => {
|
||||
await svc.record({ ...PICK, pickedLat: 54.0891234, pickedLng: 12.1372987, biasLat: 54.08871, biasLng: 12.14049 });
|
||||
const row = conn.prepare('SELECT * FROM place_shadow_picks').get() as Record<string, number>;
|
||||
const row = testDb.prepare('SELECT * FROM place_shadow_picks').get() as Record<string, number>;
|
||||
expect(row.picked_lat).toBe(54.089);
|
||||
expect(row.picked_lng).toBe(12.137);
|
||||
expect(row.bias_lat).toBe(54.089);
|
||||
@@ -106,7 +88,7 @@ describe('PlaceShadowService', () => {
|
||||
|
||||
it('stores an absent bias and place id as NULL rather than inventing zeroes', async () => {
|
||||
await svc.record({ ...PICK, biasLat: undefined, biasLng: undefined, pickedPlaceId: undefined });
|
||||
const row = conn.prepare('SELECT * FROM place_shadow_picks').get() as Record<string, unknown>;
|
||||
const row = testDb.prepare('SELECT * FROM place_shadow_picks').get() as Record<string, unknown>;
|
||||
expect(row.bias_lat).toBeNull();
|
||||
expect(row.bias_lng).toBeNull();
|
||||
expect(row.picked_place_id).toBeNull();
|
||||
@@ -156,7 +138,7 @@ describe('PlaceShadowService', () => {
|
||||
});
|
||||
|
||||
it('answers on an empty corpus without dividing by zero', async () => {
|
||||
conn.exec('DELETE FROM place_shadow_picks');
|
||||
testDb.exec('DELETE FROM place_shadow_picks');
|
||||
const s = await svc.summary();
|
||||
expect(s).toMatchObject({ total: 0, liveTopOneShare: 0, liveTopFiveShare: 0, oldest: null, newest: null });
|
||||
expect(s.retentionDays).toBe(RETENTION_DAYS);
|
||||
@@ -214,21 +196,25 @@ describe('PlaceShadowService', () => {
|
||||
it('removes only rows past the window', async () => {
|
||||
await svc.record(PICK);
|
||||
await svc.record(PICK);
|
||||
conn.prepare("UPDATE place_shadow_picks SET created_at = datetime('now', '-200 days') WHERE id = 1").run();
|
||||
// `resetTestDb` never resets `sqlite_sequence` (ids keep growing across
|
||||
// tests in this file, by design — see its own docstring), so the
|
||||
// "first" row's id is read back rather than hardcoded as `1`.
|
||||
const [first] = testDb.prepare('SELECT id FROM place_shadow_picks ORDER BY id ASC').all() as { id: number }[];
|
||||
testDb.prepare("UPDATE place_shadow_picks SET created_at = datetime('now', '-200 days') WHERE id = ?").run(first.id);
|
||||
expect(await svc.purgeExpired()).toBe(1);
|
||||
expect((await svc.summary()).total).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps a row that is one day short of the window', async () => {
|
||||
await svc.record(PICK);
|
||||
conn.prepare("UPDATE place_shadow_picks SET created_at = datetime('now', ?)")
|
||||
testDb.prepare("UPDATE place_shadow_picks SET created_at = datetime('now', ?)")
|
||||
.run(`-${RETENTION_DAYS - 1} days`);
|
||||
expect(await svc.purgeExpired()).toBe(0);
|
||||
});
|
||||
|
||||
it('runs even while the log is switched off, so old rows still age out', async () => {
|
||||
await svc.record(PICK);
|
||||
conn.prepare("UPDATE place_shadow_picks SET created_at = datetime('now', '-200 days')").run();
|
||||
testDb.prepare("UPDATE place_shadow_picks SET created_at = datetime('now', '-200 days')").run();
|
||||
enable(false);
|
||||
expect(await svc.purgeExpired()).toBe(1);
|
||||
});
|
||||
|
||||
@@ -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, sharedTestOrm } from '../../helpers/test-uow';
|
||||
import { createTestUnitOfWork, createTestAppSettingsRepo, createTestUsersRepo, createTestTagsRepo, createTestPlaceRatingsRepo, createTestAssignmentParticipantsRepo, sharedTestOrm } from '../../helpers/test-uow';
|
||||
|
||||
const dbs = new DatabaseService(testDb);
|
||||
const photoCacheStub = { removeIfUnreferenced: vi.fn() } as unknown as PlacePhotoCacheService;
|
||||
@@ -86,7 +86,7 @@ async function svc(searchNominatim: MapsService['searchNominatim']): Promise<Pla
|
||||
},
|
||||
reverseGeocode: vi.fn(async () => null),
|
||||
} as unknown as MapsService,
|
||||
new QueryHelpersService(dbs),
|
||||
new QueryHelpersService(await createTestTagsRepo(dbs.connection), await createTestPlaceRatingsRepo(dbs.connection), await createTestAssignmentParticipantsRepo(dbs.connection)),
|
||||
new UnsplashService(await createTestAppSettingsRepo(dbs.connection), await createTestUsersRepo(dbs.connection), new RuntimeEnvService(), storageFx.storage),
|
||||
photoCacheStub,
|
||||
new JourneyDomainService(dbs, new RealtimeService(), new TrekPhotosRepository(dbs), await createTestUnitOfWork(dbs.connection)),
|
||||
|
||||
@@ -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, sharedTestOrm } from '../../helpers/test-uow';
|
||||
import { createTestUnitOfWork, createTestAppSettingsRepo, createTestUsersRepo, createTestTagsRepo, createTestPlaceRatingsRepo, createTestAssignmentParticipantsRepo, sharedTestOrm } from '../../helpers/test-uow';
|
||||
import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository';
|
||||
import type { UsersRepository } from '../../../src/db/repositories/Users.repository';
|
||||
|
||||
@@ -113,7 +113,7 @@ async function makePlacesService(
|
||||
new PermissionsService(await createTestAppSettingsRepo(dbs.connection), await createTestUnitOfWork(dbs.connection)),
|
||||
new RealtimeService(),
|
||||
maps,
|
||||
new QueryHelpersService(dbs),
|
||||
new QueryHelpersService(await createTestTagsRepo(dbs.connection), await createTestPlaceRatingsRepo(dbs.connection), await createTestAssignmentParticipantsRepo(dbs.connection)),
|
||||
new UnsplashService(await createTestAppSettingsRepo(dbs.connection), await createTestUsersRepo(dbs.connection), new RuntimeEnvService(), placesStorageFx.storage),
|
||||
photoCacheStub,
|
||||
new JourneyDomainService(dbs, new RealtimeService(), new TrekPhotosRepository(dbs), await createTestUnitOfWork(dbs.connection)),
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Unit tests for QueryHelpersService (Plan 3c Task 1) — its first dedicated
|
||||
* test file (inventory §15c: previously exercised only transitively through
|
||||
* days/assignments/places/share suites). Real rows through
|
||||
* `TagsRepository`/`PlaceRatingsRepository`/`AssignmentParticipantsRepository`.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import { createSnapshotTestDb } from '../../helpers/db-mock';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
|
||||
import { createDay, createDayAssignment, createPlace, createTag, createTrip, createUser } from '../../helpers/factories';
|
||||
import { Tags } from '../../../src/db/entities/Tags.entity';
|
||||
import type { TagsRepository } from '../../../src/db/repositories/Tags.repository';
|
||||
import { PlaceRatings } from '../../../src/db/entities/PlaceRatings.entity';
|
||||
import type { PlaceRatingsRepository } from '../../../src/db/repositories/PlaceRatings.repository';
|
||||
import { AssignmentParticipants } from '../../../src/db/entities/AssignmentParticipants.entity';
|
||||
import type { AssignmentParticipantsRepository } from '../../../src/db/repositories/AssignmentParticipants.repository';
|
||||
import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpers.service';
|
||||
|
||||
const testDb = createSnapshotTestDb();
|
||||
let t: TestOrm;
|
||||
let svc: QueryHelpersService;
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestOrm(testDb);
|
||||
const tags: TagsRepository = t.repo(Tags);
|
||||
const placeRatings: PlaceRatingsRepository = t.repo(PlaceRatings);
|
||||
const assignmentParticipants: AssignmentParticipantsRepository = t.repo(AssignmentParticipants);
|
||||
svc = new QueryHelpersService(tags, placeRatings, assignmentParticipants);
|
||||
});
|
||||
beforeEach(() => { resetTestDb(testDb); t.clear(); });
|
||||
afterAll(async () => { await t.close(); testDb.close(); });
|
||||
|
||||
function attachTag(tagId: number, placeId: number): void {
|
||||
testDb.prepare('INSERT INTO place_tags (tag_id, place_id) VALUES (?, ?)').run(tagId, placeId);
|
||||
}
|
||||
|
||||
function rate(placeId: number, userId: number, rating: number): void {
|
||||
testDb.prepare('INSERT INTO place_ratings (place_id, user_id, rating) VALUES (?, ?, ?)').run(placeId, userId, rating);
|
||||
}
|
||||
|
||||
function addParticipant(assignmentId: number, userId: number): void {
|
||||
testDb.prepare('INSERT INTO assignment_participants (assignment_id, user_id) VALUES (?, ?)').run(assignmentId, userId);
|
||||
}
|
||||
|
||||
describe('QueryHelpersService.loadTagsByPlaceIds', () => {
|
||||
it('QH-001: an empty placeIds array yields an empty Record', async () => {
|
||||
expect(await svc.loadTagsByPlaceIds([])).toEqual({});
|
||||
});
|
||||
|
||||
it('QH-002: indexes tags by place id, non-compact carries every column but the join key', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
const tag = createTag(testDb, user.id, { name: 'Beach', color: '#ff0000' });
|
||||
attachTag(tag.id, place.id);
|
||||
|
||||
const byPlace = await svc.loadTagsByPlaceIds([place.id]);
|
||||
expect(byPlace[place.id]).toHaveLength(1);
|
||||
expect(byPlace[place.id][0]).toMatchObject({ id: tag.id, user_id: user.id, name: 'Beach', color: '#ff0000' });
|
||||
expect(byPlace[place.id][0]).not.toHaveProperty('place_id');
|
||||
});
|
||||
|
||||
it('QH-003: compact drops user_id', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
const tag = createTag(testDb, user.id, { name: 'Compact' });
|
||||
attachTag(tag.id, place.id);
|
||||
|
||||
const byPlace = await svc.loadTagsByPlaceIds([place.id], { compact: true });
|
||||
expect(byPlace[place.id][0]).toEqual({ id: tag.id, name: 'Compact', color: tag.color, created_at: expect.any(String) });
|
||||
});
|
||||
|
||||
it('QH-004: batches across several places, grouping correctly, a place with no tags gets no key', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const placeA = createPlace(testDb, trip.id, { name: 'A' });
|
||||
const placeB = createPlace(testDb, trip.id, { name: 'B' });
|
||||
const placeC = createPlace(testDb, trip.id, { name: 'C' });
|
||||
const tag = createTag(testDb, user.id);
|
||||
attachTag(tag.id, placeA.id);
|
||||
attachTag(tag.id, placeB.id);
|
||||
|
||||
const byPlace = await svc.loadTagsByPlaceIds([placeA.id, placeB.id, placeC.id]);
|
||||
expect(Object.keys(byPlace).map(Number).sort()).toEqual([placeA.id, placeB.id].sort());
|
||||
expect(byPlace[placeC.id]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('QH-004b: a second tag on the same place appends to the existing array instead of resetting it', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
const tagA = createTag(testDb, user.id, { name: 'First' });
|
||||
const tagB = createTag(testDb, user.id, { name: 'Second' });
|
||||
attachTag(tagA.id, place.id);
|
||||
attachTag(tagB.id, place.id);
|
||||
|
||||
const byPlace = await svc.loadTagsByPlaceIds([place.id]);
|
||||
expect(byPlace[place.id].map((t) => t.name).sort()).toEqual(['First', 'Second']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('QueryHelpersService.loadRatingsByPlaceIds', () => {
|
||||
it('QH-005: an empty placeIds array yields an empty Record', async () => {
|
||||
expect(await svc.loadRatingsByPlaceIds([])).toEqual({});
|
||||
});
|
||||
|
||||
it('QH-006: indexes ratings by place id with the voter\'s username/avatar', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: voter } = createUser(testDb, { username: 'rater1' });
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
rate(place.id, voter.id, 4);
|
||||
|
||||
const byPlace = await svc.loadRatingsByPlaceIds([place.id]);
|
||||
expect(byPlace[place.id]).toEqual([{ user_id: voter.id, username: 'rater1', avatar: null, rating: 4 }]);
|
||||
});
|
||||
|
||||
it('QH-007: a place with no ratings gets no key', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
expect(await svc.loadRatingsByPlaceIds([place.id])).toEqual({});
|
||||
});
|
||||
|
||||
it('QH-007b: a second rating on the same place appends to the existing array instead of resetting it', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: voterA } = createUser(testDb, { username: 'voter-a' });
|
||||
const { user: voterB } = createUser(testDb, { username: 'voter-b' });
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
rate(place.id, voterA.id, 5);
|
||||
rate(place.id, voterB.id, 2);
|
||||
|
||||
const byPlace = await svc.loadRatingsByPlaceIds([place.id]);
|
||||
expect(byPlace[place.id].map((r) => r.username).sort()).toEqual(['voter-a', 'voter-b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('QueryHelpersService.loadParticipantsByAssignmentIds', () => {
|
||||
it('QH-008: an empty assignmentIds array yields an empty Record', async () => {
|
||||
expect(await svc.loadParticipantsByAssignmentIds([])).toEqual({});
|
||||
});
|
||||
|
||||
it('QH-009: indexes participants by assignment id, with NO COALESCE(display_name, username) — raw username only', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: participant } = createUser(testDb, { username: 'raw-username' });
|
||||
testDb.prepare('UPDATE users SET display_name = ? WHERE id = ?').run('Ignored Display Name', participant.id);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
const assignment = createDayAssignment(testDb, day.id, place.id);
|
||||
addParticipant(assignment.id, participant.id);
|
||||
|
||||
const byAssignment = await svc.loadParticipantsByAssignmentIds([assignment.id]);
|
||||
expect(byAssignment[assignment.id]).toEqual([{ user_id: participant.id, username: 'raw-username', avatar: null }]);
|
||||
});
|
||||
|
||||
it('QH-010: an assignment with no participants gets no key', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
const assignment = createDayAssignment(testDb, day.id, place.id);
|
||||
expect(await svc.loadParticipantsByAssignmentIds([assignment.id])).toEqual({});
|
||||
});
|
||||
|
||||
it('QH-010b: a second participant on the same assignment appends to the existing array instead of resetting it', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: p1 } = createUser(testDb, { username: 'p1' });
|
||||
const { user: p2 } = createUser(testDb, { username: 'p2' });
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
const assignment = createDayAssignment(testDb, day.id, place.id);
|
||||
addParticipant(assignment.id, p1.id);
|
||||
addParticipant(assignment.id, p2.id);
|
||||
|
||||
const byAssignment = await svc.loadParticipantsByAssignmentIds([assignment.id]);
|
||||
expect(byAssignment[assignment.id].map((p) => p.username).sort()).toEqual(['p1', 'p2']);
|
||||
});
|
||||
});
|
||||
@@ -60,7 +60,7 @@ import { ShareService, publicReservationMetadata } from '../../../src/nest/share
|
||||
import { SettingsService } from '../../../src/nest/settings/settings.service';
|
||||
import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpers.service';
|
||||
import type { User } from '../../../src/types';
|
||||
import { sharedTestOrm, createTestUnitOfWork, createTestAppSettingsRepo, createTestSettingsRepo } from '../../helpers/test-uow';
|
||||
import { sharedTestOrm, createTestUnitOfWork, createTestAppSettingsRepo, createTestSettingsRepo, createTestTagsRepo, createTestPlaceRatingsRepo, createTestAssignmentParticipantsRepo } from '../../helpers/test-uow';
|
||||
import type { TestOrm } from '../../helpers/test-orm';
|
||||
|
||||
let svc: ShareService;
|
||||
@@ -78,7 +78,7 @@ beforeAll(async () => {
|
||||
dbs,
|
||||
new SettingsService(await createTestUnitOfWork(testDb), await createTestAppSettingsRepo(testDb), await createTestSettingsRepo(testDb)),
|
||||
permissionsStub,
|
||||
new QueryHelpersService(dbs),
|
||||
new QueryHelpersService(await createTestTagsRepo(dbs.connection), await createTestPlaceRatingsRepo(dbs.connection), await createTestAssignmentParticipantsRepo(dbs.connection)),
|
||||
photoCacheStub,
|
||||
await createTestUnitOfWork(testDb),
|
||||
);
|
||||
|
||||
@@ -40,7 +40,7 @@ import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
|
||||
import { TripMembershipService } from '../../../src/nest/trip-membership/trip-membership.service';
|
||||
import { TripInviteService } from '../../../src/nest/trip-invite/trip-invite.service';
|
||||
import { createTestUnitOfWork, createTestAppSettingsRepo } from '../../helpers/test-uow';
|
||||
import { createTestUnitOfWork, createTestAppSettingsRepo, createTestTripsRepo, createTestTripMembersRepo } from '../../helpers/test-uow';
|
||||
|
||||
// One DatabaseService over the shared in-memory handle, so every collaborator
|
||||
// reads and writes the same rows.
|
||||
@@ -48,7 +48,7 @@ const dbs = new DatabaseService(testDb);
|
||||
let svc: TripInviteService;
|
||||
beforeAll(async () => {
|
||||
const uow = await createTestUnitOfWork(dbs.connection);
|
||||
svc = new TripInviteService(dbs, new PermissionsService(await createTestAppSettingsRepo(dbs.connection), uow), new TripMembershipService(dbs), uow);
|
||||
svc = new TripInviteService(dbs, new PermissionsService(await createTestAppSettingsRepo(dbs.connection), uow), new TripMembershipService(await createTestTripsRepo(dbs.connection), await createTestTripMembersRepo(dbs.connection)), uow);
|
||||
});
|
||||
|
||||
beforeAll(() => { createTables(testDb); runMigrations(testDb); });
|
||||
|
||||
@@ -2,30 +2,39 @@
|
||||
* Unit tests for TripMembershipService.joinTripAsMember — TRIP-JOIN-001..004.
|
||||
* The shared add-by-id helper behind trip invite links (#1143) and trip-bound
|
||||
* admin invites (#1402): idempotent, owner-safe, missing-trip-safe.
|
||||
*
|
||||
* Rebuilt on real rows through `TripsRepository`/`TripMembersRepository`
|
||||
* (Plan 3c Task 1): the legacy version constructed `TripMembershipService`
|
||||
* directly on a `DatabaseService(testDb)` over hand-run legacy DDL
|
||||
* (`createTables`/`runMigrations`); this now uses `createSnapshotTestDb()` +
|
||||
* `createTestOrm()`, the same harness every other converted repository test
|
||||
* uses.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
return { testDb: db, dbMock: { db } };
|
||||
});
|
||||
vi.mock('../../../../src/db/database', () => dbMock);
|
||||
|
||||
import { createTables } from '../../../../src/db/schema';
|
||||
import { runMigrations } from '../../../../src/db/migrations';
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import { createSnapshotTestDb } from '../../../helpers/db-mock';
|
||||
import { resetTestDb } from '../../../helpers/test-db';
|
||||
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
|
||||
import { createUser, createTrip } from '../../../helpers/factories';
|
||||
import { DatabaseService } from '../../../../src/nest/database/database.service';
|
||||
import { Trips } from '../../../../src/db/entities/Trips.entity';
|
||||
import type { TripsRepository } from '../../../../src/db/repositories/Trips.repository';
|
||||
import { TripMembers } from '../../../../src/db/entities/TripMembers.entity';
|
||||
import type { TripMembersRepository } from '../../../../src/db/repositories/TripMembers.repository';
|
||||
import { TripMembershipService } from '../../../../src/nest/trip-membership/trip-membership.service';
|
||||
|
||||
const joinTripAsMember = (tripId: number, userId: number, invitedBy: number | null) =>
|
||||
new TripMembershipService(new DatabaseService(testDb)).joinTripAsMember(tripId, userId, invitedBy);
|
||||
const testDb = createSnapshotTestDb();
|
||||
let t: TestOrm;
|
||||
let trips: TripsRepository;
|
||||
let tripMembers: TripMembersRepository;
|
||||
let svc: TripMembershipService;
|
||||
|
||||
beforeAll(() => { createTables(testDb); runMigrations(testDb); });
|
||||
beforeEach(() => resetTestDb(testDb));
|
||||
afterAll(() => testDb.close());
|
||||
beforeAll(async () => {
|
||||
t = await createTestOrm(testDb);
|
||||
trips = t.repo(Trips);
|
||||
tripMembers = t.repo(TripMembers);
|
||||
svc = new TripMembershipService(trips, tripMembers);
|
||||
});
|
||||
beforeEach(() => { resetTestDb(testDb); t.clear(); });
|
||||
afterAll(async () => { await t.close(); testDb.close(); });
|
||||
|
||||
function memberRow(tripId: number, userId: number) {
|
||||
return testDb.prepare('SELECT * FROM trip_members WHERE trip_id = ? AND user_id = ?').get(tripId, userId);
|
||||
@@ -37,7 +46,7 @@ describe('joinTripAsMember', () => {
|
||||
const { user: joiner } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
|
||||
const r = await joinTripAsMember(trip.id, joiner.id, null);
|
||||
const r = await svc.joinTripAsMember(trip.id, joiner.id, null);
|
||||
expect(r).toEqual({ joined: true, tripId: trip.id });
|
||||
expect(memberRow(trip.id, joiner.id)).toBeTruthy();
|
||||
});
|
||||
@@ -46,7 +55,7 @@ describe('joinTripAsMember', () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
|
||||
const r = await joinTripAsMember(trip.id, owner.id, null);
|
||||
const r = await svc.joinTripAsMember(trip.id, owner.id, null);
|
||||
expect(r.joined).toBe(false);
|
||||
expect(memberRow(trip.id, owner.id)).toBeUndefined();
|
||||
});
|
||||
@@ -56,29 +65,61 @@ describe('joinTripAsMember', () => {
|
||||
const { user: joiner } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
|
||||
expect((await joinTripAsMember(trip.id, joiner.id, owner.id)).joined).toBe(true);
|
||||
expect((await joinTripAsMember(trip.id, joiner.id, owner.id)).joined).toBe(false);
|
||||
expect((await svc.joinTripAsMember(trip.id, joiner.id, owner.id)).joined).toBe(true);
|
||||
expect((await svc.joinTripAsMember(trip.id, joiner.id, owner.id)).joined).toBe(false);
|
||||
const count = testDb.prepare('SELECT COUNT(*) as n FROM trip_members WHERE trip_id = ? AND user_id = ?').get(trip.id, joiner.id) as { n: number };
|
||||
expect(count.n).toBe(1);
|
||||
});
|
||||
|
||||
it('TRIP-JOIN-004: no-ops for a missing trip', async () => {
|
||||
const { user: joiner } = createUser(testDb);
|
||||
const r = await joinTripAsMember(999999, joiner.id, null);
|
||||
const r = await svc.joinTripAsMember(999999, joiner.id, null);
|
||||
expect(r.joined).toBe(false);
|
||||
});
|
||||
|
||||
// Plan 3c inventory §18.4: TB4 → TB5 → TB6 is a non-transactional
|
||||
// check-then-act sequence, unchanged by this conversion. Two concurrent
|
||||
// joins can both pass the "already a member" check — this pins TODAY'S
|
||||
// outcome (a duplicate-membership row survives the trip_members table's
|
||||
// own UNIQUE(trip_id, user_id) constraint check only by luck of timing;
|
||||
// what the test actually proves is that the service itself does not
|
||||
// serialize the two calls, not that the DB will always accept both).
|
||||
it('TRIP-JOIN-005 (concurrency, §18.4): two concurrent joins for the same (trip, user) race the check-then-act window — the service performs no locking/serialization of its own', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: joiner } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
|
||||
const results = await Promise.allSettled([
|
||||
svc.joinTripAsMember(trip.id, joiner.id, null),
|
||||
svc.joinTripAsMember(trip.id, joiner.id, null),
|
||||
]);
|
||||
// Today's actual outcome (run under real concurrency, not assumed): the
|
||||
// method performs no locking/serialization of its own, so both calls can
|
||||
// pass the TB5 "already a member" check before either TB6 INSERT lands —
|
||||
// the loser then hits the table's own UNIQUE(trip_id, user_id) constraint
|
||||
// and REJECTS, it does not swallow the error into `{joined: false}`. At
|
||||
// least one call always fulfills (the winner); pinning "both always
|
||||
// fulfill" was wrong (verified: failed under the full-suite coverage
|
||||
// run's real scheduling) — the row-count assertion below is the actual
|
||||
// safety net, not a specific promise-settlement shape.
|
||||
const fulfilled = results.filter((r) => r.status === 'fulfilled');
|
||||
expect(fulfilled.length).toBeGreaterThanOrEqual(1);
|
||||
// Exactly one membership row exists afterwards either way — the UNIQUE
|
||||
// constraint on (trip_id, user_id) is the actual safety net today, not
|
||||
// application-level locking.
|
||||
const count = testDb.prepare('SELECT COUNT(*) as n FROM trip_members WHERE trip_id = ? AND user_id = ?').get(trip.id, joiner.id) as { n: number };
|
||||
expect(count.n).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// The leaf reads that replaced trips.bridge for BudgetMcp/CostsRpc — see the
|
||||
// service docblock for why they live on this dependency-free module.
|
||||
describe('leaf membership reads', () => {
|
||||
const svc = () => new TripMembershipService(new DatabaseService(testDb));
|
||||
|
||||
it('TRIP-READ-001: getOwnerId answers the owner and null for a missing trip', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
expect(await svc().getOwnerId(trip.id)).toBe(owner.id);
|
||||
expect(await svc().getOwnerId(999999)).toBeNull();
|
||||
expect(await svc.getOwnerId(trip.id)).toBe(owner.id);
|
||||
expect(await svc.getOwnerId(999999)).toBeNull();
|
||||
});
|
||||
|
||||
it('TRIP-READ-002: listMemberUserIds excludes the owner and follows added_at order', async () => {
|
||||
@@ -88,8 +129,8 @@ describe('leaf membership reads', () => {
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
testDb.prepare("INSERT INTO trip_members (trip_id, user_id, added_at) VALUES (?, ?, '2026-01-02')").run(trip.id, m2.id);
|
||||
testDb.prepare("INSERT INTO trip_members (trip_id, user_id, added_at) VALUES (?, ?, '2026-01-01')").run(trip.id, m1.id);
|
||||
expect(await svc().listMemberUserIds(trip.id)).toEqual([m1.id, m2.id]);
|
||||
expect(await svc().listMemberUserIds(999999)).toEqual([]);
|
||||
expect(await svc.listMemberUserIds(trip.id)).toEqual([m1.id, m2.id]);
|
||||
expect(await svc.listMemberUserIds(999999)).toEqual([]);
|
||||
});
|
||||
|
||||
it('TRIP-READ-003: listAccessibleTripIds unions owned and member trips, newest first', async () => {
|
||||
@@ -102,7 +143,7 @@ describe('leaf membership reads', () => {
|
||||
// Distinct created_at so the ORDER BY is actually asserted, not assumed.
|
||||
testDb.prepare("UPDATE trips SET created_at = '2026-01-01' WHERE id = ?").run(owned.id);
|
||||
testDb.prepare("UPDATE trips SET created_at = '2026-01-02' WHERE id = ?").run(memberOf.id);
|
||||
const ids = await svc().listAccessibleTripIds(user.id);
|
||||
const ids = await svc.listAccessibleTripIds(user.id);
|
||||
expect(ids).toEqual([memberOf.id, owned.id]);
|
||||
expect(ids).not.toContain(foreign.id);
|
||||
});
|
||||
|
||||
@@ -81,6 +81,8 @@ import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repos
|
||||
import {
|
||||
createTestUnitOfWork, createTestAppSettingsRepo, createTestUsersRepo, sharedTestOrm,
|
||||
createTestDaysRepo, createTestDayAssignmentsRepo, createTestDayNotesRepo, createTestTripsRepo,
|
||||
createTestTagsRepo, createTestPlaceRatingsRepo, createTestAssignmentParticipantsRepo,
|
||||
createTestGooglePlacePhotoMetaRepo, createTestPlacesRepo,
|
||||
} from '../../helpers/test-uow';
|
||||
|
||||
// Real sibling services over the same in-memory DB — the aggregation runs the
|
||||
@@ -88,10 +90,11 @@ import {
|
||||
// up here instead of being papered over by a stub.
|
||||
const dbs = () => new DatabaseService(testDb);
|
||||
|
||||
|
||||
// One shared cache instance (the PlacePhotoCacheService rule): the in-flight dedup in
|
||||
// PlacePhotoCacheService only works while both consumers hold the same object.
|
||||
const photoCache = new PlacePhotoCacheService(dbs(), makeStorageFixture('photos/google/').storage);
|
||||
// Plan 3c Task 1: built inside the async `beforeAll` below now — the
|
||||
// constructor needs two repositories, resolved through `createTestOrm`.
|
||||
let photoCache: PlacePhotoCacheService;
|
||||
|
||||
let accommodationsSvc: Awaited<ReturnType<typeof makeAccommodationsService>>;
|
||||
beforeAll(async () => {
|
||||
@@ -102,12 +105,18 @@ let daysSvc: DaysService;
|
||||
let placesSvc: PlacesService;
|
||||
let membersSvc: TripMembersService;
|
||||
beforeAll(async () => {
|
||||
photoCache = new PlacePhotoCacheService(
|
||||
dbs(),
|
||||
makeStorageFixture('photos/google/').storage,
|
||||
await createTestGooglePlacePhotoMetaRepo(dbs().connection),
|
||||
await createTestPlacesRepo(dbs().connection),
|
||||
);
|
||||
budgetSvc = new BudgetService(dbs(), new PermissionsService(await createTestAppSettingsRepo(dbs().connection), await createTestUnitOfWork(dbs().connection)), new ExchangeRatesService(), new RealtimeService(), await createTestUnitOfWork(dbs().connection));
|
||||
daysSvc = new DaysService(
|
||||
dbs(),
|
||||
new PermissionsService(await createTestAppSettingsRepo(dbs().connection), await createTestUnitOfWork(dbs().connection)),
|
||||
new RealtimeService(),
|
||||
new QueryHelpersService(dbs()),
|
||||
new QueryHelpersService(await createTestTagsRepo(dbs().connection), await createTestPlaceRatingsRepo(dbs().connection), await createTestAssignmentParticipantsRepo(dbs().connection)),
|
||||
await createTestUnitOfWork(dbs().connection),
|
||||
await createTestDaysRepo(dbs().connection),
|
||||
await createTestDayAssignmentsRepo(dbs().connection),
|
||||
@@ -116,7 +125,7 @@ beforeAll(async () => {
|
||||
);
|
||||
placesSvc = new PlacesService(
|
||||
dbs(), new PermissionsService(await createTestAppSettingsRepo(dbs().connection), await createTestUnitOfWork(dbs().connection)), new RealtimeService(),
|
||||
new MapsService(dbs(), photoCache, await createTestAppSettingsRepo(dbs().connection), await createTestUsersRepo(dbs().connection)), new QueryHelpersService(dbs()),
|
||||
new MapsService(dbs(), photoCache, await createTestAppSettingsRepo(dbs().connection), await createTestUsersRepo(dbs().connection)), new QueryHelpersService(await createTestTagsRepo(dbs().connection), await createTestPlaceRatingsRepo(dbs().connection), await createTestAssignmentParticipantsRepo(dbs().connection)),
|
||||
new UnsplashService(await createTestAppSettingsRepo(dbs().connection), await createTestUsersRepo(dbs().connection), new RuntimeEnvService(), makeStorageFixture('').storage), photoCache,
|
||||
new JourneyDomainService(dbs(), new RealtimeService(), new TrekPhotosRepository(dbs()), await createTestUnitOfWork(dbs().connection)),
|
||||
makeStorageFixture('').storage,
|
||||
|
||||
@@ -91,6 +91,8 @@ import { EphemeralTokenService } from '../../../src/nest/auth/ephemeral-token.se
|
||||
import {
|
||||
createTestUnitOfWork, createTestAppSettingsRepo, createTestUsersRepo, sharedTestOrm,
|
||||
createTestDaysRepo, createTestDayAssignmentsRepo, createTestDayNotesRepo, createTestTripsRepo,
|
||||
createTestTagsRepo, createTestPlaceRatingsRepo, createTestAssignmentParticipantsRepo,
|
||||
createTestGooglePlacePhotoMetaRepo, createTestPlacesRepo,
|
||||
} from '../../helpers/test-uow';
|
||||
|
||||
// Real sibling services over the same in-memory DB — updateTrip's date-shift
|
||||
@@ -114,7 +116,9 @@ const dbs = () => new DatabaseService(testDb, dbsEm);
|
||||
// missing collaborator would look like a pass while swallowing a TypeError.
|
||||
// One PlacePhotoCacheService for both PlacesService and MapsService, matching
|
||||
// production, where the in-flight dedup only works on a shared instance.
|
||||
const photoCache = new PlacePhotoCacheService(dbs(), makeStorageFixture('photos/google/').storage);
|
||||
// Plan 3c Task 1: built inside the async `beforeAll` below now — the
|
||||
// constructor needs two repositories, resolved through `createTestOrm`.
|
||||
let photoCache: PlacePhotoCacheService;
|
||||
const coversFx = makeStorageFixture('covers/');
|
||||
|
||||
|
||||
@@ -135,12 +139,18 @@ let membersSvc: TripMembersService;
|
||||
let readModelSvc: TripReadModelService;
|
||||
beforeAll(async () => {
|
||||
dbsEm = (await sharedTestOrm(testDb)).em;
|
||||
photoCache = new PlacePhotoCacheService(
|
||||
dbs(),
|
||||
makeStorageFixture('photos/google/').storage,
|
||||
await createTestGooglePlacePhotoMetaRepo(dbs().connection),
|
||||
await createTestPlacesRepo(dbs().connection),
|
||||
);
|
||||
budgetSvc = new BudgetService(dbs(), new PermissionsService(await createTestAppSettingsRepo(dbs().connection), await createTestUnitOfWork(dbs().connection)), new ExchangeRatesService(), new RealtimeService(), await createTestUnitOfWork(dbs().connection));
|
||||
daysSvc = new DaysService(
|
||||
dbs(),
|
||||
new PermissionsService(await createTestAppSettingsRepo(dbs().connection), await createTestUnitOfWork(dbs().connection)),
|
||||
new RealtimeService(),
|
||||
new QueryHelpersService(dbs()),
|
||||
new QueryHelpersService(await createTestTagsRepo(dbs().connection), await createTestPlaceRatingsRepo(dbs().connection), await createTestAssignmentParticipantsRepo(dbs().connection)),
|
||||
await createTestUnitOfWork(dbs().connection),
|
||||
await createTestDaysRepo(dbs().connection),
|
||||
await createTestDayAssignmentsRepo(dbs().connection),
|
||||
@@ -152,7 +162,7 @@ beforeAll(async () => {
|
||||
new PermissionsService(await createTestAppSettingsRepo(dbs().connection), await createTestUnitOfWork(dbs().connection)),
|
||||
new RealtimeService(),
|
||||
new MapsService(dbs(), photoCache, await createTestAppSettingsRepo(dbs().connection), await createTestUsersRepo(dbs().connection)),
|
||||
new QueryHelpersService(dbs()),
|
||||
new QueryHelpersService(await createTestTagsRepo(dbs().connection), await createTestPlaceRatingsRepo(dbs().connection), await createTestAssignmentParticipantsRepo(dbs().connection)),
|
||||
new UnsplashService(await createTestAppSettingsRepo(dbs().connection), await createTestUsersRepo(dbs().connection), new RuntimeEnvService(), coversFx.storage),
|
||||
photoCache,
|
||||
new JourneyDomainService(dbs(), new RealtimeService(), new TrekPhotosRepository(dbs()), await createTestUnitOfWork(dbs().connection)),
|
||||
|
||||
@@ -17,7 +17,7 @@ import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest';
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
return { testDb: db, dbMock: { db, closeDb: () => {}, reinitialize: () => {}, canAccessTrip: () => null } };
|
||||
return { testDb: db, dbMock: { db, closeDb: () => {}, reinitialize: () => {}, canAccessTrip: async () => null } };
|
||||
});
|
||||
vi.mock('../../../src/db/database', () => dbMock);
|
||||
import { db as dbConn } from '../../../src/db/database';
|
||||
|
||||
@@ -16,7 +16,7 @@ import type { Request } from 'express';
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
return { testDb: db, dbMock: { db, closeDb: () => {}, reinitialize: () => {}, canAccessTrip: () => null } };
|
||||
return { testDb: db, dbMock: { db, closeDb: () => {}, reinitialize: () => {}, canAccessTrip: async () => null } };
|
||||
});
|
||||
vi.mock('../../../src/db/database', () => dbMock);
|
||||
import { db as dbConn } from '../../../src/db/database';
|
||||
|
||||
@@ -35,7 +35,7 @@ const dbRegistry = () => {
|
||||
/** trips.getById lives on the decorators now, so the audit cases bind it through them. */
|
||||
const tripsRegistry = () => {
|
||||
const db = {
|
||||
canAccessTrip: (tripId: number, userId: number) => (tripId === 1 && userId === 42 ? { id: 1, user_id: 42 } : undefined),
|
||||
canAccessTrip: async (tripId: number, userId: number) => (tripId === 1 && userId === 42 ? { id: 1, user_id: 42 } : undefined),
|
||||
prepare: () => ({ get: () => ({ id: 1, title: 'Japan' }), all: () => [] }),
|
||||
} as unknown as DatabaseService;
|
||||
const guards = new PluginGuards(db, {} as never, {} as never);
|
||||
|
||||
@@ -14,7 +14,7 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vites
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
return { testDb: db, dbMock: { db, closeDb: () => {}, reinitialize: () => {}, canAccessTrip: () => null } };
|
||||
return { testDb: db, dbMock: { db, closeDb: () => {}, reinitialize: () => {}, canAccessTrip: async () => null } };
|
||||
});
|
||||
vi.mock('../../../src/db/database', () => dbMock);
|
||||
import { db as dbConn } from '../../../src/db/database';
|
||||
|
||||
@@ -21,8 +21,8 @@ const { testDb, dbMock } = vi.hoisted(() => {
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags,
|
||||
canAccessTrip: () => null,
|
||||
isOwner: () => false,
|
||||
canAccessTrip: async () => null,
|
||||
isOwner: async () => false,
|
||||
};
|
||||
return { testDb: db, dbMock: mock };
|
||||
});
|
||||
@@ -54,19 +54,36 @@ import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repos
|
||||
import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
|
||||
import { notificationsStub } from '../../helpers/notifications';
|
||||
import { makeStorageFixture } from '../../helpers/storage-fixture';
|
||||
import { createTestUnitOfWork, createTestAppSettingsRepo, createTestUsersRepo, sharedTestOrm } from '../../helpers/test-uow';
|
||||
import {
|
||||
createTestUnitOfWork,
|
||||
createTestAppSettingsRepo,
|
||||
createTestUsersRepo,
|
||||
createTestTagsRepo,
|
||||
createTestPlaceRatingsRepo,
|
||||
createTestAssignmentParticipantsRepo,
|
||||
createTestGooglePlacePhotoMetaRepo,
|
||||
createTestPlacesRepo,
|
||||
sharedTestOrm,
|
||||
} from '../../helpers/test-uow';
|
||||
|
||||
const dbs = new DatabaseService(testDb);
|
||||
const realtime = new RealtimeService();
|
||||
const runtimeEnv = new RuntimeEnvService();
|
||||
// One cache instance shared by maps and places, the way the container wires it:
|
||||
// the service's stampede guard only works if there is exactly one of them.
|
||||
const photoCache = new PlacePhotoCacheService(dbs, makeStorageFixture('photos/google/').storage);
|
||||
|
||||
|
||||
let packing: PackingService;
|
||||
let places: PlacesService;
|
||||
let photoCache: PlacePhotoCacheService;
|
||||
beforeAll(async () => {
|
||||
// One cache instance shared by maps and places, the way the container
|
||||
// wires it: the service's stampede guard only works if there is exactly
|
||||
// one of them. Plan 3c Task 1: PlacePhotoCacheService now takes the two
|
||||
// repositories too.
|
||||
photoCache = new PlacePhotoCacheService(
|
||||
dbs,
|
||||
makeStorageFixture('photos/google/').storage,
|
||||
await createTestGooglePlacePhotoMetaRepo(dbs.connection),
|
||||
await createTestPlacesRepo(dbs.connection),
|
||||
);
|
||||
// Plan 3c Task 0b: `dbs` is constructed at module load, before any
|
||||
// `beforeAll` can resolve a real `EntityManager` — the four
|
||||
// repository-backed methods are spied directly on this instance instead,
|
||||
@@ -82,7 +99,7 @@ beforeAll(async () => {
|
||||
new PermissionsService(await createTestAppSettingsRepo(dbs.connection), await createTestUnitOfWork(dbs.connection)),
|
||||
realtime,
|
||||
new MapsService(dbs, photoCache, await createTestAppSettingsRepo(dbs.connection), await createTestUsersRepo(dbs.connection)),
|
||||
new QueryHelpersService(dbs),
|
||||
new QueryHelpersService(await createTestTagsRepo(dbs.connection), await createTestPlaceRatingsRepo(dbs.connection), await createTestAssignmentParticipantsRepo(dbs.connection)),
|
||||
new UnsplashService(await createTestAppSettingsRepo(dbs.connection), await createTestUsersRepo(dbs.connection), runtimeEnv, makeStorageFixture('').storage),
|
||||
photoCache,
|
||||
new JourneyDomainService(dbs, realtime, new TrekPhotosRepository(dbs), await createTestUnitOfWork(dbs.connection)),
|
||||
|
||||
@@ -9,7 +9,7 @@ const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const db = new Database(':memory:');
|
||||
// FKs off: this suite only checks media_type persistence, not owner/user integrity.
|
||||
db.exec('PRAGMA foreign_keys = OFF');
|
||||
const mock = { db, closeDb: () => {}, reinitialize: () => {}, getPlaceWithTags: () => null, canAccessTrip: () => null, isOwner: () => false };
|
||||
const mock = { db, closeDb: () => {}, reinitialize: () => {}, getPlaceWithTags: async () => null, canAccessTrip: async () => null, isOwner: async () => false };
|
||||
return { testDb: db, dbMock: mock };
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user