diff --git a/server/src/db/dialect/sql-functions.ts b/server/src/db/dialect/sql-functions.ts index cb9d85526..7cb443bfb 100644 --- a/server/src/db/dialect/sql-functions.ts +++ b/server/src/db/dialect/sql-functions.ts @@ -629,3 +629,83 @@ export function castIntegerKysely( 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 = `. 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()) = 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); +} + diff --git a/server/src/db/repositories/_shared/reservation-travelers-owns.ts b/server/src/db/repositories/_shared/reservation-travelers-owns.ts new file mode 100644 index 000000000..06d85ec1b --- /dev/null +++ b/server/src/db/repositories/_shared/reservation-travelers-owns.ts @@ -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 + * ` IN (SELECT FROM reservations INNER JOIN reservation_travelers + * rt ON … WHERE rt.user_id = ?)` for `$some` and ` NOT IN (SELECT + * 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` 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, + userId: number, +): ExpressionWrapper { + 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)), + ]); +} diff --git a/server/tests/unit/db/dialect/sql-functions.test.ts b/server/tests/unit/db/dialect/sql-functions.test.ts index 87e1cbc29..0520571e4 100644 --- a/server/tests/unit/db/dialect/sql-functions.test.ts +++ b/server/tests/unit/db/dialect/sql-functions.test.ts @@ -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 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/); + }); }); diff --git a/server/tests/unit/db/repositories/_shared/reservation-travelers-owns.parity.test.ts b/server/tests/unit/db/repositories/_shared/reservation-travelers-owns.parity.test.ts new file mode 100644 index 000000000..60f165c0f --- /dev/null +++ b/server/tests/unit/db/repositories/_shared/reservation-travelers-owns.parity.test.ts @@ -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 { + 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 { + 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 { + const rows = await t.em + .getKysely() + .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); + }); +});