mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(mcp): bags, quantities and the sharing tiers of a packing item
Bag assignment was reachable only while bulk-importing a list, and by bag name, never for an item that already exists. Quantity, weight and the private flag were not reachable at all, and the whole three-tier sharing model was invisible: an item made by a tool was always Common.
This commit is contained in:
@@ -10,6 +10,13 @@ import { AuthService } from '../auth/auth.service';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { noAccess, permissionDenied, adminRequired } from '../../mcp/tools/_shared';
|
||||
import { PackingService } from './packing.service';
|
||||
import {
|
||||
packingCreateItemRequestSchema,
|
||||
packingSetSharingRequestSchema,
|
||||
packingUpdateBagRequestSchema,
|
||||
packingUpdateItemRequestSchema,
|
||||
type PackingVisibility,
|
||||
} from '@trek/shared';
|
||||
import { addonGate } from '../addons/addon-gate';
|
||||
import { AddonsService } from '../addons/addons.service';
|
||||
|
||||
@@ -32,6 +39,10 @@ function parseId(value: string | string[]): number | null {
|
||||
* read/write access markers (the legacy `if (R)` / `if (W)` checks, resolved
|
||||
* by trekMcpAccessPolicy). The two template-management tools keep their inline
|
||||
* admin gate (isAdminUser), matching the REST routes.
|
||||
*
|
||||
* set_packing_item_sharing is the one tool with no legacy ancestor: PUT
|
||||
* /:id/sharing had no counterpart here, so the whole three-tier sharing model
|
||||
* (#858) was invisible to an assistant.
|
||||
*/
|
||||
@McpController()
|
||||
export class PackingMcp {
|
||||
@@ -46,24 +57,38 @@ export class PackingMcp {
|
||||
|
||||
@Tool({
|
||||
name: 'create_packing_item',
|
||||
description: 'Add an item to the packing checklist for a trip.',
|
||||
description: 'Add an item to the packing checklist for a trip. It lands on the common list everyone shares unless visibility says otherwise; use set_packing_item_sharing to move an existing item between those tiers.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
name: z.string().min(1).max(200),
|
||||
category: z.string().max(100).optional().describe('Packing category (e.g. Clothes, Electronics)'),
|
||||
checked: packingCreateItemRequestSchema.shape.checked.describe('Create the item already ticked off'),
|
||||
is_private: packingCreateItemRequestSchema.shape.is_private.describe('Keep the item to yourself; visibility says the same thing with more nuance'),
|
||||
visibility: packingCreateItemRequestSchema.shape.visibility.describe("Which list the item belongs to: 'common' (the group pool, the default), 'personal' (yours alone), or 'shared' (yours plus recipient_ids)"),
|
||||
recipient_ids: packingCreateItemRequestSchema.shape.recipient_ids.describe("For visibility 'shared': the trip members the item is brought for. Ignored otherwise, and ids outside the trip roster are dropped"),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
when: packingAddonOn,
|
||||
access: { group: 'packing', mode: 'write' },
|
||||
})
|
||||
async createPackingItem({ tripId, name, category }: { tripId: number; name: string; category?: string }, ctx: McpContext) {
|
||||
async createPackingItem(
|
||||
{ tripId, name, category, checked, is_private, visibility, recipient_ids }: { tripId: number; name: string; category?: string; checked?: boolean | number; is_private?: boolean; visibility?: PackingVisibility; recipient_ids?: number[] },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
const item = this.packing.createItem(tripId, { name, category: category || 'General' }, ctx.userId);
|
||||
// A tool-made item is Common today, so this asks the room. Routed through
|
||||
// viewersOf anyway, so the day createItem learns to make a restricted one
|
||||
// this does not have to be remembered a second time.
|
||||
const item = this.packing.createItem(tripId, {
|
||||
name,
|
||||
category: category || 'General',
|
||||
// checked takes a boolean or the legacy 0/1, exactly as the REST body does.
|
||||
checked: checked === undefined ? undefined : !!checked,
|
||||
is_private,
|
||||
visibility,
|
||||
recipient_ids,
|
||||
}, ctx.userId);
|
||||
// A restricted item (#858) reaches its owner and recipients only; a Common
|
||||
// one answers null here and goes to the whole room.
|
||||
this.guards.safeBroadcast(tripId, 'packing:created', { item }, this.packing.viewersOf(item));
|
||||
return ok({ item });
|
||||
}
|
||||
@@ -119,25 +144,88 @@ export class PackingMcp {
|
||||
|
||||
@Tool({
|
||||
name: 'update_packing_item',
|
||||
description: 'Rename a packing item or change its category.',
|
||||
description: 'Change a packing item: rename it, recategorise it, move it into a bag, set how many are needed, record its weight, or flip it between the common list and your own. Ticking it off is toggle_packing_item; choosing who a private item is shared with is set_packing_item_sharing.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
itemId: z.number().int().positive(),
|
||||
name: z.string().min(1).max(200).optional(),
|
||||
category: z.string().max(100).optional(),
|
||||
bag_id: packingUpdateItemRequestSchema.shape.bag_id.describe('Bag to pack the item into (ids come from list_packing_bags); null takes it out of its bag'),
|
||||
quantity: packingUpdateItemRequestSchema.shape.quantity.describe('How many to pack, clamped to 1-999'),
|
||||
weight_grams: packingUpdateItemRequestSchema.shape.weight_grams.describe('Weight in grams, which feeds the bag fill bar; null clears it'),
|
||||
is_private: packingUpdateItemRequestSchema.shape.is_private.describe('true takes the item off the common list and onto the caller\'s own'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
when: packingAddonOn,
|
||||
access: { group: 'packing', mode: 'write' },
|
||||
})
|
||||
async updatePackingItem({ tripId, itemId, name, category }: { tripId: number; itemId: number; name?: string; category?: string }, ctx: McpContext) {
|
||||
async updatePackingItem(
|
||||
{ tripId, itemId, name, category, bag_id, quantity, weight_grams, is_private }: { tripId: number; itemId: number; name?: string; category?: string; bag_id?: number | null; quantity?: number; weight_grams?: number | null; is_private?: boolean },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
const bodyKeys = ['name', 'category'].filter(k => k === 'name' ? name !== undefined : category !== undefined);
|
||||
const item = this.packing.updateItem(tripId, itemId, { name, category }, bodyKeys, undefined, ctx.userId);
|
||||
const fields = { name, category, bag_id, quantity, weight_grams, is_private };
|
||||
// The service reads presence from bodyKeys, so a field has to be named there
|
||||
// for an explicit null to clear it rather than read as "leave it alone".
|
||||
const bodyKeys = Object.keys(fields).filter(k => fields[k as keyof typeof fields] !== undefined);
|
||||
// Privacy state before the write, so a public↔private flip routes the
|
||||
// broadcast the way the REST route does instead of leaking a freshly
|
||||
// privatized item (or leaving a stale copy on everyone else's screen).
|
||||
const wasPrivate = !!this.packing.getItemPrivacy(tripId, itemId)?.is_private;
|
||||
const item = this.packing.updateItem(tripId, itemId, fields, bodyKeys, undefined, ctx.userId);
|
||||
if (!item) return errorResult('Packing item not found.');
|
||||
this.guards.safeBroadcast(tripId, 'packing:updated', { item }, this.packing.viewersOf(item));
|
||||
this.broadcastItemUpdate(tripId, itemId, item, wasPrivate);
|
||||
return ok({ item });
|
||||
}
|
||||
|
||||
/**
|
||||
* The four privacy transitions of an item update (#858), as
|
||||
* PackingService.broadcastUpdate does them for REST, but over safeBroadcast so
|
||||
* the events keep the MCP marker and the tool survives a broadcast failure.
|
||||
*/
|
||||
private broadcastItemUpdate(tripId: number, itemId: number, item: { is_private?: number; owner_id?: number | null; recipients?: { user_id: number }[] }, wasPrivate: boolean) {
|
||||
const viewers = this.packing.viewersOf(item);
|
||||
if (item.is_private) {
|
||||
// Newly restricted: take it off the room's screens first, then hand it
|
||||
// back to the people who may still see it.
|
||||
if (!wasPrivate) this.guards.safeBroadcast(tripId, 'packing:deleted', { itemId });
|
||||
this.guards.safeBroadcast(tripId, wasPrivate ? 'packing:updated' : 'packing:created', { item }, viewers);
|
||||
return;
|
||||
}
|
||||
// Newly common: the members who never had the row need it created, not updated.
|
||||
if (wasPrivate) this.guards.safeBroadcast(tripId, 'packing:created', { item });
|
||||
this.guards.safeBroadcast(tripId, 'packing:updated', { item });
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'set_packing_item_sharing',
|
||||
description: 'Move an existing packing item between the three sharing tiers: the common list the whole trip pools into, the owner\'s own list, or shared with named trip members. Only the item\'s owner may change this. Everything else about an item is update_packing_item.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
itemId: z.number().int().positive(),
|
||||
visibility: packingSetSharingRequestSchema.shape.visibility.describe("'common' puts the item in the group pool, 'personal' keeps it to the owner, 'shared' covers the people in recipient_ids"),
|
||||
recipient_ids: packingSetSharingRequestSchema.shape.recipient_ids.describe("For 'shared': the trip members the item is brought for. Ids outside the trip roster are dropped, and any previous recipients are replaced"),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
when: packingAddonOn,
|
||||
access: { group: 'packing', mode: 'write' },
|
||||
})
|
||||
async setPackingItemSharing(
|
||||
{ tripId, itemId, visibility, recipient_ids }: { tripId: number; itemId: number; visibility: PackingVisibility; recipient_ids?: number[] },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
const item = this.packing.setItemSharing(tripId, itemId, ctx.userId, visibility, recipient_ids ?? []);
|
||||
if (!item) return errorResult('Packing item not found.');
|
||||
if ((item as { forbidden?: boolean }).forbidden) return errorResult('Only the owner can change sharing.');
|
||||
// The viewer set just changed: drop the item from the whole room, then hand
|
||||
// it back to whoever may see it now, as the REST route does.
|
||||
this.guards.safeBroadcast(tripId, 'packing:deleted', { itemId });
|
||||
this.guards.safeBroadcast(tripId, 'packing:created', { item }, this.packing.viewersOf(item));
|
||||
return ok({ item });
|
||||
}
|
||||
|
||||
@@ -204,25 +292,34 @@ export class PackingMcp {
|
||||
|
||||
@Tool({
|
||||
name: 'update_packing_bag',
|
||||
description: 'Rename or recolor a packing bag.',
|
||||
description: 'Rename or recolor a packing bag, give it a weight limit, or hand it to one traveller. Who else packs into it is set_bag_members.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
bagId: z.number().int().positive(),
|
||||
name: z.string().optional(),
|
||||
color: z.string().optional(),
|
||||
weight_limit_grams: packingUpdateBagRequestSchema.shape.weight_limit_grams.describe('Allowance in grams the bag is measured against (the fill bar); null lifts the limit'),
|
||||
user_id: packingUpdateBagRequestSchema.shape.user_id.describe('Trip member the bag belongs to; null leaves it unassigned, and an id outside the trip roster unassigns it too'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
when: packingAddonOn,
|
||||
access: { group: 'packing', mode: 'write' },
|
||||
})
|
||||
async updatePackingBag({ tripId, bagId, name, color }: { tripId: number; bagId: number; name?: string; color?: string }, ctx: McpContext) {
|
||||
async updatePackingBag(
|
||||
{ tripId, bagId, name, color, weight_limit_grams, user_id }: { tripId: number; bagId: number; name?: string; color?: string; weight_limit_grams?: number | null; user_id?: number | null },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
const fields: Record<string, unknown> = {};
|
||||
const fields: { name?: string; color?: string; weight_limit_grams?: number | null; user_id?: number | null } = {};
|
||||
const bodyKeys: string[] = [];
|
||||
if (name !== undefined) { fields.name = name; bodyKeys.push('name'); }
|
||||
if (color !== undefined) { fields.color = color; bodyKeys.push('color'); }
|
||||
// Both follow the presence protocol: an omitted key leaves the value alone,
|
||||
// an explicit null clears it.
|
||||
if (weight_limit_grams !== undefined) { fields.weight_limit_grams = weight_limit_grams; bodyKeys.push('weight_limit_grams'); }
|
||||
if (user_id !== undefined) { fields.user_id = user_id; bodyKeys.push('user_id'); }
|
||||
const updated = this.packing.updateBag(tripId, bagId, fields, bodyKeys);
|
||||
if (!updated) return errorResult('Bag not found.');
|
||||
// Hydrate with the members array (matches create_packing_bag, listBags, and the schema).
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* 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
|
||||
* 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.
|
||||
@@ -44,7 +45,7 @@ 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 { createUser, createTrip, createPackingItem, addTripMember } from '../../helpers/factories';
|
||||
import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness';
|
||||
import { ADDON_IDS } from '../../../src/addons';
|
||||
|
||||
@@ -68,6 +69,11 @@ async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>)
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -131,6 +137,109 @@ describe('Tool: create_packing_item', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// update_packing_item
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -182,6 +291,383 @@ describe('Tool: update_packing_item', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -306,6 +792,7 @@ describe('Packing tools — scope gating', () => {
|
||||
'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[]> {
|
||||
@@ -317,7 +804,7 @@ describe('Packing tools — scope gating', () => {
|
||||
}
|
||||
}
|
||||
|
||||
it('registers all seventeen tools with null scopes (full access)', async () => {
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user