mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
1f5deeba6c
* fix: clean up dangling FK references before deleting a user Resolves FOREIGN KEY constraint failed (500) on DELETE /api/admin/users/:id and DELETE /api/auth/me when the target user had rows in trip_members.invited_by, share_tokens.created_by, budget_items.paid_by_user_id, journeys.user_id, journey_entries.author_id, journey_contributors.user_id, or journey_share_tokens.created_by — none of which had ON DELETE clauses. Introduces deleteUserCompletely() in userCleanupService.ts which wraps all cleanup and the final DELETE FROM users in a single transaction. Both adminService.deleteUser and authService.deleteAccount now call it instead of the bare DELETE. Tests ADMIN-005b and AUTH-040 cover all reference types including notification sender/recipient and notice dismissals. * test: extend FK deletion tests to cover journeys, files, and photos ADMIN-005b and AUTH-040 now also seed and assert: - owned journey with entries (cascade-deleted via journeys.user_id cleanup) - trip_files.uploaded_by (SET NULL — file survives, attribution cleared) - trek_photos.owner_id (SET NULL — photo record survives, owner cleared) - trip_photos.user_id (CASCADE — photo association removed) * test: extend user deletion tests to cover all FK relationships ADMIN-005b and AUTH-040 now seed and assert every user FK relationship: CASCADE (row deleted): trips, trip_members, tags, mcp_tokens, oauth_tokens, oauth_consents, vacay_plans, vacay_plan_members, bucket_list, visited_countries, visited_regions, packing_templates, invite_tokens, collab_notes, settings, password_reset_tokens, notification_channel_preferences SET NULL (row survives, column nulled): categories, todo_items.assigned_user_id, packing_bags, audit_log Caught and fixed: notification_preferences was dropped in migration 72; correct table is notification_channel_preferences. * fix: preserve URL hash and OIDC redirect target through login flow - Include location.hash in redirect param at all three producer sites (ProtectedRoute, axios 401 interceptor, OAuthAuthorizePage) so hash fragments survive the login bounce - Stash redirectTarget in sessionStorage before any OIDC provider redirect and restore it after the code exchange, since the IdP strips the original ?redirect= param during the roundtrip - Clear sessionStorage on OIDC error to avoid stale state - Add tests covering sessionStorage stash on mount, navigate to saved redirect after OIDC exchange, fallback to /dashboard, and cleanup on error * fix: use day position instead of ID for accommodation date range clamping Math.min/Math.max over raw day IDs breaks the start/end picker when a trip's day IDs are non-monotonic relative to day_number (normal after repeated generateDays extend/shrink cycles). Replaced with findIndex lookups so clamping is always based on positional order. Closes #889 * fix: normalize env var comparisons to be case-insensitive All NODE_ENV, DEMO_MODE, OIDC_ONLY, FORCE_HTTPS, COOKIE_SECURE, and ALLOW_INTERNAL_NETWORK checks now use .toLowerCase() so values like 'Production' or 'True' behave identically to their lowercase forms. Also adds APP_VERSION to the startup banner. * fix: delete surplus days when shortening a trip When shrinking a trip's date range, surplus days are now deleted along with their assignments, notes, and accommodations (cascade). Places remain in the trip pool; reservations keep their day reference nulled by the existing ON DELETE SET NULL constraint (issue #909). Updates TRIP-SVC-011 to reflect the new behaviour; adds TRIP-SVC-016 as a regression test for the empty-day case. * fix: auto-backup retention deletes itself and manual backups on Docker Two bugs in cleanupOldBackups: 1. Filter was .endsWith('.zip') — swept manual backup-*.zip files too. Now restricted to auto-backup-* prefix. 2. Age was derived from stat.birthtimeMs, which is 0 on overlayfs (Docker default), making every backup appear epoch-old and get deleted immediately. Age is now parsed from the filename timestamp and falls back to mtimeMs (reliable on overlayfs). Also converts inline require('./services/auditLog') calls to a static import throughout scheduler.ts, and adds 8 unit tests covering the fixed retention logic including the overlayfs regression case. * test: update TRIP-024 to match delete behavior on trip shrink * feat: add bypass-branch-check label to skip branch enforcement
1071 lines
44 KiB
TypeScript
1071 lines
44 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';
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Step 1: Bare in-memory DB — schema applied in beforeAll after mocks register
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
const { testDb, dbMock } = vi.hoisted(() => {
|
||
const Database = require('better-sqlite3');
|
||
const db = new Database(':memory:');
|
||
db.exec('PRAGMA journal_mode = WAL');
|
||
db.exec('PRAGMA foreign_keys = ON');
|
||
db.exec('PRAGMA busy_timeout = 5000');
|
||
|
||
const mock = {
|
||
db,
|
||
closeDb: () => {},
|
||
reinitialize: () => {},
|
||
getPlaceWithTags: (placeId: number) => {
|
||
const place: any = db.prepare(`
|
||
SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon
|
||
FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?
|
||
`).get(placeId);
|
||
if (!place) return null;
|
||
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||
},
|
||
canAccessTrip: (tripId: any, userId: number) =>
|
||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||
isOwner: (tripId: any, userId: number) =>
|
||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||
};
|
||
|
||
return { testDb: db, dbMock: mock };
|
||
});
|
||
|
||
vi.mock('../../src/db/database', () => dbMock);
|
||
vi.mock('../../src/config', () => ({
|
||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||
updateJwtSecret: () => {},
|
||
}));
|
||
|
||
import { createApp } from '../../src/app';
|
||
import { createTables } from '../../src/db/schema';
|
||
import { runMigrations } from '../../src/db/migrations';
|
||
import { resetTestDb } from '../helpers/test-db';
|
||
import { createUser, createAdmin, createTrip, addTripMember, createPlace, createReservation, createTag, createDayAccommodation, createBudgetItem, createPackingItem, createDayNote, createDayAssignment } from '../helpers/factories';
|
||
import { authCookie } from '../helpers/auth';
|
||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||
import { invalidatePermissionsCache } from '../../src/services/permissions';
|
||
|
||
const app: Application = createApp();
|
||
|
||
beforeAll(() => { createTables(testDb); runMigrations(testDb); });
|
||
beforeEach(() => {
|
||
resetTestDb(testDb);
|
||
loginAttempts.clear();
|
||
mfaAttempts.clear();
|
||
invalidatePermissionsCache();
|
||
});
|
||
afterAll(() => { 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 = testDb.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY date').all(res.body.trip.id) as any[];
|
||
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 = testDb.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number').all(res.body.trip.id) as any[];
|
||
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 = testDb.prepare('SELECT * FROM days WHERE trip_id = ?').all(res.body.trip.id) as any[];
|
||
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
|
||
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('perm_trip_create', 'admin')").run();
|
||
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);
|
||
|
||
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('perm_trip_create', 'admin')").run();
|
||
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
|
||
testDb.prepare('UPDATE trips SET is_archived = 1 WHERE id = ?').run(archivedTrip.id);
|
||
|
||
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' });
|
||
|
||
testDb.prepare('UPDATE trips SET is_archived = 1 WHERE id = ?').run(archivedTrip.id);
|
||
|
||
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
|
||
testDb.prepare('UPDATE trips SET is_archived = 1 WHERE id = ?').run(trip.id);
|
||
|
||
// 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)
|
||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('perm_trip_archive', 'trip_owner')").run();
|
||
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
|
||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('perm_trip_edit', 'trip_owner')").run();
|
||
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 = testDb.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number; date: string }[];
|
||
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 = testDb.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number; date: string | null }[];
|
||
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 = testDb.prepare('SELECT * FROM day_assignments WHERE id = ?').get(assignment.id) as { day_id: number } | undefined;
|
||
expect(assignmentsAfter).toBeDefined();
|
||
expect(assignmentsAfter!.day_id).toBe(daysAfter[0].id);
|
||
|
||
const notesAfter = testDb.prepare('SELECT * FROM day_notes WHERE id = ?').get(note.id) as { day_id: number } | undefined;
|
||
expect(notesAfter).toBeDefined();
|
||
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 = testDb.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number }[];
|
||
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 = testDb.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number; date: string | null }[];
|
||
expect(daysAfter).toHaveLength(3);
|
||
expect(daysAfter.every(d => d.date !== null)).toBe(true);
|
||
|
||
// Overflow days and their assignments deleted
|
||
const all = testDb.prepare('SELECT * FROM day_assignments WHERE id IN (?, ?)').all(a4.id, a5.id) as { id: number }[];
|
||
expect(all).toHaveLength(0);
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// 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 → 403', 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));
|
||
|
||
// getTripOwner finds the trip (it exists); checkPermission fails for non-members → 403
|
||
expect(res.status).toBe(403);
|
||
|
||
// Trip still exists
|
||
const tripInDb = testDb.prepare('SELECT id FROM trips WHERE id = ?').get(trip.id);
|
||
expect(tripInDb).toBeDefined();
|
||
});
|
||
|
||
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 = testDb.prepare('SELECT id FROM places WHERE trip_id = ?').all(trip.id);
|
||
expect(places).toHaveLength(0);
|
||
|
||
const reservations = testDb.prepare('SELECT id FROM reservations WHERE trip_id = ?').all(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 = testDb.prepare('SELECT * FROM trip_members WHERE trip_id = ? AND user_id = ?').get(trip.id, invitee.id);
|
||
expect(dbEntry).toBeDefined();
|
||
});
|
||
|
||
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-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 = testDb.prepare('SELECT * FROM trip_members WHERE trip_id = ? AND user_id = ?').get(trip.id, member.id);
|
||
expect(dbEntry).toBeUndefined();
|
||
});
|
||
|
||
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-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)
|
||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('perm_member_manage', 'trip_owner')").run();
|
||
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 = testDb.prepare('SELECT * FROM trips WHERE id = ?').get(res.body.trip.id) as any;
|
||
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);
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// 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 = testDb.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as any[];
|
||
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' });
|
||
testDb.prepare('INSERT INTO place_tags (place_id, tag_id) VALUES (?, ?)').run(place.id, tag.id);
|
||
|
||
// Day assignment
|
||
testDb.prepare(
|
||
'INSERT INTO day_assignments (day_id, place_id, order_index, notes) VALUES (?, ?, 0, ?)'
|
||
).run(days[0].id, place.id, '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 = testDb.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number').all(newId) as any[];
|
||
expect(newDays).toHaveLength(3);
|
||
|
||
// Place copied
|
||
const newPlaces = testDb.prepare('SELECT * FROM places WHERE trip_id = ?').all(newId) as any[];
|
||
expect(newPlaces).toHaveLength(1);
|
||
expect(newPlaces[0].name).toBe('Tower Bridge');
|
||
|
||
// Place tag copied
|
||
const newTags = testDb.prepare(
|
||
'SELECT pt.* FROM place_tags pt JOIN places p ON p.id = pt.place_id WHERE p.trip_id = ?'
|
||
).all(newId) as any[];
|
||
expect(newTags).toHaveLength(1);
|
||
|
||
// Assignment copied
|
||
const newAssignments = testDb.prepare(
|
||
'SELECT da.* FROM day_assignments da JOIN days d ON d.id = da.day_id WHERE d.trip_id = ?'
|
||
).all(newId) as any[];
|
||
expect(newAssignments).toHaveLength(1);
|
||
|
||
// Accommodation copied
|
||
const newAccom = testDb.prepare('SELECT * FROM day_accommodations WHERE trip_id = ?').all(newId) as any[];
|
||
expect(newAccom).toHaveLength(1);
|
||
|
||
// Reservation copied
|
||
const newResv = testDb.prepare('SELECT * FROM reservations WHERE trip_id = ?').all(newId) as any[];
|
||
expect(newResv).toHaveLength(1);
|
||
|
||
// Budget copied
|
||
const newBudget = testDb.prepare('SELECT * FROM budget_items WHERE trip_id = ?').all(newId) as any[];
|
||
expect(newBudget).toHaveLength(1);
|
||
|
||
// Packing copied (checked reset to 0)
|
||
const newPacking = testDb.prepare('SELECT * FROM packing_items WHERE trip_id = ?').all(newId) as any[];
|
||
expect(newPacking).toHaveLength(1);
|
||
expect(newPacking[0].checked).toBe(0);
|
||
|
||
// Day note copied
|
||
const newNotes = testDb.prepare('SELECT * FROM day_notes WHERE trip_id = ?').all(newId) as any[];
|
||
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
|
||
testDb.prepare(
|
||
'INSERT INTO todo_items (trip_id, name, checked, category, sort_order, due_date, description, priority) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
|
||
).run(trip.id, 'Buy tickets', 0, 'Transport', 0, '2026-06-01', 'Check Ryanair', 1);
|
||
testDb.prepare(
|
||
'INSERT INTO todo_items (trip_id, name, checked, category, sort_order, assigned_user_id, priority) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||
).run(trip.id, 'Book hotel', 1, 'Accommodation', 1, user.id, 0);
|
||
|
||
// Two budget category order rows
|
||
const insOrder = testDb.prepare('INSERT INTO budget_category_order (trip_id, category, sort_order) VALUES (?, ?, ?)');
|
||
insOrder.run(trip.id, 'Transport', 0);
|
||
insOrder.run(trip.id, 'Accommodation', 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 = testDb.prepare('SELECT * FROM todo_items WHERE trip_id = ? ORDER BY sort_order').all(newId) as any[];
|
||
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 = testDb.prepare('SELECT category, sort_order FROM budget_category_order WHERE trip_id = ? ORDER BY sort_order').all(newId) as any[];
|
||
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);
|
||
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(trip.id, 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);
|
||
});
|
||
});
|