feat(server): plan 3d groundwork — dialect helpers, visibility predicate, feed and cron context ratchets

This commit is contained in:
jubnl
2026-09-23 13:52:38 +02:00
parent 4ceb94613f
commit 93324e71c4
24 changed files with 1473 additions and 30 deletions
+82
View File
@@ -895,6 +895,79 @@ export function RULE11_referencedColumns(metadata: EntityMetadata[]): Referenced
return fixups;
}
/** A CHECK constraint this run repaired — its introspected expression had swallowed trailing DDL text. */
export interface CheckExpressionFixup {
className: string;
checkName: string | undefined;
}
/**
* RULE12 (metadata level): repair a CHECK expression that MikroORM's own
* SQLite introspection over-captured from a trailing table-level constraint
* (Plan 3d Task 0, R10 — `roadtrip_day_boundaries.fraction`).
*
* `SqliteSchemaHelper#getChecks` (verified directly against
* `node_modules/@mikro-orm/sql/dialects/sqlite/SqliteSchemaHelper.js`, not
* assumed) extracts a column's inline CHECK with `/ (check \((.*)\))/i`
* against that column's own slice of `PRAGMA table_xinfo`'s definition text.
* The regex's greedy `.*` runs to the LAST `)` in that slice — correct when
* the checked column is followed only by other columns, wrong when it is the
* last column before a table-level constraint with its own parens. The
* migration (`Migration20200101040100_the_road_trip_day_boundaries_carried_the.ts`)
* declares exactly that shape:
*
* fraction REAL NOT NULL CHECK (fraction BETWEEN 0 AND 1),
* PRIMARY KEY (trip_id, day_number)
*
* — so the introspected expression comes back as
* `"fraction BETWEEN 0 AND 1), PRIMARY KEY (trip_id, day_number"`,
* not the DDL's `"fraction BETWEEN 0 AND 1"`. This is a third-party
* introspection defect (the migration's own DDL is correct, and
* `day_number`'s sibling CHECK on the same table — not the last column
* before the table constraint — introspects cleanly), so it is repaired
* here rather than by touching the migration or hand-editing the entity.
*
* The fix walks the captured expression tracking parenthesis depth,
* starting at 1 (the string has already had its own OUTER, matching
* `CHECK (...)` parens stripped by the upstream regex): `(` increments,
* `)` decrements. The first time depth returns to 0 is where the check's
* OWN expression actually closes — the DDL's CHECK is, by definition,
* itself a balanced parenthesised expression, so that is exactly its end;
* everything captured after it is the swallowed trailing constraint text,
* discarded. An expression whose parens never rebalance to 0 before the
* string ends (no parens at all — `day_number >= 1`,
* `end_date >= start_date` — or balanced-but-never-closing nested ones) is
* left byte-identical: this only fires on the specific over-capture shape,
* never on a clean expression.
*/
export function RULE12_fixGarbledCheckExpressions(metadata: EntityMetadata[]): CheckExpressionFixup[] {
const fixups: CheckExpressionFixup[] = [];
for (const meta of metadata) {
for (const check of meta.checks) {
if (typeof check.expression !== 'string') continue;
const expr = check.expression;
let depth = 1;
let cut = -1;
for (let i = 0; i < expr.length; i++) {
if (expr[i] === '(') depth++;
else if (expr[i] === ')') {
depth--;
if (depth === 0) {
cut = i;
break;
}
}
}
if (cut === -1) continue; // parens never rebalance to 0 — a clean expression, nothing swallowed
const fixed = expr.slice(0, cut).trimEnd();
if (fixed === expr) continue;
check.expression = fixed;
fixups.push({ className: meta.className, checkName: check.name });
}
}
return fixups;
}
/** A scalar property whose literal default the renderer's own heuristic drops or mis-renders — see below. */
export interface DefaultFixup {
className: string;
@@ -973,6 +1046,8 @@ export interface RuleFixups {
repositoryMarkers: RepositoryTypeMarkerFixup[];
/** Rule 11's non-PK FK referenced-column fixups. */
referencedColumns: ReferencedColumnsFixup[];
/** Rule 12's garbled-CHECK-expression repairs. */
checkExpressions: CheckExpressionFixup[];
}
/**
@@ -1010,6 +1085,10 @@ export function applyRules(
// property names — the same names the text pass below must find in the
// rendered `properties: {...}` block.
const referencedColumns = RULE11_referencedColumns(metadata);
// Independent of every rule above (it only touches `meta.checks`, which
// the renderer dumps verbatim — no text pass depends on it, same as
// RULE7/RULE9); placed last only by convention (highest rule number).
const checkExpressions = RULE12_fixGarbledCheckExpressions(metadata);
return {
joinColumns,
defaults,
@@ -1018,6 +1097,7 @@ export function applyRules(
retypedScalars: [...retypedByRule1, ...timestamps, ...jsonColumns],
repositoryMarkers,
referencedColumns,
checkExpressions,
};
}
@@ -1668,6 +1748,7 @@ export async function generateEntities(): Promise<GenerateResult> {
retypedScalars: [],
repositoryMarkers: [],
referencedColumns: [],
checkExpressions: [],
};
const rawFiles = await generator.generate({
entityDefinition: 'defineEntity',
@@ -1704,6 +1785,7 @@ export async function generateEntities(): Promise<GenerateResult> {
retypedScalars: fixups.retypedScalars.filter((f) => f.className === className),
repositoryMarkers: fixups.repositoryMarkers.filter((f) => f.className === className),
referencedColumns: fixups.referencedColumns.filter((f) => f.className === className),
checkExpressions: fixups.checkExpressions.filter((f) => f.className === className),
};
files.set(`${className}.entity.ts`, applyTextPasses(raw, relevantFixups));
}
+181 -1
View File
@@ -212,8 +212,19 @@ export function coalesce(platform: Platform, ref: string, fallbackRef: string):
* `COALESCE(<col>, ?)` — the value-side twin of `coalesce()` above, for the
* `COALESCE(col, ?)` UPDATE-SET shape (`places.service.ts:344,351,363,…`).
* `value` is bound as a genuine parameter, never interpolated.
*
* Typed `RawQueryFragment & symbol` (widened from a bare `RawQueryFragment`
* in Plan 3d Task 0, R3 — `_shared/reservation-visibility.ts`'s
* `publicReservationCondition` needs `COALESCE(alias.ingest_state, 'live')`
* as a `.where({...})` filter KEY, `{ [coalesceParam(...)]: { $ne: 'staged'
* } }`, the same brand `lower()`/`lowerTrim()`/`castInteger()` carry for
* that use): every existing UPDATE-SET caller (`Places.repository.ts`'s six
* `coalesceParam(...)` assignments into a loosely-typed `data` object) keeps
* typechecking unchanged — `RawQueryFragment & symbol` is assignable
* anywhere a plain `RawQueryFragment` is expected, a strict widening with no
* behaviour change (the function body is untouched).
*/
export function coalesceParam(platform: Platform, ref: string, value: string | number | null): RawQueryFragment {
export function coalesceParam(platform: Platform, ref: string, value: string | number | null): RawQueryFragment & symbol {
if (platform instanceof SqlitePlatform) return raw(`COALESCE(${column(ref)}, ?)`, [value]);
return unsupported(platform);
}
@@ -320,3 +331,172 @@ export function countAllRef(platform: Platform): RawQueryFragment & symbol {
return unsupported(platform);
}
// ---------------------------------------------------------------------------
// Plan 3d Task 0 (R6) — no consumer yet: Tasks 2/4 wire these in as each
// converts the statement that needs them (`reservations.service.ts` RS11 and
// RS20 `listUpcoming`, `reservation-visibility.ts` RV2, DY23 in
// `days.service.ts`). Added here, with their own SQLF-0xx tests pinning the
// SQLite text against a raw statement on seeded rows, so Tasks 1-6 import a
// tested helper instead of writing one inline.
// ---------------------------------------------------------------------------
/**
* `<col> GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]*'` — "does this
* column's TEXT value start with an ISO calendar date (`YYYY-MM-DD`)?", the
* legacy `DATED` constant (`reservations.service.ts:120`:
* `` `r.reservation_time GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]*'` ``,
* used at `:453-454` inside RS20's two `CASE WHEN` arms). Spelled with
* SQLite's `[0-9]` character-class glob syntax, NOT the plan's shorthand
* `'????-??-??*'` — verified directly against the legacy source, not
* assumed: a `?` in SQLite GLOB matches any single character (letters
* included), so `????-??-??*` is a strictly WIDER, non-equivalent pattern
* (it would also match `abcd-ef-gh...`); only the digit-class form matches
* the legacy's actual behaviour byte-for-byte.
*
* A full boolean expression, not a bare value — pair with a `CASE WHEN`
* builder or a QB/Kysely raw condition, never nest inside another raw
* fragment (see `coalesce()`'s docstring on why nesting doesn't work).
* Kysely has no `GLOB` comparison operator (`operator-node.d.ts`'s
* `ComparisonOperator` union omits it — verified against the installed
* 0.29.6 typings, per the inventory's §19 MikroORM/Kysely capability table)
* — this helper's SQLite-only reach is therefore the only portable spelling
* for this shape until a second platform is added.
*/
export function startsWithIsoDate(platform: Platform, ref: string): RawQueryFragment {
if (platform instanceof SqlitePlatform) {
return raw(`${column(ref)} GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]*'`);
}
return unsupported(platform);
}
/**
* `substr(<col>, <start>[, <length>])`, 1-based on both ends — the legacy
* shape at `reservations.service.ts:453` (`substr(r.reservation_time, 1,
* 10)`, three-arg), `:454` (`substr(r.reservation_time, 12)`, two-arg — "from
* position 12 to the end of the string") and `days.service.ts` DY23
* (`SUBSTR(reservation_time, 11)`, same two-arg shape, spelled uppercase in
* that statement — SQL function names are case-insensitive, so this helper's
* lowercase spelling is behaviourally identical). `length` is optional,
* matching SQLite's own two-arg `substr(X, Y)` form (everything from `Y` to
* the end) — passing it renders the three-arg form instead. `start` and
* `length` are validated integers and spelled directly (not bound): they are
* always source-code constants at every known call site (`1`, `10`, `11`,
* `12`), never user input, the same trust boundary `dateAdd`'s day count and
* `columnIncrementedBy`'s amount already rely on in this file.
*/
export function substring(platform: Platform, ref: string, start: number, length?: number): RawQueryFragment {
if (!Number.isInteger(start) || start < 1) {
throw new Error(`sql-functions: substring needs a 1-based integer start, got ${start}`);
}
if (length !== undefined && (!Number.isInteger(length) || length < 0)) {
throw new Error(`sql-functions: substring needs a non-negative integer length, got ${length}`);
}
if (platform instanceof SqlitePlatform) {
if (length === undefined) return raw(`substr(${column(ref)}, ${start})`);
return raw(`substr(${column(ref)}, ${start}, ${length})`);
}
return unsupported(platform);
}
/**
* A part of a `concat()` expression: either a column reference (rendered
* through `column()`, the same identifier validation every other helper in
* this file applies) or a plain value bound as a genuine parameter (`?`) —
* never interpolated, even for what today's only legacy caller spells as a
* literal (`'T'` in `d.date || 'T' || a.check_in`,
* `reservations.service.ts:475, :493`): a bound constant and an interpolated
* one produce byte-identical SQLite behaviour for a plain string, and
* binding is the shape every other value-side helper in this file already
* prefers (`coalesceParam`, `lowerParam`, `absDifference`).
*/
export type ConcatPart = { column: string } | { value: string };
/**
* `<part> || <part> || …` — SQLite (and Postgres) string concatenation, the
* legacy shape at `reservations.service.ts:475, :493`
* (`d.date || 'T' || a.check_in`, RS20's two check-in/check-out arms) and
* `days.service.ts` DY23 (`:date || SUBSTR(reservation_time, 11)`, inside an
* `UPDATE … SET`). Needs at least two parts — a one-part "concatenation" is
* a caller bug, not a legitimate call. Each part is a `ConcatPart` (see its
* own docstring) rather than a bare string, so a column reference and a
* bound literal can never be confused with each other the way an untyped
* `...parts: string[]` would invite (a plain string could mean either).
*
* Deliberately does NOT accept a nested `RawQueryFragment` as a part (e.g.
* the output of `substring()` above) — the same limitation `coalesce()`'s
* docstring documents: `RawQueryFragment`'s `[Symbol.toPrimitive]` only
* implements the `'string'` coercion hint (the object-key position), not the
* `'default'` hint a template-literal embed or an array-join would need, so
* composing `concat(platform, substring(...), ...)` throws at the `raw()`
* boundary rather than rendering silently wrong SQL. A caller that needs
* `SUBSTR(...)` as one side of a `||` builds that one statement directly
* (Kysely's `'||'` binary operator, per the inventory's §17a/§19), the same
* way RS20's own CASE/CTE shape is built outside this file's helpers.
*/
export function concat(platform: Platform, ...parts: readonly ConcatPart[]): RawQueryFragment {
if (parts.length < 2) {
throw new Error(`sql-functions: concat needs at least two parts, got ${parts.length}`);
}
if (platform instanceof SqlitePlatform) {
const bindings: string[] = [];
const sql = parts
.map((part) => {
if ('column' in part) return column(part.column);
bindings.push(part.value);
return '?';
})
.join(' || ');
return raw(sql, bindings);
}
return unsupported(platform);
}
/**
* `CAST(<col> AS INTEGER)` — the legacy shape at `reservations.service.ts:471,
* :489` (inside RS20's correlated `stay_first_reservation_id`/title
* subqueries: `CAST(res.accommodation_id AS INTEGER) = a.id`) and
* `reservation-visibility.ts:106-107` (RV2's `publicStaySql`:
* `CAST(vr.accommodation_id AS INTEGER) = ${alias}.id`). Exists specifically
* for `reservations.accommodation_id`, a TEXT column holding integer ids
* with no FK (§18.1 of the inventory — some rows read back as `"14.0"`):
* casting it to INTEGER before comparing against a genuine INTEGER column
* (`day_accommodations.id`) applies numeric affinity to BOTH sides, matching
* `"14"` and `"14.0"` alike, the same way the legacy statement does.
*
* Typed `RawQueryFragment & symbol`, the same brand `lower()`/`lowerTrim()`
* carry: both known legacy call sites compare the cast against ANOTHER
* COLUMN, not a bound value (`= a.id`, `= ${alias}.id`) — the shape is
* `{ [castInteger(platform, 'vr.accommodation_id')]: columnRef(platform,
* 'a.id') }`, pairing this helper's filter-KEY brand with `columnRef()`'s
* existing VALUE-side column reference. The brand also lets it appear in a
* `.select()` projection via `.as(alias)` (the `lowerTrim`/SQLF-027 dual-use
* precedent) for a caller that only needs the cast INTEGER value itself.
*/
export function castInteger(platform: Platform, ref: string): RawQueryFragment & symbol {
if (platform instanceof SqlitePlatform) return raw(`CAST(${column(ref)} AS INTEGER)`);
return unsupported(platform);
}
/**
* `ABS(JULIANDAY(<col>) - JULIANDAY(?))` — the legacy shape at
* `reservations.service.ts:307` (RS11, `resolveDayIdFromTime`'s
* nearest-day fallback: `ORDER BY ABS(JULIANDAY(date) - JULIANDAY(?)) ASC,
* date ASC LIMIT 1`), which clamps an imported booking's date to the
* closest day row in the trip when no exact match exists. `isoDate` is
* bound as a genuine parameter (never interpolated), matching the legacy
* statement's own bind. Distinct from `absDifference()` above:
* `absDifference` is `ABS(<col> - ?)` on plain NUMERIC values (for a
* lat/lng tolerance filter); this helper compares two `JULIANDAY(...)`
* calendar values. Typed `RawQueryFragment & symbol`, the same brand
* `countAllRef()` carries, so it is usable as an `.orderBy()` object key —
* `.orderBy({ [dayDistance(platform, 'date', isoDate)]: 'asc' })` —
* matching the legacy's own `ORDER BY ABS(JULIANDAY(...) - JULIANDAY(?)) ASC`
* shape exactly (an aliased column cannot be ordered by, the same
* `ExtractRawAliases` limitation `countAllRef`'s own docstring / SQLF-024's
* comment documents; ordering by the EXPRESSION itself is what works).
*/
export function dayDistance(platform: Platform, ref: string, isoDate: string): RawQueryFragment & symbol {
if (platform instanceof SqlitePlatform) return raw(`ABS(JULIANDAY(${column(ref)}) - JULIANDAY(?))`, [isoDate]);
return unsupported(platform);
}
@@ -23,7 +23,7 @@ export const RoadtripDayBoundariesSchema = defineEntity({
checks: [
{
name: 'roadtrip_day_boundaries_fraction_check',
expression: 'fraction BETWEEN 0 AND 1), PRIMARY KEY (trip_id, day_number',
expression: 'fraction BETWEEN 0 AND 1',
},
{
name: 'roadtrip_day_boundaries_day_number_check',
@@ -0,0 +1,136 @@
import type { Platform } from '@mikro-orm/core';
import type { ExpressionBuilder } from 'kysely';
import { coalesceParam } from '../../dialect/sql-functions';
/**
* The visibility predicate an anonymous viewer's reads are filtered
* through — ONE source, typed, for `reservations`/`day_accommodations`
* (Plan 3d Task 0, R3).
*
* Derived from the two legacy SQL-fragment builders this file supersedes
* (`src/nest/reservations/reservation-visibility.ts`'s `publicReservationSql`
* and `publicStaySql`, kept in place — string form — until Tasks 2/4/5
* convert their own consumers and `share`'s owner is decided, §14.6):
*
* - **RV1** `publicReservationSql(alias)` — `` COALESCE(${alias}.ingest_state,
* 'live') <> 'staged' ``. A reservation is public unless an automated
* ingest staged it for review.
* - **RV2** `publicStaySql(alias)` — a stay is public when nothing points at
* it (added by hand), or when a LIVE booking does:
* `(NOT EXISTS (…) OR EXISTS (… AND <RV1>))`, correlated on
* `CAST(vr.accommodation_id AS INTEGER) = ${alias}.id` (`accommodation_id`
* is TEXT with no FK, §18.1 — some rows read back as `"14.0"`).
*
* Two shapes are exported because the two predicates need different query
* APIs: RV1 has no correlated subquery, so it renders as a typed MikroORM
* QueryBuilder filter OBJECT (`publicReservationCondition`) — spreadable
* into any `.where({...})` call at any table alias, exactly like the
* legacy fragment builder's own runtime-alias shape, no compile-time table
* binding needed (rule 23: a typed condition, not a string). RV2's
* NOT EXISTS/EXISTS pair has no MikroORM QueryBuilder expression (no
* relation exists from `day_accommodations` to `reservations` via
* `accommodation_id` — R10 keeps it that way; QB's `$exists` operator
* checks embeddable/JSON path existence, not a correlated SQL subquery),
* so it is built with Kysely's own typed `ExpressionBuilder`
* (`publicStayExists`) — `eb.exists`/`eb.not` (Kysely's `unary('exists'|
* 'not', …)` shortcuts), `eb.cast`, `eb.fn.coalesce`, all fully typed,
* never a `raw()` call or a `sql` tagged template (both banned under
* `src/db/repositories/**` by ESLint's `no-restricted-syntax`, checked
* directly against `server/eslint.config.mjs`).
*
* **`publicStayExists` is deliberately narrow, not maximally generic** —
* `ReservationVisibilityKyselyDB` fixes the two aliases every known
* caller already uses (`a` for the outer stay row, `vr` for the
* correlated reservations lookup — "Alias interpolation is a
* caller-supplied constant ('r', 'vr', 'a'); never user input", inventory
* §2d). `Trips.repository.ts`'s `tripSelectQuery` docstring records why a
* shared Kysely helper across differently-joined outer queries doesn't
* compose well ("a shared private helper can't be typed across these four
* callbacks either… `leftJoin`'s alias widens each callback's own
* `ExpressionBuilder` table-set in a way a separate generic method
* signature can't match structurally") — the same limitation would bite a
* predicate meant to serve arbitrarily-different outer queries. This
* function instead requires exactly one contract: the caller's outer query
* is built with `.selectFrom('day_accommodations as a')` (or any
* `.selectFrom`/`.leftJoin` chain that puts an `a` alias with an `id`
* column into scope) and a `reservations` table exists in the same `DB`
* interface. A consumer whose own local Kysely `DB` interface satisfies
* `ReservationVisibilityKyselyDB` structurally can pass its `eb` straight
* through, extending its interface with `& ReservationVisibilityKyselyDB`
* if needed (the same additive-intersection shape
* `DayAssignmentsKyselyDB`/`AssignmentTimeSortKyselyDB` already use for
* their own narrow, per-query interfaces) — proven end-to-end (not just
* typechecked) by this file's own test and by the string-vs-predicate
* parity harness (`tests/unit/db/repositories/_shared/reservation-visibility.test.ts`),
* which Tasks 2/4/5 reuse rather than re-derive.
*
* `ingest_state` visibility is deliberately NOT filtered for authenticated
* reads (`reservation-visibility.ts`'s own docstring: "the staging inbox
* has to see its own rows, or nobody can confirm them") — these two
* exports exist ONLY for the two anonymous surfaces (the ICS feeds,
* `share`) that need it; nothing in this file changes an authenticated
* list.
*/
/**
* RV1 as a typed QB filter-object condition:
* `COALESCE(<alias>.ingest_state, 'live') <> 'staged'`. Spread into a
* `.where({...})` call — `{ ...publicReservationCondition(platform, 'r'),
* trip_id: tripId }` — at any table alias the caller's own query already
* established; this needs no compile-time table binding, the same way
* `coalesceParam` itself needs none (its `ref` argument is validated only
* by `column()`'s identifier-shape regex, not against a live entity's
* declared columns).
*/
export function publicReservationCondition(platform: Platform, alias: string) {
return { [coalesceParam(platform, `${alias}.ingest_state`, 'live')]: { $ne: 'staged' } };
}
/**
* `reservations`/`day_accommodations`'s REAL table names, the shape
* `publicStayExists` needs. A consumer's own local Kysely `DB` interface
* (the `DayAssignmentsKyselyDB`/`AssignmentTimeSortKyselyDB`/
* `TripSelectKyselyDB` per-query-interface precedent) satisfies this by
* structural extension — declaring `day_accommodations`/`reservations`
* with at least these columns is enough; `Kysely<DB>` typed against it and
* queried via `.selectFrom('day_accommodations as a')` (the fixed alias
* every known legacy caller uses — see this file's module docstring) is
* what actually produces the aliased `a` member `publicStayExists`
* requires; Kysely computes that from THIS interface's `day_accommodations`
* entry, not from a hand-declared `a` key (a table's alias is a property of
* the QUERY, never of the `DB` interface itself).
*/
export interface ReservationVisibilityKyselyDB {
day_accommodations: { id: number };
reservations: { id: number; accommodation_id: number | string | null; ingest_state: string | null };
}
/**
* RV2 as a Kysely predicate: `(NOT EXISTS (…) OR EXISTS (… AND <RV1>))`,
* correlated on `CAST(vr.accommodation_id AS INTEGER) = a.id`. Call inside
* a `.where((eb) => publicStayExists(eb))` on a query already
* `.selectFrom('day_accommodations as a')` against a `Kysely<DB>` whose
* `DB` satisfies `ReservationVisibilityKyselyDB`. The parameter type is
* fixed to exactly the shape `.selectFrom('day_accommodations as a')`
* itself produces (`ReservationVisibilityKyselyDB` plus the derived `a`
* alias member) rather than generic over an arbitrary caller `DB`/`TB` —
* `Trips.repository.ts`'s `tripSelectQuery` docstring records why a shared
* Kysely helper generic across differently-shaped outer queries doesn't
* compose ("a shared private helper can't be typed across these four
* callbacks either… a separate generic method signature can't match
* structurally"); this function accepts exactly one contract instead,
* proven end-to-end by this file's own parity-harness test.
*/
export function publicStayExists(eb: ExpressionBuilder<ReservationVisibilityKyselyDB & { a: { id: number } }, 'a'>) {
const linkedTo = eb
.selectFrom('reservations as vr')
.select('vr.id')
.where((eb2) => eb2(eb2.cast(eb2.ref('vr.accommodation_id'), 'integer'), '=', eb2.ref('a.id')));
return eb.or([
eb.not(eb.exists(linkedTo)),
eb.exists(
linkedTo.where((eb2) => eb2(eb2.fn.coalesce(eb2.ref('vr.ingest_state'), eb2.val('live')), '<>', eb2.val('staged'))),
),
]);
}
+1 -1
View File
@@ -302,7 +302,7 @@ finer-grained module instead:
- `reservations.controller.ts` → `airtrail.bridge`: the pull genuinely needs
ReservationsService, so `AirtrailModule` keeps it — but the write-back push
only needs the hydrated reservation READ. That read became
`ReservationsReadRepository` (leaf, the trek_photos pattern), the link
`ReservationsReadService` (leaf, the trek_photos pattern), the link
lifecycle + push became `AirtrailLinkService` in `AirtrailCoreModule`, and
the controller injects it. `airtrail.bridge` is deleted, and
`notifications.instance.ts` — whose last consumer it was — died with it.
@@ -3,7 +3,7 @@ import { ADDON_IDS } from '../../addons';
import { DatabaseService } from '../database/database.service';
import { RealtimeService } from '../realtime/realtime.service';
import { AddonsService } from '../addons/addons.service';
import { ReservationsReadRepository } from '../reservations/reservations-read.repository';
import { ReservationsReadService } from '../reservations/reservations-read.service';
import { logError } from '../audit/audit-log.logger';
import { AirtrailAuthError, type AirtrailCreds, type AirtrailFlightRaw } from './airtrail.client';
import { AirtrailClient } from './airtrail.client';
@@ -15,7 +15,7 @@ import { buildSavePayload } from './airtrail-sync.helpers';
* The AirTrail link lifecycle — the enablement gate, the detach policy, the
* multi-leg guard (#1535) and the TREK → AirTrail write-back (#1240) — split
* out of AirtrailSyncService so ReservationsModule can inject it: it reads
* reservations through the leaf ReservationsReadRepository, never through
* reservations through the leaf ReservationsReadService, never through
* ReservationsService, so AirtrailCoreModule stays off the
* ReservationsModule → AirtrailModule → ReservationsModule loop that used to
* force airtrail.bridge. The pull half stays in AirtrailSyncService, which
@@ -28,7 +28,7 @@ export class AirtrailLinkService {
private readonly db: DatabaseService,
private readonly realtime: RealtimeService,
private readonly addons: AddonsService,
private readonly reads: ReservationsReadRepository,
private readonly reads: ReservationsReadService,
private readonly client: AirtrailClient,
private readonly airtrail: AirtrailService,
) {}
@@ -1,5 +1,5 @@
import { Module } from '@nestjs/common';
import { ReservationsReadRepository } from './reservations-read.repository';
import { ReservationsReadService } from './reservations-read.service';
/**
* A leaf on purpose (the trip-membership precedent): AirtrailCoreModule needs
@@ -7,7 +7,7 @@ import { ReservationsReadRepository } from './reservations-read.repository';
* in — which would re-close the cycle that airtrail.bridge used to dodge.
*/
@Module({
providers: [ReservationsReadRepository],
exports: [ReservationsReadRepository],
providers: [ReservationsReadService],
exports: [ReservationsReadService],
})
export class ReservationsReadModule {}
@@ -17,9 +17,21 @@ export function toTraveler(r: { user_id: number; username: string; avatar: strin
* ReservationsModule → AirtrailCoreModule → ReservationsModule — the cycle
* that used to force airtrail.bridge. ReservationsService injects this and
* delegates, so there is exactly one copy of each query.
*
* Renamed from `ReservationsReadRepository` (Plan 3d Task 0, R11 — inventory
* §18.3): this is a plain Nest `@Injectable` over `DatabaseService.prepare`,
* not a MikroORM repository extending `TrekRepository`, so it must not carry
* the `Repository` suffix — that name is reserved for the ORM layer's "one
* repository per entity" convention (D4). Its three statements (RR1-RR3)
* still convert to `ReservationsRepository`/`ReservationEndpointsRepository`/
* `ReservationTravelersRepository` methods in a later Plan 3d task (per §18.3
* option (a), which then deletes this class and turns
* `ReservationsReadModule` into a plain `MikroOrmModule.forFeature([...])`
* leaf, the `TripMembershipModule` precedent) — Task 0 converts no SQL site,
* so only the rename happens here, with zero behaviour change.
*/
@Injectable()
export class ReservationsReadRepository {
export class ReservationsReadService {
constructor(private readonly db: DatabaseService) {}
async getReservationWithJoins(id: string | number): Promise<ReservationRow | undefined> {
@@ -4,7 +4,7 @@ import { UnitOfWork } from '../database/unit-of-work';
import type { TrekWsPayload, TrekWsTripEventName } from '@trek/shared';
import { RealtimeService } from '../realtime/realtime.service';
import { PermissionsService } from '../permissions/permissions.service';
import { ReservationsReadRepository, toTraveler } from './reservations-read.repository';
import { ReservationsReadService, toTraveler } from './reservations-read.service';
import { keepMirroredPrice } from './reservation-metadata';
import type { Reservation, User } from '../../types';
import { BudgetService } from '../budget/budget.service';
@@ -150,7 +150,7 @@ export class ReservationsService {
private readonly budget: BudgetService,
private readonly realtime: RealtimeService,
private readonly notifications: NotificationsService,
private readonly reads: ReservationsReadRepository,
private readonly reads: ReservationsReadService,
private readonly accommodations: AccommodationsService,
private readonly uow: UnitOfWork,
) {}
+2 -2
View File
@@ -38,7 +38,7 @@ import { PlacesMcp } from '../../src/nest/places/places.mcp';
import { PlacesService } from '../../src/nest/places/places.service';
import { ReservationsMcp } from '../../src/nest/reservations/reservations.mcp';
import { ReservationsService } from '../../src/nest/reservations/reservations.service';
import { ReservationsReadRepository } from '../../src/nest/reservations/reservations-read.repository';
import { ReservationsReadService } from '../../src/nest/reservations/reservations-read.service';
import { TagsMcp } from '../../src/nest/tags/tags.mcp';
import { TagsService } from '../../src/nest/tags/tags.service';
import { SettingsService } from '../../src/nest/settings/settings.service';
@@ -227,7 +227,7 @@ export async function createMcpTestRegistry(): Promise<McpRegistry> {
await createTestTripsRepo(dbService.connection),
);
// Built after it: a hotel booking writes the stay's day stop through this one.
const reservationsService = new ReservationsService(dbService, permissionsService, budgetService, realtimeService, notificationsStub(), new ReservationsReadRepository(dbService), accommodationsService, await createTestUnitOfWork(dbService.connection));
const reservationsService = new ReservationsService(dbService, permissionsService, budgetService, realtimeService, notificationsStub(), new ReservationsReadService(dbService), accommodationsService, await createTestUnitOfWork(dbService.connection));
const membersService = new TripMembersService(dbService, budgetService, new UserCleanupService(dbService, budgetService, await createTestUnitOfWork(dbService.connection), usersRepo), permissionsService, realtimeService, notificationsStub(), await createTestUnitOfWork(dbService.connection), await createTestTripsRepo(dbService.connection), await createTestTripMembersRepo(dbService.connection), usersRepo);
const tripsService = new TripsService(
dbService,
+2 -2
View File
@@ -14,7 +14,7 @@ import { PluginOAuthService } from '../../src/nest/plugins/oauth/plugin-oauth.se
import { BudgetService } from '../../src/nest/budget/budget.service';
import { ExchangeRatesService } from '../../src/nest/budget/exchange-rates.service';
import { ReservationsService } from '../../src/nest/reservations/reservations.service';
import { ReservationsReadRepository } from '../../src/nest/reservations/reservations-read.repository';
import { ReservationsReadService } from '../../src/nest/reservations/reservations-read.service';
import { TagsService } from '../../src/nest/tags/tags.service';
import { CategoriesService } from '../../src/nest/categories/categories.service';
import { TodoService } from '../../src/nest/todo/todo.service';
@@ -149,7 +149,7 @@ export async function createPluginRpcHostFactory(dbs: DatabaseService): Promise<
await createTestTripsRepo(dbs.connection),
);
// After accommodations: a hotel booking writes the stay's day stop through it.
const reservations = new ReservationsService(dbs, permissions, budget, realtime, notificationsStub(), new ReservationsReadRepository(dbs), accommodations, await createTestUnitOfWork(dbs.connection));
const reservations = new ReservationsService(dbs, permissions, budget, realtime, notificationsStub(), new ReservationsReadService(dbs), accommodations, await createTestUnitOfWork(dbs.connection));
const trips = new TripsService(dbs, reservations, days, permissions, budget, vacay, realtime, unsplash, generalStorage, await createTestUnitOfWork(dbs.connection), (await sharedTestOrm(dbs.connection)).em);
const members = new TripMembersService(dbs, budget, new UserCleanupService(dbs, budget, await createTestUnitOfWork(dbs.connection), usersRepo), permissions, realtime, notificationsStub(), await createTestUnitOfWork(dbs.connection), await createTestTripsRepo(dbs.connection), await createTestTripMembersRepo(dbs.connection), usersRepo);
const guards = new PluginGuards(dbs, permissions, addons);
@@ -0,0 +1,160 @@
/**
* `airtrail-sync` cron tick request-context ratchet (Plan 3d Task 0,
* deliverable 4b — inventory §12, the Plan 3a `*-003` shape
* `orm-request-context-seams.test.ts`'s SEAM-003 established for
* `PlaceShadowRetentionJob`).
*
* `AirtrailSyncJob.tick` → `AirtrailSyncService.runAirtrailSync` →
* `syncOwner` is the ONLY cron in the program that reaches a 3d service
* (`ReservationsService.getReservation`/`.update`, inventory §12's boot/cron
* table) — `airtrail-sync-boot` is already in BOOT-SWEEP-001's name list
* (it reads `app_settings` only, raw), but nothing before this file drove
* the TICK itself (not the boot sweep) through the real, production-wired
* `CronRegistrarService` into `ReservationsService`. `register()` already
* wraps every registered tick in `withRequestContext` unconditionally, fail
* closed on a missing ORM (`cron-registrar.service.ts`) — this pins that the
* wrap is actually reached from THIS job's tick, the same way SEAM-003 pins
* it for `PlaceShadowRetentionJob`'s.
*
* `ReservationsService` is not yet repository-backed (Plan 3d Tasks 2/4
* convert it) — the wrap around `getReservation` below performs a REAL
* repository read from exactly the point in the tick where the real,
* converted implementation will sit, proving the context is live now, ready
* for that conversion, the same technique `feeds-request-context.test.ts`'s
* SEAM-FEED-001 uses for the anonymous ICS routes.
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import type { INestApplication } from '@nestjs/common';
vi.mock('../../src/db/database', async () => {
const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock');
return buildDbMock(createSnapshotTestDb());
});
vi.mock('../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
SESSION_DURATION: '24h',
SESSION_DURATION_MS: 86400000,
SESSION_DURATION_SECONDS: 86400,
DEFAULT_LANGUAGE: 'en',
}));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn(), getOnlineUserIds: vi.fn(() => []) }));
import { MikroORM } from '@mikro-orm/core';
import { SchedulerRegistry } from '@nestjs/schedule';
import { db as testDb } from '../../src/db/database';
import { buildApp } from '../../src/bootstrap';
import { CronRegistrarService } from '../../src/nest/scheduling/cron-registrar.service';
import { AirtrailSyncJob } from '../../src/nest/integrations/airtrail-sync.job';
import { AirtrailLinkService } from '../../src/nest/integrations/airtrail-link.service';
import { AirtrailService } from '../../src/nest/integrations/airtrail.service';
import { AirtrailClient, type AirtrailFlightRaw } from '../../src/nest/integrations/airtrail.client';
import { ReservationsService } from '../../src/nest/reservations/reservations.service';
import { Users } from '../../src/db/entities/Users.entity';
import { createTrip, createUser } from '../helpers/factories';
describe('airtrail-sync cron tick runs inside a request context', () => {
let app: INestApplication;
beforeAll(async () => {
app = await buildApp();
});
afterAll(async () => {
await app.close();
testDb.close();
});
it('AIRTRAIL-CTX-001: firing the real registered tick reaches ReservationsService.getReservation with a live request context, against the SAME MikroORM app.get(MikroORM) does', async () => {
const orm = app.get(MikroORM);
const registrar = app.get(CronRegistrarService);
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const result = testDb
.prepare(
`INSERT INTO reservations (trip_id, title, type, status, external_source, external_id, external_owner_user_id, sync_enabled, external_hash)
VALUES (?, 'AirTrail Flight', 'flight', 'confirmed', 'airtrail', '999', ?, 1, 'stale-hash')`,
)
.run(trip.id, user.id);
const reservationId = result.lastInsertRowid as number;
const flight: AirtrailFlightRaw = {
id: 999,
from: null,
to: null,
date: '2026-01-01',
datePrecision: 'day',
departure: null,
arrival: null,
departureScheduled: null,
arrivalScheduled: null,
airline: null,
flightNumber: null,
aircraft: null,
aircraftReg: null,
note: null,
};
const link = app.get(AirtrailLinkService);
const airtrail = app.get(AirtrailService);
const client = app.get(AirtrailClient);
const reservations = app.get(ReservationsService);
const syncEnabledSpy = vi.spyOn(link, 'syncGloballyEnabled').mockResolvedValue(true);
const credsSpy = vi.spyOn(airtrail, 'getAirtrailCredentials').mockResolvedValue({
baseUrl: 'https://airtrail.test',
apiKey: 'test-key',
allowInsecureTls: false,
});
const flightsSpy = vi.spyOn(client, 'listFlights').mockResolvedValue([flight]);
let repoRead: unknown;
let caught: unknown;
const originalGetReservation = reservations.getReservation.bind(reservations);
const getReservationSpy = vi.spyOn(reservations, 'getReservation').mockImplementation(async (id, tripId) => {
try {
repoRead = await orm.em.getRepository(Users).findOne({ id: user.id });
} catch (e) {
caught = e;
}
return originalGetReservation(id, tripId);
});
const isEnabledSpy = vi.spyOn(registrar, 'isEnabled').mockReturnValue(true);
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
try {
// Real onApplicationBootstrap: registers the tick for real through
// CronRegistrarService.register (task-6's withRequestContext wrap),
// the same choke point SEAM-003 pins for PlaceShadowRetentionJob.
await app.get(AirtrailSyncJob).onApplicationBootstrap();
const job = app.get(SchedulerRegistry).getCronJob('airtrail-sync');
await job.fireOnTick();
// cron's CronJob.fireOnTick() only awaits its callback with
// `waitForCompletion: true` (SEAM-003's own docstring) — register()
// does not set that option either, so give the awaited
// withRequestContext(orm, tick) promise a chance to actually settle.
await new Promise((resolve) => setImmediate(resolve));
await new Promise((resolve) => setImmediate(resolve));
expect(caught).toBeUndefined();
expect(repoRead).toBeTruthy();
expect(getReservationSpy).toHaveBeenCalled();
const suspicious = errSpy.mock.calls
.map((args) => args.map(String).join(' '))
.filter((line) => /cannotUseGlobalContext|global EntityManager/i.test(line));
expect(suspicious).toEqual([]);
} finally {
registrar.unregister('airtrail-sync');
isEnabledSpy.mockRestore();
errSpy.mockRestore();
getReservationSpy.mockRestore();
flightsSpy.mockRestore();
credsSpy.mockRestore();
syncEnabledSpy.mockRestore();
testDb.prepare('DELETE FROM reservations WHERE id = ?').run(reservationId);
}
});
});
@@ -0,0 +1,185 @@
/**
* R1 async-closure mutation harness (Plan 3d Task 0, deliverable 4d;
* inventory §18.4; Plan 3c Task 0a's independent trap — "a Promise is
* always truthy → every 404 unreachable").
*
* Three synchronous closures wrap `this.db.get(...)` today and are used
* directly as booleans:
*
* - **RB2** `DayBoundariesService.save`'s `belongs` (`day-boundaries.service.ts:16-19`):
* `if (!belongs(from) || to !== null && !belongs(to))`.
* - **RS21** `ReservationsService.referencesOutsideTrip`'s `elsewhere`
* (`reservations.service.ts:539-542`), called through `check(field,
* elsewhere(table, id))` for every foreign-key-shaped field.
* - **RS23** `ReservationsService.unresolvedReferences`'s `onTrip`
* (`reservations.service.ts:592-593`): `if (data.day_id && !onTrip('days',
* data.day_id)) offenders.push('day_id')`.
*
* Converting each closure's own body to a repository call is Task 1/2's
* job, not this one's — but the MOMENT a closure becomes genuinely async, a
* call site that keeps `elsewhere('days', id)` (no `await`) receives a
* `Promise`, which is always truthy. The three closures do NOT all break
* the same direction:
*
* - RB2: `!belongs(x)` becomes `!Promise` = always `false` → the refusal
* branch never fires → **a FOREIGN assignment id is silently accepted**.
* RB2-001 below drives this direction: a `from_assignment_id` belonging
* to a different trip must still be refused (404).
* - RS21: `elsewhere(...)` itself becomes the `offending` argument to
* `check()`; a `Promise` is always truthy, so `check()` ALWAYS pushes the
* field — **every id, including a genuinely valid one on the SAME trip,
* gets flagged as foreign**. RS21-002 drives this direction (the one
* that actually ratchets RS21): a `day_id` genuinely on the target trip
* must succeed (201), not fail with "Not part of this trip". RS21-001 is
* the ordinary-behaviour baseline (a genuinely foreign id is refused
* today) kept alongside it for completeness.
* - RS23: `!onTrip(...)` becomes `!Promise` = always `false` → the
* offender is never pushed → **an id that resolves to nothing is
* silently accepted**. RS23-001 drives this direction: a `day_id` that
* does not exist at all must be refused (400 "Unknown reference").
*
* Each test below drives the REAL route (`buildApp()`, `supertest`, a real
* JWT) as the trip's own owner using a non-member's / a foreign / an
* unresolvable id — never a unit double of the service — so a future
* task's dropped `await` fails HTTP-level, the same way Plan 3c Task 0a's
* mutation table proves M1/M2 fail the real guard rather than a synchronous
* stand-in.
*
* **Mutation proof (manually applied and reverted while writing this file,
* per the brief; not left in the tree as a permanent code change — `git
* diff --stat` on both service files is empty):**
*
* - `reservations.service.ts:539` — `const elsewhere = (table, id) => {...}`
* changed to `const elsewhere = async (table, id) => {...}`, no `await`
* added at any of its 7 call sites. Two findings: `tsc --noEmit` ALREADY
* refuses this mutation at compile time (7 × TS2345, `Argument of type
* 'Promise<boolean>' is not assignable to parameter of type 'boolean'`
* — `check(field: string, offending: boolean)`'s explicit parameter
* type catches it) — but `vitest` transpiles through SWC, which does
* NOT typecheck, so the SAME mutation compiles and runs under `vitest
* run` (the actual CI test command) and RS21-002 turns red there:
* `POST .../reservations` returned 400 `{"error":"Not part of this
* trip: day_id"}` for a day that genuinely belongs to the target trip.
* So `typecheck` is a real (if incidental) second line of defence for
* THIS one closure specifically, because of `check()`'s typed
* parameter — RS21-002 is still the test that catches it at the level
* the brief asks for (the real route, driven through `vitest run`).
* - `day-boundaries.service.ts:16` — `belongs` changed to `async (id) =>
* this.db.get(...)`, no `await` at its two call sites. `!Promise` is
* valid TypeScript in a boolean context (no typed intermediate
* parameter here, unlike RS21's `check()`) — `tsc --noEmit` passes this
* mutation silently. RB2-001 turned red: `PUT .../day-boundaries` with
* a foreign `from_assignment_id` returned 200, not 404 — the id was
* silently accepted. This is the closure with NO compile-time net at
* all; RB2-001 is the only thing that would catch it.
* - `reservations.service.ts:592` — `onTrip` changed the same way, no
* `await` at its 3 call sites (`if (data.day_id && !onTrip(...))`).
* Also no typed intermediate parameter — `tsc --noEmit` passes it
* silently, same as `belongs`. RS23-001 turned red: a `day_id` that
* resolves to nothing was accepted (201), not refused (400 "Unknown
* reference").
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import type { INestApplication } from '@nestjs/common';
vi.mock('../../src/db/database', async () => {
const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock');
return buildDbMock(createSnapshotTestDb());
});
vi.mock('../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
SESSION_DURATION: '24h',
SESSION_DURATION_MS: 86400000,
SESSION_DURATION_SECONDS: 86400,
DEFAULT_LANGUAGE: 'en',
}));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn(), getOnlineUserIds: vi.fn(() => []) }));
import request from 'supertest';
import type { Application } from 'express';
import { db as testDb } from '../../src/db/database';
import { buildApp } from '../../src/bootstrap';
import { createDay, createDayAssignment, createPlace, createTrip, createUser } from '../helpers/factories';
import { generateToken } from '../helpers/auth';
describe('R1 async-closure truthiness guards (RB2/RS21/RS23) — driven through the real routes', () => {
let app: INestApplication;
let httpApp: Application;
beforeAll(async () => {
app = await buildApp();
httpApp = app.getHttpAdapter().getInstance() as Application;
testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'roadtrip'").run();
});
afterAll(async () => {
await app.close();
testDb.close();
});
it('RB2-001: DayBoundariesService.save refuses a from_assignment_id that belongs to a DIFFERENT trip (404 "Stop not found") — the direction an un-awaited `belongs` would silently accept', async () => {
const { user: owner } = createUser(testDb);
const tripA = createTrip(testDb, owner.id, { title: 'Trip A' });
createDay(testDb, tripA.id);
const { user: otherOwner } = createUser(testDb);
const tripB = createTrip(testDb, otherOwner.id, { title: 'Trip B' });
const dayB = createDay(testDb, tripB.id);
const placeB = createPlace(testDb, tripB.id);
const foreignAssignment = createDayAssignment(testDb, dayB.id, placeB.id);
const res = await request(httpApp)
.put(`/api/trips/${tripA.id}/roadtrip/day-boundaries`)
.set('Authorization', `Bearer ${generateToken(owner.id)}`)
.send({ day_number: 1, from_assignment_id: foreignAssignment.id, to_assignment_id: null, fraction: 1 });
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Stop not found' });
});
it('RS21-001: POST .../reservations refuses a day_id that belongs to a DIFFERENT trip (400 "Not part of this trip: day_id") — baseline', async () => {
const { user: owner } = createUser(testDb);
const tripA = createTrip(testDb, owner.id, { title: 'Trip A' });
const { user: otherOwner } = createUser(testDb);
const tripB = createTrip(testDb, otherOwner.id, { title: 'Trip B' });
const dayB = createDay(testDb, tripB.id);
const res = await request(httpApp)
.post(`/api/trips/${tripA.id}/reservations`)
.set('Authorization', `Bearer ${generateToken(owner.id)}`)
.send({ title: 'Foreign day booking', day_id: dayB.id });
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: 'Not part of this trip: day_id' });
});
it('RS21-002 (the load-bearing direction): POST .../reservations SUCCEEDS for a day_id genuinely on the SAME trip — an un-awaited `elsewhere` would flag every id, including this one, as foreign', async () => {
const { user: owner } = createUser(testDb);
const tripA = createTrip(testDb, owner.id, { title: 'Trip A' });
const dayA = createDay(testDb, tripA.id);
const res = await request(httpApp)
.post(`/api/trips/${tripA.id}/reservations`)
.set('Authorization', `Bearer ${generateToken(owner.id)}`)
.send({ title: 'Same-trip booking', day_id: dayA.id });
expect(res.status).toBe(201);
expect((res.body as { reservation?: { day_id?: number } }).reservation?.day_id).toBe(dayA.id);
});
it('RS23-001 (the load-bearing direction): POST .../reservations refuses a day_id that resolves to NOTHING (400 "Unknown reference: day_id") — an un-awaited `onTrip` would never flag it', async () => {
const { user: owner } = createUser(testDb);
const tripA = createTrip(testDb, owner.id, { title: 'Trip A' });
const res = await request(httpApp)
.post(`/api/trips/${tripA.id}/reservations`)
.set('Authorization', `Bearer ${generateToken(owner.id)}`)
.send({ title: 'Nonexistent day booking', day_id: 999_999_999 });
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: 'Unknown reference: day_id' });
});
});
@@ -82,6 +82,7 @@ import { ReminderJobsService } from '../../src/nest/notifications/reminder-jobs.
import { DocSyncJob } from '../../src/nest/doc-sync/doc-sync.job';
import { AirtrailSyncJob } from '../../src/nest/integrations/airtrail-sync.job';
import { DawarichSyncJob } from '../../src/nest/integrations/dawarich-sync.job';
import { AirportsService } from '../../src/nest/airports/airports.service';
describe('Every onApplicationBootstrap boot sweep runs inside a request context', () => {
let app: INestApplication;
@@ -95,7 +96,7 @@ describe('Every onApplicationBootstrap boot sweep runs inside a request context'
testDb.close();
});
it('BOOT-SWEEP-001: the seven jobs\' REAL onApplicationBootstrap, driven through the real production-wired CronRegistrarService, each routes through runOnBoot (structurally asserted), and journey-thumbs — the one sweep that reaches a repository today — never logs cannotUseGlobalContext / "global EntityManager"', async () => {
it('BOOT-SWEEP-001: the seven jobs\' plus AirportsService\'s REAL onApplicationBootstrap, driven through the real production-wired CronRegistrarService, each routes through runOnBoot (structurally asserted), and journey-thumbs — the one sweep that reaches a repository today — never logs cannotUseGlobalContext / "global EntityManager"', async () => {
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const registrar = app.get(CronRegistrarService);
const isEnabledSpy = vi.spyOn(registrar, 'isEnabled').mockReturnValue(true);
@@ -121,6 +122,13 @@ describe('Every onApplicationBootstrap boot sweep runs inside a request context'
await app.get(DocSyncJob).onApplicationBootstrap();
await app.get(AirtrailSyncJob).onApplicationBootstrap();
await app.get(DawarichSyncJob).onApplicationBootstrap();
// Plan 3d Task 0: `AirportsService` is a service, not a `*.job.ts`
// provider (it has no `register()`/cron tick of its own — no
// `isEnabled()` gate either, inventory §12), so it sits outside the
// seven-job loop above; its `onApplicationBootstrap` still routes its
// raw-SQL flight-endpoint backfill through the same `runOnBoot` choke
// point and belongs in this ratchet's name list.
await app.get(AirportsService).onApplicationBootstrap();
await Promise.all(collected);
// Settle any pending microtasks/macrotasks a fire-and-forget boot call left
@@ -139,6 +147,7 @@ describe('Every onApplicationBootstrap boot sweep runs inside a request context'
// by name, regardless of whether its sweep body happens to touch a
// repository yet.
expect(runOnBootSpy.mock.calls.map((c) => c[0]).sort()).toEqual([
'airports-flight-endpoints-boot',
'airtrail-sync-boot',
'dawarich-sync-boot',
'docsync-boot',
@@ -0,0 +1,166 @@
/**
* Anonymous ICS feed request-context ratchet (Plan 3d Task 0, deliverable
* 4a — inventory §12/§18.17).
*
* `GET /api/feed/trip/:token.ics` and `GET /api/feed/user/:token.ics`
* (`feeds.controller.ts`'s `FeedsPublicController`, class-level `@Public`)
* are ordinary Nest routes reached by an anonymous caller with no session —
* `@Public()` exempts only the auth guard, and `@mikro-orm/nestjs`'s
* `MikroOrmMiddleware` (registered by `MikroOrmModule.forRoot` at its
* default, `forRoutes({ path: '*', method: ALL })`) runs ahead of every
* route including these two, the same argument `nest-mcp/registry.ts`'s own
* middleware wiring makes for `/mcp`. No existing test pinned this before
* this file (inventory §12): the e2e feeds harness builds its own module
* graph, not `buildApp()`'s real production wiring, and `FeedsService`'s own
* `buildTripIcs`/`buildUserIcs` swallow EVERY calendar exception into a
* plain `return null` with no log line at all (`feeds.service.ts:130,
* :176` — bare `catch {}`), so a future regression that makes a converted
* (Task 4/5) repository read throw `cannotUseGlobalContext` would come back
* as the exact SAME 404 `{"error":"Feed not found"}` a genuinely wrong
* token produces — indistinguishable by response shape alone (§18.17: "the
* boot probe must diff bodies and the log, not statuses alone").
*
* SEAM-FEED-001 is the ratchet that actually distinguishes the two: it
* drives the REAL route for a VALID token with a stub standing in for the
* eventual repository-backed `CalendarService.buildTripCalendar` (the same
* technique `orm-request-context-seams.test.ts`'s SEAM-002 uses for the
* pre-init uploads route) and asserts BOTH that the HTTP response succeeds
* AND that a genuine repository read, performed from exactly the point in
* the request lifecycle the real implementation will occupy, resolves
* against the SAME `MikroORM` instance `app.get(MikroORM)` does — a
* `cannotUseGlobalContext` throw there fails this test outright (it is not
* swallowed the way `FeedsService`'s own catch swallows a normal
* `CalendarService` throw, because the read happens with `await`, inside
* the stub, before the stub returns). SEAM-FEED-002 pins today's genuine
* "wrong token" 404 body, unmutated. SEAM-FEED-003 is the mutation proof:
* it forces the exact `cannotUseGlobalContext`-shaped throw §18.17
* describes and shows the route degrades to the SAME 404 body a wrong
* token produces — proving, by direct observation, that the 404 ALONE
* cannot tell the two apart, which is exactly why SEAM-FEED-001's positive
* "the read must actually happen and succeed" assertion — not a status-code
* check — is the ratchet that would actually go red the day a real
* conversion regresses this.
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import type { INestApplication } from '@nestjs/common';
vi.mock('../../src/db/database', async () => {
const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock');
return buildDbMock(createSnapshotTestDb());
});
vi.mock('../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
SESSION_DURATION: '24h',
SESSION_DURATION_MS: 86400000,
SESSION_DURATION_SECONDS: 86400,
DEFAULT_LANGUAGE: 'en',
}));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn(), getOnlineUserIds: vi.fn(() => []) }));
import { randomUUID } from 'node:crypto';
import { MikroORM } from '@mikro-orm/core';
import request from 'supertest';
import type { Application } from 'express';
import { db as testDb } from '../../src/db/database';
import { buildApp } from '../../src/bootstrap';
import { CalendarService } from '../../src/nest/calendar/calendar.service';
import { Trips } from '../../src/db/entities/Trips.entity';
import { createTrip, createUser } from '../helpers/factories';
describe('Anonymous ICS feed routes run inside a request context', () => {
let app: INestApplication;
beforeAll(async () => {
app = await buildApp();
});
afterAll(async () => {
await app.close();
testDb.close();
});
it('SEAM-FEED-001: GET /api/feed/trip/:token.ics for a VALID token performs a real repository read, inside the request, against the SAME MikroORM app.get(MikroORM) does', async () => {
const orm = app.get(MikroORM);
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Context-Proof Trip' });
const token = randomUUID();
testDb.prepare('UPDATE trips SET feed_token = ? WHERE id = ?').run(token, trip.id);
let repoRead: unknown;
let caught: unknown;
const calendar = app.get(CalendarService);
const spy = vi.spyOn(calendar, 'buildTripCalendar').mockImplementation(async (tripId: string | number) => {
try {
repoRead = await orm.em.getRepository(Trips).findOne({ id: Number(tripId) });
} catch (e) {
caught = e;
}
return { calName: 'Stub Calendar', filename: 'stub.ics', timezones: new Map(), events: [] };
});
try {
const httpApp = app.getHttpAdapter().getInstance() as Application;
const res = await request(httpApp).get(`/api/feed/trip/${token}.ics`);
expect(caught).toBeUndefined();
expect(repoRead).toBeTruthy();
expect(res.status).toBe(200);
expect(res.headers['content-type']).toMatch(/text\/calendar/);
expect(res.text).toContain('BEGIN:VCALENDAR');
expect(res.text).toContain('Stub Calendar');
} finally {
spy.mockRestore();
}
});
it('SEAM-FEED-002: GET /api/feed/trip/:token.ics for a WRONG token is the legacy 404 body, unmutated code', async () => {
const httpApp = app.getHttpAdapter().getInstance() as Application;
const res = await request(httpApp).get(`/api/feed/trip/${randomUUID()}.ics`);
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Feed not found' });
});
/**
* The mutation §18.17 warns about, applied for real: `CalendarService
* .buildTripCalendar` throws the exact shape a missing request context
* throws (`ValidationError.cannotUseGlobalContext()`'s message —
* `@mikro-orm/core/errors.js`). `FeedsService.buildTripIcs`'s bare
* `catch {}` (`feeds.service.ts:130`) swallows it into `return null`,
* same as a wrong token — proving the 404 body ALONE cannot distinguish
* the two, which is why SEAM-FEED-001's positive read-succeeded assertion
* (not a status/body check) is the test that actually catches a real
* regression of this shape.
*/
it('SEAM-FEED-003 (mutation proof): a forced cannotUseGlobalContext throw inside the calendar builder degrades a VALID token to the SAME 404 body as a wrong one — the 404 alone cannot tell them apart', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Mutation-Proof Trip' });
const token = randomUUID();
testDb.prepare('UPDATE trips SET feed_token = ? WHERE id = ?').run(token, trip.id);
const calendar = app.get(CalendarService);
const spy = vi.spyOn(calendar, 'buildTripCalendar').mockImplementation(async () => {
throw new Error(
"ValidationError: Using global context, please provide the EM instance via ContextProvider.forkEntityManager() or wrap your queries via em.transactional(cb) or MikroORM.RequestContext.create(em, cb) (cannotUseGlobalContext)",
);
});
try {
const httpApp = app.getHttpAdapter().getInstance() as Application;
const res = await request(httpApp).get(`/api/feed/trip/${token}.ics`);
// Same shape as SEAM-FEED-002's genuinely-wrong-token 404 — this IS the
// hole §18.17 documents. SEAM-FEED-001 is what a real regression here
// actually fails.
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Feed not found' });
} finally {
spy.mockRestore();
}
});
it('SEAM-FEED-004: GET /api/feed/user/:token.ics for a WRONG token is the legacy 404 body', async () => {
const httpApp = app.getHttpAdapter().getInstance() as Application;
const res = await request(httpApp).get(`/api/feed/user/${randomUUID()}.ics`);
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Feed not found' });
});
});
@@ -8,21 +8,26 @@ import { Users } from '../../../../src/db/entities/Users.entity';
import {
absDifference,
caseWhenEquals,
castInteger,
coalesce,
coalesceParam,
columnIncrementedBy,
columnRef,
concat,
countAll,
countAllRef,
currentTimestamp,
dateAdd,
dateOf,
dayDistance,
lower,
lowerParam,
lowerTrim,
maxOf,
minOf,
nowMinusDays,
startsWithIsoDate,
substring,
trim,
} from '../../../../src/db/dialect/sql-functions';
@@ -489,4 +494,196 @@ describe('sql-functions (sqlite)', () => {
const foreign = new FakePlatform();
expect(() => countAllRef(foreign)).toThrow(/no implementation for platform FakePlatform/);
});
// Plan 3d Task 0 (R6): no consumer yet — Tasks 2/4 wire these in as each
// converts RS11/RS20/RV2/DY23. Pinned here so a later task imports a
// tested helper instead of writing one inline.
it('SQLF-034: startsWithIsoDate matches a value that begins with YYYY-MM-DD, and only that shape (dates, non-dates, "2026-1-1", empty, NULL)', async () => {
const platform = t.em.getPlatform();
const cases: Array<[string | null, boolean]> = [
['2026-09-21', true],
['2026-09-21T13:05:09Z', true],
['2026-09-21 13:05:09', true],
['not-a-date', false],
['2026-1-1', false], // not zero-padded — the legacy pattern is exactly 4-2-2 digit classes, not a wildcard shorthand
['', false],
[null, false],
];
for (const [value, expected] of cases) {
const { user } = createUser(testDb);
testDb.prepare('UPDATE users SET display_name = ? WHERE id = ?').run(value, user.id);
const row = await t.em.createQueryBuilder(Users, 'u')
.select([startsWithIsoDate(platform, 'u.display_name').as('matched')])
.where({ id: user.id })
.execute('get', false);
expect(Boolean((row as { matched: number | null }).matched)).toBe(expected);
const raw = testDb
.prepare(`SELECT (display_name GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]*') as matched FROM users WHERE id = ?`)
.get(user.id) as { matched: number | null };
expect(Boolean(raw.matched)).toBe(expected);
}
});
it('SQLF-035: an unknown platform fails closed for startsWithIsoDate', () => {
class FakePlatform extends Platform {}
const foreign = new FakePlatform();
expect(() => startsWithIsoDate(foreign, 'u.reservation_time')).toThrow(/no implementation for platform FakePlatform/);
});
it('SQLF-036: substring(ref, start) — the two-arg form — reads from a 1-based position to the end, matching substr(col, N)', async () => {
const { user } = createUser(testDb, { username: 'iso-ts' });
testDb.prepare('UPDATE users SET display_name = ? WHERE id = ?').run('2026-09-21T13:05:09Z', user.id);
const platform = t.em.getPlatform();
const row = await t.em.createQueryBuilder(Users, 'u')
.select([substring(platform, 'u.display_name', 12).as('n')])
.where({ id: user.id })
.execute('get', false);
expect((row as { n: string }).n).toBe('13:05:09Z');
const expected = testDb.prepare('SELECT substr(display_name, 12) as n FROM users WHERE id = ?').get(user.id) as { n: string };
expect((row as { n: string }).n).toBe(expected.n);
});
it('SQLF-037: substring(ref, start, length) — the three-arg form — matches substr(col, N, L)', async () => {
const { user } = createUser(testDb, { username: 'iso-ts-2' });
testDb.prepare('UPDATE users SET display_name = ? WHERE id = ?').run('2026-09-21T13:05:09Z', user.id);
const platform = t.em.getPlatform();
const row = await t.em.createQueryBuilder(Users, 'u')
.select([substring(platform, 'u.display_name', 1, 10).as('n')])
.where({ id: user.id })
.execute('get', false);
expect((row as { n: string }).n).toBe('2026-09-21');
const expected = testDb.prepare('SELECT substr(display_name, 1, 10) as n FROM users WHERE id = ?').get(user.id) as { n: string };
expect((row as { n: string }).n).toBe(expected.n);
});
it('SQLF-038: substring rejects a non-positive start or a negative length', () => {
const platform = t.em.getPlatform();
expect(() => substring(platform, 'u.x', 0)).toThrow(/1-based integer start/);
expect(() => substring(platform, 'u.x', 1.5)).toThrow(/1-based integer start/);
expect(() => substring(platform, 'u.x', 1, -1)).toThrow(/non-negative integer length/);
expect(() => substring(platform, 'u.x', 1, 1.5)).toThrow(/non-negative integer length/);
});
it('SQLF-039: an unknown platform fails closed for substring', () => {
class FakePlatform extends Platform {}
const foreign = new FakePlatform();
expect(() => substring(foreign, 'u.x', 1)).toThrow(/no implementation for platform FakePlatform/);
});
it('SQLF-040: concat renders <col> || <bound-literal> || <col>, matching the legacy d.date || \'T\' || a.check_in shape', async () => {
const { user } = createUser(testDb, { username: 'concat-me' });
testDb.prepare('UPDATE users SET display_name = ? WHERE id = ?').run('13:05', user.id);
const platform = t.em.getPlatform();
const row = await t.em.createQueryBuilder(Users, 'u')
.select([concat(platform, { column: 'u.username' }, { value: 'T' }, { column: 'u.display_name' }).as('n')])
.where({ id: user.id })
.execute('get', false);
expect((row as { n: string }).n).toBe('concat-meT13:05');
const expected = testDb.prepare("SELECT username || ? || display_name as n FROM users WHERE id = ?").get('T', user.id) as { n: string };
expect((row as { n: string }).n).toBe(expected.n);
});
it('SQLF-041: concat rejects fewer than two parts', () => {
const platform = t.em.getPlatform();
expect(() => concat(platform, { column: 'u.a' })).toThrow(/at least two parts/);
expect(() => concat(platform)).toThrow(/at least two parts/);
});
it('SQLF-042: an unknown platform fails closed for concat', () => {
class FakePlatform extends Platform {}
const foreign = new FakePlatform();
expect(() => concat(foreign, { column: 'u.a' }, { value: 'x' })).toThrow(/no implementation for platform FakePlatform/);
});
// §18.1: reservations.accommodation_id is TEXT holding integer ids with no
// FK; some rows read back as "14.0". castInteger exists so a comparison
// against a genuine INTEGER column matches both spellings the way the
// legacy CAST(... AS INTEGER) statement does.
it('SQLF-043: castInteger matches both "14" and "14.0" TEXT rows against a bound INTEGER value (the accommodation_id shape, §18.1)', async () => {
const platform = t.em.getPlatform();
const { user: exact } = createUser(testDb);
testDb.prepare('UPDATE users SET display_name = ? WHERE id = ?').run('14', exact.id);
const { user: dotZero } = createUser(testDb);
testDb.prepare('UPDATE users SET display_name = ? WHERE id = ?').run('14.0', dotZero.id);
const { user: other } = createUser(testDb);
testDb.prepare('UPDATE users SET display_name = ? WHERE id = ?').run('15', other.id);
const rows = await t.em.createQueryBuilder(Users, 'u')
.select('u.id')
.where({ id: { $in: [exact.id, dotZero.id, other.id] }, [castInteger(platform, 'u.display_name')]: 14 })
.orderBy({ id: 'asc' })
.execute('all', false);
expect((rows as { id: number }[]).map((r) => r.id)).toEqual([exact.id, dotZero.id]);
const expected = testDb
.prepare('SELECT id FROM users WHERE id IN (?, ?, ?) AND CAST(display_name AS INTEGER) = ? ORDER BY id ASC')
.all(exact.id, dotZero.id, other.id, 14);
expect(rows).toEqual(expected);
});
it('SQLF-044: castInteger compares against another column via columnRef, matching CAST(col AS INTEGER) = other_col (the real RV2/RS20 shape)', async () => {
const platform = t.em.getPlatform();
const { user: exact } = createUser(testDb);
testDb.prepare('UPDATE users SET display_name = ? WHERE id = ?').run(String(exact.id), exact.id);
const { user: dotZero } = createUser(testDb);
testDb.prepare('UPDATE users SET display_name = ? WHERE id = ?').run(`${dotZero.id}.0`, dotZero.id);
const { user: mismatched } = createUser(testDb);
testDb.prepare('UPDATE users SET display_name = ? WHERE id = ?').run(String(exact.id), mismatched.id);
const rows = await t.em.createQueryBuilder(Users, 'u')
.select('u.id')
.where({ id: { $in: [exact.id, dotZero.id, mismatched.id] }, [castInteger(platform, 'u.display_name')]: columnRef(platform, 'u.id') })
.orderBy({ id: 'asc' })
.execute('all', false);
expect((rows as { id: number }[]).map((r) => r.id)).toEqual([exact.id, dotZero.id]);
const expected = testDb
.prepare('SELECT id FROM users WHERE id IN (?, ?, ?) AND CAST(display_name AS INTEGER) = id ORDER BY id ASC')
.all(exact.id, dotZero.id, mismatched.id);
expect(rows).toEqual(expected);
});
it('SQLF-045: an unknown platform fails closed for castInteger', () => {
class FakePlatform extends Platform {}
const foreign = new FakePlatform();
expect(() => castInteger(foreign, 'u.accommodation_id')).toThrow(/no implementation for platform FakePlatform/);
});
it('SQLF-046: dayDistance orders by nearest calendar date, matching ORDER BY ABS(JULIANDAY(col) - JULIANDAY(?)) ASC', async () => {
const platform = t.em.getPlatform();
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 { user: c } = createUser(testDb);
testDb.prepare("UPDATE users SET created_at = '2026-06-20 00:00:00' WHERE id = ?").run(c.id);
const target = '2026-06-18';
const rows = await t.em.createQueryBuilder(Users, 'u')
.select('u.id')
.where({ id: { $in: [a.id, b.id, c.id] } })
.orderBy({ [dayDistance(platform, 'u.created_at', target)]: 'asc' })
.execute('all', false);
expect((rows as { id: number }[]).map((r) => r.id)).toEqual([c.id, b.id, a.id]);
const expected = testDb
.prepare('SELECT id FROM users WHERE id IN (?, ?, ?) ORDER BY ABS(JULIANDAY(created_at) - JULIANDAY(?)) ASC')
.all(a.id, b.id, c.id, target);
expect(rows).toEqual(expected);
});
it('SQLF-047: an unknown platform fails closed for dayDistance', () => {
class FakePlatform extends Platform {}
const foreign = new FakePlatform();
expect(() => dayDistance(foreign, 'u.date', '2026-01-01')).toThrow(/no implementation for platform FakePlatform/);
});
});
@@ -10,6 +10,7 @@ import { createTestOrm } from '../../helpers/test-orm';
import {
assertFilesGenerated,
BOOLEAN_COLUMNS,
CheckExpressionFixup,
JoinColumnFixup,
JsonColumnFixup,
KNOWN_DIFFS,
@@ -29,6 +30,7 @@ import {
RULE9_addImplicitUniqueConstraints,
RULE10_repositoryTypeMarker,
RULE11_referencedColumns,
RULE12_fixGarbledCheckExpressions,
RULE_normalizeLiteralDefaults,
applyTextPasses,
checkEntities,
@@ -493,6 +495,59 @@ describe('RULE11_referencedColumns', () => {
});
});
describe('RULE12_fixGarbledCheckExpressions', () => {
it('RULE12-001: the real roadtrip_day_boundaries.fraction over-capture is trimmed to the DDL\'s own CHECK expression', () => {
const meta = fixtureMeta('RoadtripDayBoundaries', 'roadtrip_day_boundaries', []);
meta.checks = [
{
name: 'roadtrip_day_boundaries_fraction_check',
expression: 'fraction BETWEEN 0 AND 1), PRIMARY KEY (trip_id, day_number',
},
];
const fixups = RULE12_fixGarbledCheckExpressions([meta]);
expect(meta.checks[0].expression).toBe('fraction BETWEEN 0 AND 1');
expect(fixups).toEqual<CheckExpressionFixup[]>([{ className: 'RoadtripDayBoundaries', checkName: 'roadtrip_day_boundaries_fraction_check' }]);
});
it('RULE12-002: a clean expression with no parens at all (day_number >= 1) is left byte-identical, no fixup', () => {
const meta = fixtureMeta('RoadtripDayBoundaries', 'roadtrip_day_boundaries', []);
meta.checks = [{ name: 'roadtrip_day_boundaries_day_number_check', expression: 'day_number >= 1' }];
const fixups = RULE12_fixGarbledCheckExpressions([meta]);
expect(meta.checks[0].expression).toBe('day_number >= 1');
expect(fixups).toEqual([]);
});
it('RULE12-003: a clean expression whose own nested parens never fully rebalance to 0 (an IN-list) is left byte-identical, no fixup', () => {
const meta = fixtureMeta('SchoolHolidayPeriods', 'school_holiday_periods', []);
meta.checks = [{ name: 'sh_status_check', expression: "status IN ('confirmed', 'tentative')" }];
const fixups = RULE12_fixGarbledCheckExpressions([meta]);
expect(meta.checks[0].expression).toBe("status IN ('confirmed', 'tentative')");
expect(fixups).toEqual([]);
});
it('RULE12-004: a table-level expression with no trailing garbage (end_date >= start_date) is left byte-identical, no fixup', () => {
const meta = fixtureMeta('SchoolHolidayPeriods', 'school_holiday_periods', []);
meta.checks = [{ name: 'school_holiday_periods_end_date_check', expression: 'end_date >= start_date' }];
const fixups = RULE12_fixGarbledCheckExpressions([meta]);
expect(meta.checks[0].expression).toBe('end_date >= start_date');
expect(fixups).toEqual([]);
});
it('RULE12-005: a non-string expression (Raw / callback) is skipped, never thrown on', () => {
const meta = fixtureMeta('X', 'x', []);
const callback = () => 'whatever';
meta.checks = [{ name: 'x_check', expression: callback as unknown as string }];
expect(() => RULE12_fixGarbledCheckExpressions([meta])).not.toThrow();
expect(meta.checks[0].expression).toBe(callback);
});
it('RULE12-006: a table with no checks at all is a no-op', () => {
const meta = fixtureMeta('X', 'x', []);
const fixups = RULE12_fixGarbledCheckExpressions([meta]);
expect(fixups).toEqual([]);
});
});
describe('RULE9_addImplicitUniqueConstraints', () => {
it('RULE9-001: a table with a matching implicit-unique entry gets a uniques: block referencing the columns as properties — a plain (non-FK) column keeps its own name', () => {
const userId = fixtureProp({ name: 'user_id', primary: false });
@@ -923,6 +978,7 @@ describe('applyTextPasses', () => {
retypedScalars: [],
repositoryMarkers: [{ className: 'X', repositoryClassName: 'XRepository' }],
referencedColumns: [{ className: 'X', propName: 'countryRef', referencedColumnNames: ['code'] }],
checkExpressions: [],
});
expect(out).toContain(".joinColumn('country')");
expect(out).toContain(".referencedColumnNames('code')");
@@ -0,0 +1,260 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { createSnapshotTestDb } from '../../../../helpers/db-mock';
import { resetTestDb } from '../../../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../../../helpers/test-orm';
import { createDayAccommodation, createPlace, createReservation, createTrip, createUser } from '../../../../helpers/factories';
import { Reservations } from '../../../../../src/db/entities/Reservations.entity';
import {
publicReservationCondition,
publicStayExists,
type ReservationVisibilityKyselyDB,
} from '../../../../../src/db/repositories/_shared/reservation-visibility';
import { publicReservationSql, publicStaySql } from '../../../../../src/nest/reservations/reservation-visibility';
const testDb = createSnapshotTestDb();
let t: TestOrm;
beforeAll(async () => { t = await createTestOrm(testDb); });
beforeEach(() => { resetTestDb(testDb); t.clear(); });
afterAll(async () => { await t.close(); testDb.close(); });
/**
* R3's string-vs-predicate parity harness: seeds staged/live/mixed
* reservations and stays, then runs BOTH the legacy raw-string fragment
* (`src/nest/reservations/reservation-visibility.ts`) and the new typed
* predicate (`src/db/repositories/_shared/reservation-visibility.ts`)
* against the identical rows, asserting identical row-id sets. Tasks 2/4/5
* reuse this harness rather than re-derive it (brief item 3).
*/
describe('reservation-visibility parity (RV1: publicReservationCondition vs publicReservationSql)', () => {
it('RESVIS-001: a mix of live/staged reservations — the typed condition and the legacy string fragment select the identical row set', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const live1 = createReservation(testDb, trip.id, { title: 'Live 1' });
const live2 = createReservation(testDb, trip.id, { title: 'Live 2' });
const staged1 = createReservation(testDb, trip.id, { title: 'Staged 1' });
testDb.prepare("UPDATE reservations SET ingest_state = 'staged' WHERE id = ?").run(staged1.id);
const staged2 = createReservation(testDb, trip.id, { title: 'Staged 2' });
testDb.prepare("UPDATE reservations SET ingest_state = 'staged' WHERE id = ?").run(staged2.id);
const platform = t.em.getPlatform();
const typed = await t.em.createQueryBuilder(Reservations, 'r')
.select(['r.id'])
.where({ 'r.trip_id': trip.id, ...publicReservationCondition(platform, 'r') })
.orderBy({ id: 'asc' })
.execute<{ id: number }[]>('all', false);
const legacy = testDb
.prepare(`SELECT id FROM reservations r WHERE r.trip_id = ? AND ${publicReservationSql('r')} ORDER BY id ASC`)
.all(trip.id) as { id: number }[];
expect(typed.map((r) => r.id)).toEqual([live1.id, live2.id]);
expect(typed).toEqual(legacy);
});
it('RESVIS-002: every reservation staged — both forms select nothing', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const staged = createReservation(testDb, trip.id, { title: 'Staged only' });
testDb.prepare("UPDATE reservations SET ingest_state = 'staged' WHERE id = ?").run(staged.id);
const platform = t.em.getPlatform();
const typed = await t.em.createQueryBuilder(Reservations, 'r')
.select(['r.id'])
.where({ 'r.trip_id': trip.id, ...publicReservationCondition(platform, 'r') })
.execute<{ id: number }[]>('all', false);
const legacy = testDb
.prepare(`SELECT id FROM reservations r WHERE r.trip_id = ? AND ${publicReservationSql('r')}`)
.all(trip.id) as { id: number }[];
expect(typed).toEqual([]);
expect(typed).toEqual(legacy);
});
it('RESVIS-003: a different table alias (the share.service.ts / calendar.service.ts "vr" shape) renders identically', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const live = createReservation(testDb, trip.id, { title: 'Live' });
const staged = createReservation(testDb, trip.id, { title: 'Staged' });
testDb.prepare("UPDATE reservations SET ingest_state = 'staged' WHERE id = ?").run(staged.id);
const platform = t.em.getPlatform();
const typed = await t.em.createQueryBuilder(Reservations, 'vr')
.select(['vr.id'])
.where({ 'vr.trip_id': trip.id, ...publicReservationCondition(platform, 'vr') })
.execute<{ id: number }[]>('all', false);
const legacy = testDb
.prepare(`SELECT id FROM reservations vr WHERE vr.trip_id = ? AND ${publicReservationSql('vr')}`)
.all(trip.id) as { id: number }[];
expect(typed.map((r) => r.id)).toEqual([live.id]);
expect(typed).toEqual(legacy);
});
});
/**
* `Kysely<StayVisibilityTestDB>` typed the way `TrekRepository.kysely()`
* types every other repository's Kysely escape hatch — here obtained
* directly off the test ORM's EntityManager (this module has no repository
* of its own; it is a shared predicate builder, not a `TrekRepository`
* subclass). Extends `ReservationVisibilityKyselyDB`'s real-table-name
* shape with the one extra column this test's own WHERE clause needs
* (`day_accommodations.trip_id`) — `.selectFrom('day_accommodations as a')`
* below derives the aliased `a` member FROM `day_accommodations` (see
* `ReservationVisibilityKyselyDB`'s own docstring), so the resulting `eb` is
* exactly the shape `publicStayExists` requires.
*/
interface StayVisibilityTestDB extends ReservationVisibilityKyselyDB {
day_accommodations: { id: number; trip_id: number };
}
describe('reservation-visibility parity (RV2: publicStayExists vs publicStaySql)', () => {
it('RESVIS-004: no reservation points at the stay — public (the NOT EXISTS branch)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2026-09-01', end_date: '2026-09-05' });
const place = createPlace(testDb, trip.id);
const days = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number ASC').all(trip.id) as { id: number }[];
const stay = createDayAccommodation(testDb, trip.id, place.id, days[0].id, days[1].id);
const typed = await t.em.getKysely<StayVisibilityTestDB>()
.selectFrom('day_accommodations as a')
.select('a.id')
.where('a.trip_id', '=', trip.id)
.where((eb) => publicStayExists(eb))
.orderBy('a.id', 'asc')
.execute();
const legacy = testDb
.prepare(`SELECT id FROM day_accommodations a WHERE a.trip_id = ? AND ${publicStaySql('a')} ORDER BY a.id ASC`)
.all(trip.id) as { id: number }[];
expect(typed.map((r) => r.id)).toEqual([stay.id]);
expect(typed).toEqual(legacy);
});
it('RESVIS-005: a live booking points at the stay — public (the EXISTS + live branch)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2026-09-01', end_date: '2026-09-05' });
const place = createPlace(testDb, trip.id);
const days = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number ASC').all(trip.id) as { id: number }[];
const stay = createDayAccommodation(testDb, trip.id, place.id, days[0].id, days[1].id);
const booking = createReservation(testDb, trip.id, { title: 'Hotel', type: 'hotel' });
testDb.prepare("UPDATE reservations SET accommodation_id = ? WHERE id = ?").run(String(stay.id), booking.id);
const typed = await t.em.getKysely<StayVisibilityTestDB>()
.selectFrom('day_accommodations as a')
.select('a.id')
.where('a.trip_id', '=', trip.id)
.where((eb) => publicStayExists(eb))
.execute();
const legacy = testDb
.prepare(`SELECT id FROM day_accommodations a WHERE a.trip_id = ? AND ${publicStaySql('a')}`)
.all(trip.id) as { id: number }[];
expect(typed.map((r) => r.id)).toEqual([stay.id]);
expect(typed).toEqual(legacy);
});
it('RESVIS-006: only a staged booking points at the stay — hidden (neither branch matches)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2026-09-01', end_date: '2026-09-05' });
const place = createPlace(testDb, trip.id);
const days = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number ASC').all(trip.id) as { id: number }[];
const stay = createDayAccommodation(testDb, trip.id, place.id, days[0].id, days[1].id);
const booking = createReservation(testDb, trip.id, { title: 'Hotel', type: 'hotel' });
testDb.prepare("UPDATE reservations SET accommodation_id = ?, ingest_state = 'staged' WHERE id = ?").run(String(stay.id), booking.id);
const typed = await t.em.getKysely<StayVisibilityTestDB>()
.selectFrom('day_accommodations as a')
.select('a.id')
.where('a.trip_id', '=', trip.id)
.where((eb) => publicStayExists(eb))
.execute();
const legacy = testDb
.prepare(`SELECT id FROM day_accommodations a WHERE a.trip_id = ? AND ${publicStaySql('a')}`)
.all(trip.id) as { id: number }[];
expect(typed).toEqual([]);
expect(typed).toEqual(legacy);
});
it('RESVIS-007: a staged AND a live booking both point at the stay — public (the live one is enough)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2026-09-01', end_date: '2026-09-05' });
const place = createPlace(testDb, trip.id);
const days = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number ASC').all(trip.id) as { id: number }[];
const stay = createDayAccommodation(testDb, trip.id, place.id, days[0].id, days[1].id);
const stagedBooking = createReservation(testDb, trip.id, { title: 'Staged copy', type: 'hotel' });
testDb.prepare("UPDATE reservations SET accommodation_id = ?, ingest_state = 'staged' WHERE id = ?").run(String(stay.id), stagedBooking.id);
const liveBooking = createReservation(testDb, trip.id, { title: 'Live copy', type: 'hotel' });
testDb.prepare("UPDATE reservations SET accommodation_id = ? WHERE id = ?").run(String(stay.id), liveBooking.id);
const typed = await t.em.getKysely<StayVisibilityTestDB>()
.selectFrom('day_accommodations as a')
.select('a.id')
.where('a.trip_id', '=', trip.id)
.where((eb) => publicStayExists(eb))
.execute();
const legacy = testDb
.prepare(`SELECT id FROM day_accommodations a WHERE a.trip_id = ? AND ${publicStaySql('a')}`)
.all(trip.id) as { id: number }[];
expect(typed.map((r) => r.id)).toEqual([stay.id]);
expect(typed).toEqual(legacy);
});
// §18.1: reservations.accommodation_id is TEXT with no FK; some rows read
// back as "14.0". The CAST(... AS INTEGER) both the legacy fragment and
// publicStayExists use must match that spelling too, not just the plain
// integer-as-string form.
it('RESVIS-008: accommodation_id stored in the "14.0" TEXT form still resolves the link — public', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2026-09-01', end_date: '2026-09-05' });
const place = createPlace(testDb, trip.id);
const days = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number ASC').all(trip.id) as { id: number }[];
const stay = createDayAccommodation(testDb, trip.id, place.id, days[0].id, days[1].id);
const booking = createReservation(testDb, trip.id, { title: 'Hotel', type: 'hotel' });
testDb.prepare("UPDATE reservations SET accommodation_id = ? WHERE id = ?").run(`${stay.id}.0`, booking.id);
const typed = await t.em.getKysely<StayVisibilityTestDB>()
.selectFrom('day_accommodations as a')
.select('a.id')
.where('a.trip_id', '=', trip.id)
.where((eb) => publicStayExists(eb))
.execute();
const legacy = testDb
.prepare(`SELECT id FROM day_accommodations a WHERE a.trip_id = ? AND ${publicStaySql('a')}`)
.all(trip.id) as { id: number }[];
expect(typed.map((r) => r.id)).toEqual([stay.id]);
expect(typed).toEqual(legacy);
});
it('RESVIS-009: a mixed trip — one unlinked stay, one live-linked stay, one staged-only stay — every stay resolves identically between both forms', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2026-09-01', end_date: '2026-09-06' });
const place = createPlace(testDb, trip.id);
const days = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number ASC').all(trip.id) as { id: number }[];
const unlinked = createDayAccommodation(testDb, trip.id, place.id, days[0].id, days[1].id);
const liveLinked = createDayAccommodation(testDb, trip.id, place.id, days[2].id, days[3].id);
const liveBooking = createReservation(testDb, trip.id, { title: 'Live', type: 'hotel' });
testDb.prepare('UPDATE reservations SET accommodation_id = ? WHERE id = ?').run(String(liveLinked.id), liveBooking.id);
const stagedOnly = createDayAccommodation(testDb, trip.id, place.id, days[4].id, days[5].id);
const stagedBooking = createReservation(testDb, trip.id, { title: 'Staged', type: 'hotel' });
testDb.prepare("UPDATE reservations SET accommodation_id = ?, ingest_state = 'staged' WHERE id = ?").run(String(stagedOnly.id), stagedBooking.id);
const typed = await t.em.getKysely<StayVisibilityTestDB>()
.selectFrom('day_accommodations as a')
.select('a.id')
.where('a.trip_id', '=', trip.id)
.where((eb) => publicStayExists(eb))
.orderBy('a.id', 'asc')
.execute();
const legacy = testDb
.prepare(`SELECT id FROM day_accommodations a WHERE a.trip_id = ? AND ${publicStaySql('a')} ORDER BY a.id ASC`)
.all(trip.id) as { id: number }[];
expect(typed.map((r) => r.id)).toEqual([unlinked.id, liveLinked.id]);
expect(typed).toEqual(legacy);
});
});
@@ -49,7 +49,7 @@ import { DatabaseService } from '../../../src/nest/database/database.service';
import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
import { ReservationsService } from '../../../src/nest/reservations/reservations.service';
import { ReservationsReadRepository } from '../../../src/nest/reservations/reservations-read.repository';
import { ReservationsReadService } from '../../../src/nest/reservations/reservations-read.service';
import { BudgetService } from '../../../src/nest/budget/budget.service';
import { ExchangeRatesService } from '../../../src/nest/budget/exchange-rates.service';
import { CalendarService, foldICS } from '../../../src/nest/calendar/calendar.service';
@@ -69,7 +69,7 @@ beforeAll(async () => {
budgetSvc = new BudgetService(dbs(), new PermissionsService(await createTestAppSettingsRepo(dbs().connection), await createTestUnitOfWork(dbs().connection)), new ExchangeRatesService(), new RealtimeService(), await createTestUnitOfWork(dbs().connection));
svc = new CalendarService(
dbs(),
new ReservationsService(dbs(), new PermissionsService(await createTestAppSettingsRepo(dbs().connection), await createTestUnitOfWork(dbs().connection)), budgetSvc, new RealtimeService(), notificationsStub(), new ReservationsReadRepository(dbs()), await accommodationsOver(dbs()), await createTestUnitOfWork(dbs().connection)),
new ReservationsService(dbs(), new PermissionsService(await createTestAppSettingsRepo(dbs().connection), await createTestUnitOfWork(dbs().connection)), budgetSvc, new RealtimeService(), notificationsStub(), new ReservationsReadService(dbs()), await accommodationsOver(dbs()), await createTestUnitOfWork(dbs().connection)),
);
});
@@ -50,7 +50,7 @@ import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, createReservation, createBudgetItem, createPlace, createDay, createDayAccommodation, createDayAssignment, addTripMember } from '../../helpers/factories';
import type { PermissionsService } from '../../../src/nest/permissions/permissions.service';
import { ReservationsService } from '../../../src/nest/reservations/reservations.service';
import { ReservationsReadRepository } from '../../../src/nest/reservations/reservations-read.repository';
import { ReservationsReadService } from '../../../src/nest/reservations/reservations-read.service';
import type { BudgetService } from '../../../src/nest/budget/budget.service';
// Was reservations.bridge, deleted along with the other three that had no
// consumer outside the container. The cases below kept their assertions and
@@ -80,7 +80,7 @@ import { createTestUnitOfWork, createTestDatabaseService } from '../../helpers/t
let svc: ReservationsService;
beforeAll(async () => {
const dbs = await createTestDatabaseService(testDb);
svc = new ReservationsService(dbs, permissionsStub, budget as unknown as BudgetService, new RealtimeService(), notificationsStub(notif.send), new ReservationsReadRepository(dbs), await makeAccommodationsService(testDb), await createTestUnitOfWork(testDb));
svc = new ReservationsService(dbs, permissionsStub, budget as unknown as BudgetService, new RealtimeService(), notificationsStub(notif.send), new ReservationsReadService(dbs), await makeAccommodationsService(testDb), await createTestUnitOfWork(testDb));
});
beforeAll(() => { createTables(testDb); runMigrations(testDb); });
@@ -56,7 +56,7 @@ import { TodoService } from '../../../src/nest/todo/todo.service';
import { PackingService } from '../../../src/nest/packing/packing.service';
import { FilesService } from '../../../src/nest/files/files.service';
import { ReservationsService } from '../../../src/nest/reservations/reservations.service';
import { ReservationsReadRepository } from '../../../src/nest/reservations/reservations-read.repository';
import { ReservationsReadService } from '../../../src/nest/reservations/reservations-read.service';
import { BudgetService } from '../../../src/nest/budget/budget.service';
import { ExchangeRatesService } from '../../../src/nest/budget/exchange-rates.service';
import { CollabService } from '../../../src/nest/collab/collab.service';
@@ -146,7 +146,7 @@ const buildReadModel = async (tripsRepo: TripsRepository, roster: TripMembersSer
new TripReadModelService(
tripsRepo, roster, daysSvc, accommodationsSvc, budgetSvc,
new PackingService(dbs(), new PermissionsService(await createTestAppSettingsRepo(dbs().connection), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub(), await createTestUnitOfWork(dbs().connection)),
new ReservationsService(dbs(), new PermissionsService(await createTestAppSettingsRepo(dbs().connection), await createTestUnitOfWork(dbs().connection)), budgetSvc, new RealtimeService(), notificationsStub(), new ReservationsReadRepository(dbs()), await accommodationsOver(dbs()), await createTestUnitOfWork(dbs().connection)),
new ReservationsService(dbs(), new PermissionsService(await createTestAppSettingsRepo(dbs().connection), await createTestUnitOfWork(dbs().connection)), budgetSvc, new RealtimeService(), notificationsStub(), new ReservationsReadService(dbs()), await accommodationsOver(dbs()), await createTestUnitOfWork(dbs().connection)),
new CollabService(dbs(), new PermissionsService(await createTestAppSettingsRepo(dbs().connection), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub(), makeStorageFixture('').storage, new RateLimitService(), await createTestUnitOfWork(dbs().connection)),
placesSvc,
new TodoService(dbs(), new PermissionsService(await createTestAppSettingsRepo(dbs().connection), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), await createTestUnitOfWork(dbs().connection)),
+3 -3
View File
@@ -63,7 +63,7 @@ import { TodoService } from '../../../src/nest/todo/todo.service';
import { PackingService } from '../../../src/nest/packing/packing.service';
import { FilesService } from '../../../src/nest/files/files.service';
import { ReservationsService } from '../../../src/nest/reservations/reservations.service';
import { ReservationsReadRepository } from '../../../src/nest/reservations/reservations-read.repository';
import { ReservationsReadService } from '../../../src/nest/reservations/reservations-read.service';
import { BudgetService } from '../../../src/nest/budget/budget.service';
import { ExchangeRatesService } from '../../../src/nest/budget/exchange-rates.service';
import { CollabService } from '../../../src/nest/collab/collab.service';
@@ -178,7 +178,7 @@ beforeAll(async () => {
);
svc = new TripsService(
dbs(),
new ReservationsService(dbs(), new PermissionsService(await createTestAppSettingsRepo(dbs().connection), await createTestUnitOfWork(dbs().connection)), budgetSvc, new RealtimeService(), notificationsStub(), new ReservationsReadRepository(dbs()), accommodationsSvc, await createTestUnitOfWork(dbs().connection)),
new ReservationsService(dbs(), new PermissionsService(await createTestAppSettingsRepo(dbs().connection), await createTestUnitOfWork(dbs().connection)), budgetSvc, new RealtimeService(), notificationsStub(), new ReservationsReadService(dbs()), accommodationsSvc, await createTestUnitOfWork(dbs().connection)),
daysSvc,
new PermissionsService(await createTestAppSettingsRepo(dbs().connection), await createTestUnitOfWork(dbs().connection)),
budgetSvc,
@@ -193,7 +193,7 @@ beforeAll(async () => {
readModelSvc = new TripReadModelService(
await createTestTripsRepo(dbs().connection), membersSvc, daysSvc, accommodationsSvc, budgetSvc,
new PackingService(dbs(), new PermissionsService(await createTestAppSettingsRepo(dbs().connection), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub(), await createTestUnitOfWork(dbs().connection)),
new ReservationsService(dbs(), new PermissionsService(await createTestAppSettingsRepo(dbs().connection), await createTestUnitOfWork(dbs().connection)), budgetSvc, new RealtimeService(), notificationsStub(), new ReservationsReadRepository(dbs()), accommodationsSvc, await createTestUnitOfWork(dbs().connection)),
new ReservationsService(dbs(), new PermissionsService(await createTestAppSettingsRepo(dbs().connection), await createTestUnitOfWork(dbs().connection)), budgetSvc, new RealtimeService(), notificationsStub(), new ReservationsReadService(dbs()), accommodationsSvc, await createTestUnitOfWork(dbs().connection)),
new CollabService(dbs(), new PermissionsService(await createTestAppSettingsRepo(dbs().connection), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub(), coversFx.storage, new RateLimitService(), await createTestUnitOfWork(dbs().connection)),
placesSvc,
new TodoService(dbs(), new PermissionsService(await createTestAppSettingsRepo(dbs().connection), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), await createTestUnitOfWork(dbs().connection)),
@@ -18,7 +18,7 @@ import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
import { BudgetService } from '../../../src/nest/budget/budget.service';
import { ExchangeRatesService } from '../../../src/nest/budget/exchange-rates.service';
import { ReservationsService } from '../../../src/nest/reservations/reservations.service';
import { ReservationsReadRepository } from '../../../src/nest/reservations/reservations-read.repository';
import { ReservationsReadService } from '../../../src/nest/reservations/reservations-read.service';
import type { AirtrailClient } from '../../../src/nest/integrations/airtrail.client';
import type { AirtrailService } from '../../../src/nest/integrations/airtrail.service';
import { notificationsStub } from '../../helpers/notifications';
@@ -44,7 +44,7 @@ async function makeImportService(): Promise<AirtrailImportService> {
new BudgetService(dbs(), permissions, new ExchangeRatesService(), realtime, await createTestUnitOfWork(dbs().connection)),
realtime,
notificationsStub(),
new ReservationsReadRepository(dbs()),
new ReservationsReadService(dbs()),
await accommodationsOver(dbs()), await createTestUnitOfWork(dbs().connection),
),
{ listFlights } as unknown as AirtrailClient,
@@ -23,7 +23,7 @@ import type { RealtimeService } from '../../../src/nest/realtime/realtime.servic
import type { AddonsService } from '../../../src/nest/addons/addons.service';
import { AirtrailSyncService } from '../../../src/nest/integrations/airtrail-sync.service';
import type { ReservationsService } from '../../../src/nest/reservations/reservations.service';
import type { ReservationsReadRepository } from '../../../src/nest/reservations/reservations-read.repository';
import type { ReservationsReadService } from '../../../src/nest/reservations/reservations-read.service';
import type { AirtrailClient } from '../../../src/nest/integrations/airtrail.client';
import type { AirtrailService } from '../../../src/nest/integrations/airtrail.service';
@@ -61,7 +61,7 @@ function makeServices(): { link: AirtrailLinkService; sync: AirtrailSyncService
db,
{ broadcast: vi.fn() } as unknown as RealtimeService,
{ isAddonEnabled: vi.fn(() => true) } as unknown as AddonsService,
{ getReservationWithJoins } as unknown as ReservationsReadRepository,
{ getReservationWithJoins } as unknown as ReservationsReadService,
client,
airtrail,
);