mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
00cbc5aae3
tests/setup.ts mocks src/config once with tests/helpers/test-config.ts. A suite that needs other values calls overrideTestConfig(), and the setup file restores the defaults after every case. The 191 per-file factories are gone; config.test.ts takes the real module through vi.unmock, and the few suites that signed tokens with their own literal secret read it from TEST_CONFIG.
1374 lines
57 KiB
TypeScript
1374 lines
57 KiB
TypeScript
/**
|
||
* Trips API integration tests.
|
||
* Covers TRIP-001 through TRIP-022.
|
||
*/
|
||
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';
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// 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());
|
||
});
|
||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||
|
||
import { db as testDb } from '../../src/db/database';
|
||
import { MikroORM } from '@mikro-orm/core';
|
||
import { buildApp } from '../../src/bootstrap';
|
||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||
import type { FactoryOrm } from '../helpers/factories/context';
|
||
import { createUser, createAdmin, createTrip, addTripMember, createPlace, createReservation, createTag, createDayAccommodation, createBudgetItem, createPackingItem, createDayNote, createDayAssignment } from '../helpers/factories';
|
||
import { authCookie } from '../helpers/auth';
|
||
import { countRows, findRow, findRows, insertRow, insertRows, updateRows } from '../helpers/factories/rows';
|
||
import { readTripDays } from '../helpers/factories/trips';
|
||
import { readUser } from '../helpers/factories/users';
|
||
import { setAppSetting } from '../helpers/factories/settings';
|
||
import { tagPlace } from '../helpers/factories/places';
|
||
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';
|
||
|
||
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');
|
||
});
|
||
});
|