mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
f23de88b74
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).
650 lines
30 KiB
TypeScript
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');
|
|
});
|
|
});
|