mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 22:16:26 +00:00
8fd46c4c18
Small requests from the discussions, each built end to end: planner and map (cycling mode, date first headings, clear a day, stay times per day, off-route stops, map lock, compact unplanned markers, pending stays, cable car, transit quick picks), places (location filter, category from OSM, open in maps, geo: link, image from an attachment, enrichment on file import, places near a pin, place name language, Amap first in China), lists and costs (packed counter, inherited sharing, Markdown to-dos, payment notes, bookings CSV, my plan PDF), sharing (unplanned places, travel only, without photos), journey (photo zoom, drafts, photo order, trip suggestion, day jump), atlas (region counter, region bucket list, places per country), vacay half company holidays, studio copy and paste plus the book's own layouts, and RESTORE_FROM_BACKUP for a first start restore.
1107 lines
47 KiB
TypeScript
1107 lines
47 KiB
TypeScript
/**
|
|
* Unit tests for the packing MCP surface (PackingMcp, DI-discovered):
|
|
* create_packing_item, update_packing_item, toggle_packing_item,
|
|
* delete_packing_item, set_packing_item_sharing, plus the two bag fields that
|
|
* drive the fill bar, the registration-time scope/addon gating and the
|
|
* trek://trips/{tripId}/packing + .../packing/bags resources (moved from the
|
|
* legacy registerResources — see resources.test.ts). The advanced tools live
|
|
* in tools-packing-advanced.test.ts.
|
|
*
|
|
* All of it attaches via the nest-mcp registry inside registerTools, so every
|
|
* harness here keeps withTools on (the resources are NOT registered by the
|
|
* legacy registerResources fan-out anymore).
|
|
*/
|
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
|
|
|
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: () => null,
|
|
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: () => {},
|
|
}));
|
|
|
|
const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
|
|
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
|
|
|
|
import { createTables } from '../../../src/db/schema';
|
|
import { runMigrations } from '../../../src/db/migrations';
|
|
import { resetTestDb } from '../../helpers/test-db';
|
|
import { createUser, createTrip, createPackingItem, addTripMember } from '../../helpers/factories';
|
|
import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness';
|
|
import { ADDON_IDS } from '../../../src/addons';
|
|
|
|
beforeAll(() => {
|
|
createTables(testDb);
|
|
runMigrations(testDb);
|
|
});
|
|
|
|
beforeEach(() => {
|
|
resetTestDb(testDb);
|
|
broadcastMock.mockClear();
|
|
delete process.env.DEMO_MODE;
|
|
});
|
|
|
|
afterAll(() => {
|
|
testDb.close();
|
|
});
|
|
|
|
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
|
|
const h = await createMcpHarness({ userId, withResources: false });
|
|
try { await fn(h); } finally { await h.cleanup(); }
|
|
}
|
|
|
|
/** The text of a failed call, including the SDK's own schema-validation refusals
|
|
* (which come back as an error result, not a rejection). */
|
|
const errorText = (result: unknown) =>
|
|
((result as { content?: { text?: string }[] }).content ?? []).map((c) => c.text ?? '').join(' ');
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// create_packing_item
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Tool: create_packing_item', () => {
|
|
it('creates a packing item', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'create_packing_item',
|
|
arguments: { tripId: trip.id, name: 'Passport', category: 'Documents' },
|
|
});
|
|
const data = parseToolResult(result) as any;
|
|
expect(data.item.name).toBe('Passport');
|
|
expect(data.item.category).toBe('Documents');
|
|
expect(data.item.checked).toBe(0);
|
|
});
|
|
});
|
|
|
|
it('defaults category to "General"', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'create_packing_item',
|
|
arguments: { tripId: trip.id, name: 'Sunscreen' },
|
|
});
|
|
const data = parseToolResult(result) as any;
|
|
expect(data.item.category).toBe('General');
|
|
});
|
|
});
|
|
|
|
it('broadcasts packing:created event', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
await h.client.callTool({ name: 'create_packing_item', arguments: { tripId: trip.id, name: 'Hat' } });
|
|
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:created', expect.any(Object));
|
|
});
|
|
});
|
|
|
|
it('returns access denied for non-member', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { user: other } = createUser(testDb);
|
|
const trip = createTrip(testDb, other.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({ name: 'create_packing_item', arguments: { tripId: trip.id, name: 'X' } });
|
|
expect(result.isError).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('blocks demo user', async () => {
|
|
process.env.DEMO_MODE = 'true';
|
|
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
|
const trip = createTrip(testDb, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({ name: 'create_packing_item', arguments: { tripId: trip.id, name: 'X' } });
|
|
expect(result.isError).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// create_packing_item: the sharing tiers and the pre-checked flag (#858)
|
|
//
|
|
// A tool-made item used to land on the Common list whatever was asked for:
|
|
// there was no way to say "this is mine" or "I'm bringing it for these two",
|
|
// which is the whole three-tier model the REST route has taken since #858.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const itemRow = (id: number) =>
|
|
testDb.prepare('SELECT * FROM packing_items WHERE id = ?').get(id) as
|
|
{ id: number; checked: number; is_private: number; owner_id: number | null; bag_id: number | null; quantity: number; weight_grams: number | null };
|
|
|
|
/** The id of the item a create call just made, typed rather than cast wide open. */
|
|
const createdItemId = (result: Parameters<typeof parseToolResult>[0]) =>
|
|
(parseToolResult(result) as { item: { id: number } }).item.id;
|
|
|
|
const recipientIds = (itemId: number) =>
|
|
(testDb.prepare('SELECT user_id FROM packing_item_recipients WHERE item_id = ? ORDER BY user_id').all(itemId) as { user_id: number }[])
|
|
.map((r) => r.user_id);
|
|
|
|
describe('Tool: create_packing_item sharing', () => {
|
|
it('puts a personal item on the caller\'s own list', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'create_packing_item',
|
|
arguments: { tripId: trip.id, name: 'Insulin pens', visibility: 'personal' },
|
|
});
|
|
const row = itemRow(createdItemId(result));
|
|
expect(row.is_private).toBe(1);
|
|
expect(row.owner_id).toBe(user.id);
|
|
});
|
|
});
|
|
|
|
it('records the recipients of a shared item and drops ids off the trip roster', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { user: mate } = createUser(testDb);
|
|
const { user: stranger } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
addTripMember(testDb, trip.id, mate.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'create_packing_item',
|
|
arguments: { tripId: trip.id, name: 'Sunscreen', visibility: 'shared', recipient_ids: [mate.id, stranger.id] },
|
|
});
|
|
const itemId = createdItemId(result);
|
|
expect(itemRow(itemId).is_private).toBe(1);
|
|
expect(recipientIds(itemId)).toEqual([mate.id]);
|
|
});
|
|
});
|
|
|
|
it('honours the legacy is_private flag', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'create_packing_item',
|
|
arguments: { tripId: trip.id, name: 'Gift', is_private: true },
|
|
});
|
|
expect(itemRow(createdItemId(result)).is_private).toBe(1);
|
|
});
|
|
});
|
|
|
|
it('creates an already-checked item', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'create_packing_item',
|
|
arguments: { tripId: trip.id, name: 'Charger', checked: true },
|
|
});
|
|
expect(itemRow(createdItemId(result)).checked).toBe(1);
|
|
});
|
|
});
|
|
|
|
it('keeps a personal item off the rest of the room', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
await h.client.callTool({
|
|
name: 'create_packing_item',
|
|
arguments: { tripId: trip.id, name: 'Insulin pens', visibility: 'personal' },
|
|
});
|
|
});
|
|
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:created', expect.any(Object), undefined, user.id);
|
|
expect(broadcastMock).not.toHaveBeenCalledWith(trip.id, 'packing:created', expect.any(Object));
|
|
});
|
|
|
|
it('refuses a visibility tier that does not exist', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'create_packing_item',
|
|
arguments: { tripId: trip.id, name: 'X', visibility: 'secret' },
|
|
});
|
|
expect(result.isError).toBe(true);
|
|
expect(errorText(result)).toContain('visibility');
|
|
});
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// create_packing_item: bag, quantity, weight in one write (#2154)
|
|
//
|
|
// The tool used to inherit the create schema's gap: an assistant could not
|
|
// give a new item a weight or put it in a bag without a second write.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Tool: create_packing_item bag, quantity, weight (#2154)', () => {
|
|
it('creates the item with its bag, quantity and weight in one write', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const bagId = Number(testDb.prepare('INSERT INTO packing_bags (trip_id, name) VALUES (?, ?)').run(trip.id, 'Carry-On').lastInsertRowid);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'create_packing_item',
|
|
arguments: { tripId: trip.id, name: 'Tent', bag_id: bagId, quantity: 3, weight_grams: 250 },
|
|
});
|
|
const row = itemRow(createdItemId(result));
|
|
expect(row.bag_id).toBe(bagId);
|
|
expect(row.quantity).toBe(3);
|
|
expect(row.weight_grams).toBe(250);
|
|
});
|
|
});
|
|
|
|
it('refuses a bag from another trip, inserting nothing', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const otherTrip = createTrip(testDb, user.id);
|
|
const foreignBag = Number(testDb.prepare('INSERT INTO packing_bags (trip_id, name) VALUES (?, ?)').run(otherTrip.id, 'Foreign').lastInsertRowid);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'create_packing_item',
|
|
arguments: { tripId: trip.id, name: 'Tent', bag_id: foreignBag },
|
|
});
|
|
expect(result.isError).toBe(true);
|
|
expect(errorText(result)).toContain('Bag not found');
|
|
});
|
|
expect((testDb.prepare('SELECT COUNT(*) AS n FROM packing_items WHERE trip_id = ?').get(trip.id) as { n: number }).n).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// update_packing_item
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Tool: update_packing_item', () => {
|
|
it('updates packing item name and category', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const item = createPackingItem(testDb, trip.id, { name: 'Old', category: 'Clothes' });
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'update_packing_item',
|
|
arguments: { tripId: trip.id, itemId: item.id, name: 'New Name', category: 'Electronics' },
|
|
});
|
|
const data = parseToolResult(result) as any;
|
|
expect(data.item.name).toBe('New Name');
|
|
expect(data.item.category).toBe('Electronics');
|
|
});
|
|
});
|
|
|
|
it('counts packed pieces like the REST route does (#2296)', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const item = createPackingItem(testDb, trip.id, { name: 'Shirts' });
|
|
await withHarness(user.id, async (h) => {
|
|
await h.client.callTool({ name: 'update_packing_item', arguments: { tripId: trip.id, itemId: item.id, quantity: 4 } });
|
|
const partial = parseToolResult(await h.client.callTool({ name: 'update_packing_item', arguments: { tripId: trip.id, itemId: item.id, packed_quantity: 3 } })) as any;
|
|
expect(partial.item).toMatchObject({ packed_quantity: 3, checked: 0 });
|
|
const full = parseToolResult(await h.client.callTool({ name: 'update_packing_item', arguments: { tripId: trip.id, itemId: item.id, packed_quantity: 4 } })) as any;
|
|
expect(full.item).toMatchObject({ packed_quantity: null, checked: 1 });
|
|
});
|
|
});
|
|
|
|
it('broadcasts packing:updated event', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const item = createPackingItem(testDb, trip.id);
|
|
await withHarness(user.id, async (h) => {
|
|
await h.client.callTool({ name: 'update_packing_item', arguments: { tripId: trip.id, itemId: item.id, name: 'Updated' } });
|
|
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:updated', expect.any(Object));
|
|
});
|
|
});
|
|
|
|
it('returns error for item not found', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({ name: 'update_packing_item', arguments: { tripId: trip.id, itemId: 99999, name: 'X' } });
|
|
expect(result.isError).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('returns access denied for non-member', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { user: other } = createUser(testDb);
|
|
const trip = createTrip(testDb, other.id);
|
|
const item = createPackingItem(testDb, trip.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({ name: 'update_packing_item', arguments: { tripId: trip.id, itemId: item.id, name: 'X' } });
|
|
expect(result.isError).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// update_packing_item: bag, quantity, weight and privacy
|
|
//
|
|
// The tool could only rename and recategorise, so an assistant could not put an
|
|
// item in a bag at all once it existed: bag assignment was reachable only at
|
|
// bulk_import_packing time, by bag name.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Tool: update_packing_item bag, quantity, weight, privacy', () => {
|
|
const makeBag = (tripId: number, name = 'Carry-On') =>
|
|
Number(testDb.prepare('INSERT INTO packing_bags (trip_id, name) VALUES (?, ?)').run(tripId, name).lastInsertRowid);
|
|
|
|
it('moves an item into a bag', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const item = createPackingItem(testDb, trip.id);
|
|
const bagId = makeBag(trip.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'update_packing_item',
|
|
arguments: { tripId: trip.id, itemId: item.id, bag_id: bagId },
|
|
});
|
|
expect(result.isError).toBeFalsy();
|
|
expect(itemRow(item.id).bag_id).toBe(bagId);
|
|
});
|
|
});
|
|
|
|
it('takes an item out of its bag with an explicit null', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const item = createPackingItem(testDb, trip.id);
|
|
const bagId = makeBag(trip.id);
|
|
testDb.prepare('UPDATE packing_items SET bag_id = ? WHERE id = ?').run(bagId, item.id);
|
|
await withHarness(user.id, async (h) => {
|
|
await h.client.callTool({
|
|
name: 'update_packing_item',
|
|
arguments: { tripId: trip.id, itemId: item.id, bag_id: null },
|
|
});
|
|
expect(itemRow(item.id).bag_id).toBeNull();
|
|
});
|
|
});
|
|
|
|
it('sets the quantity', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const item = createPackingItem(testDb, trip.id);
|
|
await withHarness(user.id, async (h) => {
|
|
await h.client.callTool({
|
|
name: 'update_packing_item',
|
|
arguments: { tripId: trip.id, itemId: item.id, quantity: 3 },
|
|
});
|
|
expect(itemRow(item.id).quantity).toBe(3);
|
|
});
|
|
});
|
|
|
|
it('sets and clears the weight', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const item = createPackingItem(testDb, trip.id);
|
|
await withHarness(user.id, async (h) => {
|
|
await h.client.callTool({
|
|
name: 'update_packing_item',
|
|
arguments: { tripId: trip.id, itemId: item.id, weight_grams: 850 },
|
|
});
|
|
expect(itemRow(item.id).weight_grams).toBe(850);
|
|
|
|
await h.client.callTool({
|
|
name: 'update_packing_item',
|
|
arguments: { tripId: trip.id, itemId: item.id, weight_grams: null },
|
|
});
|
|
expect(itemRow(item.id).weight_grams).toBeNull();
|
|
});
|
|
});
|
|
|
|
it('leaves the bag alone when the key is omitted', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const item = createPackingItem(testDb, trip.id);
|
|
const bagId = makeBag(trip.id);
|
|
testDb.prepare('UPDATE packing_items SET bag_id = ? WHERE id = ?').run(bagId, item.id);
|
|
await withHarness(user.id, async (h) => {
|
|
await h.client.callTool({
|
|
name: 'update_packing_item',
|
|
arguments: { tripId: trip.id, itemId: item.id, name: 'Renamed' },
|
|
});
|
|
expect(itemRow(item.id).bag_id).toBe(bagId);
|
|
});
|
|
});
|
|
|
|
it('takes a common item onto the caller\'s own list, and off everyone else\'s screen', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const item = createPackingItem(testDb, trip.id);
|
|
await withHarness(user.id, async (h) => {
|
|
await h.client.callTool({
|
|
name: 'update_packing_item',
|
|
arguments: { tripId: trip.id, itemId: item.id, is_private: true },
|
|
});
|
|
});
|
|
const row = itemRow(item.id);
|
|
expect(row.is_private).toBe(1);
|
|
// An unowned item is claimed by whoever privatizes it, or the visibility
|
|
// filter would have nobody to match.
|
|
expect(row.owner_id).toBe(user.id);
|
|
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:deleted', expect.any(Object));
|
|
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:created', expect.any(Object), undefined, user.id);
|
|
});
|
|
|
|
it('hands a private item back to the room when it goes common again', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const item = createPackingItem(testDb, trip.id);
|
|
testDb.prepare('UPDATE packing_items SET is_private = 1, owner_id = ? WHERE id = ?').run(user.id, item.id);
|
|
await withHarness(user.id, async (h) => {
|
|
await h.client.callTool({
|
|
name: 'update_packing_item',
|
|
arguments: { tripId: trip.id, itemId: item.id, is_private: false },
|
|
});
|
|
});
|
|
expect(itemRow(item.id).is_private).toBe(0);
|
|
// Created first: the members who never had the row cannot apply an update to it.
|
|
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:created', expect.any(Object));
|
|
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:updated', expect.any(Object));
|
|
});
|
|
|
|
it('refuses a bag id that is not a number', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const item = createPackingItem(testDb, trip.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'update_packing_item',
|
|
arguments: { tripId: trip.id, itemId: item.id, bag_id: 'carry-on' },
|
|
});
|
|
expect(result.isError).toBe(true);
|
|
expect(errorText(result)).toContain('bag_id');
|
|
expect(itemRow(item.id).bag_id).toBeNull();
|
|
});
|
|
});
|
|
|
|
it('refuses a bag from another trip (#2154)', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const otherTrip = createTrip(testDb, user.id);
|
|
const item = createPackingItem(testDb, trip.id);
|
|
const foreignBag = makeBag(otherTrip.id, 'Foreign');
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'update_packing_item',
|
|
arguments: { tripId: trip.id, itemId: item.id, bag_id: foreignBag },
|
|
});
|
|
expect(result.isError).toBe(true);
|
|
expect(errorText(result)).toContain('Bag not found');
|
|
expect(itemRow(item.id).bag_id).toBeNull();
|
|
});
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// set_packing_item_sharing (#858): the tool the surface never had
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Tool: set_packing_item_sharing', () => {
|
|
it('moves a common item onto the caller\'s own list', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const item = createPackingItem(testDb, trip.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'set_packing_item_sharing',
|
|
arguments: { tripId: trip.id, itemId: item.id, visibility: 'personal' },
|
|
});
|
|
expect(result.isError).toBeFalsy();
|
|
});
|
|
const row = itemRow(item.id);
|
|
expect(row.is_private).toBe(1);
|
|
expect(row.owner_id).toBe(user.id);
|
|
});
|
|
|
|
it('shares an item with named members and drops ids off the trip roster', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { user: mate } = createUser(testDb);
|
|
const { user: stranger } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
addTripMember(testDb, trip.id, mate.id);
|
|
const item = createPackingItem(testDb, trip.id);
|
|
await withHarness(user.id, async (h) => {
|
|
await h.client.callTool({
|
|
name: 'set_packing_item_sharing',
|
|
arguments: { tripId: trip.id, itemId: item.id, visibility: 'shared', recipient_ids: [mate.id, stranger.id] },
|
|
});
|
|
});
|
|
expect(itemRow(item.id).is_private).toBe(1);
|
|
expect(recipientIds(item.id)).toEqual([mate.id]);
|
|
});
|
|
|
|
it('returns an item to the common list and forgets its recipients', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { user: mate } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
addTripMember(testDb, trip.id, mate.id);
|
|
const item = createPackingItem(testDb, trip.id);
|
|
testDb.prepare('UPDATE packing_items SET is_private = 1, owner_id = ? WHERE id = ?').run(user.id, item.id);
|
|
testDb.prepare('INSERT INTO packing_item_recipients (item_id, user_id) VALUES (?, ?)').run(item.id, mate.id);
|
|
await withHarness(user.id, async (h) => {
|
|
await h.client.callTool({
|
|
name: 'set_packing_item_sharing',
|
|
arguments: { tripId: trip.id, itemId: item.id, visibility: 'common' },
|
|
});
|
|
});
|
|
expect(itemRow(item.id).is_private).toBe(0);
|
|
expect(recipientIds(item.id)).toEqual([]);
|
|
});
|
|
|
|
it('rebuilds the room\'s view: gone for everyone, back for the people who may see it', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const item = createPackingItem(testDb, trip.id);
|
|
await withHarness(user.id, async (h) => {
|
|
await h.client.callTool({
|
|
name: 'set_packing_item_sharing',
|
|
arguments: { tripId: trip.id, itemId: item.id, visibility: 'personal' },
|
|
});
|
|
});
|
|
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:deleted', expect.any(Object));
|
|
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:created', expect.any(Object), undefined, user.id);
|
|
});
|
|
|
|
it('lets only the owner change sharing', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { user: mate } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
addTripMember(testDb, trip.id, mate.id);
|
|
const item = createPackingItem(testDb, trip.id);
|
|
testDb.prepare('UPDATE packing_items SET owner_id = ? WHERE id = ?').run(mate.id, item.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'set_packing_item_sharing',
|
|
arguments: { tripId: trip.id, itemId: item.id, visibility: 'personal' },
|
|
});
|
|
expect(result.isError).toBe(true);
|
|
});
|
|
expect(itemRow(item.id).is_private).toBe(0);
|
|
});
|
|
|
|
it('returns error for item not found', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'set_packing_item_sharing',
|
|
arguments: { tripId: trip.id, itemId: 99999, visibility: 'personal' },
|
|
});
|
|
expect(result.isError).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('returns access denied for non-member', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { user: other } = createUser(testDb);
|
|
const trip = createTrip(testDb, other.id);
|
|
const item = createPackingItem(testDb, trip.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'set_packing_item_sharing',
|
|
arguments: { tripId: trip.id, itemId: item.id, visibility: 'personal' },
|
|
});
|
|
expect(result.isError).toBe(true);
|
|
});
|
|
expect(itemRow(item.id).is_private).toBe(0);
|
|
});
|
|
|
|
it('blocks demo user', async () => {
|
|
process.env.DEMO_MODE = 'true';
|
|
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
|
const trip = createTrip(testDb, user.id);
|
|
const item = createPackingItem(testDb, trip.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'set_packing_item_sharing',
|
|
arguments: { tripId: trip.id, itemId: item.id, visibility: 'personal' },
|
|
});
|
|
expect(result.isError).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('refuses a visibility tier that does not exist', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const item = createPackingItem(testDb, trip.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'set_packing_item_sharing',
|
|
arguments: { tripId: trip.id, itemId: item.id, visibility: 'nobody' },
|
|
});
|
|
expect(result.isError).toBe(true);
|
|
expect(errorText(result)).toContain('visibility');
|
|
expect(itemRow(item.id).is_private).toBe(0);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// update_packing_bag: weight limit and owner (the values behind the fill bar)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Tool: update_packing_bag limit and owner', () => {
|
|
const bagRow = (id: number) =>
|
|
testDb.prepare('SELECT * FROM packing_bags WHERE id = ?').get(id) as
|
|
{ id: number; name: string; weight_limit_grams: number | null; user_id: number | null };
|
|
|
|
const makeBag = (tripId: number) =>
|
|
Number(testDb.prepare('INSERT INTO packing_bags (trip_id, name) VALUES (?, ?)').run(tripId, 'Carry-On').lastInsertRowid);
|
|
|
|
it('sets a weight limit', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const bagId = makeBag(trip.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'update_packing_bag',
|
|
arguments: { tripId: trip.id, bagId, weight_limit_grams: 8000 },
|
|
});
|
|
expect(result.isError).toBeFalsy();
|
|
expect(bagRow(bagId).weight_limit_grams).toBe(8000);
|
|
});
|
|
});
|
|
|
|
it('lifts the limit with an explicit null', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const bagId = makeBag(trip.id);
|
|
testDb.prepare('UPDATE packing_bags SET weight_limit_grams = 8000 WHERE id = ?').run(bagId);
|
|
await withHarness(user.id, async (h) => {
|
|
await h.client.callTool({
|
|
name: 'update_packing_bag',
|
|
arguments: { tripId: trip.id, bagId, weight_limit_grams: null },
|
|
});
|
|
expect(bagRow(bagId).weight_limit_grams).toBeNull();
|
|
});
|
|
});
|
|
|
|
it('leaves the limit alone when the key is omitted', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const bagId = makeBag(trip.id);
|
|
testDb.prepare('UPDATE packing_bags SET weight_limit_grams = 8000 WHERE id = ?').run(bagId);
|
|
await withHarness(user.id, async (h) => {
|
|
await h.client.callTool({ name: 'update_packing_bag', arguments: { tripId: trip.id, bagId, name: 'Backpack' } });
|
|
const row = bagRow(bagId);
|
|
expect(row.name).toBe('Backpack');
|
|
expect(row.weight_limit_grams).toBe(8000);
|
|
});
|
|
});
|
|
|
|
it('assigns the bag to a trip member', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { user: mate } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
addTripMember(testDb, trip.id, mate.id);
|
|
const bagId = makeBag(trip.id);
|
|
await withHarness(user.id, async (h) => {
|
|
await h.client.callTool({ name: 'update_packing_bag', arguments: { tripId: trip.id, bagId, user_id: mate.id } });
|
|
expect(bagRow(bagId).user_id).toBe(mate.id);
|
|
});
|
|
});
|
|
|
|
it('leaves the bag unassigned for an id off the trip roster', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { user: stranger } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const bagId = makeBag(trip.id);
|
|
await withHarness(user.id, async (h) => {
|
|
await h.client.callTool({ name: 'update_packing_bag', arguments: { tripId: trip.id, bagId, user_id: stranger.id } });
|
|
expect(bagRow(bagId).user_id).toBeNull();
|
|
});
|
|
});
|
|
|
|
it('refuses a weight limit that is not a number', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const bagId = makeBag(trip.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'update_packing_bag',
|
|
arguments: { tripId: trip.id, bagId, weight_limit_grams: '8kg' },
|
|
});
|
|
expect(result.isError).toBe(true);
|
|
expect(errorText(result)).toContain('weight_limit_grams');
|
|
expect(bagRow(bagId).weight_limit_grams).toBeNull();
|
|
});
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// toggle_packing_item
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Tool: toggle_packing_item', () => {
|
|
it('checks a packing item', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const item = createPackingItem(testDb, trip.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'toggle_packing_item',
|
|
arguments: { tripId: trip.id, itemId: item.id, checked: true },
|
|
});
|
|
const data = parseToolResult(result) as any;
|
|
expect(data.item.checked).toBe(1);
|
|
});
|
|
});
|
|
|
|
it('unchecks a packing item', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const item = createPackingItem(testDb, trip.id);
|
|
testDb.prepare('UPDATE packing_items SET checked = 1 WHERE id = ?').run(item.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'toggle_packing_item',
|
|
arguments: { tripId: trip.id, itemId: item.id, checked: false },
|
|
});
|
|
const data = parseToolResult(result) as any;
|
|
expect(data.item.checked).toBe(0);
|
|
});
|
|
});
|
|
|
|
it('broadcasts packing:updated event', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const item = createPackingItem(testDb, trip.id);
|
|
await withHarness(user.id, async (h) => {
|
|
await h.client.callTool({ name: 'toggle_packing_item', arguments: { tripId: trip.id, itemId: item.id, checked: true } });
|
|
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:updated', expect.any(Object));
|
|
});
|
|
});
|
|
|
|
it('returns error for item not found', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({ name: 'toggle_packing_item', arguments: { tripId: trip.id, itemId: 99999, checked: true } });
|
|
expect(result.isError).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('returns access denied for non-member', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { user: other } = createUser(testDb);
|
|
const trip = createTrip(testDb, other.id);
|
|
const item = createPackingItem(testDb, trip.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({ name: 'toggle_packing_item', arguments: { tripId: trip.id, itemId: item.id, checked: true } });
|
|
expect(result.isError).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// delete_packing_item
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Tool: delete_packing_item', () => {
|
|
it('deletes an existing packing item', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const item = createPackingItem(testDb, trip.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({ name: 'delete_packing_item', arguments: { tripId: trip.id, itemId: item.id } });
|
|
const data = parseToolResult(result) as any;
|
|
expect(data.success).toBe(true);
|
|
expect(testDb.prepare('SELECT id FROM packing_items WHERE id = ?').get(item.id)).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
it('broadcasts packing:deleted event', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const item = createPackingItem(testDb, trip.id);
|
|
await withHarness(user.id, async (h) => {
|
|
await h.client.callTool({ name: 'delete_packing_item', arguments: { tripId: trip.id, itemId: item.id } });
|
|
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:deleted', expect.any(Object));
|
|
});
|
|
});
|
|
|
|
it('returns error for item not found', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({ name: 'delete_packing_item', arguments: { tripId: trip.id, itemId: 99999 } });
|
|
expect(result.isError).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('returns access denied for non-member', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { user: other } = createUser(testDb);
|
|
const trip = createTrip(testDb, other.id);
|
|
const item = createPackingItem(testDb, trip.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({ name: 'delete_packing_item', arguments: { tripId: trip.id, itemId: item.id } });
|
|
expect(result.isError).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Scope gating (packing read/write, registration-time)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Packing tools — scope gating', () => {
|
|
const READ_TOOLS = ['list_packing_bags', 'get_packing_category_assignees', 'list_packing_templates'];
|
|
const WRITE_TOOLS = [
|
|
'create_packing_item', 'toggle_packing_item', 'delete_packing_item', 'update_packing_item',
|
|
'reorder_packing_items', 'create_packing_bag', 'update_packing_bag', 'delete_packing_bag',
|
|
'set_bag_members', 'set_packing_category_assignees', 'apply_packing_template',
|
|
'save_packing_template', 'delete_packing_template', 'bulk_import_packing',
|
|
'set_packing_item_sharing',
|
|
];
|
|
|
|
async function listToolNames(userId: number, scopes: string[] | null): Promise<string[]> {
|
|
const h = await createMcpHarness({ userId, withResources: false, scopes });
|
|
try {
|
|
return (await h.client.listTools()).tools.map((t) => t.name);
|
|
} finally {
|
|
await h.cleanup();
|
|
}
|
|
}
|
|
|
|
it('registers all eighteen tools with null scopes (full access)', async () => {
|
|
const { user } = createUser(testDb);
|
|
const names = await listToolNames(user.id, null);
|
|
for (const tool of [...READ_TOOLS, ...WRITE_TOOLS]) expect(names).toContain(tool);
|
|
});
|
|
|
|
it('registers only the read tools with packing:read', async () => {
|
|
const { user } = createUser(testDb);
|
|
const names = await listToolNames(user.id, ['packing:read']);
|
|
for (const tool of READ_TOOLS) expect(names).toContain(tool);
|
|
for (const tool of WRITE_TOOLS) expect(names).not.toContain(tool);
|
|
});
|
|
|
|
it('registers no packing tools for an unrelated scope', async () => {
|
|
const { user } = createUser(testDb);
|
|
const names = await listToolNames(user.id, ['budget:read']);
|
|
for (const tool of [...READ_TOOLS, ...WRITE_TOOLS]) expect(names).not.toContain(tool);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Addon gating (packing addon, the legacy whole-registrar early return —
|
|
// now the `when` predicate on every PackingMcp entry)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Packing tools — packing addon gating', () => {
|
|
it('registers nothing (tools or resources) when the packing addon is disabled', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
testDb.prepare('UPDATE addons SET enabled = 0 WHERE id = ?').run(ADDON_IDS.PACKING);
|
|
try {
|
|
await withHarness(user.id, async (h) => {
|
|
const names = (await h.client.listTools()).tools.map((t) => t.name);
|
|
expect(names).not.toContain('create_packing_item');
|
|
expect(names).not.toContain('list_packing_bags');
|
|
await expect(h.client.readResource({ uri: `trek://trips/${trip.id}/packing` })).rejects.toThrow();
|
|
await expect(h.client.readResource({ uri: `trek://trips/${trip.id}/packing/bags` })).rejects.toThrow();
|
|
});
|
|
} finally {
|
|
testDb.prepare('UPDATE addons SET enabled = 1 WHERE id = ?').run(ADDON_IDS.PACKING);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// trek://trips/{tripId}/packing resource (moved from the legacy registerResources)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Resource: trek://trips/{tripId}/packing', () => {
|
|
it('returns packing items for a trip', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
createPackingItem(testDb, trip.id, { name: 'Passport' });
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.readResource({ uri: `trek://trips/${trip.id}/packing` });
|
|
const items = parseResourceResult(result) as { name: string }[];
|
|
expect(items).toHaveLength(1);
|
|
expect(items[0].name).toBe('Passport');
|
|
});
|
|
});
|
|
|
|
it('hides another member\'s private items from the requesting user (#858)', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { user: owner } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
testDb.prepare('INSERT INTO packing_items (trip_id, name, checked, sort_order, is_private, owner_id) VALUES (?, ?, 0, 0, 1, ?)')
|
|
.run(trip.id, 'Secret gift', owner.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.readResource({ uri: `trek://trips/${trip.id}/packing` });
|
|
expect(parseResourceResult(result)).toEqual([]);
|
|
});
|
|
});
|
|
|
|
it('returns the access-denied payload for a non-member', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { user: other } = createUser(testDb);
|
|
const trip = createTrip(testDb, other.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.readResource({ uri: `trek://trips/${trip.id}/packing` });
|
|
expect(parseResourceResult(result)).toEqual({ error: 'Trip not found or access denied' });
|
|
});
|
|
});
|
|
|
|
it('returns the access-denied payload for a malformed trip id', async () => {
|
|
const { user } = createUser(testDb);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.readResource({ uri: 'trek://trips/not-a-number/packing' });
|
|
expect(parseResourceResult(result)).toEqual({ error: 'Trip not found or access denied' });
|
|
});
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// trek://trips/{tripId}/packing/bags resource (moved from the legacy registerResources)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Resource: trek://trips/{tripId}/packing/bags', () => {
|
|
it('returns the bags with their members', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const bagId = Number(testDb.prepare('INSERT INTO packing_bags (trip_id, name) VALUES (?, ?)').run(trip.id, 'Carry-On').lastInsertRowid);
|
|
testDb.prepare('INSERT INTO packing_bag_members (bag_id, user_id) VALUES (?, ?)').run(bagId, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.readResource({ uri: `trek://trips/${trip.id}/packing/bags` });
|
|
const bags = parseResourceResult(result) as { name: string; members: { user_id: number }[] }[];
|
|
expect(bags).toHaveLength(1);
|
|
expect(bags[0].name).toBe('Carry-On');
|
|
expect(bags[0].members.map((m) => m.user_id)).toEqual([user.id]);
|
|
});
|
|
});
|
|
|
|
it('returns the access-denied payload for a non-member', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { user: other } = createUser(testDb);
|
|
const trip = createTrip(testDb, other.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.readResource({ uri: `trek://trips/${trip.id}/packing/bags` });
|
|
expect(parseResourceResult(result)).toEqual({ error: 'Trip not found or access denied' });
|
|
});
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Private items over MCP (#1976)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* A restricted packing item, changed through a tool, must reach only the people
|
|
* who may see it.
|
|
*
|
|
* The REST and RPC surfaces have scoped this since #858 — they go through
|
|
* emitToViewers, which hands the event to the owner and the recipients. The MCP
|
|
* tools broadcast to the whole trip room instead, so asking an assistant to
|
|
* tick off something on your own list pushed that row to every other member.
|
|
*
|
|
* It did not stop at the wire either: the client stores what arrives
|
|
* (remoteEventHandler -> putPackingItem -> bulkPut) with no owner check, and the
|
|
* offline read path returns every cached row for the trip. So the leaked item
|
|
* stayed in the other member's IndexedDB and rendered whenever their next read
|
|
* fell back to the cache.
|
|
*
|
|
* These cases pin the wire, which is where it has to be fixed: a room-wide call
|
|
* still takes three arguments, so nothing about a shared item changes.
|
|
*/
|
|
describe('a restricted packing item over MCP', () => {
|
|
const makePrivate = (itemId: number, ownerId: number) =>
|
|
testDb.prepare('UPDATE packing_items SET is_private = 1, owner_id = ? WHERE id = ?').run(ownerId, itemId);
|
|
|
|
it('is ticked off for its owner alone, not for the whole trip', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const item = createPackingItem(testDb, trip.id);
|
|
makePrivate(item.id, user.id);
|
|
|
|
await withHarness(user.id, async (h) => {
|
|
await h.client.callTool({
|
|
name: 'toggle_packing_item',
|
|
arguments: { tripId: trip.id, itemId: item.id, checked: true },
|
|
});
|
|
});
|
|
|
|
// The assertion that would have caught the leak: a fifth argument naming
|
|
// the only user this may reach.
|
|
expect(broadcastMock).toHaveBeenCalledWith(
|
|
trip.id, 'packing:updated', expect.any(Object), undefined, user.id,
|
|
);
|
|
expect(broadcastMock).not.toHaveBeenCalledWith(trip.id, 'packing:updated', expect.any(Object));
|
|
});
|
|
|
|
it('is renamed for its owner alone', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const item = createPackingItem(testDb, trip.id);
|
|
makePrivate(item.id, user.id);
|
|
|
|
await withHarness(user.id, async (h) => {
|
|
await h.client.callTool({
|
|
name: 'update_packing_item',
|
|
arguments: { tripId: trip.id, itemId: item.id, name: 'Insulin pens' },
|
|
});
|
|
});
|
|
|
|
expect(broadcastMock).toHaveBeenCalledWith(
|
|
trip.id, 'packing:updated', expect.any(Object), undefined, user.id,
|
|
);
|
|
expect(broadcastMock).not.toHaveBeenCalledWith(trip.id, 'packing:updated', expect.any(Object));
|
|
});
|
|
|
|
/*
|
|
* The delete carries only an id, which looks harmless — but it names an id
|
|
* the other members were never told about, and it removes a row from their
|
|
* store that a leak had put there. Scoped the same way, from the row the
|
|
* delete hands back.
|
|
*/
|
|
it('is deleted for its owner alone', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const item = createPackingItem(testDb, trip.id);
|
|
makePrivate(item.id, user.id);
|
|
|
|
await withHarness(user.id, async (h) => {
|
|
await h.client.callTool({
|
|
name: 'delete_packing_item',
|
|
arguments: { tripId: trip.id, itemId: item.id },
|
|
});
|
|
});
|
|
|
|
expect(broadcastMock).toHaveBeenCalledWith(
|
|
trip.id, 'packing:deleted', expect.any(Object), undefined, user.id,
|
|
);
|
|
expect(broadcastMock).not.toHaveBeenCalledWith(trip.id, 'packing:deleted', expect.any(Object));
|
|
});
|
|
|
|
it('still tells the whole room about a shared one, unchanged', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const item = createPackingItem(testDb, trip.id);
|
|
|
|
await withHarness(user.id, async (h) => {
|
|
await h.client.callTool({
|
|
name: 'toggle_packing_item',
|
|
arguments: { tripId: trip.id, itemId: item.id, checked: true },
|
|
});
|
|
});
|
|
|
|
// Three arguments exactly, which is what every other packing case asserts.
|
|
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:updated', expect.any(Object));
|
|
});
|
|
});
|