mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
06bee95ae3
A euro trip read in dollars books a bill entered in dollars at the rate of the day, from the euro's quote: 12,345.67 USD at 1.1398 is 10,831.44 EUR. The Costs screen and the settlement then turned those euros back into dollars with the dollar's own quote, 1 / 0.87732. Frankfurter rounds the two quotes separately, so they are not exact inverses, and the bill read $12,346.05 the moment it was saved, a $250.00 one $250.01. The error grows with the amount, which is why the large rows showed it first. Paying back what settle-up offered for such a bill left the trip open with the debt flipped the other way. Both sides now convert trip money to the display currency with the trip currency's own quote, the one every entry rate is frozen from. On the server, settlement() fetches that quote for the REST route and for the MCP get_settlement_summary tool, which now makes the same call, and a legacy transfer without a currency reads back through the same factor. On the client, useExchangeRates takes the trip currency as an anchor and converts through its cached quote, falling back to the display currency's while it is missing, so it works offline from cache and needs no change to the settlement response. The final budget builds each row from the unrounded trip amount, because a euro cent is worth more than a dollar cent and 10,831.44 EUR came back as 12,345.68; the balances and settle-up still net whole trip cents. A same-day bill in the display currency now reads exactly as typed on the row, the payer chip, the day header, the totals, Outstanding and the final budget, on both shells and on the shared trip page, and paying the amount settle-up offers leaves both balances at zero. When the rate really moved, the row keeps the value the totals count, with what was entered and the euros it was booked at underneath, "$801.76 -> 685,26 EUR". One helper builds that line for the desktop list, the phone list, payments, the final budget breakdown and the shared page; it prints nothing for an amount of 0 or a bill that reads as typed. The expense editors share one module for their preview. The split summary puts the share the row lends or borrows next to the entered one, "$400.88 -> $390.53 each", and an expense saved without a currency still opens in the trip currency. Both CSV exports add the amount in the trip currency when it differs from the display currency. Kept from before: the desktop totals recompute when rates arrive, a booking's linked cost is labelled in the trip currency, a legacy transfer is read in the display currency, the shared page reads the frozen rate in whole cents, a booking import keeps the currency it was quoted in at a rate frozen before the write (REST and plugin RPC), and the MCP trip summary, the budget-overview prompt and the per-person summary add every row up in trip cents at its frozen rate. The packing-list prompt reads the trip title directly instead of building the whole summary, which could wait on a rates fetch.
383 lines
21 KiB
TypeScript
383 lines
21 KiB
TypeScript
/**
|
|
* Reservations + accommodations module e2e — exercises both migrated mounts
|
|
* through the real JwtAuthGuard against a temp SQLite db. Reservation SQL runs
|
|
* for real (ReservationsService is DI-native, no mock — the temp db carries the
|
|
* full schema via createTables + runMigrations), and so does the accommodation
|
|
* SQL (the injected DaysService is DI-native too); the budget service, the
|
|
* permission check and the WebSocket broadcast stay mocked.
|
|
*/
|
|
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
|
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
|
|
import { ReservationsModule } from '../../src/nest/reservations/reservations.module';
|
|
// Accommodations left reservations/ for a domain of their own; this container has
|
|
// to assemble both or the /accommodations cases below 404 while production serves them.
|
|
import { AccommodationsModule } from '../../src/nest/accommodations/accommodations.module';
|
|
import { sessionCookie } from './harness';
|
|
import { DatabaseModule } from '../../src/nest/database/database.module';
|
|
import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
|
|
import { Test } from '@nestjs/testing';
|
|
|
|
import cookieParser from 'cookie-parser';
|
|
import type { Server } from 'http';
|
|
import request from 'supertest';
|
|
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi, type MockInstance } from 'vitest';
|
|
|
|
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');
|
|
// What production runs (db/database.ts) and what createTestDb gives every
|
|
// unit suite. Without it the reservation foreign keys are inert here, and an
|
|
// id that resolves to nothing passes the mount unnoticed.
|
|
tmp.exec('PRAGMA foreign_keys = ON');
|
|
return { db: tmp };
|
|
});
|
|
|
|
const { canAccessTrip } = vi.hoisted(() => ({ canAccessTrip: vi.fn() }));
|
|
vi.mock('../../src/db/database', () => ({
|
|
db,
|
|
canAccessTrip,
|
|
isOwner: vi.fn(() => true),
|
|
getPlaceWithTags: vi.fn(),
|
|
closeDb: () => {},
|
|
reinitialize: () => {},
|
|
}));
|
|
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() }));
|
|
const { notificationSend } = vi.hoisted(() => ({ notificationSend: vi.fn().mockResolvedValue(undefined) }));
|
|
import { PermissionsService } from '../../src/nest/permissions/permissions.service';
|
|
|
|
// Since the permissions DI migration, the check is a spy on the container's
|
|
// PermissionsService singleton (created in beforeAll, after build()).
|
|
let checkPermission: MockInstance;
|
|
|
|
// The budget-sync seam runs the real injected BudgetService (BudgetModule is
|
|
// imported by ReservationsModule since the budget fold) over the same temp db.
|
|
|
|
import { createTables } from '../../src/db/schema';
|
|
import { runMigrations } from '../../src/db/migrations';
|
|
import { NotificationsService } from '../../src/nest/notifications/notifications.service';
|
|
import { ExchangeRatesService } from '../../src/nest/budget/exchange-rates.service';
|
|
|
|
describe('Reservations + accommodations e2e (real auth guard + temp SQLite, real reservation SQL)', () => {
|
|
let server: Server;
|
|
let app: Awaited<ReturnType<typeof build>>;
|
|
let tripId: number;
|
|
|
|
async function build() {
|
|
const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, RealtimeModule, ReservationsModule, AccommodationsModule] })
|
|
.overrideProvider(NotificationsService)
|
|
.useValue({ send: notificationSend })
|
|
// A price quoted in a foreign currency freezes the rate of the day; this is that
|
|
// day's table, so no case ever reaches the network.
|
|
.overrideProvider(ExchangeRatesService)
|
|
.useValue({ getRates: async (base: string) => (base.toUpperCase() === 'EUR' ? { EUR: 1, USD: 1.17 } : null) })
|
|
.compile();
|
|
const nest = moduleRef.createNestApplication();
|
|
nest.use(cookieParser());
|
|
nest.useGlobalFilters(new TrekExceptionFilter());
|
|
nest.useGlobalPipes(new ZodValidationPipe());
|
|
await nest.init();
|
|
return nest;
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
createTables(db);
|
|
runMigrations(db);
|
|
// The temp db carries the real schema (password_hash NOT NULL), so seed the
|
|
// auth user directly instead of via the trimmed-DDL seedUser helper.
|
|
db.prepare(
|
|
"INSERT INTO users (id, username, email, password_hash, role, password_version) VALUES (1, 'e2e-user', 'e2e@example.test', 'x', 'user', 0)",
|
|
).run();
|
|
tripId = Number(db.prepare("INSERT INTO trips (user_id, title) VALUES (1, 'E2E Trip')").run().lastInsertRowid);
|
|
app = await build();
|
|
checkPermission = vi.spyOn(app.get(PermissionsService), 'checkPermission');
|
|
server = app.getHttpServer();
|
|
});
|
|
|
|
beforeEach(() => {
|
|
canAccessTrip.mockImplementation((id: unknown) => db.prepare('SELECT * FROM trips WHERE id = ?').get(id));
|
|
checkPermission.mockReturnValue(true);
|
|
notificationSend.mockClear();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await app.close();
|
|
});
|
|
|
|
it('401 without a cookie (reservations)', async () => {
|
|
expect((await request(server).get(`/api/trips/${tripId}/reservations`)).status).toBe(401);
|
|
});
|
|
|
|
it('200 list reservations (real SQL, joins attached)', async () => {
|
|
const rid = db.prepare("INSERT INTO reservations (trip_id, title, type) VALUES (?, 'Hotel', 'hotel')").run(tripId).lastInsertRowid;
|
|
const res = await request(server).get(`/api/trips/${tripId}/reservations`).set('Cookie', sessionCookie(1));
|
|
expect(res.status).toBe(200);
|
|
const row = res.body.reservations.find((r: { id: number }) => r.id === Number(rid));
|
|
expect(row).toMatchObject({ title: 'Hotel', type: 'hotel', endpoints: [], travelers: [] });
|
|
});
|
|
|
|
it('401 without a cookie (upcoming feed)', async () => {
|
|
expect((await request(server).get('/api/reservations/upcoming')).status).toBe(401);
|
|
});
|
|
|
|
it('200 cross-trip upcoming reservations feed, without the hotels (#1934)', async () => {
|
|
db.prepare("INSERT INTO reservations (trip_id, title, type, reservation_time) VALUES (?, 'Flight', 'flight', '2999-01-01T10:00:00')").run(tripId);
|
|
db.prepare("INSERT INTO reservations (trip_id, title, type, reservation_time) VALUES (?, 'Stay', 'hotel', '2999-01-01T09:00:00')").run(tripId);
|
|
const res = await request(server).get('/api/reservations/upcoming').set('Cookie', sessionCookie(1));
|
|
expect(res.status).toBe(200);
|
|
const titles = res.body.reservations.map((r: { title: string }) => r.title);
|
|
expect(titles).toContain('Flight');
|
|
expect(titles).not.toContain('Stay');
|
|
});
|
|
|
|
it('404 when trip not accessible (reservations)', async () => {
|
|
canAccessTrip.mockReturnValue(undefined);
|
|
const res = await request(server).get(`/api/trips/${tripId}/reservations`).set('Cookie', sessionCookie(1));
|
|
expect(res.status).toBe(404);
|
|
expect(res.body).toEqual({ error: 'Trip not found' });
|
|
});
|
|
|
|
it('201 create keeps an imported price in the currency it was quoted in, at a frozen rate (#2525)', async () => {
|
|
const res = await request(server)
|
|
.post(`/api/trips/${tripId}/reservations`)
|
|
.set('Cookie', sessionCookie(1))
|
|
.send({
|
|
title: 'Aparthotel Silver', type: 'hotel',
|
|
metadata: { price: '801.76', priceCurrency: 'USD' },
|
|
create_budget_entry: { total_price: 801.76, category: 'accommodation', currency: 'USD' },
|
|
});
|
|
expect(res.status).toBe(201);
|
|
// It used to land as 801.76 with no currency, which is 801.76 of the trip's euros.
|
|
const item = db.prepare('SELECT total_price, currency, exchange_rate FROM budget_items WHERE reservation_id = ?').get(res.body.reservation.id);
|
|
expect(item).toEqual({ total_price: 801.76, currency: 'USD', exchange_rate: 1.17 });
|
|
});
|
|
|
|
it('201 create leaves a price without a currency in the trip currency, as before', async () => {
|
|
const res = await request(server)
|
|
.post(`/api/trips/${tripId}/reservations`)
|
|
.set('Cookie', sessionCookie(1))
|
|
.send({ title: 'Museum', type: 'event', create_budget_entry: { total_price: 20, currency: 'not a code' } });
|
|
expect(res.status).toBe(201);
|
|
const item = db.prepare('SELECT total_price, currency, exchange_rate FROM budget_items WHERE reservation_id = ?').get(res.body.reservation.id);
|
|
expect(item).toEqual({ total_price: 20, currency: null, exchange_rate: 1 });
|
|
});
|
|
|
|
it('201 create reservation (real insert + booking notification), 400 without title', async () => {
|
|
const ok = await request(server)
|
|
.post(`/api/trips/${tripId}/reservations`)
|
|
.set('Cookie', sessionCookie(1))
|
|
.send({ title: 'Hotel' });
|
|
expect(ok.status).toBe(201);
|
|
expect(ok.body.reservation).toMatchObject({ title: 'Hotel', type: 'other', status: 'pending' });
|
|
expect(db.prepare('SELECT title FROM reservations WHERE id = ?').get(ok.body.reservation.id)).toEqual({ title: 'Hotel' });
|
|
// The fire-and-forget booking notification reaches the (mocked) notification service.
|
|
await vi.waitFor(() => expect(notificationSend).toHaveBeenCalled());
|
|
expect(notificationSend).toHaveBeenCalledWith(expect.objectContaining({ event: 'booking_change', actorId: 1 }));
|
|
|
|
const bad = await request(server).post(`/api/trips/${tripId}/reservations`).set('Cookie', sessionCookie(1)).send({});
|
|
expect(bad.status).toBe(400);
|
|
expect(bad.body.error).toContain('title');
|
|
});
|
|
|
|
// The reported repro (#2355): an id that resolves to nothing used to reach
|
|
// the statement and come back as an unhandled SqliteError, i.e. a bare 500.
|
|
it('400 on an update whose place_id exists nowhere, and the row is left alone', async () => {
|
|
const rid = Number(db.prepare("INSERT INTO reservations (trip_id, title, type) VALUES (?, 'Dinner', 'other')").run(tripId).lastInsertRowid);
|
|
|
|
const res = await request(server)
|
|
.put(`/api/trips/${tripId}/reservations/${rid}`)
|
|
.set('Cookie', sessionCookie(1))
|
|
.send({ title: 'Dinner, later', place_id: 999999 });
|
|
|
|
expect(res.status).toBe(400);
|
|
expect(res.body).toEqual({ error: 'Unknown reference: place_id' });
|
|
expect(db.prepare('SELECT title, place_id FROM reservations WHERE id = ?').get(rid)).toEqual({ title: 'Dinner', place_id: null });
|
|
});
|
|
|
|
it('400 on a create whose create_accommodation day exists nowhere, and no stay is written', async () => {
|
|
const placeId = Number(db.prepare('INSERT INTO places (trip_id, name) VALUES (?, ?)').run(tripId, 'Hotel Unknown').lastInsertRowid);
|
|
const dayId = Number(db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, 7, ?)').run(tripId, '2026-03-07').lastInsertRowid);
|
|
const before = db.prepare('SELECT COUNT(*) as c FROM day_accommodations WHERE trip_id = ?').get(tripId);
|
|
|
|
const res = await request(server)
|
|
.post(`/api/trips/${tripId}/reservations`)
|
|
.set('Cookie', sessionCookie(1))
|
|
.send({ title: 'Stay', type: 'hotel', create_accommodation: { place_id: placeId, start_day_id: dayId, end_day_id: 999999 } });
|
|
|
|
expect(res.status).toBe(400);
|
|
expect(res.body).toEqual({ error: 'Unknown reference: create_accommodation.end_day_id' });
|
|
expect(db.prepare('SELECT COUNT(*) as c FROM day_accommodations WHERE trip_id = ?').get(tripId)).toEqual(before);
|
|
});
|
|
|
|
// #522, which must survive all of the above: shortening a trip cascades the
|
|
// stay away and leaves the booking pointing at a gap, and the booking still
|
|
// has to be savable.
|
|
it('200 on an update whose stored accommodation_id no longer resolves', async () => {
|
|
const rid = Number(
|
|
db.prepare("INSERT INTO reservations (trip_id, title, type, accommodation_id) VALUES (?, 'Stay', 'hotel', 999999)").run(tripId).lastInsertRowid,
|
|
);
|
|
|
|
const res = await request(server)
|
|
.put(`/api/trips/${tripId}/reservations/${rid}`)
|
|
.set('Cookie', sessionCookie(1))
|
|
.send({ title: 'Stay, renamed', accommodation_id: 999999 });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(db.prepare('SELECT title, accommodation_id FROM reservations WHERE id = ?').get(rid))
|
|
.toEqual({ title: 'Stay, renamed', accommodation_id: null });
|
|
});
|
|
|
|
it('200 list accommodations + 201 create (real insert + auto hotel reservation), 404 on bad refs', async () => {
|
|
const placeId = Number(db.prepare('INSERT INTO places (trip_id, name) VALUES (?, ?)').run(tripId, 'Grand Hotel').lastInsertRowid);
|
|
const dayId = Number(db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, 1, ?)').run(tripId, '2026-03-01').lastInsertRowid);
|
|
const create = await request(server)
|
|
.post(`/api/trips/${tripId}/accommodations`)
|
|
.set('Cookie', sessionCookie(1))
|
|
.send({ place_id: placeId, start_day_id: dayId, end_day_id: dayId, check_in: '15:00' });
|
|
expect(create.status).toBe(201);
|
|
expect(create.body.accommodation).toMatchObject({ place_id: placeId, start_day_id: dayId, end_day_id: dayId, place_name: 'Grand Hotel' });
|
|
// The partner hotel reservation is auto-created by the real DaysService SQL.
|
|
const linked = db.prepare('SELECT * FROM reservations WHERE accommodation_id = ?').get(create.body.accommodation.id) as { type: string; status: string };
|
|
expect(linked).toMatchObject({ type: 'hotel', status: 'confirmed' });
|
|
const list = await request(server).get(`/api/trips/${tripId}/accommodations`).set('Cookie', sessionCookie(1));
|
|
expect(list.status).toBe(200);
|
|
expect(list.body.accommodations).toHaveLength(1);
|
|
expect(list.body.accommodations[0]).toMatchObject({ id: create.body.accommodation.id, place_name: 'Grand Hotel' });
|
|
const badRefs = await request(server)
|
|
.post(`/api/trips/${tripId}/accommodations`)
|
|
.set('Cookie', sessionCookie(1))
|
|
.send({ place_id: 99999, start_day_id: dayId, end_day_id: dayId });
|
|
expect(badRefs.status).toBe(404);
|
|
expect(badRefs.body).toEqual({ error: 'Place not found' });
|
|
});
|
|
|
|
it('201 create also puts the place on its check-in day, and the delete takes that stop back', async () => {
|
|
// The road-trip view builds its stops from day_assignments and only looks the stay
|
|
// up afterwards, so a booking without one never reaches the route: the complaint
|
|
// was having to enter the same hotel a second time as an ordinary place.
|
|
const placeId = Number(db.prepare('INSERT INTO places (trip_id, name) VALUES (?, ?)').run(tripId, 'Hotel Adlon').lastInsertRowid);
|
|
const dayId = Number(db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, 4, ?)').run(tripId, '2026-03-04').lastInsertRowid);
|
|
|
|
const create = await request(server)
|
|
.post(`/api/trips/${tripId}/accommodations`)
|
|
.set('Cookie', sessionCookie(1))
|
|
.send({ place_id: placeId, start_day_id: dayId, end_day_id: dayId });
|
|
expect(create.status).toBe(201);
|
|
// In the answer, not only on the socket: the broadcast skips the sender.
|
|
expect(create.body.assignment).toMatchObject({ day_id: dayId, place_id: placeId });
|
|
expect(db.prepare('SELECT stop_type FROM places WHERE id = ?').get(placeId)).toMatchObject({ stop_type: 'hotel' });
|
|
|
|
const del = await request(server)
|
|
.delete(`/api/trips/${tripId}/accommodations/${create.body.accommodation.id}`)
|
|
.set('Cookie', sessionCookie(1));
|
|
expect(del.status).toBe(200);
|
|
expect(del.body.removedAssignments).toEqual([{ id: create.body.assignment.id, dayId }]);
|
|
expect(db.prepare('SELECT id FROM day_assignments WHERE day_id = ?').all(dayId)).toEqual([]);
|
|
});
|
|
|
|
it('404 when trip not accessible (accommodations)', async () => {
|
|
canAccessTrip.mockReturnValue(undefined);
|
|
const res = await request(server).get(`/api/trips/${tripId}/accommodations`).set('Cookie', sessionCookie(1));
|
|
expect(res.status).toBe(404);
|
|
expect(res.body).toEqual({ error: 'Trip not found' });
|
|
});
|
|
|
|
it('400 accommodation create without refs', async () => {
|
|
const res = await request(server)
|
|
.post(`/api/trips/${tripId}/accommodations`)
|
|
.set('Cookie', sessionCookie(1))
|
|
.send({ place_id: 2 });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body).toEqual({ error: 'place_id, start_day_id, and end_day_id are required' });
|
|
});
|
|
|
|
// The per-day branch of updatePositions used to bind the caller's ids straight
|
|
// into reservation_day_positions without ever looking at the tripId it was
|
|
// handed, so a request authorised on your own trip could rewrite the ordering
|
|
// of someone else's. The legacy branch three lines below it always scoped by
|
|
// trip_id — these pin that the two behave the same way now.
|
|
describe('positions are confined to the trip in the URL', () => {
|
|
let foreignTripId: number;
|
|
let foreignReservationId: number;
|
|
let foreignDayId: number;
|
|
let ownDayId: number;
|
|
// Days are unique per (trip_id, day_number) and this block seeds a fresh pair
|
|
// for every case, so the numbers have to keep climbing.
|
|
let dayNumber = 100;
|
|
|
|
beforeEach(() => {
|
|
db.prepare(
|
|
"INSERT OR IGNORE INTO users (id, username, email, password_hash, role, password_version) VALUES (2, 'victim', 'victim@example.test', 'x', 'user', 0)",
|
|
).run();
|
|
foreignTripId = Number(db.prepare("INSERT INTO trips (user_id, title) VALUES (2, 'Someone else')").run().lastInsertRowid);
|
|
foreignReservationId = Number(db.prepare("INSERT INTO reservations (trip_id, title, type) VALUES (?, 'Secret', 'other')").run(foreignTripId).lastInsertRowid);
|
|
dayNumber += 1;
|
|
foreignDayId = Number(db.prepare("INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, '2026-05-01')").run(foreignTripId, dayNumber).lastInsertRowid);
|
|
ownDayId = Number(db.prepare("INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, '2026-05-01')").run(tripId, dayNumber).lastInsertRowid);
|
|
});
|
|
|
|
function positionRow(reservationId: number, dayId: number) {
|
|
return db.prepare('SELECT position FROM reservation_day_positions WHERE reservation_id = ? AND day_id = ?').get(reservationId, dayId);
|
|
}
|
|
|
|
it('writes nothing when both ids belong to another trip', async () => {
|
|
const res = await request(server)
|
|
.put(`/api/trips/${tripId}/reservations/positions`)
|
|
.set('Cookie', sessionCookie(1))
|
|
.send({ positions: [{ id: foreignReservationId, day_plan_position: 999 }], day_id: foreignDayId });
|
|
expect(res.status).toBe(200);
|
|
expect(positionRow(foreignReservationId, foreignDayId)).toBeUndefined();
|
|
});
|
|
|
|
it('writes nothing when only the reservation is foreign', async () => {
|
|
const res = await request(server)
|
|
.put(`/api/trips/${tripId}/reservations/positions`)
|
|
.set('Cookie', sessionCookie(1))
|
|
.send({ positions: [{ id: foreignReservationId, day_plan_position: 5 }], day_id: ownDayId });
|
|
expect(res.status).toBe(200);
|
|
expect(positionRow(foreignReservationId, ownDayId)).toBeUndefined();
|
|
});
|
|
|
|
it('writes nothing when only the day is foreign', async () => {
|
|
const ownReservationId = Number(db.prepare("INSERT INTO reservations (trip_id, title, type) VALUES (?, 'Mine', 'other')").run(tripId).lastInsertRowid);
|
|
const res = await request(server)
|
|
.put(`/api/trips/${tripId}/reservations/positions`)
|
|
.set('Cookie', sessionCookie(1))
|
|
.send({ positions: [{ id: ownReservationId, day_plan_position: 5 }], day_id: foreignDayId });
|
|
expect(res.status).toBe(200);
|
|
expect(positionRow(ownReservationId, foreignDayId)).toBeUndefined();
|
|
});
|
|
|
|
it('still stores a position when both ids are on the trip', async () => {
|
|
const ownReservationId = Number(db.prepare("INSERT INTO reservations (trip_id, title, type) VALUES (?, 'Mine', 'other')").run(tripId).lastInsertRowid);
|
|
const res = await request(server)
|
|
.put(`/api/trips/${tripId}/reservations/positions`)
|
|
.set('Cookie', sessionCookie(1))
|
|
.send({ positions: [{ id: ownReservationId, day_plan_position: 3 }], day_id: ownDayId });
|
|
expect(res.status).toBe(200);
|
|
expect(positionRow(ownReservationId, ownDayId)).toEqual({ position: 3 });
|
|
});
|
|
|
|
// An id that exists nowhere used to raise a foreign-key error, which the
|
|
// exception filter turned into a 500 — telling the caller apart from the
|
|
// 200 a real id returns, for any id on the instance.
|
|
it('answers a nonexistent reservation the same way it answers a foreign one', async () => {
|
|
const res = await request(server)
|
|
.put(`/api/trips/${tripId}/reservations/positions`)
|
|
.set('Cookie', sessionCookie(1))
|
|
.send({ positions: [{ id: 999999, day_plan_position: 1 }], day_id: ownDayId });
|
|
expect(res.status).toBe(200);
|
|
});
|
|
|
|
it('does not fall over when day_plan_position is omitted', async () => {
|
|
const ownReservationId = Number(db.prepare("INSERT INTO reservations (trip_id, title, type) VALUES (?, 'Mine', 'other')").run(tripId).lastInsertRowid);
|
|
const res = await request(server)
|
|
.put(`/api/trips/${tripId}/reservations/positions`)
|
|
.set('Cookie', sessionCookie(1))
|
|
.send({ positions: [{ id: ownReservationId }], day_id: ownDayId });
|
|
expect(res.status).toBe(200);
|
|
});
|
|
});
|
|
});
|