mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
8f171453d3
A restricted packing item, changed through an MCP tool, went to every socket in the trip room. Ticking something off your own list through an assistant pushed that row to every other member. The REST and RPC surfaces have scoped these events since #858 — both go through emitToViewers, which delivers to the owner and the recipients and nobody else. The MCP tools called safeBroadcast, which took three arguments and had no way to say who may see the thing that changed. So safeBroadcast learned that fourth argument, and the packing tools pass viewersOf(item), which is the same answer the REST route asks for. A shared item still answers null and still goes to the room in a three-argument call, so nothing about it changes — including what the hundred-odd broadcast mocks in the suite record. The client stops trusting the wire on this too. It used to `put` whatever arrived, and the offline read hands back every cached row for the trip with no privacy filter, so a single stray event lodged another member's item in that browser permanently. It now drops a restricted item that is neither mine nor shared with me — deleting rather than skipping, so a list that was already leaked into heals itself the next time an event about that item comes past. Closes #1976
549 lines
23 KiB
TypeScript
549 lines
23 KiB
TypeScript
/**
|
|
* Unit tests for the packing MCP surface (PackingMcp, DI-discovered):
|
|
* create_packing_item, update_packing_item, toggle_packing_item,
|
|
* delete_packing_item, plus 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 } 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(); }
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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('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);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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',
|
|
];
|
|
|
|
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 seventeen 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));
|
|
});
|
|
});
|