Files
TREK/server/tests/integration/trips.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

1354 lines
57 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Trips API integration tests.
* Covers TRIP-001 through TRIP-022.
*/
import { buildApp } from '../../src/bootstrap';
import { db as testDb } from '../../src/db/database';
import { AppSettings } from '../../src/db/entities/AppSettings.entity';
import { BudgetCategoryOrder } from '../../src/db/entities/BudgetCategoryOrder.entity';
import { BudgetItems } from '../../src/db/entities/BudgetItems.entity';
import { DayAccommodations } from '../../src/db/entities/DayAccommodations.entity';
import { DayAssignments } from '../../src/db/entities/DayAssignments.entity';
import { DayNotes } from '../../src/db/entities/DayNotes.entity';
import { Days } from '../../src/db/entities/Days.entity';
import { PackingItems } from '../../src/db/entities/PackingItems.entity';
import { Places } from '../../src/db/entities/Places.entity';
import { Reservations } from '../../src/db/entities/Reservations.entity';
import { Tags } from '../../src/db/entities/Tags.entity';
import { TodoItems } from '../../src/db/entities/TodoItems.entity';
import { TripMembers } from '../../src/db/entities/TripMembers.entity';
import { Trips } from '../../src/db/entities/Trips.entity';
import { Users } from '../../src/db/entities/Users.entity';
import { invalidatePermissionsCache } from '../../src/nest/permissions/permissions-cache';
import { authCookie } from '../helpers/auth';
import {
createUser,
createAdmin,
createTrip,
addTripMember,
createPlace,
createReservation,
createTag,
createDayAccommodation,
createBudgetItem,
createPackingItem,
createDayNote,
createDayAssignment,
} from '../helpers/factories';
import type { FactoryOrm } from '../helpers/factories/context';
import { tagPlace } from '../helpers/factories/places';
import { countRows, findRow, findRows, insertRow, insertRows, updateRows } from '../helpers/factories/rows';
import { setAppSetting } from '../helpers/factories/settings';
import { readTripDays } from '../helpers/factories/trips';
import { readUser } from '../helpers/factories/users';
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';
// ─────────────────────────────────────────────────────────────────────────────
// Step 1: Bare 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;
let orm: FactoryOrm;
beforeAll(async () => {
nestApp = await buildApp();
app = nestApp.getHttpAdapter().getInstance();
orm = nestApp.get(MikroORM);
});
beforeEach(async () => {
resetTestDb(testDb);
await resetRateLimits(nestApp);
await invalidatePermissionsCache();
});
afterAll(async () => {
await nestApp.close();
testDb.close();
});
// ─────────────────────────────────────────────────────────────────────────────
// Create trip (TRIP-001, TRIP-002, TRIP-003)
// ─────────────────────────────────────────────────────────────────────────────
describe('Create trip', () => {
it('TRIP-001 — POST /api/trips with start_date/end_date returns 201 and auto-generates days', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.post('/api/trips')
.set('Cookie', authCookie(user.id))
.send({ title: 'Paris Adventure', start_date: '2026-06-01', end_date: '2026-06-05' });
expect(res.status).toBe(201);
expect(res.body.trip).toBeDefined();
expect(res.body.trip.title).toBe('Paris Adventure');
// Verify days were generated (5 days: Jun 1–5)
const days = await findRows(orm, Days, { trip: res.body.trip.id }, { date: 'asc' });
expect(days).toHaveLength(5);
expect(days[0].date).toBe('2026-06-01');
expect(days[4].date).toBe('2026-06-05');
});
it('TRIP-002 — POST /api/trips without dates returns 201 and creates 7 dateless placeholder days', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.post('/api/trips')
.set('Cookie', authCookie(user.id))
.send({ title: 'Open-ended Trip' });
expect(res.status).toBe(201);
expect(res.body.trip).toBeDefined();
expect(res.body.trip.start_date).toBeNull();
expect(res.body.trip.end_date).toBeNull();
// Should have 7 dateless placeholder days
const days = await readTripDays(orm, res.body.trip.id);
expect(days).toHaveLength(7);
expect(days[0].date).toBeNull();
});
it('TRIP-002b — POST /api/trips with day_count creates correct number of dateless days', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.post('/api/trips')
.set('Cookie', authCookie(user.id))
.send({ title: 'Custom Days Trip', day_count: 20 });
expect(res.status).toBe(201);
expect(res.body.trip.start_date).toBeNull();
expect(res.body.trip.day_count).toBe(20);
const days = await findRows(orm, Days, { trip: res.body.trip.id });
expect(days).toHaveLength(20);
});
it('TRIP-001 — POST /api/trips requires a title, returns 400 without one', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.post('/api/trips')
.set('Cookie', authCookie(user.id))
.send({ description: 'No title here' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/title/i);
});
it('TRIP-001 — POST /api/trips rejects end_date before start_date with 400', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.post('/api/trips')
.set('Cookie', authCookie(user.id))
.send({ title: 'Bad Dates', start_date: '2026-06-10', end_date: '2026-06-05' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/end date/i);
});
it('TRIP-003 — trip_create permission set to admin blocks regular user with 403', async () => {
const { user } = createUser(testDb);
// Restrict trip creation to admins only
await insertRow(orm, AppSettings, { key: 'perm_trip_create', value: 'admin' });
await invalidatePermissionsCache();
const res = await request(app)
.post('/api/trips')
.set('Cookie', authCookie(user.id))
.send({ title: 'Forbidden Trip' });
expect(res.status).toBe(403);
expect(res.body.error).toMatch(/permission/i);
});
it('TRIP-003 — trip_create permission set to admin allows admin user', async () => {
const { user: admin } = createAdmin(testDb);
await insertRow(orm, AppSettings, { key: 'perm_trip_create', value: 'admin' });
await invalidatePermissionsCache();
const res = await request(app).post('/api/trips').set('Cookie', authCookie(admin.id)).send({ title: 'Admin Trip' });
expect(res.status).toBe(201);
});
it('TRIP-001 — unauthenticated POST /api/trips returns 401', async () => {
const res = await request(app).post('/api/trips').send({ title: 'No Auth' });
expect(res.status).toBe(401);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// List trips (TRIP-004, TRIP-005)
// ─────────────────────────────────────────────────────────────────────────────
describe('List trips', () => {
it('TRIP-004 — GET /api/trips returns own trips and member trips, not other users trips', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const { user: stranger } = createUser(testDb);
const ownTrip = createTrip(testDb, owner.id, { title: "Owner's Trip" });
const memberTrip = createTrip(testDb, stranger.id, { title: "Stranger's Trip (member)" });
createTrip(testDb, stranger.id, { title: "Stranger's Private Trip" });
// Add member to one of stranger's trips
addTripMember(testDb, memberTrip.id, member.id);
const ownerRes = await request(app).get('/api/trips').set('Cookie', authCookie(owner.id));
expect(ownerRes.status).toBe(200);
const ownerTripIds = ownerRes.body.trips.map((t: any) => t.id);
expect(ownerTripIds).toContain(ownTrip.id);
expect(ownerTripIds).not.toContain(memberTrip.id);
const memberRes = await request(app).get('/api/trips').set('Cookie', authCookie(member.id));
expect(memberRes.status).toBe(200);
const memberTripIds = memberRes.body.trips.map((t: any) => t.id);
expect(memberTripIds).toContain(memberTrip.id);
expect(memberTripIds).not.toContain(ownTrip.id);
});
it('TRIP-005 — GET /api/trips excludes archived trips by default', async () => {
const { user } = createUser(testDb);
const activeTrip = createTrip(testDb, user.id, { title: 'Active Trip' });
const archivedTrip = createTrip(testDb, user.id, { title: 'Archived Trip' });
// Archive the second trip directly in the DB
await updateRows(orm, Trips, { id: archivedTrip.id }, { is_archived: 1 });
const res = await request(app).get('/api/trips').set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
const tripIds = res.body.trips.map((t: any) => t.id);
expect(tripIds).toContain(activeTrip.id);
expect(tripIds).not.toContain(archivedTrip.id);
});
it('TRIP-005 — GET /api/trips?archived=1 returns only archived trips', async () => {
const { user } = createUser(testDb);
const activeTrip = createTrip(testDb, user.id, { title: 'Active Trip' });
const archivedTrip = createTrip(testDb, user.id, { title: 'Archived Trip' });
await updateRows(orm, Trips, { id: archivedTrip.id }, { is_archived: 1 });
const res = await request(app).get('/api/trips?archived=1').set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
const tripIds = res.body.trips.map((t: any) => t.id);
expect(tripIds).toContain(archivedTrip.id);
expect(tripIds).not.toContain(activeTrip.id);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Get trip (TRIP-006, TRIP-007, TRIP-016, TRIP-017)
// ─────────────────────────────────────────────────────────────────────────────
describe('Get trip', () => {
it('TRIP-006 — GET /api/trips/:id for own trip returns 200 with full trip object', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'My Trip', description: 'A lovely trip' });
const res = await request(app).get(`/api/trips/${trip.id}`).set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.trip).toBeDefined();
expect(res.body.trip.id).toBe(trip.id);
expect(res.body.trip.title).toBe('My Trip');
expect(res.body.trip.is_owner).toBe(1);
});
it('TRIP-007 — GET /api/trips/:id for another users trip returns 404', async () => {
const { user: owner } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: "Owner's Trip" });
const res = await request(app).get(`/api/trips/${trip.id}`).set('Cookie', authCookie(other.id));
expect(res.status).toBe(404);
expect(res.body.error).toMatch(/not found/i);
});
it('TRIP-017 — Member can access trip → 200', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: 'Shared Trip' });
addTripMember(testDb, trip.id, member.id);
const res = await request(app).get(`/api/trips/${trip.id}`).set('Cookie', authCookie(member.id));
expect(res.status).toBe(200);
expect(res.body.trip.id).toBe(trip.id);
expect(res.body.trip.is_owner).toBe(0);
});
it('TRIP-006 — GET /api/trips/:id for non-existent trip returns 404', async () => {
const { user } = createUser(testDb);
const res = await request(app).get('/api/trips/999999').set('Cookie', authCookie(user.id));
expect(res.status).toBe(404);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Update trip (TRIP-008, TRIP-009, TRIP-010)
// ─────────────────────────────────────────────────────────────────────────────
describe('Update trip', () => {
it('TRIP-008 — PUT /api/trips/:id updates title and description for owner → 200', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Original Title' });
const res = await request(app)
.put(`/api/trips/${trip.id}`)
.set('Cookie', authCookie(user.id))
.send({ title: 'Updated Title', description: 'New description' });
expect(res.status).toBe(200);
expect(res.body.trip.title).toBe('Updated Title');
expect(res.body.trip.description).toBe('New description');
});
it('TRIP-009 — Archive trip (PUT with is_archived:true) removes it from normal list', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'To Archive' });
const archiveRes = await request(app)
.put(`/api/trips/${trip.id}`)
.set('Cookie', authCookie(user.id))
.send({ is_archived: true });
expect(archiveRes.status).toBe(200);
expect(archiveRes.body.trip.is_archived).toBe(1);
// Should not appear in the normal list
const listRes = await request(app).get('/api/trips').set('Cookie', authCookie(user.id));
const tripIds = listRes.body.trips.map((t: any) => t.id);
expect(tripIds).not.toContain(trip.id);
});
it('TRIP-009 — Unarchive trip reappears in normal list', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Archived Trip' });
// Archive it first
await updateRows(orm, Trips, { id: trip.id }, { is_archived: 1 });
// Unarchive via API
const unarchiveRes = await request(app)
.put(`/api/trips/${trip.id}`)
.set('Cookie', authCookie(user.id))
.send({ is_archived: false });
expect(unarchiveRes.status).toBe(200);
expect(unarchiveRes.body.trip.is_archived).toBe(0);
// Should appear in the normal list again
const listRes = await request(app).get('/api/trips').set('Cookie', authCookie(user.id));
const tripIds = listRes.body.trips.map((t: any) => t.id);
expect(tripIds).toContain(trip.id);
});
it('TRIP-010 — Archive by trip member is denied when trip_archive is set to trip_owner', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: 'Members Trip' });
addTripMember(testDb, trip.id, member.id);
// Restrict archiving to trip_owner only (this is actually the default, but set explicitly)
await setAppSetting(orm, 'perm_trip_archive', 'trip_owner');
await invalidatePermissionsCache();
const res = await request(app)
.put(`/api/trips/${trip.id}`)
.set('Cookie', authCookie(member.id))
.send({ is_archived: true });
expect(res.status).toBe(403);
expect(res.body.error).toMatch(/permission/i);
});
it('TRIP-008 — Member cannot edit trip title when trip_edit is set to trip_owner', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: 'Original' });
addTripMember(testDb, trip.id, member.id);
// Default trip_edit is trip_owner — members should be blocked
await setAppSetting(orm, 'perm_trip_edit', 'trip_owner');
await invalidatePermissionsCache();
const res = await request(app)
.put(`/api/trips/${trip.id}`)
.set('Cookie', authCookie(member.id))
.send({ title: 'Hacked Title' });
expect(res.status).toBe(403);
});
it('TRIP-008 — PUT /api/trips/:id returns 404 for non-existent trip', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.put('/api/trips/999999')
.set('Cookie', authCookie(user.id))
.send({ title: 'Ghost Update' });
expect(res.status).toBe(404);
});
it('TRIP-023 — Shifting trip date range preserves day assignments positionally', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2026-08-01', end_date: '2026-08-05' });
const days = await readTripDays(orm, trip.id);
expect(days).toHaveLength(5);
const place = createPlace(testDb, trip.id);
const assignment = createDayAssignment(testDb, days[0].id, place.id);
const note = createDayNote(testDb, days[1].id, trip.id, { text: 'pack sunscreen' });
// Shift forward 10 days (zero overlap with original range)
const res = await request(app)
.put(`/api/trips/${trip.id}`)
.set('Cookie', authCookie(user.id))
.send({ start_date: '2026-08-11', end_date: '2026-08-15' });
expect(res.status).toBe(200);
const daysAfter = await readTripDays(orm, trip.id);
expect(daysAfter).toHaveLength(5);
expect(daysAfter.map((d) => d.date)).toEqual([
'2026-08-11',
'2026-08-12',
'2026-08-13',
'2026-08-14',
'2026-08-15',
]);
const assignmentsAfter = await findRow(orm, DayAssignments, { id: assignment.id });
expect(assignmentsAfter).not.toBeNull();
expect(assignmentsAfter!.day_id).toBe(daysAfter[0].id);
const notesAfter = await findRow(orm, DayNotes, { id: note.id });
expect(notesAfter).not.toBeNull();
expect(notesAfter!.day_id).toBe(daysAfter[1].id);
});
it('TRIP-024 — Shrinking trip date range deletes overflow days and their content', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2026-09-01', end_date: '2026-09-05' });
const days = await readTripDays(orm, trip.id);
const place = createPlace(testDb, trip.id);
const a4 = createDayAssignment(testDb, days[3].id, place.id);
const a5 = createDayAssignment(testDb, days[4].id, place.id);
// Shrink from 5 to 3 days
const res = await request(app)
.put(`/api/trips/${trip.id}`)
.set('Cookie', authCookie(user.id))
.send({ start_date: '2026-09-01', end_date: '2026-09-03' });
expect(res.status).toBe(200);
const daysAfter = await readTripDays(orm, trip.id);
expect(daysAfter).toHaveLength(3);
expect(daysAfter.every((d) => d.date !== null)).toBe(true);
// Overflow days and their assignments deleted
const all = await findRows(orm, DayAssignments, { id: { $in: [a4.id, a5.id] } });
expect(all).toHaveLength(0);
});
it('TRIP-028: An earlier end takes the last day with its stay, while the hotel booking of that stay remains', async () => {
// Pins what the trip dialog warns about before such a save: the stay goes as
// a whole, even with nights still inside the trip, and its booking does not.
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2026-09-01', end_date: '2026-09-06' });
const days = await readTripDays(orm, trip.id);
const place = createPlace(testDb, trip.id, { name: 'Harbour Hotel' });
const stay = createDayAccommodation(testDb, trip.id, place.id, days[3].id, days[5].id);
const booking = createReservation(testDb, trip.id, {
title: 'Harbour Hotel booking',
type: 'hotel',
day_id: days[3].id,
});
await updateRows(orm, Reservations, { id: booking.id }, { accommodation_id: String(stay.id) });
const sightseeing = createDayAssignment(testDb, days[5].id, place.id);
const res = await request(app)
.put(`/api/trips/${trip.id}`)
.set('Cookie', authCookie(user.id))
.send({ start_date: '2026-09-01', end_date: '2026-09-05' });
expect(res.status).toBe(200);
const daysAfter = await readTripDays(orm, trip.id);
expect(daysAfter.map((d) => d.id)).toEqual(days.slice(0, 5).map((d) => d.id));
expect(daysAfter.at(-1)!.date).toBe('2026-09-05');
expect(await findRow(orm, DayAccommodations, { id: stay.id })).toBeNull();
expect(await findRow(orm, DayAssignments, { id: sightseeing.id })).toBeNull();
expect((await findRow(orm, Reservations, { id: booking.id }))?.title).toBe('Harbour Hotel booking');
expect(await findRow(orm, Places, { id: place.id })).not.toBeNull();
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Delete trip (TRIP-018, TRIP-019, TRIP-022)
// ─────────────────────────────────────────────────────────────────────────────
describe('Delete trip', () => {
it('TRIP-018 — DELETE /api/trips/:id by owner returns 200 and trip is no longer accessible', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'To Delete' });
const deleteRes = await request(app).delete(`/api/trips/${trip.id}`).set('Cookie', authCookie(user.id));
expect(deleteRes.status).toBe(200);
expect(deleteRes.body.success).toBe(true);
// Trip should no longer be accessible
const getRes = await request(app).get(`/api/trips/${trip.id}`).set('Cookie', authCookie(user.id));
expect(getRes.status).toBe(404);
});
it('TRIP-019 — Regular user cannot delete another users trip → 404', async () => {
const { user: owner } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: "Owner's Trip" });
const res = await request(app).delete(`/api/trips/${trip.id}`).set('Cookie', authCookie(other.id));
// 404, not 403: someone with no access at all must not be able to tell an
// existing trip from a missing one by walking sequential ids. A member who
// simply lacks trip_delete still gets 403 — see the case below.
expect(res.status).toBe(404);
// Trip still exists
const tripInDb = await findRow(orm, Trips, { id: trip.id });
expect(tripInDb).not.toBeNull();
});
it('TRIP-019 — Trip member cannot delete trip → 403', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: 'Shared Trip' });
addTripMember(testDb, trip.id, member.id);
const res = await request(app).delete(`/api/trips/${trip.id}`).set('Cookie', authCookie(member.id));
expect(res.status).toBe(403);
expect(res.body.error).toMatch(/permission/i);
});
it('TRIP-022 — Trip with places and reservations can be deleted (cascade)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip With Data' });
// Add associated data
createPlace(testDb, trip.id, { name: 'Eiffel Tower' });
createReservation(testDb, trip.id, { title: 'Hotel Booking', type: 'hotel' });
const deleteRes = await request(app).delete(`/api/trips/${trip.id}`).set('Cookie', authCookie(user.id));
expect(deleteRes.status).toBe(200);
expect(deleteRes.body.success).toBe(true);
// Verify cascade: places and reservations should be gone
const places = await findRows(orm, Places, { trip: trip.id });
expect(places).toHaveLength(0);
const reservations = await findRows(orm, Reservations, { trip: trip.id });
expect(reservations).toHaveLength(0);
});
it('TRIP-018 — Admin can delete another users trip', async () => {
const { user: admin } = createAdmin(testDb);
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: "User's Trip" });
const res = await request(app).delete(`/api/trips/${trip.id}`).set('Cookie', authCookie(admin.id));
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
});
it('TRIP-018 — DELETE /api/trips/:id for non-existent trip returns 404', async () => {
const { user } = createUser(testDb);
const res = await request(app).delete('/api/trips/999999').set('Cookie', authCookie(user.id));
expect(res.status).toBe(404);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Members (TRIP-013, TRIP-014, TRIP-015)
// ─────────────────────────────────────────────────────────────────────────────
describe('Trip members', () => {
it('TRIP-015 — GET /api/trips/:id/members returns owner and members list', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: 'Team Trip' });
addTripMember(testDb, trip.id, member.id);
const res = await request(app).get(`/api/trips/${trip.id}/members`).set('Cookie', authCookie(owner.id));
expect(res.status).toBe(200);
expect(res.body.owner).toBeDefined();
expect(res.body.owner.id).toBe(owner.id);
expect(Array.isArray(res.body.members)).toBe(true);
expect(res.body.members.some((m: any) => m.id === member.id)).toBe(true);
expect(res.body.current_user_id).toBe(owner.id);
});
it('TRIP-013 — POST /api/trips/:id/members adds a member by email → 201', async () => {
const { user: owner } = createUser(testDb);
const { user: invitee } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: 'Team Trip' });
const res = await request(app)
.post(`/api/trips/${trip.id}/members`)
.set('Cookie', authCookie(owner.id))
.send({ identifier: invitee.email });
expect(res.status).toBe(201);
expect(res.body.member).toBeDefined();
expect(res.body.member.email).toBe(invitee.email);
expect(res.body.member.role).toBe('member');
// Verify in DB
const dbEntry = await findRow(orm, TripMembers, { trip: trip.id, user: invitee.id });
expect(dbEntry).not.toBeNull();
});
it('TRIP-013 — POST /api/trips/:id/members adds a member by username → 201', async () => {
const { user: owner } = createUser(testDb);
const { user: invitee } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: 'Team Trip' });
const res = await request(app)
.post(`/api/trips/${trip.id}/members`)
.set('Cookie', authCookie(owner.id))
.send({ identifier: invitee.username });
expect(res.status).toBe(201);
expect(res.body.member.id).toBe(invitee.id);
});
it('TRIP-013 — Adding a non-existent user returns 404', async () => {
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: 'Team Trip' });
const res = await request(app)
.post(`/api/trips/${trip.id}/members`)
.set('Cookie', authCookie(owner.id))
.send({ identifier: 'nobody@nowhere.example.com' });
expect(res.status).toBe(404);
expect(res.body.error).toMatch(/user not found/i);
});
it('TRIP-013 — Adding a user who is already a member returns 400', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: 'Team Trip' });
addTripMember(testDb, trip.id, member.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/members`)
.set('Cookie', authCookie(owner.id))
.send({ identifier: member.email });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/already/i);
});
it('TRIP-013 — Adding a member by whitespace-padded username resolves correctly → 201', async () => {
const { user: owner } = createUser(testDb);
const { user: invitee } = createUser(testDb, { username: 'paddeduser' });
const trip = createTrip(testDb, owner.id, { title: 'Padded Trip' });
const res = await request(app)
.post(`/api/trips/${trip.id}/members`)
.set('Cookie', authCookie(owner.id))
.send({ identifier: ' paddeduser ' });
expect(res.status).toBe(201);
expect(res.body.member.id).toBe(invitee.id);
});
it('TRIP-014 — DELETE /api/trips/:id/members/:userId removes a member → 200', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: 'Team Trip' });
addTripMember(testDb, trip.id, member.id);
const res = await request(app)
.delete(`/api/trips/${trip.id}/members/${member.id}`)
.set('Cookie', authCookie(owner.id));
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
// Verify removal in DB
const dbEntry = await findRow(orm, TripMembers, { trip: trip.id, user: member.id });
expect(dbEntry).toBeNull();
});
it('TRIP-014 — Member can remove themselves from a trip → 200', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: 'Team Trip' });
addTripMember(testDb, trip.id, member.id);
const res = await request(app)
.delete(`/api/trips/${trip.id}/members/${member.id}`)
.set('Cookie', authCookie(member.id));
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
});
it('TRIP-TRANSFER-001 — owner hands the trip to a member; roles swap (#973)', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: 'Team Trip' });
addTripMember(testDb, trip.id, member.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/transfer`)
.set('Cookie', authCookie(owner.id))
.send({ newOwnerId: member.id });
expect(res.status).toBe(201);
expect(res.body.success).toBe(true);
const row = await findRow(orm, Trips, { id: trip.id });
expect(row?.user_id).toBe(member.id);
const memberRows = (await findRows(orm, TripMembers, { trip: trip.id })).map((r) => r.user_id);
expect(memberRows).toContain(owner.id);
expect(memberRows).not.toContain(member.id);
});
it('TRIP-TRANSFER-002 — a non-owner cannot transfer ownership → 403', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: 'Team Trip' });
addTripMember(testDb, trip.id, member.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/transfer`)
.set('Cookie', authCookie(member.id))
.send({ newOwnerId: member.id });
expect(res.status).toBe(403);
});
it('TRIP-TRANSFER-003 — cannot transfer to a non-member → 400', async () => {
const { user: owner } = createUser(testDb);
const { user: stranger } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: 'Team Trip' });
const res = await request(app)
.post(`/api/trips/${trip.id}/transfer`)
.set('Cookie', authCookie(owner.id))
.send({ newOwnerId: stranger.id });
expect(res.status).toBe(400);
});
it('TRIP-GUEST-001 — owner creates a guest; it appears as a member and is shielded from auth (#1362)', async () => {
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: 'Camping' });
const created = await request(app)
.post(`/api/trips/${trip.id}/guests`)
.set('Cookie', authCookie(owner.id))
.send({ name: 'Grandma' });
expect(created.status).toBe(201);
expect(created.body.member.is_guest).toBe(true);
expect(created.body.member.username).toBe('Grandma');
const guestId = created.body.member.id;
// Surfaces in the members list every assignment picker consumes.
const members = await request(app).get(`/api/trips/${trip.id}/members`).set('Cookie', authCookie(owner.id));
const guest = members.body.members.find((m: any) => m.id === guestId);
expect(guest).toBeTruthy();
expect(guest.is_guest).toBe(true);
// NOT in the global user directory (the member-add picker source).
const dir = await request(app).get('/api/auth/users').set('Cookie', authCookie(owner.id));
expect(dir.body.users.some((u: any) => u.id === guestId)).toBe(false);
// The synthetic email can never authenticate (resolves as an unknown email).
const email = (await readUser(orm, guestId)).email;
const login = await request(app).post('/api/auth/login').send({ email, password: 'anything' });
expect(login.status).toBe(401);
});
it('TRIP-GUEST-004 — the same guest name is allowed on two trips (no "Name 2", #1446)', async () => {
const { user: owner } = createUser(testDb);
const tripA = createTrip(testDb, owner.id, { title: 'Trip A' });
const tripB = createTrip(testDb, owner.id, { title: 'Trip B' });
const addJake = (tripId: number) =>
request(app).post(`/api/trips/${tripId}/guests`).set('Cookie', authCookie(owner.id)).send({ name: 'Jake' });
const a = await addJake(tripA.id);
const b = await addJake(tripB.id);
expect(a.status).toBe(201);
expect(b.status).toBe(201);
// both keep the plain name — the second is NOT auto-renamed to "Jake 2"
expect(a.body.member.username).toBe('Jake');
expect(b.body.member.username).toBe('Jake');
expect(b.body.member.id).not.toBe(a.body.member.id);
// and each trip's member list shows "Jake" (not the internal uuid handle)
const membersB = await request(app).get(`/api/trips/${tripB.id}/members`).set('Cookie', authCookie(owner.id));
expect(membersB.body.members.find((m: any) => m.id === b.body.member.id).username).toBe('Jake');
});
it('TRIP-GUEST-002 — guest CRUD is owner-only', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: 'Camping' });
addTripMember(testDb, trip.id, member.id);
// A non-owner member cannot create a guest.
const denied = await request(app)
.post(`/api/trips/${trip.id}/guests`)
.set('Cookie', authCookie(member.id))
.send({ name: 'Nope' });
expect(denied.status).toBe(403);
const created = await request(app)
.post(`/api/trips/${trip.id}/guests`)
.set('Cookie', authCookie(owner.id))
.send({ name: 'Kid' });
const guestId = created.body.member.id;
// Rename + delete by the owner.
const renamed = await request(app)
.put(`/api/trips/${trip.id}/guests/${guestId}`)
.set('Cookie', authCookie(owner.id))
.send({ name: 'Junior' });
expect(renamed.status).toBe(200);
// #1446: the human name lives in display_name now (username is a non-shown uuid handle)
expect((await readUser(orm, guestId)).display_name).toBe('Junior');
const removed = await request(app)
.delete(`/api/trips/${trip.id}/guests/${guestId}`)
.set('Cookie', authCookie(owner.id));
expect(removed.status).toBe(200);
expect(await findRow(orm, Users, { id: guestId })).toBeNull();
});
it('TRIP-GUEST-003 — a guest cannot be invited as a member to any trip (#1362)', async () => {
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: 'Camping' });
const otherTrip = createTrip(testDb, owner.id, { title: 'Other' });
const created = await request(app)
.post(`/api/trips/${trip.id}/guests`)
.set('Cookie', authCookie(owner.id))
.send({ name: 'Eve' });
const email = (await readUser(orm, created.body.member.id)).email;
const invite = await request(app)
.post(`/api/trips/${otherTrip.id}/members`)
.set('Cookie', authCookie(owner.id))
.send({ identifier: email });
expect(invite.status).toBe(404);
});
it('TRIP-013 — Non-owner member cannot add other members when member_manage is trip_owner', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const { user: invitee } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: 'Team Trip' });
addTripMember(testDb, trip.id, member.id);
// Restrict member management to trip_owner (default)
await setAppSetting(orm, 'perm_member_manage', 'trip_owner');
await invalidatePermissionsCache();
const res = await request(app)
.post(`/api/trips/${trip.id}/members`)
.set('Cookie', authCookie(member.id))
.send({ identifier: invitee.email });
expect(res.status).toBe(403);
expect(res.body.error).toMatch(/permission/i);
});
it('TRIP-015 — Non-member cannot list trip members → 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}/members`).set('Cookie', authCookie(stranger.id));
expect(res.status).toBe(404);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Copy trip (TRIP-023, TRIP-024)
// ─────────────────────────────────────────────────────────────────────────────
describe('Copy trip', () => {
it('TRIP-023 — POST /api/trips/:id/copy creates a duplicate trip with 201', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Original Trip', description: 'Desc' });
const res = await request(app).post(`/api/trips/${trip.id}/copy`).set('Cookie', authCookie(user.id)).send({});
expect(res.status).toBe(201);
expect(res.body.trip).toBeDefined();
expect(res.body.trip.id).not.toBe(trip.id);
expect(res.body.trip.title).toBe('Original Trip');
});
it('TRIP-023 — copy accepts a custom title for the new trip', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Source' });
const res = await request(app)
.post(`/api/trips/${trip.id}/copy`)
.set('Cookie', authCookie(user.id))
.send({ title: 'Custom Copy' });
expect(res.status).toBe(201);
expect(res.body.trip.title).toBe('Custom Copy');
});
it('TRIP-023 — copied trip belongs to the requesting user', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: 'Shared Trip' });
addTripMember(testDb, trip.id, member.id);
const res = await request(app).post(`/api/trips/${trip.id}/copy`).set('Cookie', authCookie(member.id)).send({});
expect(res.status).toBe(201);
const newTrip = await findRow(orm, Trips, { id: res.body.trip.id });
expect(newTrip?.user_id).toBe(member.id);
});
it('TRIP-024 — non-member cannot copy a trip → 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).post(`/api/trips/${trip.id}/copy`).set('Cookie', authCookie(stranger.id)).send({});
expect(res.status).toBe(404);
});
it('TRIP-024 — copy of non-existent trip returns 404', async () => {
const { user } = createUser(testDb);
const res = await request(app).post('/api/trips/999999/copy').set('Cookie', authCookie(user.id)).send({});
expect(res.status).toBe(404);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// ICS export (TRIP-025)
// ─────────────────────────────────────────────────────────────────────────────
describe('ICS export', () => {
it('TRIP-025 — GET /api/trips/:id/export.ics returns text/calendar content', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Calendar Trip' });
const res = await request(app).get(`/api/trips/${trip.id}/export.ics`).set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.headers['content-type']).toMatch(/text\/calendar/);
expect(res.text).toContain('BEGIN:VCALENDAR');
expect(res.text).toContain('END:VCALENDAR');
});
it('TRIP-025 — non-member cannot export ICS → 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}/export.ics`).set('Cookie', authCookie(stranger.id));
expect(res.status).toBe(404);
});
it('TRIP-025 — unauthenticated export 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}/export.ics`);
expect(res.status).toBe(401);
});
it('TRIP-025b — the trip payload never carries feed_token, not even for the owner', async () => {
// feed_token is the sole credential for the anonymous ICS feed. TRIP_SELECT
// reads `t.*`, so without the blanking it ships in every trip response and
// gating /feed/token on share_manage would achieve nothing: any member
// could read the value straight out of GET /api/trips/:id.
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip' });
await updateRows(orm, Trips, { id: trip.id }, { feed_token: 'secret-feed-token' });
const one = await request(app).get(`/api/trips/${trip.id}`).set('Cookie', authCookie(user.id));
expect(one.status).toBe(200);
expect(one.body.trip.feed_token ?? null).toBeNull();
expect(JSON.stringify(one.body)).not.toContain('secret-feed-token');
const list = await request(app).get('/api/trips').set('Cookie', authCookie(user.id));
expect(JSON.stringify(list.body)).not.toContain('secret-feed-token');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Copy trip with full data (covers loop bodies in the copy transaction)
// ─────────────────────────────────────────────────────────────────────────────
describe('Copy trip with data', () => {
it('TRIP-026 — copy preserves days, places, tags, assignments, accommodations, reservations, budget, packing, notes', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, {
title: 'Data-Rich Trip',
start_date: '2025-09-01',
end_date: '2025-09-03',
});
const days = await readTripDays(orm, trip.id);
expect(days.length).toBe(3);
// Place with a tag
const place = createPlace(testDb, trip.id, { name: 'Tower Bridge' });
const tag = createTag(testDb, user.id, { name: 'Landmark' });
await tagPlace(orm, place.id, [tag.id]);
// Day assignment
await insertRow(orm, DayAssignments, {
day: days[0].id,
place: place.id,
order_index: 0,
notes: 'Visit in morning',
});
// Accommodation spanning days 0→1
createDayAccommodation(testDb, trip.id, place.id, days[0].id, days[1].id);
// Reservation on day 0
createReservation(testDb, trip.id, { title: 'Flight Out', type: 'flight', day_id: days[0].id });
// Budget item
createBudgetItem(testDb, trip.id, { name: 'Flights', total_price: 400 });
// Packing item
createPackingItem(testDb, trip.id, { name: 'Toothbrush' });
// Day note
createDayNote(testDb, days[0].id, trip.id, { text: 'Pack early!' });
const res = await request(app)
.post(`/api/trips/${trip.id}/copy`)
.set('Cookie', authCookie(user.id))
.send({ title: 'Data-Rich Trip (Copy)' });
expect(res.status).toBe(201);
const newId = res.body.trip.id;
expect(newId).not.toBe(trip.id);
// Days copied
const newDays = await readTripDays(orm, newId);
expect(newDays).toHaveLength(3);
// Place copied
const newPlaces = await findRows(orm, Places, { trip: newId });
expect(newPlaces).toHaveLength(1);
expect(newPlaces[0].name).toBe('Tower Bridge');
// Place tag copied
// One place_tags row per (place, tag) pair on the copy's places.
let newTags = 0;
for (const p of newPlaces) newTags += await countRows(orm, Tags, { place_tags_inverse: p.id });
expect(newTags).toBe(1);
// Assignment copied
const newAssignments = await findRows(orm, DayAssignments, { day: { trip: newId } });
expect(newAssignments).toHaveLength(1);
// Accommodation copied
const newAccom = await findRows(orm, DayAccommodations, { trip: newId });
expect(newAccom).toHaveLength(1);
// Reservation copied
const newResv = await findRows(orm, Reservations, { trip: newId });
expect(newResv).toHaveLength(1);
// Budget copied
const newBudget = await findRows(orm, BudgetItems, { trip: newId });
expect(newBudget).toHaveLength(1);
// Packing copied (checked reset to 0)
const newPacking = await findRows(orm, PackingItems, { trip: newId });
expect(newPacking).toHaveLength(1);
expect(newPacking[0].checked).toBe(0);
// Day note copied
const newNotes = await findRows(orm, DayNotes, { trip: newId });
expect(newNotes).toHaveLength(1);
expect(newNotes[0].text).toBe('Pack early!');
});
it('TRIP-027 — copy preserves todos (unchecked, unassigned) and budget category order', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Todo Trip' });
// Two todos: one checked and assigned — both should arrive unchecked and unassigned
await insertRow(orm, TodoItems, {
trip: trip.id,
name: 'Buy tickets',
checked: 0,
category: 'Transport',
sort_order: 0,
due_date: '2026-06-01',
description: 'Check Ryanair',
priority: 1,
});
await insertRow(orm, TodoItems, {
trip: trip.id,
name: 'Book hotel',
checked: 1,
category: 'Accommodation',
sort_order: 1,
assignedUser: user.id,
priority: 0,
});
// Two budget category order rows
await insertRows(orm, BudgetCategoryOrder, [
{ trip: trip.id, category: 'Transport', sort_order: 0 },
{ trip: trip.id, category: 'Accommodation', sort_order: 1 },
]);
const res = await request(app)
.post(`/api/trips/${trip.id}/copy`)
.set('Cookie', authCookie(user.id))
.send({ title: 'Todo Trip (Copy)' });
expect(res.status).toBe(201);
const newId = res.body.trip.id;
// Todos copied with checked reset and assigned_user_id nulled
const newTodos = await findRows(orm, TodoItems, { trip: newId }, { sort_order: 'asc' });
expect(newTodos).toHaveLength(2);
expect(newTodos[0].name).toBe('Buy tickets');
expect(newTodos[0].category).toBe('Transport');
expect(newTodos[0].checked).toBe(0);
expect(newTodos[0].assigned_user_id).toBeNull();
expect(newTodos[0].due_date).toBe('2026-06-01');
expect(newTodos[0].description).toBe('Check Ryanair');
expect(newTodos[0].priority).toBe(1);
expect(newTodos[1].name).toBe('Book hotel');
expect(newTodos[1].checked).toBe(0);
expect(newTodos[1].assigned_user_id).toBeNull();
// Budget category order copied
const newOrder = await findRows(orm, BudgetCategoryOrder, { trip: newId }, { sort_order: 'asc' });
expect(newOrder).toHaveLength(2);
expect(newOrder[0]).toMatchObject({ category: 'Transport', sort_order: 0 });
expect(newOrder[1]).toMatchObject({ category: 'Accommodation', sort_order: 1 });
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Bundle endpoint — GET /api/trips/:id/bundle
// ─────────────────────────────────────────────────────────────────────────────
describe('Trip bundle', () => {
it('BUNDLE-001 — returns all sub-collections for owned trip', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2026-07-01', end_date: '2026-07-03' });
const res = await request(app).get(`/api/trips/${trip.id}/bundle`).set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.trip).toBeDefined();
expect(res.body.trip.id).toBe(trip.id);
expect(Array.isArray(res.body.days)).toBe(true);
expect(res.body.days).toHaveLength(3);
expect(Array.isArray(res.body.places)).toBe(true);
expect(Array.isArray(res.body.packingItems)).toBe(true);
expect(Array.isArray(res.body.todoItems)).toBe(true);
expect(Array.isArray(res.body.budgetItems)).toBe(true);
expect(Array.isArray(res.body.reservations)).toBe(true);
expect(Array.isArray(res.body.files)).toBe(true);
});
it('BUNDLE-002 — returns 404 for trip that does not exist', async () => {
const { user } = createUser(testDb);
const res = await request(app).get('/api/trips/999999/bundle').set('Cookie', authCookie(user.id));
expect(res.status).toBe(404);
});
it('BUNDLE-003 — returns 404 when user has no access to trip', async () => {
const { user: owner } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const res = await request(app).get(`/api/trips/${trip.id}/bundle`).set('Cookie', authCookie(other.id));
expect(res.status).toBe(404);
});
it('BUNDLE-004 — members can fetch bundle', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
await insertRow(orm, TripMembers, { trip: trip.id, user: member.id });
const res = await request(app).get(`/api/trips/${trip.id}/bundle`).set('Cookie', authCookie(member.id));
expect(res.status).toBe(200);
expect(res.body.trip.id).toBe(trip.id);
});
it('BUNDLE-005 — returns 401 when unauthenticated', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app).get(`/api/trips/${trip.id}/bundle`);
expect(res.status).toBe(401);
});
it("BUNDLE-006 — packingItems are scoped to the viewer: another member's private item stays out (#858)", async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
await insertRow(orm, TripMembers, { trip: trip.id, user: member.id });
await insertRow(orm, PackingItems, { trip: trip.id, name: 'Tent', checked: 0, sort_order: 0 });
await insertRow(orm, PackingItems, {
trip: trip.id,
name: 'Secret gift',
checked: 0,
sort_order: 1,
is_private: 1,
owner: owner.id,
});
const ownerView = await request(app).get(`/api/trips/${trip.id}/bundle`).set('Cookie', authCookie(owner.id));
expect(ownerView.body.packingItems.map((i: { name: string }) => i.name).sort()).toEqual(['Secret gift', 'Tent']);
const memberView = await request(app).get(`/api/trips/${trip.id}/bundle`).set('Cookie', authCookie(member.id));
expect(memberView.body.packingItems.map((i: { name: string }) => i.name)).toEqual(['Tent']);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Cover upload parity (TRIP-P01…P05) — written BEFORE the storage-upload swap.
// ─────────────────────────────────────────────────────────────────────────────
describe('Trip cover upload parity', () => {
const fsMod = require('fs') as typeof import('fs');
const pathMod = require('path') as typeof import('path');
const FIXTURE_IMG = pathMod.join(__dirname, '../fixtures/small-image.jpg');
const coversDir = pathMod.join(__dirname, '../../uploads/covers');
afterAll(() => {
fsMod.rmSync(coversDir, { recursive: true, force: true });
});
it('TRIP-P01 — cover upload returns /uploads/covers/<uuid> and writes the file', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/cover`)
.set('Cookie', authCookie(user.id))
.attach('cover', FIXTURE_IMG, 'cover.png');
expect(res.status).toBe(201);
expect(res.body.cover_image).toMatch(/^\/uploads\/covers\/[0-9a-f-]{36}\.png$/);
const diskName = res.body.cover_image.replace('/uploads/covers/', '');
expect(fsMod.existsSync(pathMod.join(coversDir, diskName))).toBe(true);
});
it('TRIP-P02 — re-upload deletes the previous cover file', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const first = await request(app)
.post(`/api/trips/${trip.id}/cover`)
.set('Cookie', authCookie(user.id))
.attach('cover', FIXTURE_IMG, 'one.jpg');
const firstName = first.body.cover_image.replace('/uploads/covers/', '');
expect(fsMod.existsSync(pathMod.join(coversDir, firstName))).toBe(true);
const second = await request(app)
.post(`/api/trips/${trip.id}/cover`)
.set('Cookie', authCookie(user.id))
.attach('cover', FIXTURE_IMG, 'two.jpg');
expect(second.status).toBe(201);
expect(fsMod.existsSync(pathMod.join(coversDir, firstName))).toBe(false);
});
it('TRIP-P03 — no file → 400 "No image uploaded"', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app).post(`/api/trips/${trip.id}/cover`).set('Cookie', authCookie(user.id));
expect(res.status).toBe(400);
expect(res.body.error).toBe('No image uploaded');
});
it('TRIP-P04 — non-image upload is 500 (plain-Error filter quirk — pinned, do not "fix")', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/cover`)
.set('Cookie', authCookie(user.id))
.attach('cover', Buffer.from('plain text'), { filename: 'doc.txt', contentType: 'text/plain' });
expect(res.status).toBe(500);
});
it('TRIP-P05 — unknown trip → 404 "Trip not found"', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.post('/api/trips/999999/cover')
.set('Cookie', authCookie(user.id))
.attach('cover', FIXTURE_IMG);
expect(res.status).toBe(404);
expect(res.body.error).toBe('Trip not found');
});
});