Files
TREK/server/tests/integration/days.test.ts
T
Maurice 724c88c201 style: format server and shared with prettier
A mechanical prettier --write over server/src, server/tests, server/scripts
and shared/src with the workspaces' .prettierrc; nothing but layout and
import order changes, and every file keeps the same set of imports. shared
is formatted throughout and its format baseline is empty. The server
baseline keeps the 35 files that Prettier would grow past their lint:size
limit (they need a split first, which is not a formatting change), and the
generated entity files stay as their generator writes them.
2026-10-10 21:23:37 +02:00

906 lines
42 KiB
TypeScript

/**
* Days & Accommodations API integration tests.
* Covers DAY-001 through DAY-013 and ACCOM-001 through ACCOM-006.
*/
import { buildApp } from '../../src/bootstrap';
import { db as testDb } from '../../src/db/database';
import { AppSettings } from '../../src/db/entities/AppSettings.entity';
import { BudgetItems } from '../../src/db/entities/BudgetItems.entity';
import { DayAccommodations } from '../../src/db/entities/DayAccommodations.entity';
import { Days } from '../../src/db/entities/Days.entity';
import { Reservations } from '../../src/db/entities/Reservations.entity';
import { Trips } from '../../src/db/entities/Trips.entity';
import { legacyBoundIntegerText } from '../../src/nest/common/row-id';
import { authCookie } from '../helpers/auth';
import {
createUser,
createTrip,
createDay,
createPlace,
addTripMember,
createDayAccommodation,
createReservation,
} from '../helpers/factories';
import type { FactoryOrm } from '../helpers/factories/context';
import { countRows, deleteRows, findRow, findRows, insertRow, updateRows } from '../helpers/factories/rows';
import { setAppSetting } from '../helpers/factories/settings';
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
import { MikroORM } from '@mikro-orm/core';
import type { INestApplication } from '@nestjs/common';
import type { Application } from 'express';
import request from 'supertest';
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
// ─────────────────────────────────────────────────────────────────────────────
// In-memory DB — schema applied in beforeAll after mocks register
// ─────────────────────────────────────────────────────────────────────────────
vi.mock('../../src/db/database', async () => {
const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock');
return buildDbMock(createSnapshotTestDb());
});
let nestApp: INestApplication;
let app: Application;
/** The app's own ORM, which the factories seed and read through. */
const orm = (): FactoryOrm => nestApp.get(MikroORM);
const dayCount = (tripId: number) => countRows(orm(), Days, { trip: tripId });
/** The trip's days in day order: id, number and date, as the reorder cases compare them. */
const dayList = async (tripId: number) =>
(await findRows(orm(), Days, { trip: tripId }, { day_number: 'asc' })).map((d) => ({
id: d.id,
day_number: d.day_number,
date: d.date,
}));
/** The stay's check-in and check-out days. */
const stayDays = async (id: number) => {
const stay = await findRow(orm(), DayAccommodations, { id });
return { start_day_id: stay?.start_day_id, end_day_id: stay?.end_day_id };
};
beforeAll(async () => {
nestApp = await buildApp();
app = nestApp.getHttpAdapter().getInstance();
});
beforeEach(async () => {
resetTestDb(testDb);
await resetRateLimits(nestApp);
});
afterAll(async () => {
await nestApp.close();
testDb.close();
});
// ─────────────────────────────────────────────────────────────────────────────
// List days (DAY-001, DAY-002)
// ─────────────────────────────────────────────────────────────────────────────
describe('List days', () => {
it('DAY-001 — GET /api/trips/:tripId/days returns days for a trip the user can access', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Paris Trip', start_date: '2026-06-01', end_date: '2026-06-03' });
const res = await request(app).get(`/api/trips/${trip.id}/days`).set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.days).toBeDefined();
expect(Array.isArray(res.body.days)).toBe(true);
expect(res.body.days).toHaveLength(3);
});
it('DAY-001 — Member can list days for a shared trip', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id, {
title: 'Shared Trip',
start_date: '2026-07-01',
end_date: '2026-07-02',
});
addTripMember(testDb, trip.id, member.id);
const res = await request(app).get(`/api/trips/${trip.id}/days`).set('Cookie', authCookie(member.id));
expect(res.status).toBe(200);
expect(res.body.days).toHaveLength(2);
});
it('DAY-002 — Non-member cannot list days (404)', async () => {
const { user: owner } = createUser(testDb);
const { user: stranger } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: 'Private Trip' });
const res = await request(app).get(`/api/trips/${trip.id}/days`).set('Cookie', authCookie(stranger.id));
expect(res.status).toBe(404);
});
it('DAY-002 — Unauthenticated request returns 401', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip' });
const res = await request(app).get(`/api/trips/${trip.id}/days`);
expect(res.status).toBe(401);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Create day (DAY-006, DAY-011 to DAY-013)
// ─────────────────────────────────────────────────────────────────────────────
describe('Create day', () => {
it('DAY-006 — POST /api/trips/:tripId/days creates a standalone day with no date', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Open Trip' });
const res = await request(app)
.post(`/api/trips/${trip.id}/days`)
.set('Cookie', authCookie(user.id))
.send({ notes: 'A free day' });
expect(res.status).toBe(201);
expect(res.body.day).toBeDefined();
expect(res.body.day.trip_id).toBe(trip.id);
expect(res.body.day.date).toBeNull();
expect(res.body.day.notes).toBe('A free day');
});
it('DAY-006 — POST /api/trips/:tripId/days creates a day with a date', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Dated Trip' });
const res = await request(app)
.post(`/api/trips/${trip.id}/days`)
.set('Cookie', authCookie(user.id))
.send({ date: '2026-08-15' });
expect(res.status).toBe(201);
expect(res.body.day.date).toBe('2026-08-15');
});
it('DAY-006 — Non-member cannot create a day (404)', async () => {
const { user: owner } = createUser(testDb);
const { user: stranger } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: 'Private' });
const res = await request(app)
.post(`/api/trips/${trip.id}/days`)
.set('Cookie', authCookie(stranger.id))
.send({ notes: 'Infiltration' });
expect(res.status).toBe(404);
});
it('DAY-011: a dated create adds the next date behind the dated days, and the trip ends on it', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip', start_date: '2026-08-01', end_date: '2026-08-02' });
const spare = createDay(testDb, trip.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/days`)
.set('Cookie', authCookie(user.id))
.send({ dated: true });
expect(res.status).toBe(201);
expect(res.body.day).toMatchObject({ trip_id: trip.id, day_number: 3, date: '2026-08-03' });
expect(res.body.trip).toMatchObject({ id: trip.id, end_date: '2026-08-03', day_count: 4 });
expect(res.body.trip).not.toHaveProperty('feed_token', expect.anything());
const reread = await request(app).get(`/api/trips/${trip.id}`).set('Cookie', authCookie(user.id));
expect(reread.body.trip).toMatchObject({ start_date: '2026-08-01', end_date: '2026-08-03' });
const list = await request(app).get(`/api/trips/${trip.id}/days`).set('Cookie', authCookie(user.id));
expect(list.body.days.map((d: { id: number; date: string | null }) => [d.id, d.date])).toEqual([
[expect.any(Number), '2026-08-01'],
[expect.any(Number), '2026-08-02'],
[res.body.day.id, '2026-08-03'],
[spare.id, null],
]);
});
it('DAY-012: a dated create on a trip without dates is refused with 400 and adds nothing', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Open Trip' });
createDay(testDb, trip.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/days`)
.set('Cookie', authCookie(user.id))
.send({ dated: true });
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: 'This trip has no dates. Add a day without a date instead.' });
expect(await dayCount(trip.id)).toBe(1);
});
it('DAY-013: dated next to a position is refused by the contract with 400 and adds nothing', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip', start_date: '2026-08-01', end_date: '2026-08-02' });
const res = await request(app)
.post(`/api/trips/${trip.id}/days`)
.set('Cookie', authCookie(user.id))
.send({ dated: true, position: 1 });
expect(res.status).toBe(400);
expect(JSON.stringify(res.body)).toContain('dated cannot be combined with date or position');
expect(await dayCount(trip.id)).toBe(2);
expect((await findRow(orm(), Trips, { id: trip.id }))?.end_date).toBe('2026-08-02');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Update day (DAY-003, DAY-004)
// ─────────────────────────────────────────────────────────────────────────────
describe('Update day', () => {
it('DAY-003 — PUT /api/trips/:tripId/days/:dayId updates the day title', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'My Trip' });
const day = createDay(testDb, trip.id, { title: 'Old Title' });
const res = await request(app)
.put(`/api/trips/${trip.id}/days/${day.id}`)
.set('Cookie', authCookie(user.id))
.send({ title: 'New Title' });
expect(res.status).toBe(200);
expect(res.body.day).toBeDefined();
expect(res.body.day.title).toBe('New Title');
});
it('DAY-004 — PUT /api/trips/:tripId/days/:dayId updates the day notes', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'My Trip' });
const day = createDay(testDb, trip.id);
const res = await request(app)
.put(`/api/trips/${trip.id}/days/${day.id}`)
.set('Cookie', authCookie(user.id))
.send({ notes: 'Visit the Louvre' });
expect(res.status).toBe(200);
expect(res.body.day.notes).toBe('Visit the Louvre');
});
it('DAY-003 — PUT returns 404 for a day that does not belong to the trip', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'My Trip' });
createDay(testDb, trip.id);
const res = await request(app)
.put(`/api/trips/${trip.id}/days/999999`)
.set('Cookie', authCookie(user.id))
.send({ title: 'Ghost' });
expect(res.status).toBe(404);
expect(res.body.error).toMatch(/not found/i);
});
it('DAY-003 — Non-member cannot update a day (404)', async () => {
const { user: owner } = createUser(testDb);
const { user: stranger } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: 'Private' });
const day = createDay(testDb, trip.id, { title: 'Original' });
const res = await request(app)
.put(`/api/trips/${trip.id}/days/${day.id}`)
.set('Cookie', authCookie(stranger.id))
.send({ title: 'Hacked' });
expect(res.status).toBe(404);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Reorder days (DAY-005)
// ─────────────────────────────────────────────────────────────────────────────
describe('Reorder days', () => {
it('DAY-005 — Reorder: GET days returns them in day_number order', async () => {
const { user } = createUser(testDb);
// Create trip with 3 days auto-generated
const trip = createTrip(testDb, user.id, {
title: 'Trip',
start_date: '2026-09-01',
end_date: '2026-09-03',
});
const res = await request(app).get(`/api/trips/${trip.id}/days`).set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.days).toHaveLength(3);
// Days should be ordered by day_number ascending (the service sorts by day_number ASC)
expect(res.body.days[0].date).toBe('2026-09-01');
expect(res.body.days[2].date).toBe('2026-09-03');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Delete day
// ─────────────────────────────────────────────────────────────────────────────
describe('Delete day', () => {
it('DELETE /api/trips/:tripId/days/:dayId removes the day', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip' });
const day = createDay(testDb, trip.id);
createDay(testDb, trip.id);
const res = await request(app).delete(`/api/trips/${trip.id}/days/${day.id}`).set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(await findRow(orm(), Days, { id: day.id })).toBeNull();
});
it('DELETE /api/trips/:tripId/days/:dayId returns 404 for unknown day', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip' });
const res = await request(app).delete(`/api/trips/${trip.id}/days/999999`).set('Cookie', authCookie(user.id));
expect(res.status).toBe(404);
expect(res.body.error).toMatch(/not found/i);
});
it('DAY-007: the days after a deleted one close up, and the answer carries the trip with its new day count', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip' });
const [a, b, c] = [createDay(testDb, trip.id), createDay(testDb, trip.id), createDay(testDb, trip.id)];
const res = await request(app).delete(`/api/trips/${trip.id}/days/${b.id}`).set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ success: true, trip: { id: trip.id, day_count: 2 } });
const list = await request(app).get(`/api/trips/${trip.id}/days`).set('Cookie', authCookie(user.id));
expect(list.body.days.map((d: { id: number; day_number: number }) => [d.id, d.day_number])).toEqual([
[a.id, 1],
[c.id, 2],
]);
});
it('DAY-008: deleting a dated day with no day left to take the last date ends the trip a day earlier', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip', start_date: '2026-08-01', end_date: '2026-08-03' });
const [first] = await dayList(trip.id);
const res = await request(app).delete(`/api/trips/${trip.id}/days/${first.id}`).set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.trip).toMatchObject({ start_date: '2026-08-01', end_date: '2026-08-02' });
const reread = await request(app).get(`/api/trips/${trip.id}`).set('Cookie', authCookie(user.id));
expect(reread.body.trip).toMatchObject({ start_date: '2026-08-01', end_date: '2026-08-02' });
const list = await request(app).get(`/api/trips/${trip.id}/days`).set('Cookie', authCookie(user.id));
expect(list.body.days.map((d: { date: string }) => d.date)).toEqual(['2026-08-01', '2026-08-02']);
});
it('DAY-009: the last day of a trip is refused with 400 and stays', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip' });
const day = createDay(testDb, trip.id);
const res = await request(app).delete(`/api/trips/${trip.id}/days/${day.id}`).set('Cookie', authCookie(user.id));
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: 'A trip needs at least one day.' });
expect(await findRow(orm(), Days, { id: day.id })).not.toBeNull();
});
it('DAY-010: a member without day_edit gets 403 and the day stays', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: 'Trip' });
addTripMember(testDb, trip.id, member.id);
const day = createDay(testDb, trip.id);
createDay(testDb, trip.id);
await setAppSetting(orm(), 'perm_day_edit', 'trip_owner');
const { invalidatePermissionsCache } = await import('../../src/nest/permissions/permissions-cache');
await invalidatePermissionsCache();
try {
const res = await request(app)
.delete(`/api/trips/${trip.id}/days/${day.id}`)
.set('Cookie', authCookie(member.id));
expect(res.status).toBe(403);
expect(await findRow(orm(), Days, { id: day.id })).not.toBeNull();
} finally {
await deleteRows(orm(), AppSettings, { key: 'perm_day_edit' });
await invalidatePermissionsCache();
}
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Accommodations (ACCOM-001, ACCOM-002, ACCOM-003)
// ─────────────────────────────────────────────────────────────────────────────
describe('Accommodations', () => {
it('ACCOM-001 — POST /api/trips/:tripId/accommodations creates an accommodation', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Hotel Trip' });
const day1 = createDay(testDb, trip.id, { date: '2026-10-01' });
const day2 = createDay(testDb, trip.id, { date: '2026-10-03' });
const place = createPlace(testDb, trip.id, { name: 'Grand Hotel' });
const res = await request(app)
.post(`/api/trips/${trip.id}/accommodations`)
.set('Cookie', authCookie(user.id))
.send({
place_id: place.id,
start_day_id: day1.id,
end_day_id: day2.id,
check_in: '15:00',
check_out: '11:00',
confirmation: 'ABC123',
notes: 'Breakfast included',
});
expect(res.status).toBe(201);
expect(res.body.accommodation).toBeDefined();
expect(res.body.accommodation.place_id).toBe(place.id);
expect(res.body.accommodation.start_day_id).toBe(day1.id);
expect(res.body.accommodation.end_day_id).toBe(day2.id);
expect(res.body.accommodation.confirmation).toBe('ABC123');
});
it('ACCOM-001 — POST missing required fields returns 400', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip' });
const res = await request(app)
.post(`/api/trips/${trip.id}/accommodations`)
.set('Cookie', authCookie(user.id))
.send({ notes: 'no ids' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/required/i);
});
it('ACCOM-001 — POST with invalid place_id returns 404', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip' });
const day = createDay(testDb, trip.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/accommodations`)
.set('Cookie', authCookie(user.id))
.send({ place_id: 999999, start_day_id: day.id, end_day_id: day.id });
expect(res.status).toBe(404);
});
it('ACCOM-002 — GET /api/trips/:tripId/accommodations returns accommodations for the trip', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Hotel Trip' });
const day1 = createDay(testDb, trip.id, { date: '2026-11-01' });
const day2 = createDay(testDb, trip.id, { date: '2026-11-03' });
const place = createPlace(testDb, trip.id, { name: 'Boutique Inn' });
// Seed accommodation directly
await insertRow(orm(), DayAccommodations, { trip: trip.id, place: place.id, startDay: day1.id, endDay: day2.id });
const res = await request(app).get(`/api/trips/${trip.id}/accommodations`).set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.accommodations).toBeDefined();
expect(Array.isArray(res.body.accommodations)).toBe(true);
expect(res.body.accommodations).toHaveLength(1);
expect(res.body.accommodations[0].place_name).toBe('Boutique Inn');
});
it('ACCOM-002 — Non-member cannot get accommodations (404)', async () => {
const { user: owner } = createUser(testDb);
const { user: stranger } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: 'Private Trip' });
const res = await request(app).get(`/api/trips/${trip.id}/accommodations`).set('Cookie', authCookie(stranger.id));
expect(res.status).toBe(404);
});
it('ACCOM-003 — DELETE /api/trips/:tripId/accommodations/:id removes accommodation', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Hotel Trip' });
const day1 = createDay(testDb, trip.id, { date: '2026-12-01' });
const day2 = createDay(testDb, trip.id, { date: '2026-12-03' });
const place = createPlace(testDb, trip.id, { name: 'Budget Hostel' });
const createRes = await request(app)
.post(`/api/trips/${trip.id}/accommodations`)
.set('Cookie', authCookie(user.id))
.send({ place_id: place.id, start_day_id: day1.id, end_day_id: day2.id });
expect(createRes.status).toBe(201);
const accommodationId = createRes.body.accommodation.id;
const deleteRes = await request(app)
.delete(`/api/trips/${trip.id}/accommodations/${accommodationId}`)
.set('Cookie', authCookie(user.id));
expect(deleteRes.status).toBe(200);
expect(deleteRes.body.success).toBe(true);
// Verify removed from DB
expect(await findRow(orm(), DayAccommodations, { id: accommodationId })).toBeNull();
});
it('ACCOM-003 — DELETE non-existent accommodation returns 404', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip' });
const res = await request(app)
.delete(`/api/trips/${trip.id}/accommodations/999999`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(404);
expect(res.body.error).toMatch(/not found/i);
});
it('ACCOM-001 — Creating accommodation also creates a linked reservation', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Hotel Trip' });
const day1 = createDay(testDb, trip.id, { date: '2026-10-10' });
const day2 = createDay(testDb, trip.id, { date: '2026-10-12' });
const place = createPlace(testDb, trip.id, { name: 'Luxury Resort' });
const res = await request(app)
.post(`/api/trips/${trip.id}/accommodations`)
.set('Cookie', authCookie(user.id))
.send({ place_id: place.id, start_day_id: day1.id, end_day_id: day2.id, confirmation: 'CONF-XYZ' });
expect(res.status).toBe(201);
// Linked reservation should exist
const reservation = await findRow(orm(), Reservations, {
accommodation_id: legacyBoundIntegerText(res.body.accommodation.id),
});
expect(reservation).not.toBeNull();
expect(reservation!.type).toBe('hotel');
expect(reservation!.confirmation_number).toBe('CONF-XYZ');
});
it('ACCOM-004 — PUT /api/trips/:tripId/accommodations/:id updates the accommodation', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Hotel Trip' });
const day1 = createDay(testDb, trip.id, { date: '2026-10-20' });
const day2 = createDay(testDb, trip.id, { date: '2026-10-22' });
const day3 = createDay(testDb, trip.id, { date: '2026-10-25' });
const place = createPlace(testDb, trip.id, { name: 'City Inn' });
const createRes = await request(app)
.post(`/api/trips/${trip.id}/accommodations`)
.set('Cookie', authCookie(user.id))
.send({ place_id: place.id, start_day_id: day1.id, end_day_id: day2.id, notes: 'Original' });
expect(createRes.status).toBe(201);
const accommodationId = createRes.body.accommodation.id;
const updateRes = await request(app)
.put(`/api/trips/${trip.id}/accommodations/${accommodationId}`)
.set('Cookie', authCookie(user.id))
.send({ place_id: place.id, start_day_id: day1.id, end_day_id: day3.id, notes: 'Extended stay' });
expect(updateRes.status).toBe(200);
expect(updateRes.body.accommodation).toBeDefined();
expect(updateRes.body.accommodation.end_day_id).toBe(day3.id);
expect(updateRes.body.accommodation.notes).toBe('Extended stay');
});
it('ACCOM-004 — PUT non-existent accommodation returns 404', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip' });
const res = await request(app)
.put(`/api/trips/${trip.id}/accommodations/999999`)
.set('Cookie', authCookie(user.id))
.send({ notes: 'Ghost update' });
expect(res.status).toBe(404);
expect(res.body.error).toMatch(/not found/i);
});
it('ACCOM-003 — Deleting accommodation also removes the linked reservation', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Hotel Trip' });
const day1 = createDay(testDb, trip.id, { date: '2026-10-15' });
const day2 = createDay(testDb, trip.id, { date: '2026-10-17' });
const place = createPlace(testDb, trip.id, { name: 'Mountain Lodge' });
const createRes = await request(app)
.post(`/api/trips/${trip.id}/accommodations`)
.set('Cookie', authCookie(user.id))
.send({ place_id: place.id, start_day_id: day1.id, end_day_id: day2.id });
const accommodationId = createRes.body.accommodation.id;
const reservationBefore = await findRow(orm(), Reservations, {
accommodation_id: legacyBoundIntegerText(accommodationId),
});
expect(reservationBefore).not.toBeNull();
const deleteRes = await request(app)
.delete(`/api/trips/${trip.id}/accommodations/${accommodationId}`)
.set('Cookie', authCookie(user.id));
expect(deleteRes.status).toBe(200);
expect(await findRow(orm(), Reservations, { id: reservationBefore!.id })).toBeNull();
});
it('ACCOM-006 — DELETE accommodation also removes its linked budget item (issue #933)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Hotel Budget Trip' });
const day1 = createDay(testDb, trip.id, { date: '2026-11-01' });
const day2 = createDay(testDb, trip.id, { date: '2026-11-03' });
const place = createPlace(testDb, trip.id, { name: 'Grand Hotel' });
// Create a hotel reservation that creates an accommodation and a linked budget item
const createRes = await request(app)
.post(`/api/trips/${trip.id}/reservations`)
.set('Cookie', authCookie(user.id))
.send({
title: 'Grand Hotel Stay',
type: 'hotel',
day_id: day1.id,
create_accommodation: { place_id: place.id, start_day_id: day1.id, end_day_id: day2.id },
create_budget_entry: { total_price: 450, category: 'Accommodation' },
});
expect(createRes.status).toBe(201);
const accommodationId = await findRow(orm(), DayAccommodations, { trip: trip.id });
expect(accommodationId).not.toBeNull();
const budgetBefore = await findRow(orm(), BudgetItems, { trip: trip.id });
expect(budgetBefore).not.toBeNull();
// Delete via the accommodation endpoint (the primary bug path)
const delRes = await request(app)
.delete(`/api/trips/${trip.id}/accommodations/${accommodationId!.id}`)
.set('Cookie', authCookie(user.id));
expect(delRes.status).toBe(200);
expect(await findRow(orm(), BudgetItems, { trip: trip.id })).toBeNull();
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Plan 3c Task 9 fix wave — A-H2 (live regression): `DaysService` was
// converted with a bare `Number(tripId)` at every entry point while
// `restampReservationDates`/`assertNoInvertedAccommodation` (Plan 3d
// survivors) kept binding the RAW route string — an affinity seam a
// hex-spelled trip id never matches. `TripAccessGuard` authorises a
// hex-spelled id whose `Number()` value is a real trip (`Number('0x12')`
// is `18`), so one `PUT .../days/reorder` request could renumber that real
// trip's days (the `Number()` half) while its own inverted-accommodation
// guard silently matched zero rows (the raw-string half) — writing a stay
// whose end precedes its start with no error. Every entry point now parses
// ONCE with `toRowId` and threads that value into every survivor; a
// `toRowId` miss answers a legacy-shaped not-found instead of letting the
// guard's wider `Number()` grant reach a downstream raw bind that disagrees
// with it.
// ─────────────────────────────────────────────────────────────────────────────
describe('A-H2 — DaysService trip id parsed once, threaded to every survivor (Task 9 fix wave)', () => {
it("GET /days by the hex-spelled trip id answers the legacy-shaped miss ({ days: [] }), not the real trip's days", async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip', start_date: '2026-09-01', end_date: '2026-09-03' });
const hexTripId = '0x' + trip.id.toString(16);
const res = await request(app).get(`/api/trips/${hexTripId}/days`).set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body).toEqual({ days: [] });
});
it('PUT /days/:id by the hex-spelled trip id 404s "Day not found" (getDay\'s own toRowId gate), not the real trip\'s day', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip' });
const day = createDay(testDb, trip.id, { title: 'Original' });
const hexTripId = '0x' + trip.id.toString(16);
const res = await request(app)
.put(`/api/trips/${hexTripId}/days/${day.id}`)
.set('Cookie', authCookie(user.id))
.send({ title: 'Renamed' });
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Day not found' });
const row = await findRow(orm(), Days, { id: day.id });
expect(row?.title).toBe('Original');
});
it('POST /days by the hex-spelled trip id (append, no position) answers the guard\'s own 404 "Trip not found" and writes no day (M-1 narrowing, Task 9 fix round 2 — was a manufactured 500)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip' });
const hexTripId = '0x' + trip.id.toString(16);
const before = await dayCount(trip.id);
const res = await request(app).post(`/api/trips/${hexTripId}/days`).set('Cookie', authCookie(user.id)).send({});
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Trip not found' });
const after = await dayCount(trip.id);
expect(after).toBe(before);
});
it('POST /days {position} (insert) by the hex-spelled trip id answers the guard\'s own 404 "Trip not found" and writes no day (M-1 narrowing, Task 9 fix round 2 — was a manufactured 500)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip', start_date: '2026-09-01', end_date: '2026-09-02' });
const hexTripId = '0x' + trip.id.toString(16);
const before = await dayCount(trip.id);
const res = await request(app)
.post(`/api/trips/${hexTripId}/days`)
.set('Cookie', authCookie(user.id))
.send({ position: 1 });
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Trip not found' });
const after = await dayCount(trip.id);
expect(after).toBe(before);
});
it('POST /days (append, no position) by trip id "N.0" answers 404 "Trip not found" and writes no day (M-1 narrowing, Task 9 fix round 2)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip' });
const shapedTripId = `${trip.id}.0`;
const before = await dayCount(trip.id);
const res = await request(app).post(`/api/trips/${shapedTripId}/days`).set('Cookie', authCookie(user.id)).send({});
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Trip not found' });
const after = await dayCount(trip.id);
expect(after).toBe(before);
});
it('POST /days {position} (insert) by trip id "N.0" answers 404 "Trip not found" and writes no day (M-1 narrowing, Task 9 fix round 2)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip', start_date: '2026-09-01', end_date: '2026-09-02' });
const shapedTripId = `${trip.id}.0`;
const before = await dayCount(trip.id);
const res = await request(app)
.post(`/api/trips/${shapedTripId}/days`)
.set('Cookie', authCookie(user.id))
.send({ position: 1 });
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Trip not found' });
const after = await dayCount(trip.id);
expect(after).toBe(before);
});
it('POST /days (append, no position) by trip id "N%20" (trailing space) answers 404 "Trip not found" and writes no day (M-1 narrowing, Task 9 fix round 2)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip' });
const shapedTripId = `${trip.id}%20`;
const before = await dayCount(trip.id);
const res = await request(app).post(`/api/trips/${shapedTripId}/days`).set('Cookie', authCookie(user.id)).send({});
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Trip not found' });
const after = await dayCount(trip.id);
expect(after).toBe(before);
});
it('POST /days {position} (insert) by trip id "N%20" (trailing space) answers 404 "Trip not found" and writes no day (M-1 narrowing, Task 9 fix round 2)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip', start_date: '2026-09-01', end_date: '2026-09-02' });
const shapedTripId = `${trip.id}%20`;
const before = await dayCount(trip.id);
const res = await request(app)
.post(`/api/trips/${shapedTripId}/days`)
.set('Cookie', authCookie(user.id))
.send({ position: 1 });
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Trip not found' });
const after = await dayCount(trip.id);
expect(after).toBe(before);
});
it('POST /days (append, no position) by trip id "%2BN" (leading plus) answers 404 "Trip not found" and writes no day (M-1 narrowing, Task 9 fix round 2)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip' });
const shapedTripId = `%2B${trip.id}`;
const before = await dayCount(trip.id);
const res = await request(app).post(`/api/trips/${shapedTripId}/days`).set('Cookie', authCookie(user.id)).send({});
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Trip not found' });
const after = await dayCount(trip.id);
expect(after).toBe(before);
});
it('POST /days {position} (insert) by trip id "%2BN" (leading plus) answers 404 "Trip not found" and writes no day (M-1 narrowing, Task 9 fix round 2)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip', start_date: '2026-09-01', end_date: '2026-09-02' });
const shapedTripId = `%2B${trip.id}`;
const before = await dayCount(trip.id);
const res = await request(app)
.post(`/api/trips/${shapedTripId}/days`)
.set('Cookie', authCookie(user.id))
.send({ position: 1 });
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Trip not found' });
const after = await dayCount(trip.id);
expect(after).toBe(before);
});
it('POST /days (append, no position) by trip id "Ne0" (exponent form) answers 404 "Trip not found" and writes no day (M-1 narrowing, Task 9 fix round 2)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip' });
const shapedTripId = `${trip.id}e0`;
const before = await dayCount(trip.id);
const res = await request(app).post(`/api/trips/${shapedTripId}/days`).set('Cookie', authCookie(user.id)).send({});
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Trip not found' });
const after = await dayCount(trip.id);
expect(after).toBe(before);
});
it('POST /days {position} (insert) by trip id "Ne0" (exponent form) answers 404 "Trip not found" and writes no day (M-1 narrowing, Task 9 fix round 2)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip', start_date: '2026-09-01', end_date: '2026-09-02' });
const shapedTripId = `${trip.id}e0`;
const before = await dayCount(trip.id);
const res = await request(app)
.post(`/api/trips/${shapedTripId}/days`)
.set('Cookie', authCookie(user.id))
.send({ position: 1 });
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Trip not found' });
const after = await dayCount(trip.id);
expect(after).toBe(before);
});
it('PUT /days/reorder by the hex-spelled trip id 400s "orderedIds must be a permutation…" and writes NOTHING — the live H2 bug: it used to 200 and invert a real accommodation', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip', start_date: '2026-06-01', end_date: '2026-06-03' });
const days = await dayList(trip.id);
expect(days).toHaveLength(3);
const [d1, d2, d3] = days;
const place = createPlace(testDb, trip.id, { name: 'Hotel' });
// A valid (non-inverted) stay d1 -> d2, and a reservation on d1 — exactly
// the shape the compiled-boot evidence used to catch reorder inverting.
const stay = createDayAccommodation(testDb, trip.id, place.id, d1.id, d2.id);
const reservation = createReservation(testDb, trip.id, { day_id: d1.id, title: 'Dinner' });
await updateRows(orm(), Reservations, { id: reservation.id }, { reservation_time: '2026-06-01T19:00:00' });
const hexTripId = '0x' + trip.id.toString(16);
const res = await request(app)
.put(`/api/trips/${hexTripId}/days/reorder`)
.set('Cookie', authCookie(user.id))
.send({ orderedIds: [d2.id, d1.id, d3.id] });
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: 'orderedIds must be a permutation of the trip day ids.' });
// Nothing renumbered.
const after = await dayList(trip.id);
expect(after).toEqual(days);
// The stay is still d1 -> d2, never inverted.
const stayAfter = await stayDays(stay.id);
expect(stayAfter).toEqual({ start_day_id: d1.id, end_day_id: d2.id });
// The reservation's stamped time is untouched.
const resAfter = await findRow(orm(), Reservations, { id: reservation.id });
expect(resAfter?.reservation_time).toBe('2026-06-01T19:00:00');
});
it('PUT /days/reorder with the REAL numeric trip id still works and can still be legitimately rejected for inverting a stay (unchanged, base 400) — L-5: zero days rows changed, not only the stay', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip', start_date: '2026-06-01', end_date: '2026-06-03' });
const days = await dayList(trip.id);
const [d1, d2, d3] = days;
const place = createPlace(testDb, trip.id, { name: 'Hotel' });
const stay = createDayAccommodation(testDb, trip.id, place.id, d1.id, d3.id);
const res = await request(app)
.put(`/api/trips/${trip.id}/days/reorder`)
.set('Cookie', authCookie(user.id))
.send({ orderedIds: [d3.id, d2.id, d1.id] });
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: 'This move would make an accommodation end before it starts.' });
const stayAfter = await stayDays(stay.id);
expect(stayAfter).toEqual({ start_day_id: d1.id, end_day_id: d3.id });
// L-5: the rollback holds on every day row, not only the stay — zero rows changed.
const daysAfter = await dayList(trip.id);
expect(daysAfter).toEqual(days);
});
});