Files
TREK/server/tests/integration/packing.test.ts
T
Maurice f23de88b74 fix(packing): authorize every single-item path against the item, not just the trip
A packing item is Common, Personal or Shared with named recipients. listItems()
always applied that rule, but every path that touches one item resolved it by
trip id alone, so trip membership plus packing_edit was enough to reach an item
the member could not see. Update, delete, clone, the contributor routes and the
sharing route all shared the lookup, and the same service methods sit behind the
MCP tools and the plugin RPC, so all three transports carried the gap.

The update was a read as well: it answered with the enriched row, which hands
back the name, category, quantity, weight, the owner's username and the recipient
list of an item the caller was never shown.

Two flows laundered restricted items into places they do not belong. Saving a
trip as a template read every row in the trip, so a template captured the names
and categories of everyone's Personal items. Copying a trip did the same and
re-inserted without is_private/owner_id, so both fell back to the column defaults
and another member's Personal or Shared item reappeared in the copy as a Common
item. That path needs only trip_create and trip access, not even packing_edit.

There is now one visibility rule in the service and every single-item lookup goes
through it. The actor is threaded through the HTTP, MCP and plugin-RPC entry
points, and a missing actor denies rather than falling through unfiltered. An
item the actor may not see is reported as missing, not as forbidden, so the
routes cannot be used to confirm that an id exists.

Two deliberate contract changes fall out of that. The sharing route answers 404
instead of 403 when the caller cannot see the item at all; a visible item they do
not own still answers 403. And a template or a trip copy now carries the Common
list plus the actor's own items, with a restricted item staying restricted and
owned by the copier.

Reported privately by Chichi (@vickyzer027-hash).
2026-08-14 22:09:21 +02:00

650 lines
30 KiB
TypeScript

/**
* Packing List integration tests.
* Covers PACK-001 to PACK-014.
*/
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';
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: () => {},
SESSION_DURATION: '24h',
SESSION_DURATION_MS: 86400000,
SESSION_DURATION_SECONDS: 86400,
DEFAULT_LANGUAGE: 'en',
}));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
import { buildApp } from '../../src/bootstrap';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
import { createUser, createTrip, createPackingItem, addTripMember } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
let nestApp: INestApplication;
let app: Application;
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
nestApp = await buildApp();
app = nestApp.getHttpAdapter().getInstance();
});
beforeEach(() => {
resetTestDb(testDb);
resetRateLimits(nestApp);
});
afterAll(async () => {
await nestApp.close();
testDb.close();
});
// ─────────────────────────────────────────────────────────────────────────────
// Create packing item
// ─────────────────────────────────────────────────────────────────────────────
describe('Create packing item', () => {
it('PACK-001 — POST creates a packing item', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/packing`)
.set('Cookie', authCookie(user.id))
.send({ name: 'Passport', category: 'Documents' });
expect(res.status).toBe(201);
expect(res.body.item.name).toBe('Passport');
expect(res.body.item.category).toBe('Documents');
expect(res.body.item.checked).toBe(0);
});
it('PACK-001 — POST without name returns 400', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/packing`)
.set('Cookie', authCookie(user.id))
.send({ category: 'Clothing' });
expect(res.status).toBe(400);
});
it('PACK-014 — non-member cannot create packing item', async () => {
const { user: owner } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/packing`)
.set('Cookie', authCookie(other.id))
.send({ name: 'Sunscreen' });
expect(res.status).toBe(404);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// List packing items
// ─────────────────────────────────────────────────────────────────────────────
describe('List packing items', () => {
it('PACK-002 — GET /api/trips/:tripId/packing returns all items', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
createPackingItem(testDb, trip.id, { name: 'Toothbrush', category: 'Toiletries' });
createPackingItem(testDb, trip.id, { name: 'Shirt', category: 'Clothing' });
const res = await request(app)
.get(`/api/trips/${trip.id}/packing`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.items).toHaveLength(2);
});
it('PACK-002 — member can list packing items', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, member.id);
createPackingItem(testDb, trip.id, { name: 'Jacket' });
const res = await request(app)
.get(`/api/trips/${trip.id}/packing`)
.set('Cookie', authCookie(member.id));
expect(res.status).toBe(200);
expect(res.body.items).toHaveLength(1);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Private items (#858)
// ─────────────────────────────────────────────────────────────────────────────
describe('Private packing items (#858)', () => {
it('PACK-PRIV-001 — a private item is hidden from other members but visible to its owner', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, member.id);
// Owner creates one shared and one private item.
await request(app).post(`/api/trips/${trip.id}/packing`).set('Cookie', authCookie(owner.id)).send({ name: 'Shared tent' });
const priv = await request(app).post(`/api/trips/${trip.id}/packing`).set('Cookie', authCookie(owner.id)).send({ name: 'Surprise gift', is_private: true });
expect(priv.body.item.is_private).toBe(1);
expect(priv.body.item.owner_id).toBe(owner.id);
const ownerView = await request(app).get(`/api/trips/${trip.id}/packing`).set('Cookie', authCookie(owner.id));
const memberView = await request(app).get(`/api/trips/${trip.id}/packing`).set('Cookie', authCookie(member.id));
expect(ownerView.body.items.map((i: any) => i.name).sort()).toEqual(['Shared tent', 'Surprise gift']);
expect(memberView.body.items.map((i: any) => i.name)).toEqual(['Shared tent']);
});
it('PACK-PRIV-002 — toggling an item private hides it from other members', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, member.id);
const created = await request(app).post(`/api/trips/${trip.id}/packing`).set('Cookie', authCookie(owner.id)).send({ name: 'Diary' });
const id = created.body.item.id;
await request(app).put(`/api/trips/${trip.id}/packing/${id}`).set('Cookie', authCookie(owner.id)).send({ is_private: true });
const memberView = await request(app).get(`/api/trips/${trip.id}/packing`).set('Cookie', authCookie(member.id));
expect(memberView.body.items).toHaveLength(0);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Three-tier sharing (#858)
// ─────────────────────────────────────────────────────────────────────────────
describe('Three-tier packing sharing (#858)', () => {
it('PACK-3T-001 — existing items stay Common (visible to all) — non-breaking', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, member.id);
// A pre-existing row (default is_private=0) must remain visible to everyone.
createPackingItem(testDb, trip.id, { name: 'Group tent' });
const memberView = await request(app).get(`/api/trips/${trip.id}/packing`).set('Cookie', authCookie(member.id));
expect(memberView.body.items.map((i: any) => i.name)).toContain('Group tent');
});
it('PACK-3T-002 — a Shared item reaches the recipient (with the bringer) but no one else', async () => {
const { user: owner } = createUser(testDb);
const { user: friend } = createUser(testDb);
const { user: stranger } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, friend.id);
addTripMember(testDb, trip.id, stranger.id);
const created = await request(app).post(`/api/trips/${trip.id}/packing`).set('Cookie', authCookie(owner.id))
.send({ name: 'Power bank', visibility: 'shared', recipient_ids: [friend.id] });
expect(created.body.item.recipients.map((r: any) => r.user_id)).toEqual([friend.id]);
expect(created.body.item.owner_username).toBeTruthy();
const friendView = await request(app).get(`/api/trips/${trip.id}/packing`).set('Cookie', authCookie(friend.id));
const strangerView = await request(app).get(`/api/trips/${trip.id}/packing`).set('Cookie', authCookie(stranger.id));
expect(friendView.body.items.map((i: any) => i.name)).toContain('Power bank');
expect(strangerView.body.items.map((i: any) => i.name)).not.toContain('Power bank');
});
it('PACK-3T-003 — clone copies a Common item onto the caller\'s personal list', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, member.id);
const created = await request(app).post(`/api/trips/${trip.id}/packing`).set('Cookie', authCookie(owner.id)).send({ name: 'Adapter', visibility: 'common' });
const clone = await request(app).post(`/api/trips/${trip.id}/packing/${created.body.item.id}/clone`).set('Cookie', authCookie(member.id));
expect(clone.status).toBe(201);
expect(clone.body.item.is_private).toBe(1);
expect(clone.body.item.owner_id).toBe(member.id);
// The owner does not see the member's private clone.
const ownerView = await request(app).get(`/api/trips/${trip.id}/packing`).set('Cookie', authCookie(owner.id));
expect(ownerView.body.items.filter((i: any) => i.name === 'Adapter')).toHaveLength(1);
});
it('PACK-3T-004 — "I can bring that too" adds the caller as a contributor on a Common item', async () => {
const { user: owner } = createUser(testDb);
const { user: helper } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, helper.id);
const created = await request(app).post(`/api/trips/${trip.id}/packing`).set('Cookie', authCookie(owner.id)).send({ name: 'Sunscreen', visibility: 'common' });
const res = await request(app).post(`/api/trips/${trip.id}/packing/${created.body.item.id}/contributors`).set('Cookie', authCookie(helper.id));
expect(res.status).toBe(201);
expect(res.body.item.contributors.map((c: any) => c.user_id)).toContain(helper.id);
});
it('PACK-3T-005 — sharing can only be changed by the owner', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, member.id);
const created = await request(app).post(`/api/trips/${trip.id}/packing`).set('Cookie', authCookie(owner.id)).send({ name: 'Tent', visibility: 'personal' });
// A member who cannot see the item at all gets 404, not 403: answering 403
// would confirm that the id exists (GHSA-vh2h-288v-ggch).
const hidden = await request(app).put(`/api/trips/${trip.id}/packing/${created.body.item.id}/sharing`).set('Cookie', authCookie(member.id)).send({ visibility: 'common' });
expect(hidden.status).toBe(404);
// An item the member CAN see but does not own still answers 403.
const shared = await request(app).post(`/api/trips/${trip.id}/packing`).set('Cookie', authCookie(owner.id)).send({ name: 'Stove', visibility: 'common' });
const denied = await request(app).put(`/api/trips/${trip.id}/packing/${shared.body.item.id}/sharing`).set('Cookie', authCookie(member.id)).send({ visibility: 'personal' });
expect(denied.status).toBe(403);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Update packing item
// ─────────────────────────────────────────────────────────────────────────────
describe('Update packing item', () => {
it('PACK-003 — PUT updates packing item (toggle checked)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createPackingItem(testDb, trip.id, { name: 'Camera' });
const res = await request(app)
.put(`/api/trips/${trip.id}/packing/${item.id}`)
.set('Cookie', authCookie(user.id))
.send({ checked: true });
expect(res.status).toBe(200);
expect(res.body.item.checked).toBe(1);
});
it('PACK-003 — PUT returns 404 for non-existent item', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
.put(`/api/trips/${trip.id}/packing/99999`)
.set('Cookie', authCookie(user.id))
.send({ name: 'Updated' });
expect(res.status).toBe(404);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Delete packing item
// ─────────────────────────────────────────────────────────────────────────────
describe('Delete packing item', () => {
it('PACK-004 — DELETE removes packing item', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createPackingItem(testDb, trip.id, { name: 'Sunglasses' });
const del = await request(app)
.delete(`/api/trips/${trip.id}/packing/${item.id}`)
.set('Cookie', authCookie(user.id));
expect(del.status).toBe(200);
expect(del.body.success).toBe(true);
const list = await request(app)
.get(`/api/trips/${trip.id}/packing`)
.set('Cookie', authCookie(user.id));
expect(list.body.items).toHaveLength(0);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Bulk import
// ─────────────────────────────────────────────────────────────────────────────
describe('Bulk import packing items', () => {
it('PACK-005 — POST /import creates multiple items at once', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/packing/import`)
.set('Cookie', authCookie(user.id))
.send({
items: [
{ name: 'Toothbrush', category: 'Toiletries' },
{ name: 'Shampoo', category: 'Toiletries' },
{ name: 'Socks', category: 'Clothing' },
],
});
expect(res.status).toBe(201);
expect(res.body.items).toHaveLength(3);
expect(res.body.count).toBe(3);
});
it('PACK-005 — POST /import with empty array returns 400', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/packing/import`)
.set('Cookie', authCookie(user.id))
.send({ items: [] });
expect(res.status).toBe(400);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Reorder
// ─────────────────────────────────────────────────────────────────────────────
describe('Reorder packing items', () => {
it('PACK-006 — PUT /reorder reorders items', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const i1 = createPackingItem(testDb, trip.id, { name: 'Item A' });
const i2 = createPackingItem(testDb, trip.id, { name: 'Item B' });
const res = await request(app)
.put(`/api/trips/${trip.id}/packing/reorder`)
.set('Cookie', authCookie(user.id))
.send({ orderedIds: [i2.id, i1.id] });
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
const rows = testDb
.prepare('SELECT id, sort_order FROM packing_items WHERE trip_id = ? ORDER BY sort_order')
.all(trip.id) as Array<{ id: number; sort_order: number }>;
expect(rows[0].id).toBe(i2.id);
expect(rows[1].id).toBe(i1.id);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Bags
// ─────────────────────────────────────────────────────────────────────────────
describe('Bags', () => {
it('PACK-008 — POST /bags creates a bag', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/packing/bags`)
.set('Cookie', authCookie(user.id))
.send({ name: 'Carry-on', color: '#3b82f6' });
expect(res.status).toBe(201);
expect(res.body.bag.name).toBe('Carry-on');
});
it('PACK-008 — POST /bags without name returns 400', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/packing/bags`)
.set('Cookie', authCookie(user.id))
.send({ color: '#ff0000' });
expect(res.status).toBe(400);
});
it('PACK-011 — GET /bags returns bags list', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
// Create a bag
await request(app)
.post(`/api/trips/${trip.id}/packing/bags`)
.set('Cookie', authCookie(user.id))
.send({ name: 'Main Bag' });
const res = await request(app)
.get(`/api/trips/${trip.id}/packing/bags`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.bags).toHaveLength(1);
});
it('PACK-009 — PUT /bags/:bagId updates bag', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const createRes = await request(app)
.post(`/api/trips/${trip.id}/packing/bags`)
.set('Cookie', authCookie(user.id))
.send({ name: 'Old Name' });
const bagId = createRes.body.bag.id;
const res = await request(app)
.put(`/api/trips/${trip.id}/packing/bags/${bagId}`)
.set('Cookie', authCookie(user.id))
.send({ name: 'New Name' });
expect(res.status).toBe(200);
expect(res.body.bag.name).toBe('New Name');
});
it('PACK-010 — DELETE /bags/:bagId removes bag', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const createRes = await request(app)
.post(`/api/trips/${trip.id}/packing/bags`)
.set('Cookie', authCookie(user.id))
.send({ name: 'Temp Bag' });
const bagId = createRes.body.bag.id;
const del = await request(app)
.delete(`/api/trips/${trip.id}/packing/bags/${bagId}`)
.set('Cookie', authCookie(user.id));
expect(del.status).toBe(200);
expect(del.body.success).toBe(true);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Category assignees
// ─────────────────────────────────────────────────────────────────────────────
describe('Category assignees', () => {
it('PACK-012 — PUT /category-assignees/:category sets assignees', async () => {
const { user } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, user.id);
addTripMember(testDb, trip.id, member.id);
const res = await request(app)
.put(`/api/trips/${trip.id}/packing/category-assignees/Clothing`)
.set('Cookie', authCookie(user.id))
.send({ user_ids: [user.id, member.id] });
expect(res.status).toBe(200);
expect(res.body.assignees).toBeDefined();
});
it('PACK-013 — GET /category-assignees returns all category assignments', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
// Set an assignee first
await request(app)
.put(`/api/trips/${trip.id}/packing/category-assignees/Electronics`)
.set('Cookie', authCookie(user.id))
.send({ user_ids: [user.id] });
const res = await request(app)
.get(`/api/trips/${trip.id}/packing/category-assignees`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.assignees).toBeDefined();
});
});
describe('Packing — apply-template, bag members, save-as-template', () => {
it('PACK-015 — POST /apply-template/:templateId applies template items to trip', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const tpl = testDb.prepare("INSERT INTO packing_templates (name, created_by) VALUES ('Beach', ?)").run(user.id);
const cat = testDb.prepare("INSERT INTO packing_template_categories (template_id, name, sort_order) VALUES (?, 'Essentials', 0)").run(tpl.lastInsertRowid);
testDb.prepare("INSERT INTO packing_template_items (category_id, name, sort_order) VALUES (?, 'Sunscreen', 0)").run(cat.lastInsertRowid);
const templateId = tpl.lastInsertRowid;
const res = await request(app)
.post(`/api/trips/${trip.id}/packing/apply-template/${templateId}`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(Array.isArray(res.body.items)).toBe(true);
expect(res.body.items.length).toBeGreaterThan(0);
expect(res.body.count).toBeGreaterThan(0);
});
it('PACK-015b — POST /apply-template/:id for empty template returns 404', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
// Template with no items
const tpl = testDb.prepare("INSERT INTO packing_templates (name, created_by) VALUES ('Empty', ?)").run(user.id);
const emptyTemplateId = tpl.lastInsertRowid;
const res = await request(app)
.post(`/api/trips/${trip.id}/packing/apply-template/${emptyTemplateId}`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(404);
expect(res.body.error).toBeDefined();
});
it('PACK-016 — PUT /bags/:bagId/members sets bag members', async () => {
const { user } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, user.id);
addTripMember(testDb, trip.id, member.id);
// Create a bag first
const bagRes = await request(app)
.post(`/api/trips/${trip.id}/packing/bags`)
.set('Cookie', authCookie(user.id))
.send({ name: 'Carry-on' });
expect(bagRes.status).toBe(201);
const bagId = bagRes.body.bag.id;
const res = await request(app)
.put(`/api/trips/${trip.id}/packing/bags/${bagId}/members`)
.set('Cookie', authCookie(user.id))
.send({ user_ids: [user.id, member.id] });
expect(res.status).toBe(200);
expect(Array.isArray(res.body.members)).toBe(true);
expect(res.body.members.length).toBe(2);
});
it('PACK-016b — PUT /bags/:bagId/members for non-existent bag returns 404', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
.put(`/api/trips/${trip.id}/packing/bags/999999/members`)
.set('Cookie', authCookie(user.id))
.send({ user_ids: [user.id] });
expect(res.status).toBe(404);
expect(res.body.error).toBeDefined();
});
it('PACK-017 — POST /save-as-template saves packing list as a template (admin)', async () => {
const { user } = createUser(testDb, { role: 'admin' });
const trip = createTrip(testDb, user.id);
// Add an item so the trip has something to save
createPackingItem(testDb, trip.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/packing/save-as-template`)
.set('Cookie', authCookie(user.id))
.send({ name: 'My Summer Template' });
expect(res.status).toBe(201);
expect(res.body.template).toBeDefined();
expect(res.body.template.name).toBe('My Summer Template');
});
it('PACK-017b — POST /save-as-template without name returns 400 (admin)', async () => {
const { user } = createUser(testDb, { role: 'admin' });
const trip = createTrip(testDb, user.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/packing/save-as-template`)
.set('Cookie', authCookie(user.id))
.send({});
expect(res.status).toBe(400);
expect(res.body.error).toBeDefined();
});
it('PACK-017c — POST /save-as-template when trip has no items returns 400 (admin)', async () => {
const { user } = createUser(testDb, { role: 'admin' });
const trip = createTrip(testDb, user.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/packing/save-as-template`)
.set('Cookie', authCookie(user.id))
.send({ name: 'Empty Trip Template' });
expect(res.status).toBe(400);
expect(res.body.error).toBeDefined();
});
it('PACK-017d — POST /save-as-template is forbidden for non-admins (403)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
createPackingItem(testDb, trip.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/packing/save-as-template`)
.set('Cookie', authCookie(user.id))
.send({ name: 'My Summer Template' });
expect(res.status).toBe(403);
expect(res.body.error).toBe('Admin access required');
});
it('PACK-017e — GET /packing/templates lists templates for a trip member', async () => {
const { user: admin } = createUser(testDb, { role: 'admin' });
const trip = createTrip(testDb, admin.id);
createPackingItem(testDb, trip.id);
await request(app)
.post(`/api/trips/${trip.id}/packing/save-as-template`)
.set('Cookie', authCookie(admin.id))
.send({ name: 'Shared Template' });
const res = await request(app)
.get(`/api/trips/${trip.id}/packing/templates`)
.set('Cookie', authCookie(admin.id));
expect(res.status).toBe(200);
expect(Array.isArray(res.body.templates)).toBe(true);
expect(res.body.templates.some((t: { name: string }) => t.name === 'Shared Template')).toBe(true);
expect(res.body.templates[0]).toHaveProperty('item_count');
});
});