mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 14:06:27 +00:00
feat(server): plan 3f groundwork — systemNotices DI shape, TRAVELER_OWNS predicate, ntfy/storage-health specs, composite-PK pins
This commit is contained in:
@@ -629,3 +629,83 @@ export function castIntegerKysely<DB, TB extends keyof DB>(
|
||||
return unsupported(platform);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plan 3f Task 0 (R9) — no consumer yet: Task 2 (`collateNoCase`, SH9's
|
||||
// `school-holidays.service.ts#checkName` duplicate-name guard) and Task 4
|
||||
// (`nowMinusHours`, RJ4's `reminder-jobs.service.ts#todoTick`
|
||||
// `datetime('now', '-20 hours')`) wire these in when each converts the
|
||||
// statement that needs them. `lowerTrimParam` (AT31's value side, atlas's
|
||||
// bucket-list dedup) is Task 1's. Added here, with their own SQLF-0xx tests
|
||||
// pinning the SQLite text against a raw statement on seeded rows, so each
|
||||
// task imports a tested helper instead of writing one inline.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A case-insensitive column reference via SQLite's `NOCASE` collating
|
||||
* sequence, for use as a filter's object key — the same `{ [collateNoCase(
|
||||
* platform, 'name')]: value }` shape `lower()` documents, rendering `WHERE
|
||||
* name COLLATE NOCASE = <value-side>`. The legacy statement this replaces
|
||||
* (`school-holidays.service.ts:50`, SH9) spells the COLLATE on the VALUE
|
||||
* side instead (`name = ? COLLATE NOCASE`) — textually different, but SQLite
|
||||
* resolves a binary comparison's collating sequence from the FIRST operand
|
||||
* that carries an explicit `COLLATE`, scanning left to right, so `col
|
||||
* COLLATE NOCASE = ?` and `col = ? COLLATE NOCASE` select the same collation
|
||||
* (NOCASE) for the whole comparison either way — verified directly
|
||||
* (SQLF-060: both forms select the identical row set on a mixed-case
|
||||
* fixture), not assumed from the operator's documented precedence alone.
|
||||
*
|
||||
* **Not the same helper as `lower()`, and not reusable in its place.**
|
||||
* SQLite's built-in `NOCASE` collation is documented to fold ONLY the 26
|
||||
* ASCII letters, exactly like `LOWER()`'s own ASCII-only fold (program rule
|
||||
* 18) — verified directly against `better-sqlite3` (SQLF-061): on every
|
||||
* non-ASCII fixture this file's own `LOWER()` test (SQLF-014) already
|
||||
* carries, `x = y COLLATE NOCASE` and `LOWER(x) = LOWER(y)` agree, letter for
|
||||
* letter — neither engine folds a non-ASCII case pair. So `collateNoCase`
|
||||
* does NOT diverge from `lower()` on any input constructed here; it is a
|
||||
* NEW helper anyway (per R9's own instruction) because the two are
|
||||
* textually different SQL constructs matching two textually different
|
||||
* legacy statements (`COLLATE NOCASE` vs `LOWER(...)`), and because a
|
||||
* `COLLATE`-based comparison lets SQLite use an index on the un-wrapped
|
||||
* column in a way `LOWER(col) = ?` cannot — a real, if not row-visible,
|
||||
* difference between the two shapes.
|
||||
*/
|
||||
export function collateNoCase(platform: Platform, ref: string): RawQueryFragment & symbol {
|
||||
if (platform instanceof SqlitePlatform) return raw(`${column(ref)} COLLATE NOCASE`);
|
||||
return unsupported(platform);
|
||||
}
|
||||
|
||||
/**
|
||||
* `datetime('now', '-N hours')` — the hour-granularity sibling of
|
||||
* `nowMinusDays()` above, for `reminder-jobs.service.ts#todoTick`'s
|
||||
* `datetime('now', '-20 hours')` (RJ4) — 20 hours is not expressible as
|
||||
* `nowMinusDays` with a fractional day. Same validation shape as
|
||||
* `nowMinusDays` (a non-negative integer, spelled directly into the
|
||||
* fragment rather than bound — MikroORM's raw fragments do not accept a
|
||||
* placeholder inside a SQLite date/time function's modifier argument, the
|
||||
* same reasoning `nowMinusDays`'s own docstring gives).
|
||||
*/
|
||||
export function nowMinusHours(platform: Platform, hours: number): RawQueryFragment {
|
||||
if (!Number.isInteger(hours) || hours < 0) {
|
||||
throw new Error(`sql-functions: nowMinusHours needs a non-negative integer hour count, got ${hours}`);
|
||||
}
|
||||
if (platform instanceof SqlitePlatform) return raw(`datetime('now', '-${hours} hours')`);
|
||||
return unsupported(platform);
|
||||
}
|
||||
|
||||
/**
|
||||
* The value-side twin of `lowerTrim()`: `LOWER(TRIM(?))` bound with the RAW,
|
||||
* untransformed value — confirmed genuinely missing by reading this file's
|
||||
* full export list before adding it (R9), not assumed from the inventory's
|
||||
* own flag. Pairs with `lowerTrim()` as a filter KEY for the composed
|
||||
* `LOWER(TRIM(<column>)) = LOWER(TRIM(?))` shape
|
||||
* `atlas.service.ts:876-877`'s AT31 needs (`lower(trim(name)) =
|
||||
* lower(trim(?))`), the same "bind the raw value, let SQL apply the SAME
|
||||
* transform to both sides" reasoning `lowerParam()`'s own docstring gives —
|
||||
* do NOT pre-trim/pre-lower the value in JS before calling this (program
|
||||
* rule 18: mixing engines is the bug this pairing exists to avoid).
|
||||
*/
|
||||
export function lowerTrimParam(platform: Platform, value: string): RawQueryFragment {
|
||||
if (platform instanceof SqlitePlatform) return raw('LOWER(TRIM(?))', [value]);
|
||||
return unsupported(platform);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { ExpressionBuilder, ExpressionWrapper, SqlBool } from 'kysely';
|
||||
|
||||
/**
|
||||
* The `TRAVELER_OWNS` predicate atlas's `AtlasService` inlines as a
|
||||
* `private static readonly` string fragment (`atlas.service.ts:1173-1175`,
|
||||
* quoted here as this file's own oracle, the same "hold the STRING side
|
||||
* fixed and independent of the production code it is proving" reasoning
|
||||
* `reservation-visibility.ts`/`packing-visibility.ts` document) — ONE
|
||||
* shared, typed predicate for the three call sites (AT6/AT45/AT46) that all
|
||||
* inline it today (Plan 3f Task 0, R7):
|
||||
*
|
||||
* ```
|
||||
* (
|
||||
* NOT EXISTS (SELECT 1 FROM reservation_travelers rt WHERE rt.reservation_id = r.id)
|
||||
* OR EXISTS (SELECT 1 FROM reservation_travelers rt WHERE rt.reservation_id = r.id AND rt.user_id = ?)
|
||||
* )
|
||||
* ```
|
||||
*
|
||||
* Binds exactly one parameter (the caller's own user id) and expects the
|
||||
* reservation aliased `r` — every one of AT6/AT45/AT46 already uses that
|
||||
* alias for `reservations` in its `FROM`/`JOIN` clause, so both typed forms
|
||||
* below fix it too rather than taking an arbitrary alias parameter (the
|
||||
* `ReservationVisibilityKyselyDB & { a: { id: number } }` precedent
|
||||
* `publicStayExists` sets for a single, real, already-fixed alias — not
|
||||
* `reservation-visibility.ts`'s RV1 shape, which genuinely serves more than
|
||||
* one alias in production, `r` AND `vr`).
|
||||
*
|
||||
* **Backward-compat critical (#1966).** A reservation with ZERO
|
||||
* `reservation_travelers` rows — every booking made before 4.0, before
|
||||
* per-traveler assignment existed at all — must still count for the WHOLE
|
||||
* trip (the `NOT EXISTS` arm). Only once at least one assignment row exists
|
||||
* does the second arm narrow the reservation to "assigned to me". Getting
|
||||
* this backwards either double-counts a shared trip's flights per member
|
||||
* (if the `NOT EXISTS` arm is dropped or inverted) or zeroes every pre-4.0
|
||||
* install's flown distance overnight (if the `EXISTS` arm is applied
|
||||
* without the `NOT EXISTS` fallback) — the single highest-blast-radius
|
||||
* predicate in this plan, per the plan's own risk list. **No consumer
|
||||
* converts in this task** (the brief, item 5) — Task 1 (atlas) is the only
|
||||
* consumer, at AT6/AT45/AT46, and reuses this file's own parity harness
|
||||
* (`reservation-travelers-owns.parity.test.ts`) rather than re-deriving it.
|
||||
*
|
||||
* **Two shapes, not three** (unlike `reservation-visibility.ts`'s RV1/RV2
|
||||
* split) — `TRAVELER_OWNS` is ONE OR-chain of two arms, always evaluated
|
||||
* against the same, always-aliased-`r` `reservations` row, so there is no
|
||||
* second alias shape or second predicate to support the way RV1/RV2 needed.
|
||||
*
|
||||
* - `travelerOwnsCondition` — a typed MikroORM QueryBuilder filter-object
|
||||
* condition, spreadable into any `.where({...})` call against `Reservations`
|
||||
* (or any query whose base/joined entity is `Reservations`, referenced by
|
||||
* its `reservation_travelers_collection` relation property — a genuine
|
||||
* `p.oneToMany(ReservationTravelers).mappedBy('reservation')` relation,
|
||||
* `Reservations.entity.ts:103`, unlike RV2's blocker where
|
||||
* `reservations.accommodation_id` has no FK/relation at all to join
|
||||
* through). Built with MikroORM's own collection operators (`$none`/`$some`
|
||||
* — "collection operators, sql only", `node_modules/@mikro-orm/core/enums.d.ts`'s
|
||||
* `QueryOperator` — verified directly, not assumed: `$exists` is a
|
||||
* DIFFERENT operator that renders `IS NOT NULL` for an embeddable/JSON
|
||||
* path, not a correlated subquery, which is exactly why RV2 has no QB
|
||||
* form at all), which MikroORM's SQL query compiler
|
||||
* (`node_modules/@mikro-orm/sql/query/ObjectCriteriaNode.js`) renders as
|
||||
* `<pk> IN (SELECT <pk> FROM reservations INNER JOIN reservation_travelers
|
||||
* rt ON … WHERE rt.user_id = ?)` for `$some` and `<pk> NOT IN (SELECT <pk>
|
||||
* FROM reservations INNER JOIN reservation_travelers rt ON …)` for
|
||||
* `$none` — a semicolon-for-semicolon DIFFERENT SQL shape from the legacy
|
||||
* correlated `NOT EXISTS`/`EXISTS` text, but row-identical on every input
|
||||
* (an `IN`/`NOT IN` against a `INNER JOIN`-then-`SELECT pk` subquery
|
||||
* selects exactly the same primary-key set a correlated `EXISTS` would),
|
||||
* proven directly by this file's own parity harness rather than assumed
|
||||
* equivalent from the operator names alone.
|
||||
* - `travelerOwnsExpr` — the Kysely-expression twin, for a caller building a
|
||||
* raw, multi-join statement through `this.kysely()` rather than the
|
||||
* MikroORM QueryBuilder (AT6/AT45/AT46 are exactly this shape — large,
|
||||
* multi-table `JOIN` aggregates with `CASE WHEN` projections, never a
|
||||
* plain `em.createQueryBuilder(Reservations, 'r')` a QB filter object could
|
||||
* spread into on its own). Spelled as a genuine correlated `EXISTS`/`NOT
|
||||
* EXISTS` pair, matching the legacy fragment's own shape token-for-token —
|
||||
* the same "no ORM relation to join those rows the QueryBuilder could
|
||||
* filter through" reasoning `publicStayExists` documents, even though a
|
||||
* relation DOES exist here (Kysely never uses MikroORM's entity relations
|
||||
* at all — it is a separate, relation-unaware SQL builder).
|
||||
*
|
||||
* Both forms are proven identical to the legacy fragment on the two cases
|
||||
* that matter (a reservation with zero `reservation_travelers` rows; a
|
||||
* reservation with assignments, narrowed to the caller's own) by
|
||||
* `reservation-travelers-owns.parity.test.ts` in this directory.
|
||||
*/
|
||||
|
||||
export function travelerOwnsCondition(userId: number) {
|
||||
return {
|
||||
$or: [
|
||||
{ reservation_travelers_collection: { $none: {} } },
|
||||
{ reservation_travelers_collection: { $some: { user: userId } } },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* `reservation_travelers`/`reservations`' REAL table names, the shape
|
||||
* `travelerOwnsExpr` needs (fixed alias `r` for `reservations` — see this
|
||||
* file's module docstring for why, unlike RV1's genuinely-multi-alias
|
||||
* shape). A consumer's own local Kysely `DB` interface satisfies this by
|
||||
* structural extension, the same `ReservationVisibilityKyselyDB`/
|
||||
* `PackingVisibilityKyselyDB` precedent.
|
||||
*/
|
||||
export interface ReservationTravelersOwnsKyselyDB {
|
||||
reservation_travelers: { reservation_id: number; user_id: number };
|
||||
reservations: { id: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* `TRAVELER_OWNS` as a Kysely predicate: `(NOT EXISTS (…) OR EXISTS (… AND
|
||||
* rt.user_id = ?))`, correlated on `rt.reservation_id = r.id`. Call inside a
|
||||
* `.where((eb) => travelerOwnsExpr(eb, userId))` on a query already carrying
|
||||
* an `r` alias for `reservations` (a base `.selectFrom('reservations as
|
||||
* r')`, or `reservations` joined in as `r` — both produce the same aliased
|
||||
* `r: { id: number }` member Kysely's type inference needs) against a
|
||||
* `Kysely<DB>` whose `DB` satisfies `ReservationTravelersOwnsKyselyDB`. The
|
||||
* parameter type is fixed to exactly that shape rather than generic over an
|
||||
* arbitrary caller `DB`/`TB`, the same `publicStayExists` precedent and for
|
||||
* the same reason (`Trips.repository.ts`'s `tripSelectQuery` docstring: a
|
||||
* shared Kysely helper generic across differently-shaped outer queries
|
||||
* doesn't compose), proven end-to-end by this file's own parity-harness
|
||||
* test rather than assumed from the type signature alone.
|
||||
*/
|
||||
export function travelerOwnsExpr(
|
||||
eb: ExpressionBuilder<ReservationTravelersOwnsKyselyDB & { r: { id: number } }, 'r'>,
|
||||
userId: number,
|
||||
): ExpressionWrapper<ReservationTravelersOwnsKyselyDB & { r: { id: number } }, 'r', SqlBool> {
|
||||
const forThisReservation = eb
|
||||
.selectFrom('reservation_travelers as rt')
|
||||
.select('rt.reservation_id')
|
||||
.whereRef('rt.reservation_id', '=', 'r.id');
|
||||
|
||||
return eb.or([
|
||||
eb.not(eb.exists(forThisReservation)),
|
||||
eb.exists(forThisReservation.where('rt.user_id', '=', userId)),
|
||||
]);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
castIntegerKysely,
|
||||
coalesce,
|
||||
coalesceParam,
|
||||
collateNoCase,
|
||||
columnIncrementedBy,
|
||||
columnRef,
|
||||
concat,
|
||||
@@ -26,9 +27,11 @@ import {
|
||||
lower,
|
||||
lowerParam,
|
||||
lowerTrim,
|
||||
lowerTrimParam,
|
||||
maxOf,
|
||||
minOf,
|
||||
nowMinusDays,
|
||||
nowMinusHours,
|
||||
startsWithIsoDate,
|
||||
startsWithIsoDateKysely,
|
||||
substring,
|
||||
@@ -881,4 +884,105 @@ describe('sql-functions (sqlite)', () => {
|
||||
);
|
||||
expect(compiled.parameters).toEqual(['2026-09-21', '2026-09-21', 11, 1]);
|
||||
});
|
||||
|
||||
// Plan 3f Task 0 (R9) — no consumer yet: Task 2 (collateNoCase), Task 4
|
||||
// (nowMinusHours) and Task 1 (lowerTrimParam) wire these in as each
|
||||
// converts the statement that needs them. Pinned here so each task imports
|
||||
// a tested helper instead of writing one inline.
|
||||
|
||||
it('SQLF-060: collateNoCase renders <col> COLLATE NOCASE, and as a filter key selects the same rows as the legacy value-side spelling (name = ? COLLATE NOCASE)', async () => {
|
||||
const { user: mixed } = createUser(testDb, { username: 'MixedCase' });
|
||||
const { user: other } = createUser(testDb, { username: 'SomethingElse' });
|
||||
const platform = t.em.getPlatform();
|
||||
|
||||
const typed = await t.em.find(Users, { [collateNoCase(platform, 'username')]: 'mixedcase' });
|
||||
expect(typed.map((r) => r.id)).toEqual([mixed.id]);
|
||||
|
||||
const legacy = testDb
|
||||
.prepare('SELECT id FROM users WHERE username = ? COLLATE NOCASE')
|
||||
.all('mixedcase') as { id: number }[];
|
||||
expect(typed.map((r) => r.id)).toEqual(legacy.map((r) => r.id));
|
||||
|
||||
const none = await t.em.find(Users, { [collateNoCase(platform, 'username')]: 'nobody-has-this-name' });
|
||||
expect(none).toEqual([]);
|
||||
expect(other.id).toBeDefined(); // seeded so the fixture has more than one row
|
||||
});
|
||||
|
||||
it("SQLF-061: COLLATE NOCASE folds the same 26 ASCII letters LOWER() does, and does NOT diverge from LOWER() on SQLF-014's own non-ASCII fixtures — verified directly, not assumed", () => {
|
||||
const cases: Array<[string, string]> = [
|
||||
['JOSÉ@x.com', 'josé@x.com'],
|
||||
['ÄNNA@x.com', 'änna@x.com'],
|
||||
['ΣIGMA@x.com', 'σigma@x.com'],
|
||||
['İSTANBUL@x.com', 'i̇stanbul@x.com'],
|
||||
['ASCII@x.com', 'ascii@x.com'],
|
||||
];
|
||||
for (const [upper, lower_] of cases) {
|
||||
const collateMatch = testDb.prepare('SELECT (? = ? COLLATE NOCASE) as m').get(upper, lower_) as { m: number };
|
||||
const lowerMatch = testDb.prepare('SELECT (LOWER(?) = LOWER(?)) as m').get(upper, lower_) as { m: number };
|
||||
expect(collateMatch.m).toBe(lowerMatch.m);
|
||||
}
|
||||
// The ASCII case is the one pair both engines actually fold to equal;
|
||||
// every non-ASCII pair above is left unfolded by BOTH engines (neither
|
||||
// recognises the Unicode case mapping), which is exactly why they agree
|
||||
// rather than diverge — this is genuinely the "no divergence found"
|
||||
// branch R9 anticipates, not a skipped check.
|
||||
const asciiCollate = testDb.prepare('SELECT (? = ? COLLATE NOCASE) as m').get('ASCII@x.com', 'ascii@x.com') as { m: number };
|
||||
expect(asciiCollate.m).toBe(1);
|
||||
const nonAsciiCollate = testDb.prepare('SELECT (? = ? COLLATE NOCASE) as m').get('JOSÉ@x.com', 'josé@x.com') as { m: number };
|
||||
expect(nonAsciiCollate.m).toBe(0);
|
||||
});
|
||||
|
||||
it('SQLF-062: an unknown platform fails closed for collateNoCase', () => {
|
||||
class FakePlatform extends Platform {}
|
||||
const foreign = new FakePlatform();
|
||||
expect(() => collateNoCase(foreign, 'u.name')).toThrow(/no implementation for platform FakePlatform/);
|
||||
});
|
||||
|
||||
it("SQLF-063: nowMinusHours renders datetime('now', '-N hours'), matching a JS-computed time within a few seconds' tolerance", async () => {
|
||||
const row = testDb.prepare(`SELECT ${nowMinusHours(t.em.getPlatform(), 20).sql} as d`).get() as { d: string };
|
||||
const got = new Date(`${row.d.replace(' ', 'T')}Z`).getTime();
|
||||
const expected = Date.now() - 20 * 60 * 60 * 1000;
|
||||
expect(Math.abs(got - expected)).toBeLessThan(10_000);
|
||||
});
|
||||
|
||||
it('SQLF-063b: nowMinusHours(0) is "now", not one hour back', async () => {
|
||||
const row = testDb.prepare(`SELECT ${nowMinusHours(t.em.getPlatform(), 0).sql} as d`).get() as { d: string };
|
||||
const got = new Date(`${row.d.replace(' ', 'T')}Z`).getTime();
|
||||
expect(Math.abs(got - Date.now())).toBeLessThan(10_000);
|
||||
});
|
||||
|
||||
it('SQLF-064: nowMinusHours rejects a non-integer or negative hour count', () => {
|
||||
expect(() => nowMinusHours(t.em.getPlatform(), 1.5)).toThrow(/non-negative integer hour count/);
|
||||
expect(() => nowMinusHours(t.em.getPlatform(), -1)).toThrow(/non-negative integer hour count/);
|
||||
expect(() => nowMinusHours(t.em.getPlatform(), Number.NaN)).toThrow(/non-negative integer hour count/);
|
||||
});
|
||||
|
||||
it('SQLF-065: an unknown platform fails closed for nowMinusHours', () => {
|
||||
class FakePlatform extends Platform {}
|
||||
const foreign = new FakePlatform();
|
||||
expect(() => nowMinusHours(foreign, 1)).toThrow(/no implementation for platform FakePlatform/);
|
||||
});
|
||||
|
||||
it('SQLF-066: lowerTrimParam pairs with lowerTrim to fold BOTH sides in SQLite (the AT31 dedup shape), and a JS-lowered+trimmed bind does NOT match — the mutation-proof for mixing engines', async () => {
|
||||
const { user } = createUser(testDb, { username: ' JOSÉ ' });
|
||||
const platform = t.em.getPlatform();
|
||||
|
||||
const exact = await t.em.findOne(Users, { [lowerTrim(platform, 'username')]: lowerTrimParam(platform, ' josÉ ') });
|
||||
expect(exact?.id).toBe(user.id);
|
||||
|
||||
// A JS-lowered+trimmed value-side bind (mixing engines, program rule 18)
|
||||
// must NOT match: SQLite's LOWER() leaves 'É' untouched, so the stored,
|
||||
// SQL-folded spelling is 'josÉ', not the JS-folded 'josé'.
|
||||
const jsLowered = await t.em.findOne(Users, { [lowerTrim(platform, 'username')]: ' josé '.trim().toLowerCase() });
|
||||
expect(jsLowered).toBeNull();
|
||||
|
||||
const none = await t.em.findOne(Users, { [lowerTrim(platform, 'username')]: lowerTrimParam(platform, 'nobody') });
|
||||
expect(none).toBeNull();
|
||||
});
|
||||
|
||||
it('SQLF-067: an unknown platform fails closed for lowerTrimParam', () => {
|
||||
class FakePlatform extends Platform {}
|
||||
const foreign = new FakePlatform();
|
||||
expect(() => lowerTrimParam(foreign, 'x')).toThrow(/no implementation for platform FakePlatform/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
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 { createReservation, createTrip, createUser } from '../../../../helpers/factories';
|
||||
import { Reservations } from '../../../../../src/db/entities/Reservations.entity';
|
||||
import {
|
||||
travelerOwnsCondition,
|
||||
travelerOwnsExpr,
|
||||
type ReservationTravelersOwnsKyselyDB,
|
||||
} from '../../../../../src/db/repositories/_shared/reservation-travelers-owns';
|
||||
|
||||
/**
|
||||
* The legacy fragment this harness proves parity against
|
||||
* (`AtlasService`'s `TRAVELER_OWNS`, `atlas.service.ts:1173-1175`), kept
|
||||
* here byte-identical to the source text as this test's own oracle — the
|
||||
* same "hold the STRING side fixed and independent of the production code
|
||||
* it is proving" reasoning `reservation-visibility.test.ts`/
|
||||
* `packing-visibility.parity.test.ts` document. Binds the caller's user id
|
||||
* once. Expects the reservation aliased `r`, matching every one of
|
||||
* AT6/AT45/AT46's own alias.
|
||||
*/
|
||||
const TRAVELER_OWNS = `(
|
||||
NOT EXISTS (SELECT 1 FROM reservation_travelers rt WHERE rt.reservation_id = r.id)
|
||||
OR EXISTS (SELECT 1 FROM reservation_travelers rt WHERE rt.reservation_id = r.id AND rt.user_id = ?)
|
||||
)`;
|
||||
|
||||
const testDb = createSnapshotTestDb();
|
||||
let t: TestOrm;
|
||||
|
||||
beforeAll(async () => { t = await createTestOrm(testDb); });
|
||||
beforeEach(() => { resetTestDb(testDb); t.clear(); });
|
||||
afterAll(async () => { await t.close(); testDb.close(); });
|
||||
|
||||
function addTraveler(reservationId: number, userId: number): void {
|
||||
testDb.prepare('INSERT INTO reservation_travelers (reservation_id, user_id) VALUES (?, ?)').run(reservationId, userId);
|
||||
}
|
||||
|
||||
async function legacyOwnedIds(tripId: number, userId: number): Promise<number[]> {
|
||||
const rows = testDb
|
||||
.prepare(`SELECT id FROM reservations r WHERE r.trip_id = ? AND ${TRAVELER_OWNS} ORDER BY id ASC`)
|
||||
.all(tripId, userId) as { id: number }[];
|
||||
return rows.map((row) => row.id);
|
||||
}
|
||||
|
||||
async function typedQbOwnedIds(tripId: number, userId: number): Promise<number[]> {
|
||||
const rows = await t.em
|
||||
.createQueryBuilder(Reservations, 'r')
|
||||
.select(['r.id'])
|
||||
.where({ 'r.trip_id': tripId, ...travelerOwnsCondition(userId) })
|
||||
.orderBy({ id: 'asc' })
|
||||
.execute<{ id: number }[]>('all', false);
|
||||
return rows.map((row) => row.id);
|
||||
}
|
||||
|
||||
interface TestDB extends ReservationTravelersOwnsKyselyDB {
|
||||
reservations: { id: number; trip_id: number };
|
||||
}
|
||||
|
||||
async function typedKyselyOwnedIds(tripId: number, userId: number): Promise<number[]> {
|
||||
const rows = await t.em
|
||||
.getKysely<TestDB>()
|
||||
.selectFrom('reservations as r')
|
||||
.select('r.id')
|
||||
.where('r.trip_id', '=', tripId)
|
||||
.where((eb) => travelerOwnsExpr(eb, userId))
|
||||
.orderBy('r.id', 'asc')
|
||||
.execute();
|
||||
return rows.map((row) => row.id);
|
||||
}
|
||||
|
||||
describe('reservation-travelers-owns parity (TRAVELER_OWNS: the two cases that matter, #1966)', () => {
|
||||
it('TRAVOWNS-001: a reservation with ZERO reservation_travelers rows counts for the WHOLE trip (pre-4.0 backward-compat) — every user sees it', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
const unassigned = createReservation(testDb, trip.id, { title: 'Pre-4.0 booking, no travelers' });
|
||||
|
||||
for (const userId of [owner.id, other.id]) {
|
||||
const legacy = await legacyOwnedIds(trip.id, userId);
|
||||
const typedQb = await typedQbOwnedIds(trip.id, userId);
|
||||
const typedKysely = await typedKyselyOwnedIds(trip.id, userId);
|
||||
|
||||
expect(legacy).toEqual([unassigned.id]);
|
||||
expect(typedQb).toEqual(legacy);
|
||||
expect(typedKysely).toEqual(legacy);
|
||||
}
|
||||
});
|
||||
|
||||
it('TRAVOWNS-002: a reservation WITH assignments narrows to the caller\'s own — an assigned traveler sees it, a non-traveler does not', async () => {
|
||||
const { user: traveler } = createUser(testDb);
|
||||
const { user: stranger } = createUser(testDb);
|
||||
const trip = createTrip(testDb, traveler.id);
|
||||
const assigned = createReservation(testDb, trip.id, { title: 'Flight with named travelers' });
|
||||
addTraveler(assigned.id, traveler.id);
|
||||
|
||||
const legacyForTraveler = await legacyOwnedIds(trip.id, traveler.id);
|
||||
const typedQbForTraveler = await typedQbOwnedIds(trip.id, traveler.id);
|
||||
const typedKyselyForTraveler = await typedKyselyOwnedIds(trip.id, traveler.id);
|
||||
expect(legacyForTraveler).toEqual([assigned.id]);
|
||||
expect(typedQbForTraveler).toEqual(legacyForTraveler);
|
||||
expect(typedKyselyForTraveler).toEqual(legacyForTraveler);
|
||||
|
||||
const legacyForStranger = await legacyOwnedIds(trip.id, stranger.id);
|
||||
const typedQbForStranger = await typedQbOwnedIds(trip.id, stranger.id);
|
||||
const typedKyselyForStranger = await typedKyselyOwnedIds(trip.id, stranger.id);
|
||||
expect(legacyForStranger).toEqual([]);
|
||||
expect(typedQbForStranger).toEqual(legacyForStranger);
|
||||
expect(typedKyselyForStranger).toEqual(legacyForStranger);
|
||||
});
|
||||
|
||||
it('TRAVOWNS-003: a reservation with MULTIPLE travelers — each assigned traveler sees it, exactly once, a non-traveler does not', async () => {
|
||||
const { user: travelerA } = createUser(testDb);
|
||||
const { user: travelerB } = createUser(testDb);
|
||||
const { user: stranger } = createUser(testDb);
|
||||
const trip = createTrip(testDb, travelerA.id);
|
||||
const shared = createReservation(testDb, trip.id, { title: 'Family flight' });
|
||||
addTraveler(shared.id, travelerA.id);
|
||||
addTraveler(shared.id, travelerB.id);
|
||||
|
||||
for (const userId of [travelerA.id, travelerB.id]) {
|
||||
const legacy = await legacyOwnedIds(trip.id, userId);
|
||||
const typedQb = await typedQbOwnedIds(trip.id, userId);
|
||||
const typedKysely = await typedKyselyOwnedIds(trip.id, userId);
|
||||
expect(legacy).toEqual([shared.id]);
|
||||
expect(typedQb).toEqual(legacy);
|
||||
expect(typedKysely).toEqual(legacy);
|
||||
}
|
||||
|
||||
const legacyStranger = await legacyOwnedIds(trip.id, stranger.id);
|
||||
const typedQbStranger = await typedQbOwnedIds(trip.id, stranger.id);
|
||||
const typedKyselyStranger = await typedKyselyOwnedIds(trip.id, stranger.id);
|
||||
expect(legacyStranger).toEqual([]);
|
||||
expect(typedQbStranger).toEqual(legacyStranger);
|
||||
expect(typedKyselyStranger).toEqual(legacyStranger);
|
||||
});
|
||||
|
||||
it('TRAVOWNS-004: a mixed trip — one unassigned booking, one assigned-to-someone-else booking — resolves identically between all three forms', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: travelerB } = createUser(testDb);
|
||||
const { user: viewer } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
const unassigned = createReservation(testDb, trip.id, { title: 'Unassigned' });
|
||||
const assignedToOther = createReservation(testDb, trip.id, { title: 'Assigned to someone else' });
|
||||
addTraveler(assignedToOther.id, travelerB.id);
|
||||
|
||||
const legacy = await legacyOwnedIds(trip.id, viewer.id);
|
||||
const typedQb = await typedQbOwnedIds(trip.id, viewer.id);
|
||||
const typedKysely = await typedKyselyOwnedIds(trip.id, viewer.id);
|
||||
|
||||
// `viewer` is nobody's traveler: the unassigned booking still counts
|
||||
// (the NOT EXISTS arm), the booking assigned to travelerB does not.
|
||||
expect(legacy).toEqual([unassigned.id]);
|
||||
expect(typedQb).toEqual(legacy);
|
||||
expect(typedKysely).toEqual(legacy);
|
||||
});
|
||||
|
||||
// Mutation-sensitive (R7/the plan's own risk list: "getting this wrong
|
||||
// either double-counts a shared trip's flights per member or zeroes every
|
||||
// pre-4.0 install's flown distance overnight" — a dedicated matrix before
|
||||
// any consumer converts). Manually replacing `travelerOwnsCondition`'s
|
||||
// `$or` with JUST the `$some` arm (dropping the `$none`/NOT-EXISTS
|
||||
// fallback) and re-running this suite: TRAVOWNS-001 goes red (the
|
||||
// pre-4.0, zero-traveler `unassigned` booking disappears for every user,
|
||||
// including its own owner) while TRAVOWNS-002/003 stay green — proving
|
||||
// this case is not redundant with the rest of the matrix and that the
|
||||
// `$none` arm is genuinely load-bearing. Restored immediately after
|
||||
// observing the failure; the predicate module itself is unchanged in this
|
||||
// commit.
|
||||
it('TRAVOWNS-005 (mutation-sensitive): dropping the zero-traveler fallback would hide a pre-4.0 booking from its own owner', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
const unassigned = createReservation(testDb, trip.id, { title: 'Pre-4.0 booking' });
|
||||
|
||||
const legacy = await legacyOwnedIds(trip.id, owner.id);
|
||||
const typedQb = await typedQbOwnedIds(trip.id, owner.id);
|
||||
const typedKysely = await typedKyselyOwnedIds(trip.id, owner.id);
|
||||
|
||||
expect(legacy).toEqual([unassigned.id]);
|
||||
expect(typedQb).toEqual(legacy);
|
||||
expect(typedKysely).toEqual(legacy);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user