mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 14:36:31 +00:00
feat(server): plan 3d groundwork — dialect helpers, visibility predicate, feed and cron context ratchets
This commit is contained in:
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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'))),
|
||||
),
|
||||
]);
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
+13
-1
@@ -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,
|
||||
) {}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user