mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
b3a085df3f
The process-state ports were meant to let a store shared between processes replace the in-memory one, but they could not. The readers outside the container (the broadcast functions, the backup restore's permissions-cache flush, the pending-code sweep, hand-built consumers) went straight to the module-level in-memory instances, so an overridden provider was used by the gateway and the services while the broadcasts and the restore flush kept using the old store. And every port was synchronous, while a store in the database or on the network is async, so plugging one in would have changed every signature anyway. RateLimitStore, PendingCodeStore, OidcFlowStore and PermissionsCacheStore now return promises, and RateLimitService, OauthService, OidcService, PermissionsService and their callers await them. The in-memory stores still decide synchronously, so who gets a 429, a code or a cached level is unchanged. RoomRegistry stays synchronous: membership holds live sockets and broadcast() is a synchronous call every domain makes, so several processes need a fan-out in front of broadcast(), not a different place for the sets. The process-wide ports get a ProcessStoreSlot. PermissionsModule, OauthModule and RealtimeGatewayModule install the store the container resolved and release it on shutdown, and the free functions, the sweep and hand-built consumers read the slot, so overriding the provider changes every reader. Tests provide fake stores and check that the broadcasts, the restore flush and the sweep use them. Two callers needed more than an await. The transit MCP tools counted in a RateLimitService of their own that no provider could reach; they now take the one RateLimitModule provides (their mcp_transit_* buckets never overlap the REST ones). The link preview claimed its in-flight slot only after the budget check, which now yields, so simultaneous askers would each have been charged; it now claims the slot first, and an asker that joined someone out of budget asks again on its own budget. The unused putPendingCode/takePendingCode helpers are gone.
922 lines
44 KiB
TypeScript
922 lines
44 KiB
TypeScript
/**
|
|
* Days & Accommodations API integration tests.
|
|
* Covers DAY-001 through DAY-013 and ACCOM-001 through ACCOM-006.
|
|
*/
|
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
|
import request from 'supertest';
|
|
import type { Application } from 'express';
|
|
import type { INestApplication } from '@nestjs/common';
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// 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());
|
|
});
|
|
vi.mock('../../src/config', () => ({
|
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
|
updateJwtSecret: () => {},
|
|
SESSION_DURATION: '24h',
|
|
SESSION_DURATION_MS: 86400000,
|
|
SESSION_DURATION_SECONDS: 86400,
|
|
DEFAULT_LANGUAGE: 'en',
|
|
}));
|
|
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
|
|
|
import { db as testDb } from '../../src/db/database';
|
|
import { buildApp } from '../../src/bootstrap';
|
|
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
|
import { createUser, createTrip, createDay, createPlace, addTripMember, createDayAccommodation, createReservation } from '../helpers/factories';
|
|
import { authCookie } from '../helpers/auth';
|
|
|
|
let nestApp: INestApplication;
|
|
let app: Application;
|
|
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(testDb.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = ?').get(trip.id)).toEqual({ n: 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(testDb.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = ?').get(trip.id)).toEqual({ n: 2 });
|
|
expect(testDb.prepare('SELECT end_date FROM trips WHERE id = ?').get(trip.id)).toEqual({ end_date: '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);
|
|
|
|
const deleted = testDb.prepare('SELECT id FROM days WHERE id = ?').get(day.id);
|
|
expect(deleted).toBeUndefined();
|
|
});
|
|
|
|
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 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number LIMIT 1').get(trip.id) as { id: number };
|
|
|
|
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(testDb.prepare('SELECT id FROM days WHERE id = ?').get(day.id)).toBeDefined();
|
|
});
|
|
|
|
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);
|
|
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('perm_day_edit', 'trip_owner')").run();
|
|
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(testDb.prepare('SELECT id FROM days WHERE id = ?').get(day.id)).toBeDefined();
|
|
} finally {
|
|
testDb.prepare("DELETE FROM app_settings WHERE key = 'perm_day_edit'").run();
|
|
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
|
|
testDb.prepare(
|
|
'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id) VALUES (?, ?, ?, ?)'
|
|
).run(trip.id, place.id, day1.id, 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
|
|
const row = testDb.prepare('SELECT id FROM day_accommodations WHERE id = ?').get(accommodationId);
|
|
expect(row).toBeUndefined();
|
|
});
|
|
|
|
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 = testDb.prepare(
|
|
'SELECT * FROM reservations WHERE accommodation_id = ?'
|
|
).get(res.body.accommodation.id) as any;
|
|
expect(reservation).toBeDefined();
|
|
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 = testDb.prepare(
|
|
'SELECT id FROM reservations WHERE accommodation_id = ?'
|
|
).get(accommodationId) as any;
|
|
expect(reservationBefore).toBeDefined();
|
|
|
|
const deleteRes = await request(app)
|
|
.delete(`/api/trips/${trip.id}/accommodations/${accommodationId}`)
|
|
.set('Cookie', authCookie(user.id));
|
|
expect(deleteRes.status).toBe(200);
|
|
|
|
const reservationAfter = testDb.prepare(
|
|
'SELECT id FROM reservations WHERE id = ?'
|
|
).get(reservationBefore.id);
|
|
expect(reservationAfter).toBeUndefined();
|
|
});
|
|
|
|
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 = testDb.prepare(
|
|
'SELECT id FROM day_accommodations WHERE trip_id = ?'
|
|
).get(trip.id) as any;
|
|
expect(accommodationId).toBeDefined();
|
|
|
|
const budgetBefore = testDb.prepare(
|
|
'SELECT id FROM budget_items WHERE trip_id = ?'
|
|
).get(trip.id);
|
|
expect(budgetBefore).toBeDefined();
|
|
|
|
// 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);
|
|
|
|
const budgetAfter = testDb.prepare(
|
|
'SELECT id FROM budget_items WHERE trip_id = ?'
|
|
).get(trip.id);
|
|
expect(budgetAfter).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// 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 = testDb.prepare('SELECT title FROM days WHERE id = ?').get(day.id) as { title: string };
|
|
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 = (testDb.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = ?').get(trip.id) as { n: number }).n;
|
|
|
|
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 = (testDb.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = ?').get(trip.id) as { n: number }).n;
|
|
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 = (testDb.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = ?').get(trip.id) as { n: number }).n;
|
|
|
|
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 = (testDb.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = ?').get(trip.id) as { n: number }).n;
|
|
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 = (testDb.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = ?').get(trip.id) as { n: number }).n;
|
|
|
|
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 = (testDb.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = ?').get(trip.id) as { n: number }).n;
|
|
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 = (testDb.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = ?').get(trip.id) as { n: number }).n;
|
|
|
|
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 = (testDb.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = ?').get(trip.id) as { n: number }).n;
|
|
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 = (testDb.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = ?').get(trip.id) as { n: number }).n;
|
|
|
|
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 = (testDb.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = ?').get(trip.id) as { n: number }).n;
|
|
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 = (testDb.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = ?').get(trip.id) as { n: number }).n;
|
|
|
|
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 = (testDb.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = ?').get(trip.id) as { n: number }).n;
|
|
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 = (testDb.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = ?').get(trip.id) as { n: number }).n;
|
|
|
|
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 = (testDb.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = ?').get(trip.id) as { n: number }).n;
|
|
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 = (testDb.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = ?').get(trip.id) as { n: number }).n;
|
|
|
|
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 = (testDb.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = ?').get(trip.id) as { n: number }).n;
|
|
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 = (testDb.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = ?').get(trip.id) as { n: number }).n;
|
|
|
|
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 = (testDb.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = ?').get(trip.id) as { n: number }).n;
|
|
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 = (testDb.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = ?').get(trip.id) as { n: number }).n;
|
|
|
|
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 = (testDb.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = ?').get(trip.id) as { n: number }).n;
|
|
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 = testDb.prepare('SELECT id, day_number, date FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number; day_number: number; date: string }[];
|
|
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' });
|
|
testDb.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?').run('2026-06-01T19:00:00', reservation.id);
|
|
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 = testDb.prepare('SELECT id, day_number, date FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number; day_number: number; date: string }[];
|
|
expect(after).toEqual(days);
|
|
// The stay is still d1 -> d2, never inverted.
|
|
const stayAfter = testDb.prepare('SELECT start_day_id, end_day_id FROM day_accommodations WHERE id = ?').get(stay.id) as { start_day_id: number; end_day_id: number };
|
|
expect(stayAfter).toEqual({ start_day_id: d1.id, end_day_id: d2.id });
|
|
// The reservation's stamped time is untouched.
|
|
const resAfter = testDb.prepare('SELECT reservation_time FROM reservations WHERE id = ?').get(reservation.id) as { reservation_time: string };
|
|
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 = testDb.prepare('SELECT id, day_number, date FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number; day_number: number; date: string }[];
|
|
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 = testDb.prepare('SELECT start_day_id, end_day_id FROM day_accommodations WHERE id = ?').get(stay.id) as { start_day_id: number; end_day_id: number };
|
|
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 = testDb.prepare('SELECT id, day_number, date FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number; day_number: number; date: string }[];
|
|
expect(daysAfter).toEqual(days);
|
|
});
|
|
});
|