mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fix(feeds): keep a staged booking out of the public feed and the shared trip
The ICS feed is public with a token that never expires and prints the confirmation number into the DESCRIPTION, and the shared-trip payload does a SELECT * over reservations with share_bookings on by default. Neither filters anything, so whatever lands on a trip is published the moment it is written. That is fine while a person types every booking in, and it stops being fine as soon as something writes on their behalf. reservations.ingest_state is that gate. It defaults to live, SQLite backfills every existing row on the ALTER, and no current writer names the column, so every booking visible today stays visible. Nothing writes staged yet. The predicate takes its table alias because share.service.ts queried reservations without one. A stay is withheld only when bookings point at it and all of them are staged: a hand-added hotel has no booking at all and has to keep showing up.
This commit is contained in:
@@ -4136,6 +4136,24 @@ function runMigrations(db: Database.Database): void {
|
||||
console.warn('[migrations] Non-fatal migration step failed:', err);
|
||||
}
|
||||
},
|
||||
|
||||
// Reservations an automated ingest parked for review are 'staged' and stay
|
||||
// out of the two anonymous exports (ICS feed, shared trip) until a person
|
||||
// confirms them. Nothing writes 'staged' yet; this is the gate the mail
|
||||
// ingest writes through.
|
||||
//
|
||||
// 'live' as the default is what keeps this from being a breaking change:
|
||||
// SQLite fills every existing row on the ALTER, and no current writer names
|
||||
// the column, so every booking that is visible today stays visible.
|
||||
// Appended LAST, the array is index-addressed against schema_version.
|
||||
() => {
|
||||
const hasColumn = db
|
||||
.prepare("SELECT 1 FROM pragma_table_info('reservations') WHERE name = 'ingest_state'")
|
||||
.get();
|
||||
if (!hasColumn) {
|
||||
db.exec("ALTER TABLE reservations ADD COLUMN ingest_state TEXT NOT NULL DEFAULT 'live'");
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
if (currentVersion < migrations.length) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { ReservationsService } from '../reservations/reservations.service';
|
||||
import { publicReservationSql, publicStaySql } from '../reservations/reservation-visibility';
|
||||
import { addDays } from '../days/days.service';
|
||||
import { resolveTimeZone } from '../common/timezoneService';
|
||||
import { NotFoundError } from '../common/domain-errors';
|
||||
@@ -163,7 +164,7 @@ export class CalendarService {
|
||||
LEFT JOIN days ed ON a.end_day_id = ed.id
|
||||
LEFT JOIN days rd ON r.day_id = rd.id
|
||||
LEFT JOIN days red ON r.end_day_id = red.id
|
||||
WHERE r.trip_id = ?`,
|
||||
WHERE r.trip_id = ? AND ${publicReservationSql('r')}`,
|
||||
)
|
||||
.all(tripId) as any[];
|
||||
|
||||
@@ -569,13 +570,13 @@ export class CalendarService {
|
||||
sd.date AS start_date, ed.date AS end_date,
|
||||
p.name AS place_name, p.address AS place_address, p.lat AS place_lat, p.lng AS place_lng,
|
||||
(SELECT r.title FROM reservations r
|
||||
WHERE r.accommodation_id = a.id
|
||||
WHERE r.accommodation_id = a.id AND ${publicReservationSql('r')}
|
||||
ORDER BY r.id ASC LIMIT 1) AS reservation_title
|
||||
FROM day_accommodations a
|
||||
LEFT JOIN days sd ON a.start_day_id = sd.id
|
||||
LEFT JOIN days ed ON a.end_day_id = ed.id
|
||||
LEFT JOIN places p ON a.place_id = p.id
|
||||
WHERE a.trip_id = ?
|
||||
WHERE a.trip_id = ? AND ${publicStaySql('a')}
|
||||
ORDER BY a.id ASC
|
||||
`).all(tripId) as any[];
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Which reservation rows a viewer without a TREK session may see.
|
||||
*
|
||||
* `ingest_state` is 'live' for everything a person put on a trip, and 'staged'
|
||||
* for a row an automated ingest parked for review. Only the two anonymous
|
||||
* exports filter on it. The authenticated lists deliberately do not: the
|
||||
* staging inbox has to see its own rows, or nobody can confirm them.
|
||||
*
|
||||
* These are SQL fragments rather than a service. calendar/ and share/ are
|
||||
* separate modules and neither should have to import the other for a WHERE
|
||||
* clause. They take the table alias because share.service.ts queries
|
||||
* `reservations` without one while calendar.service.ts uses `r`.
|
||||
*
|
||||
* The COALESCE guards a NULL in the column, not a missing column: a table
|
||||
* without `ingest_state` fails the query outright rather than returning NULL.
|
||||
* The migration adds the column NOT NULL DEFAULT 'live', so a NULL should not
|
||||
* occur; the COALESCE costs nothing and keeps a row that predates a botched
|
||||
* ALTER visible rather than silently dropping it out of a live calendar feed.
|
||||
*/
|
||||
export const publicReservationSql = (alias = 'r'): string => `COALESCE(${alias}.ingest_state, 'live') <> 'staged'`;
|
||||
|
||||
/**
|
||||
* A stay is public when nothing points at it (somebody added it by hand), or
|
||||
* when a live booking does. Both halves matter: a plain EXISTS would drop every
|
||||
* hand-added hotel out of trips that are shared today.
|
||||
*
|
||||
* `accommodation_id` is a TEXT column that reads back as "14.0", hence the
|
||||
* CAST. Same coercion as reservations.service.ts.
|
||||
*/
|
||||
export const publicStaySql = (alias = 'a'): string => `(
|
||||
NOT EXISTS (SELECT 1 FROM reservations vr WHERE CAST(vr.accommodation_id AS INTEGER) = ${alias}.id)
|
||||
OR EXISTS (SELECT 1 FROM reservations vr WHERE CAST(vr.accommodation_id AS INTEGER) = ${alias}.id
|
||||
AND ${publicReservationSql('vr')})
|
||||
)`;
|
||||
@@ -5,6 +5,7 @@ import type { TripAccess } from '../database/database.service';
|
||||
import { PermissionsService } from '../permissions/permissions.service';
|
||||
import { QueryHelpersService } from '../query-helpers/query-helpers.service';
|
||||
import { PlacePhotoCacheService } from '../place-photos/place-photo-cache.service';
|
||||
import { publicReservationSql, publicStaySql } from '../reservations/reservation-visibility';
|
||||
import { SettingsService } from '../settings/settings.service';
|
||||
import type { User } from '../../types';
|
||||
|
||||
@@ -241,13 +242,18 @@ export class ShareService {
|
||||
if (!posMap.has(dp.reservation_id)) posMap.set(dp.reservation_id, {});
|
||||
posMap.get(dp.reservation_id)![dp.day_id] = dp.position;
|
||||
}
|
||||
reservations = this.dbs.all<any>('SELECT * FROM reservations WHERE trip_id = ? ORDER BY reservation_time ASC', tripId)
|
||||
// The alias is not cosmetic: the visibility predicate qualifies its column,
|
||||
// and this query had no alias to qualify against.
|
||||
reservations = this.dbs.all<any>(
|
||||
`SELECT r.* FROM reservations r
|
||||
WHERE r.trip_id = ? AND ${publicReservationSql('r')}
|
||||
ORDER BY r.reservation_time ASC`, tripId)
|
||||
.map((r) => ({ ...r, day_positions: posMap.get(r.id) ?? null }));
|
||||
|
||||
accommodations = this.dbs.all(`
|
||||
SELECT a.*, p.name as place_name, p.address as place_address, p.lat as place_lat, p.lng as place_lng
|
||||
FROM day_accommodations a JOIN places p ON a.place_id = p.id
|
||||
WHERE a.trip_id = ?
|
||||
WHERE a.trip_id = ? AND ${publicStaySql('a')}
|
||||
`, tripId);
|
||||
}
|
||||
|
||||
|
||||
@@ -625,8 +625,8 @@ export class TripsService {
|
||||
const reservationMap = new Map<number, number | bigint>();
|
||||
const insertReservation = this.db.prepare(`
|
||||
INSERT INTO reservations (trip_id, day_id, end_day_id, place_id, assignment_id, accommodation_id, title, reservation_time, reservation_end_time,
|
||||
location, confirmation_number, notes, url, status, type, metadata, day_plan_position, needs_review)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
location, confirmation_number, notes, url, status, type, metadata, day_plan_position, needs_review, ingest_state)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
for (const r of oldReservations) {
|
||||
const rr = insertReservation.run(newTripId,
|
||||
@@ -641,7 +641,10 @@ export class TripsService {
|
||||
r.accommodation_id != null ? (accomMap.get(Number(r.accommodation_id)) ?? null) : null,
|
||||
r.title, r.reservation_time, r.reservation_end_time,
|
||||
r.location, r.confirmation_number, r.notes, r.url, r.status, r.type,
|
||||
r.metadata, r.day_plan_position, r.needs_review ?? 0);
|
||||
// ingest_state travels with the copy: a staged booking must not turn
|
||||
// 'live' just because the trip was duplicated, or it lands in the
|
||||
// duplicate's public feed.
|
||||
r.metadata, r.day_plan_position, r.needs_review ?? 0, r.ingest_state ?? 'live');
|
||||
reservationMap.set(r.id, rr.lastInsertRowid);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Calendar-feed visibility e2e — proves the staged-booking gate holds on the
|
||||
* one path that needs no session at all: the public /api/feed/trip/:token.ics.
|
||||
*
|
||||
* tests/e2e/feeds.e2e.test.ts cannot cover this. It mocks buildTripCalendar and
|
||||
* never creates a reservations table, so it owns the calendar parts and the SQL
|
||||
* filter is invisible to it. This file runs the REAL CalendarService against a
|
||||
* real migrated SQLite instead, and asserts on the bytes a calendar client
|
||||
* would receive.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Server } from 'http';
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
const { db } = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const Database = require('better-sqlite3');
|
||||
const tmp = new Database(':memory:');
|
||||
tmp.exec('PRAGMA journal_mode = WAL');
|
||||
return { db: tmp };
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => ({
|
||||
db,
|
||||
canAccessTrip: (tripId: number | string, userId: number) =>
|
||||
db.prepare('SELECT id, user_id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
isOwner: () => true,
|
||||
getPlaceWithTags: () => null,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
}));
|
||||
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { DatabaseModule } from '../../src/nest/database/database.module';
|
||||
import { FeedsModule } from '../../src/nest/feeds/feeds.module';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
|
||||
describe('Calendar feed visibility e2e (real CalendarService over temp SQLite)', () => {
|
||||
let server: Server;
|
||||
let app: Awaited<ReturnType<typeof build>>;
|
||||
let feedToken: string;
|
||||
|
||||
async function build() {
|
||||
const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, FeedsModule] }).compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.useGlobalFilters(new TrekExceptionFilter());
|
||||
await nest.init();
|
||||
return nest;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
createTables(db);
|
||||
runMigrations(db);
|
||||
db.prepare(
|
||||
"INSERT INTO users (id, username, email, password_hash, role) VALUES (1, 'e2e-user', 'e2e@example.test', 'x', 'user')",
|
||||
).run();
|
||||
feedToken = 'feed-token-visibility';
|
||||
db.prepare(
|
||||
"INSERT INTO trips (id, user_id, title, start_date, end_date, feed_token) VALUES (1, 1, 'Kyoto', '2026-09-01', '2026-09-05', ?)",
|
||||
).run(feedToken);
|
||||
db.prepare("INSERT INTO days (id, trip_id, day_number, date) VALUES (1, 1, 1, '2026-09-01')").run();
|
||||
db.prepare(`INSERT INTO reservations (trip_id, day_id, title, type, status, reservation_time, confirmation_number, ingest_state)
|
||||
VALUES (1, 1, 'Parked Flight', 'flight', 'confirmed', '2026-09-01T08:00', 'SECRET1', 'staged')`).run();
|
||||
db.prepare(`INSERT INTO reservations (trip_id, day_id, title, type, status, reservation_time, confirmation_number)
|
||||
VALUES (1, 1, 'Booked Flight', 'flight', 'confirmed', '2026-09-01T12:00', 'OPEN1')`).run();
|
||||
|
||||
app = await build();
|
||||
server = app.getHttpServer();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('the public feed serves the live booking and neither the staged one nor its confirmation number', async () => {
|
||||
const res = await request(server).get(`/api/feed/trip/${feedToken}.ics`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('text/calendar');
|
||||
expect(res.text).toContain('SUMMARY:Booked Flight');
|
||||
expect(res.text).not.toContain('Parked Flight');
|
||||
// The number rides in the DESCRIPTION, so search the whole document.
|
||||
expect(res.text).not.toContain('SECRET1');
|
||||
expect(res.text).toContain('OPEN1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Boot migration: reservations.ingest_state.
|
||||
*
|
||||
* The column gates the two anonymous exports (ICS feed, shared trip) so an
|
||||
* automated ingest can park a booking for review without publishing it. Every
|
||||
* row that exists before the ALTER has to come out 'live', or the migration
|
||||
* would empty a calendar subscription that works today.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import Database from 'better-sqlite3';
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
|
||||
function dbWithReservations(): Database.Database {
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
createTables(db);
|
||||
db.prepare("INSERT INTO users (id, username, email, password_hash) VALUES (1, 'u', 'u@example.test', 'x')").run();
|
||||
db.prepare("INSERT INTO trips (id, user_id, title) VALUES (1, 1, 'T')").run();
|
||||
db.prepare("INSERT INTO reservations (id, trip_id, title, type) VALUES (1, 1, 'Old Flight', 'flight')").run();
|
||||
return db;
|
||||
}
|
||||
|
||||
describe('reservations ingest_state migration', () => {
|
||||
it('MIGRATE-INGEST-001: every pre-existing row comes out live', () => {
|
||||
const db = dbWithReservations();
|
||||
try {
|
||||
runMigrations(db);
|
||||
const row = db.prepare('SELECT ingest_state FROM reservations WHERE id = 1').get() as { ingest_state: string };
|
||||
expect(row.ingest_state).toBe('live');
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('MIGRATE-INGEST-002: running the migration twice is a no-op', () => {
|
||||
const db = dbWithReservations();
|
||||
try {
|
||||
runMigrations(db);
|
||||
// Rewind the version so the step replays against a table that already has
|
||||
// the column. Without the pragma_table_info guard the ALTER throws and
|
||||
// runMigrations exits the process.
|
||||
const version = (db.prepare('SELECT version FROM schema_version').get() as { version: number }).version;
|
||||
db.prepare('UPDATE schema_version SET version = ?').run(version - 1);
|
||||
runMigrations(db);
|
||||
|
||||
const cols = db.prepare("SELECT name FROM pragma_table_info('reservations') WHERE name = 'ingest_state'").all();
|
||||
expect(cols).toHaveLength(1);
|
||||
expect((db.prepare('SELECT version FROM schema_version').get() as { version: number }).version).toBe(version);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1243,6 +1243,98 @@ describe('car rentals', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('staged bookings', () => {
|
||||
/** A stay plus, optionally, the bookings that point at it. */
|
||||
const stayWith = (tripId: number, states: Array<{ state: string; title: string }>) => {
|
||||
const place = createPlace(testDb, tripId, { name: 'Hotel Bellevue', lat: 48.8566, lng: 2.3522 });
|
||||
const startDay = createDay(testDb, tripId, { date: '2026-09-01' });
|
||||
const endDay = createDay(testDb, tripId, { date: '2026-09-04' });
|
||||
const stayId = testDb.prepare(`
|
||||
INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out)
|
||||
VALUES (?, ?, ?, ?, '15:00', '11:00')
|
||||
`).run(tripId, place.id, startDay.id, endDay.id).lastInsertRowid as number;
|
||||
|
||||
for (const s of states) {
|
||||
testDb.prepare(`
|
||||
INSERT INTO reservations (trip_id, day_id, title, reservation_time, status, type, accommodation_id, ingest_state)
|
||||
VALUES (?, ?, ?, '2026-09-01T15:00', 'confirmed', 'hotel', ?, ?)
|
||||
`).run(tripId, startDay.id, s.title, String(stayId), s.state);
|
||||
}
|
||||
return { stayId, placeId: place.id };
|
||||
};
|
||||
|
||||
it('CAL-055: a staged booking is left out of the calendar, confirmation number included', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Kyoto' });
|
||||
const day = createDay(testDb, trip.id, { date: '2026-09-01' });
|
||||
const staged = createReservation(testDb, trip.id, { title: 'Parked Flight', type: 'flight', day_id: day.id });
|
||||
testDb.prepare(`UPDATE reservations SET ingest_state='staged', reservation_time='2026-09-01T08:00',
|
||||
confirmation_number='ABC123' WHERE id=?`).run(staged.id);
|
||||
const live = createReservation(testDb, trip.id, { title: 'Booked Flight', type: 'flight', day_id: day.id });
|
||||
testDb.prepare("UPDATE reservations SET reservation_time='2026-09-01T12:00' WHERE id=?").run(live.id);
|
||||
|
||||
const { ics } = svc.exportICS(trip.id);
|
||||
|
||||
expect(ics).toContain('SUMMARY:Booked Flight');
|
||||
expect(ics).not.toContain('Parked Flight');
|
||||
// The number rides in the DESCRIPTION, not the SUMMARY, so search the whole document.
|
||||
expect(ics).not.toContain('ABC123');
|
||||
});
|
||||
|
||||
it('CAL-056: a booking created before the column existed still exports', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Lisbon' });
|
||||
const day = createDay(testDb, trip.id, { date: '2026-09-02' });
|
||||
// No ingest_state named on the insert: exactly what every writer does today,
|
||||
// and what the ALTER backfilled onto every pre-existing row.
|
||||
const old = createReservation(testDb, trip.id, { title: 'Legacy Booking', type: 'flight', day_id: day.id });
|
||||
testDb.prepare("UPDATE reservations SET reservation_time='2026-09-02T09:00' WHERE id=?").run(old.id);
|
||||
|
||||
const { ics } = svc.exportICS(trip.id);
|
||||
|
||||
expect(ics).toContain('SUMMARY:Legacy Booking');
|
||||
});
|
||||
|
||||
it('CAL-057: an accommodation whose only booking is staged emits no stay, check-in or check-out', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Osaka' });
|
||||
stayWith(trip.id, [{ state: 'staged', title: 'Parked Hotel' }]);
|
||||
|
||||
const { ics } = svc.exportICS(trip.id);
|
||||
|
||||
expect(ics).not.toContain('Parked Hotel');
|
||||
expect(ics).not.toMatch(/UID:trek-checkin-\d+@trek/);
|
||||
expect(ics).not.toMatch(/UID:trek-checkout-\d+@trek/);
|
||||
});
|
||||
|
||||
it('CAL-058: an accommodation with no linked booking at all still emits its stay', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Nara' });
|
||||
// The regression this pins: a plain EXISTS(live) instead of
|
||||
// NOT EXISTS(any) OR EXISTS(live) would drop every hand-added hotel out of
|
||||
// trips that are shared today.
|
||||
stayWith(trip.id, []);
|
||||
|
||||
const { ics } = svc.exportICS(trip.id);
|
||||
|
||||
expect(ics).toContain('Hotel Bellevue');
|
||||
expect(ics).toMatch(/UID:trek-checkin-\d+@trek/);
|
||||
});
|
||||
|
||||
it('CAL-059: a stay with both a staged and a live booking takes its title from the live one', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Kobe' });
|
||||
// The staged row gets the lower id, so the subquery's ORDER BY r.id ASC
|
||||
// would pick it without the predicate.
|
||||
stayWith(trip.id, [{ state: 'staged', title: 'Parked Name' }, { state: 'live', title: 'Real Name' }]);
|
||||
|
||||
const { ics } = svc.exportICS(trip.id);
|
||||
|
||||
expect(ics).toContain('Real Name');
|
||||
expect(ics).not.toContain('Parked Name');
|
||||
});
|
||||
});
|
||||
|
||||
describe('folded quirk branches', () => {
|
||||
it('TRIP-SVC-048: exportICS renders untimed/notes all-day summaries, multi-leg routes, endpoint routes and train/location fields', () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
@@ -877,6 +877,18 @@ describe('ReservationsService — legacy branch parity (coverage of the folded c
|
||||
svc.syncBudgetOnUpdate(String(trip.id), '999999', 'X', 'flight', 'X', 'flight', { total_price: 0 }, undefined);
|
||||
expect(budget.deleteBudgetItem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('RESV-SVC-031: list returns staged bookings, the staging inbox has to see its own rows', () => {
|
||||
const { trip } = ownerTrip();
|
||||
createReservation(testDb, trip.id, { title: 'Live One' });
|
||||
const staged = createReservation(testDb, trip.id, { title: 'Parked One' });
|
||||
testDb.prepare("UPDATE reservations SET ingest_state = 'staged' WHERE id = ?").run(staged.id);
|
||||
|
||||
// The visibility predicate belongs to the anonymous exports (ICS feed,
|
||||
// shared trip) and must never be pulled into the authenticated list, or
|
||||
// nobody can confirm what the ingest parked.
|
||||
expect(svc.list(String(trip.id)).map((r: any) => r.title).sort()).toEqual(['Live One', 'Parked One']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ReservationsService — quirk fixes (post-fold)', () => {
|
||||
|
||||
@@ -384,6 +384,48 @@ describe('getSharedTripData', () => {
|
||||
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'carto_api_key', ' owner-key ')").run(user.id);
|
||||
expect(svc.getSharedTripData(token)!.cartoApiKey).toBe('owner-key');
|
||||
});
|
||||
|
||||
it('SHARE-SVC-029: staged bookings stay out of the public payload, confirmation number included', () => {
|
||||
const { trip, token } = seedSharedTrip();
|
||||
testDb.prepare(`INSERT INTO reservations (trip_id, title, type, status, confirmation_number, ingest_state)
|
||||
VALUES (?, 'Parked Flight', 'flight', 'confirmed', 'SECRET1', 'staged')`).run(trip.id);
|
||||
testDb.prepare(`INSERT INTO reservations (trip_id, title, type, status, confirmation_number)
|
||||
VALUES (?, 'Booked Flight', 'flight', 'confirmed', 'OPEN1')`).run(trip.id);
|
||||
|
||||
const data = svc.getSharedTripData(token)!;
|
||||
|
||||
expect((data.reservations as any[]).map((r) => r.title)).toEqual(['Booked Flight']);
|
||||
// SELECT * hands out notes, url and metadata too, so check the whole payload.
|
||||
expect(JSON.stringify(data)).not.toContain('SECRET1');
|
||||
});
|
||||
|
||||
it('SHARE-SVC-030: every booking that predates the column stays in the payload', () => {
|
||||
const { trip, token } = seedSharedTrip();
|
||||
for (let i = 0; i < 5; i++) {
|
||||
testDb.prepare(`INSERT INTO reservations (trip_id, title, type, status)
|
||||
VALUES (?, ?, 'flight', 'confirmed')`).run(trip.id, `Booking ${i}`);
|
||||
}
|
||||
|
||||
expect((svc.getSharedTripData(token)!.reservations as any[])).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('SHARE-SVC-031: accommodations backed only by a staged booking are withheld, unlinked ones are kept', () => {
|
||||
const { trip, token } = seedSharedTrip();
|
||||
const place = createPlace(testDb, trip.id, { name: 'Hotel Bellevue' });
|
||||
const day = createDay(testDb, trip.id, { date: '2026-09-01' });
|
||||
const stay = (): number => testDb.prepare(`
|
||||
INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id) VALUES (?, ?, ?, ?)
|
||||
`).run(trip.id, place.id, day.id, day.id).lastInsertRowid as number;
|
||||
|
||||
const stagedStay = stay();
|
||||
testDb.prepare(`INSERT INTO reservations (trip_id, title, type, status, accommodation_id, ingest_state)
|
||||
VALUES (?, 'Parked Hotel', 'hotel', 'confirmed', ?, 'staged')`).run(trip.id, String(stagedStay));
|
||||
const unlinkedStay = stay();
|
||||
|
||||
const rows = svc.getSharedTripData(token)!.accommodations as any[];
|
||||
|
||||
expect(rows.map((a) => a.id)).toEqual([unlinkedStay]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getSharedPlacePhotoKey ───────────────────────────────────────────────────
|
||||
|
||||
@@ -754,6 +754,26 @@ describe('folded trip CRUD', () => {
|
||||
expect((testDb.prepare('SELECT title FROM trips WHERE id = ?').get(secondCopy) as any).title).toBe('Origin');
|
||||
});
|
||||
|
||||
it('TRIP-SVC-060: copying a trip keeps a staged booking staged', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Origin', start_date: '2025-06-01', end_date: '2025-06-02' });
|
||||
testDb.prepare(`INSERT INTO reservations (trip_id, title, type, status, ingest_state)
|
||||
VALUES (?, 'Parked', 'flight', 'confirmed', 'staged')`).run(trip.id);
|
||||
testDb.prepare(`INSERT INTO reservations (trip_id, title, type, status)
|
||||
VALUES (?, 'Booked', 'flight', 'confirmed')`).run(trip.id);
|
||||
|
||||
const newTripId = svc.copy(trip.id, user.id, 'Clone');
|
||||
|
||||
// Without ingest_state on the duplicate INSERT the staged row falls back to
|
||||
// the column default and shows up in the copy's public feed.
|
||||
const rows = testDb.prepare('SELECT title, ingest_state FROM reservations WHERE trip_id = ? ORDER BY title')
|
||||
.all(newTripId) as any[];
|
||||
expect(rows).toEqual([
|
||||
{ title: 'Booked', ingest_state: 'live' },
|
||||
{ title: 'Parked', ingest_state: 'staged' },
|
||||
]);
|
||||
});
|
||||
|
||||
/**
|
||||
* Copying a trip used to take every packing row and re-insert it without
|
||||
* is_private/owner_id, so both fell back to the column defaults and another
|
||||
|
||||
@@ -153,6 +153,9 @@ export const reservationSchema = z.object({
|
||||
accommodation_id: z.union([z.number(), z.string()]).nullable().optional(),
|
||||
metadata: z.string().nullable().optional(),
|
||||
needs_review: z.number().optional(),
|
||||
// 'live' for anything a person put on the trip, 'staged' while an automated
|
||||
// ingest waits for confirmation. Only the anonymous exports filter on it.
|
||||
ingest_state: z.string().optional(),
|
||||
day_plan_position: z.number().nullable().optional(),
|
||||
created_at: z.string().optional(),
|
||||
// AirTrail (or future provider) linkage — drives the "synced" badge (#214).
|
||||
@@ -309,6 +312,12 @@ const bookingImportAccommodationSchema = z.object({
|
||||
|
||||
export const bookingImportPreviewItemSchema = z.object({
|
||||
type: z.string(),
|
||||
/**
|
||||
* The extractor could not read a type and filled one in, so the import UI
|
||||
* should offer the form the user was importing into rather than trusting the
|
||||
* placeholder (#2076).
|
||||
*/
|
||||
type_guessed: z.boolean().optional(),
|
||||
title: z.string().min(1),
|
||||
reservation_time: z.string().nullable().optional(),
|
||||
reservation_end_time: z.string().nullable().optional(),
|
||||
|
||||
Reference in New Issue
Block a user