Files
TREK/server/tests/unit/mcp/tools-trip-members.test.ts
T
Maurice 724c88c201 style: format server and shared with prettier
A mechanical prettier --write over server/src, server/tests, server/scripts
and shared/src with the workspaces' .prettierrc; nothing but layout and
import order changes, and every file keeps the same set of imports. shared
is formatted throughout and its format baseline is empty. The server
baseline keeps the 35 files that Prettier would grow past their lint:size
limit (they need a split first, which is not a formatting change), and the
generated entity files stay as their generator writes them.
2026-10-10 21:23:37 +02:00

958 lines
37 KiB
TypeScript

/**
* Unit tests for MCP trip member, guest, copy, ICS, and share-link tools:
* list_trip_members, add_trip_member, remove_trip_member,
* create_trip_guest, rename_trip_guest, delete_trip_guest,
* copy_trip, export_trip_ics, get_share_link, create_share_link, delete_share_link.
*/
import { db as testDb } from '../../../src/db/database';
import { ShareTokens } from '../../../src/db/entities/ShareTokens.entity';
import { TripMembers } from '../../../src/db/entities/TripMembers.entity';
import { Trips } from '../../../src/db/entities/Trips.entity';
import { Users } from '../../../src/db/entities/Users.entity';
import { invalidatePermissionsCache } from '../../../src/nest/permissions/permissions-cache';
import { createUser, createAdmin, createTrip, addTripMember } from '../../helpers/factories';
import { countRows, findRow } from '../../helpers/factories/rows';
import { setAppSetting } from '../../helpers/factories/settings';
import { makeShareToken } from '../../helpers/factories/trips';
import { FakeRealtimeService } from '../../helpers/fake-realtime';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
import { resetTestDb } from '../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
vi.mock('../../../src/db/database', async () => {
const { createSnapshotTestDb, buildDbMock } = await import('../../helpers/db-mock');
return buildDbMock(createSnapshotTestDb());
});
const realtime = new FakeRealtimeService();
const broadcastMock = realtime.broadcastMock;
let orm: TestOrm;
beforeAll(async () => {
orm = await createTestOrm(testDb);
});
function memberRow(tripId: number, userId: number) {
return findRow(orm, TripMembers, { trip: tripId, user: userId });
}
async function userRow(id: number) {
return (await findRow(orm, Users, { id }))!;
}
beforeEach(async () => {
resetTestDb(testDb);
broadcastMock.mockClear();
delete process.env.DEMO_MODE;
// resetTestDb truncates app_settings, but the permission cache is module-scoped
// and would keep serving whatever the previous case configured.
await invalidatePermissionsCache();
});
afterAll(async () => {
await orm.close();
testDb.close();
});
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
const h = await createMcpHarness({ realtime, userId, withResources: false });
try {
await fn(h);
} finally {
await h.cleanup();
}
}
/** Lower a configurable action the way the admin permission panel does. */
async function setPermission(action: string, level: string): Promise<void> {
await setAppSetting(orm, `perm_${action}`, level);
await invalidatePermissionsCache();
}
// ---------------------------------------------------------------------------
// list_trip_members
// ---------------------------------------------------------------------------
describe('Tool: list_trip_members', () => {
it('returns owner and empty members list for own trip', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_trip_members', arguments: { tripId: trip.id } });
const data = parseToolResult(result) as any;
expect(data.owner.id).toBe(user.id);
expect(data.owner.role).toBe('owner');
expect(Array.isArray(data.members)).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);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_trip_members', arguments: { tripId: trip.id } });
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// add_trip_member
// ---------------------------------------------------------------------------
describe('Tool: add_trip_member', () => {
it('adds a member by username', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
await withHarness(owner.id, async (h) => {
const result = await h.client.callTool({
name: 'add_trip_member',
arguments: { tripId: trip.id, identifier: member.username },
});
const data = parseToolResult(result) as any;
expect(data.member.username).toBe(member.username);
expect(data.member.role).toBe('member');
});
});
it('broadcasts member:added event', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
await withHarness(owner.id, async (h) => {
await h.client.callTool({
name: 'add_trip_member',
arguments: { tripId: trip.id, identifier: member.email },
});
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'member:added', expect.any(Object));
});
});
it('returns error when user not found', async () => {
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
await withHarness(owner.id, async (h) => {
const result = await h.client.callTool({
name: 'add_trip_member',
arguments: { tripId: trip.id, identifier: 'nonexistent@example.com' },
});
expect(result.isError).toBe(true);
});
});
it('returns error when non-owner tries to add', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const { user: outsider } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, member.id);
await withHarness(member.id, async (h) => {
const result = await h.client.callTool({
name: 'add_trip_member',
arguments: { tripId: trip.id, identifier: outsider.username },
});
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: 'add_trip_member',
arguments: { tripId: trip.id, identifier: 'someone@example.com' },
});
expect(result.isError).toBe(true);
});
});
it('lets a collaborator add when member_manage sits at trip_member', async () => {
const { user: owner } = createUser(testDb);
const { user: collaborator } = createUser(testDb);
const { user: outsider } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, collaborator.id);
await setPermission('member_manage', 'trip_member');
await withHarness(collaborator.id, async (h) => {
const result = await h.client.callTool({
name: 'add_trip_member',
arguments: { tripId: trip.id, identifier: outsider.username },
});
expect(result.isError).toBeFalsy();
const row = await memberRow(trip.id, outsider.id);
expect(row).toBeTruthy();
expect(row!.invited_by).toBe(collaborator.id);
});
});
it('lets a site admin on the trip add a member without owning it', async () => {
const { user: owner } = createUser(testDb);
const { user: admin } = createAdmin(testDb);
const { user: outsider } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, admin.id);
await withHarness(admin.id, async (h) => {
const result = await h.client.callTool({
name: 'add_trip_member',
arguments: { tripId: trip.id, identifier: outsider.username },
});
expect(result.isError).toBeFalsy();
expect(await memberRow(trip.id, outsider.id)).toBeTruthy();
});
});
it('still refuses a collaborator while member_manage sits at its trip_owner default', async () => {
const { user: owner } = createUser(testDb);
const { user: collaborator } = createUser(testDb);
const { user: outsider } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, collaborator.id);
await withHarness(collaborator.id, async (h) => {
const result = await h.client.callTool({
name: 'add_trip_member',
arguments: { tripId: trip.id, identifier: outsider.username },
});
expect(result.isError).toBe(true);
expect(await memberRow(trip.id, outsider.id)).toBeNull();
});
});
});
// ---------------------------------------------------------------------------
// remove_trip_member
// ---------------------------------------------------------------------------
describe('Tool: remove_trip_member', () => {
it('removes a member', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, member.id);
await withHarness(owner.id, async (h) => {
const result = await h.client.callTool({
name: 'remove_trip_member',
arguments: { tripId: trip.id, memberId: member.id },
});
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
const row = await memberRow(trip.id, member.id);
expect(row).toBeNull();
});
});
it('broadcasts member:removed event', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, member.id);
await withHarness(owner.id, async (h) => {
await h.client.callTool({ name: 'remove_trip_member', arguments: { tripId: trip.id, memberId: member.id } });
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'member:removed', expect.any(Object));
});
});
it('returns error when non-owner tries to remove', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, member.id);
await withHarness(member.id, async (h) => {
const result = await h.client.callTool({
name: 'remove_trip_member',
arguments: { tripId: trip.id, memberId: owner.id },
});
expect(result.isError).toBe(true);
});
});
it('leaves another member in place when a collaborator has no member_manage', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, member.id);
addTripMember(testDb, trip.id, other.id);
await withHarness(member.id, async (h) => {
const result = await h.client.callTool({
name: 'remove_trip_member',
arguments: { tripId: trip.id, memberId: other.id },
});
expect(result.isError).toBe(true);
expect(await memberRow(trip.id, other.id)).toBeTruthy();
});
});
it('lets a collaborator remove another member when member_manage sits at trip_member', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, member.id);
addTripMember(testDb, trip.id, other.id);
await setPermission('member_manage', 'trip_member');
await withHarness(member.id, async (h) => {
const result = await h.client.callTool({
name: 'remove_trip_member',
arguments: { tripId: trip.id, memberId: other.id },
});
expect(result.isError).toBeFalsy();
expect(await memberRow(trip.id, other.id)).toBeNull();
});
});
it('lets a site admin on the trip remove a member without owning it', async () => {
const { user: owner } = createUser(testDb);
const { user: admin } = createAdmin(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, admin.id);
addTripMember(testDb, trip.id, other.id);
await withHarness(admin.id, async (h) => {
const result = await h.client.callTool({
name: 'remove_trip_member',
arguments: { tripId: trip.id, memberId: other.id },
});
expect(result.isError).toBeFalsy();
expect(await memberRow(trip.id, other.id)).toBeNull();
});
});
it('lets a member remove themselves without member_manage', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, member.id);
await withHarness(member.id, async (h) => {
const result = await h.client.callTool({
name: 'remove_trip_member',
arguments: { tripId: trip.id, memberId: member.id },
});
expect((parseToolResult(result) as any).success).toBe(true);
expect(await memberRow(trip.id, member.id)).toBeNull();
});
});
});
// ---------------------------------------------------------------------------
// leave_trip
// ---------------------------------------------------------------------------
describe('Tool: leave_trip', () => {
it('drops the caller from the roster', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, member.id);
await withHarness(member.id, async (h) => {
const result = await h.client.callTool({ name: 'leave_trip', arguments: { tripId: trip.id } });
expect((parseToolResult(result) as any).success).toBe(true);
expect(await memberRow(trip.id, member.id)).toBeNull();
expect(await findRow(orm, Trips, { id: trip.id })).toBeTruthy();
});
});
it('broadcasts member:removed for the leaving user', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, member.id);
await withHarness(member.id, async (h) => {
await h.client.callTool({ name: 'leave_trip', arguments: { tripId: trip.id } });
expect(broadcastMock).toHaveBeenCalledWith(
trip.id,
'member:removed',
expect.objectContaining({ userId: member.id }),
);
});
});
it('leaves the other members alone', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, member.id);
addTripMember(testDb, trip.id, other.id);
await withHarness(member.id, async (h) => {
await h.client.callTool({ name: 'leave_trip', arguments: { tripId: trip.id } });
expect(await memberRow(trip.id, other.id)).toBeTruthy();
});
});
it('refuses the owner and keeps the trip theirs', async () => {
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
await withHarness(owner.id, async (h) => {
const result = await h.client.callTool({ name: 'leave_trip', arguments: { tripId: trip.id } });
expect(result.isError).toBe(true);
const row = (await findRow(orm, Trips, { id: trip.id }))!;
expect(row.user_id).toBe(owner.id);
});
});
it('returns access denied for a non-member', async () => {
const { user: outsider } = createUser(testDb);
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
await withHarness(outsider.id, async (h) => {
const result = await h.client.callTool({ name: 'leave_trip', arguments: { tripId: trip.id } });
expect(result.isError).toBe(true);
});
});
it('blocks demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user: owner } = createUser(testDb);
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'leave_trip', arguments: { tripId: trip.id } });
expect(result.isError).toBe(true);
expect(await memberRow(trip.id, user.id)).toBeTruthy();
});
});
});
// ---------------------------------------------------------------------------
// copy_trip
// ---------------------------------------------------------------------------
describe('Tool: copy_trip', () => {
it('duplicates a trip', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Original', start_date: '2025-01-01', end_date: '2025-01-03' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'copy_trip', arguments: { tripId: trip.id } });
const data = parseToolResult(result) as any;
expect(data.trip).toBeTruthy();
// New trip should be a different row
const count = await countRows(orm, Trips);
expect(count).toBe(2);
});
});
it('uses custom title when provided', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Original' });
await withHarness(user.id, async (h) => {
await h.client.callTool({ name: 'copy_trip', arguments: { tripId: trip.id, title: 'My Copy' } });
const newTrip = await findRow(orm, Trips, { title: 'My Copy' });
expect(newTrip).toBeTruthy();
});
});
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: 'copy_trip', arguments: { tripId: trip.id } });
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: 'copy_trip', arguments: { tripId: trip.id } });
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// export_trip_ics
// ---------------------------------------------------------------------------
describe('Tool: export_trip_ics', () => {
it('returns ICS content for a trip', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Paris Trip', start_date: '2025-06-01', end_date: '2025-06-05' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'export_trip_ics', arguments: { tripId: trip.id } });
const data = parseToolResult(result) as any;
expect(data.ics).toContain('BEGIN:VCALENDAR');
expect(data.ics).toContain('Paris Trip');
expect(data.filename).toMatch(/\.ics$/);
});
});
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: 'export_trip_ics', arguments: { tripId: trip.id } });
expect(result.isError).toBe(true);
});
});
// Same calendar filename as the REST route (#2165): U+3000 used to slip
// through the \s keep-class and crash the header there; it folds to _ now.
it('folds header-hostile whitespace out of the filename', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, {
title: '沖縄 4泊5日',
start_date: '2025-06-01',
end_date: '2025-06-05',
});
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'export_trip_ics', arguments: { tripId: trip.id } });
const data = parseToolResult(result) as { filename: string };
expect(data.filename).toBe('___4_5_.ics');
});
});
});
// ---------------------------------------------------------------------------
// get_share_link / create_share_link / delete_share_link
// ---------------------------------------------------------------------------
describe('Tool: get_share_link', () => {
it('returns null when no share link exists', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'get_share_link', arguments: { tripId: trip.id } });
const data = parseToolResult(result) as any;
expect(data.link).toBeNull();
});
});
it('returns share link info when it exists', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
// Create a share link directly
await makeShareToken(orm, trip.id, user.id, {
token: 'test-token-123',
share_map: 1,
share_bookings: 1,
share_packing: 0,
share_budget: 0,
share_collab: 0,
});
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'get_share_link', arguments: { tripId: trip.id } });
const data = parseToolResult(result) as any;
expect(data.link.token).toBe('test-token-123');
expect(data.link.share_map).toBe(true);
});
});
});
describe('Tool: create_share_link', () => {
it('creates a new share link', 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_share_link',
arguments: { tripId: trip.id, share_map: true, share_bookings: false, share_packing: false },
});
const data = parseToolResult(result) as any;
expect(data.token).toBeTruthy();
expect(data.created).toBe(true);
});
});
it('updates existing share link permissions', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await makeShareToken(orm, trip.id, user.id, {
token: 'existing-token',
share_map: 1,
share_bookings: 1,
share_packing: 0,
share_budget: 0,
share_collab: 0,
});
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_share_link',
arguments: { tripId: trip.id, share_packing: true },
});
const data = parseToolResult(result) as any;
expect(data.created).toBe(false); // updated, not created
});
});
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_share_link', arguments: { tripId: trip.id } });
expect(result.isError).toBe(true);
});
});
});
describe('Tool: delete_share_link', () => {
it('revokes the share link', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await makeShareToken(orm, trip.id, user.id, {
token: 'to-delete',
share_map: 1,
share_bookings: 1,
share_packing: 0,
share_budget: 0,
share_collab: 0,
});
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'delete_share_link', arguments: { tripId: trip.id } });
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
const row = await findRow(orm, ShareTokens, { trip: trip.id });
expect(row).toBeNull();
});
});
});
// ---------------------------------------------------------------------------
// Guests (#1362)
//
// add_trip_member resolves an existing account, so these cover the companion who
// has none: created, renamed and deleted through the assistant, owner-gated the
// same way the /guests routes are, and trip-scoped so one trip's owner cannot
// reach another trip's guest.
// ---------------------------------------------------------------------------
/** The guest id the tool reports back, for the rename/delete cases. */
async function makeGuest(h: McpHarness, tripId: number, name: string): Promise<number> {
const result = await h.client.callTool({ name: 'create_trip_guest', arguments: { tripId, name } });
return (parseToolResult(result) as any).member.id;
}
describe('Tool: create_trip_guest', () => {
it('creates a credential-less guest and returns the display name', async () => {
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
await withHarness(owner.id, async (h) => {
const result = await h.client.callTool({
name: 'create_trip_guest',
arguments: { tripId: trip.id, name: 'Anna' },
});
const data = parseToolResult(result) as any;
expect(data.member.username).toBe('Anna');
expect(data.member.role).toBe('member');
expect(data.member.is_guest).toBe(true);
const row = await userRow(data.member.id);
expect(row.is_guest).toBe(1);
expect(row.password_hash).toBe('');
expect(row.display_name).toBe('Anna');
// A synthetic address on an undeliverable domain: a guest is never emailed.
expect(row.email).toMatch(/@guests\.invalid$/);
});
});
it('joins the guest into the trip roster', async () => {
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
await withHarness(owner.id, async (h) => {
await makeGuest(h, trip.id, 'Jake');
const listed = await h.client.callTool({ name: 'list_trip_members', arguments: { tripId: trip.id } });
const data = parseToolResult(listed) as any;
expect(data.members.map((m: any) => m.username)).toContain('Jake');
});
});
it('broadcasts member:added event', async () => {
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
await withHarness(owner.id, async (h) => {
await makeGuest(h, trip.id, 'Anna');
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'member:added', expect.any(Object));
});
});
it('rejects a whitespace-only name', async () => {
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
await withHarness(owner.id, async (h) => {
const result = await h.client.callTool({
name: 'create_trip_guest',
arguments: { tripId: trip.id, name: ' ' },
});
expect(result.isError).toBe(true);
expect(await countRows(orm, Users, { is_guest: 1 })).toBe(0);
});
});
it('returns error when a member who is not the owner tries to add a guest', async () => {
const { user: owner } = createUser(testDb);
const { user: collaborator } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, collaborator.id);
await withHarness(collaborator.id, async (h) => {
const result = await h.client.callTool({
name: 'create_trip_guest',
arguments: { tripId: trip.id, name: 'Anna' },
});
expect(result.isError).toBe(true);
});
});
it('returns access denied for a non-member', async () => {
const { user: outsider } = createUser(testDb);
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
await withHarness(outsider.id, async (h) => {
const result = await h.client.callTool({
name: 'create_trip_guest',
arguments: { tripId: trip.id, name: 'Anna' },
});
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_trip_guest',
arguments: { tripId: trip.id, name: 'Anna' },
});
expect(result.isError).toBe(true);
});
});
});
describe('Tool: rename_trip_guest', () => {
it('renames the guest', async () => {
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
await withHarness(owner.id, async (h) => {
const guestId = await makeGuest(h, trip.id, 'Anna');
const result = await h.client.callTool({
name: 'rename_trip_guest',
arguments: { tripId: trip.id, guestId, name: 'Anna B.' },
});
expect((parseToolResult(result) as any).success).toBe(true);
const row = await userRow(guestId);
expect(row.display_name).toBe('Anna B.');
});
});
it('rejects a whitespace-only name', async () => {
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
await withHarness(owner.id, async (h) => {
const guestId = await makeGuest(h, trip.id, 'Anna');
const result = await h.client.callTool({
name: 'rename_trip_guest',
arguments: { tripId: trip.id, guestId, name: ' ' },
});
expect(result.isError).toBe(true);
const row = await userRow(guestId);
expect(row.display_name).toBe('Anna');
});
});
it('will not rename a guest of another trip', async () => {
const { user: owner } = createUser(testDb);
const ownTrip = createTrip(testDb, owner.id);
const otherTrip = createTrip(testDb, owner.id);
await withHarness(owner.id, async (h) => {
const guestId = await makeGuest(h, otherTrip.id, 'Anna');
const result = await h.client.callTool({
name: 'rename_trip_guest',
arguments: { tripId: ownTrip.id, guestId, name: 'Renamed' },
});
expect(result.isError).toBe(true);
const row = await userRow(guestId);
expect(row.display_name).toBe('Anna');
});
});
it('returns error when a member who is not the owner tries to rename', async () => {
const { user: owner } = createUser(testDb);
const { user: collaborator } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, collaborator.id);
let guestId = 0;
await withHarness(owner.id, async (h) => {
guestId = await makeGuest(h, trip.id, 'Anna');
});
await withHarness(collaborator.id, async (h) => {
const result = await h.client.callTool({
name: 'rename_trip_guest',
arguments: { tripId: trip.id, guestId, name: 'Renamed' },
});
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: 'rename_trip_guest',
arguments: { tripId: trip.id, guestId: 1, name: 'Anna' },
});
expect(result.isError).toBe(true);
});
});
});
describe('Tool: delete_trip_guest', () => {
it('deletes the guest outright', async () => {
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
await withHarness(owner.id, async (h) => {
const guestId = await makeGuest(h, trip.id, 'Anna');
const result = await h.client.callTool({
name: 'delete_trip_guest',
arguments: { tripId: trip.id, guestId },
});
expect((parseToolResult(result) as any).success).toBe(true);
expect(await findRow(orm, Users, { id: guestId })).toBeNull();
expect(await findRow(orm, TripMembers, { user: guestId })).toBeNull();
});
});
it('broadcasts member:removed event', async () => {
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
await withHarness(owner.id, async (h) => {
const guestId = await makeGuest(h, trip.id, 'Anna');
broadcastMock.mockClear();
await h.client.callTool({ name: 'delete_trip_guest', arguments: { tripId: trip.id, guestId } });
expect(broadcastMock).toHaveBeenCalledWith(
trip.id,
'member:removed',
expect.objectContaining({ userId: guestId }),
);
});
});
it('will not delete a guest of another trip', async () => {
const { user: owner } = createUser(testDb);
const ownTrip = createTrip(testDb, owner.id);
const otherTrip = createTrip(testDb, owner.id);
await withHarness(owner.id, async (h) => {
const guestId = await makeGuest(h, otherTrip.id, 'Anna');
const result = await h.client.callTool({
name: 'delete_trip_guest',
arguments: { tripId: ownTrip.id, guestId },
});
expect(result.isError).toBe(true);
expect(await findRow(orm, Users, { id: guestId })).not.toBeNull();
});
});
it('will not delete a real member through the guest route', async () => {
const { user: owner } = createUser(testDb);
const { user: collaborator } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, collaborator.id);
await withHarness(owner.id, async (h) => {
const result = await h.client.callTool({
name: 'delete_trip_guest',
arguments: { tripId: trip.id, guestId: collaborator.id },
});
expect(result.isError).toBe(true);
expect(await findRow(orm, Users, { id: collaborator.id })).not.toBeNull();
});
});
it('returns error when a member who is not the owner tries to delete', async () => {
const { user: owner } = createUser(testDb);
const { user: collaborator } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, collaborator.id);
let guestId = 0;
await withHarness(owner.id, async (h) => {
guestId = await makeGuest(h, trip.id, 'Anna');
});
await withHarness(collaborator.id, async (h) => {
const result = await h.client.callTool({
name: 'delete_trip_guest',
arguments: { tripId: trip.id, guestId },
});
expect(result.isError).toBe(true);
expect(await findRow(orm, Users, { id: guestId })).not.toBeNull();
});
});
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: 'delete_trip_guest',
arguments: { tripId: trip.id, guestId: 1 },
});
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// The guest routes carry TripOwnerGuard, which deliberately does NOT consult the
// permission matrix: handing a trip over and creating or deleting guests are the
// things a collaborator must never do, however generously the trip is configured.
// Lowering member_manage moves add/remove_trip_member and must move nothing here.
// ---------------------------------------------------------------------------
describe('Guest tools stay owner-only', () => {
it('refuses a collaborator all three guest tools even with member_manage at trip_member', async () => {
const { user: owner } = createUser(testDb);
const { user: collaborator } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, collaborator.id);
let guestId = 0;
await withHarness(owner.id, async (h) => {
guestId = await makeGuest(h, trip.id, 'Anna');
});
await setPermission('member_manage', 'trip_member');
await withHarness(collaborator.id, async (h) => {
expect(
(
await h.client.callTool({
name: 'create_trip_guest',
arguments: { tripId: trip.id, name: 'Bea' },
})
).isError,
).toBe(true);
expect(
(
await h.client.callTool({
name: 'rename_trip_guest',
arguments: { tripId: trip.id, guestId, name: 'Renamed' },
})
).isError,
).toBe(true);
expect(
(
await h.client.callTool({
name: 'delete_trip_guest',
arguments: { tripId: trip.id, guestId },
})
).isError,
).toBe(true);
});
const row = await userRow(guestId);
expect(row.display_name).toBe('Anna');
expect(await countRows(orm, Users, { is_guest: 1 })).toBe(1);
});
});