Files
TREK/server/tests/unit/nest/roadtrip-read-tools.test.ts
T
Maurice 9deedb80ea fix: receipts, cached places, chat previews and the Google-route tool
Saving an expense twice while its receipt is also attached to a place and a
booking hit the new unique index on (file, expense): the row kept from the
first save was skipped, the next spare link row was adopted into a duplicate
pair, and the constraint threw inside the transaction. The whole edit rolled
back with a 500, every time, so that expense could never be changed again. The
adopt now skips a link the expense already holds, and a receipt named twice in
one save is linked once.

The offline place cache was keyed on the place id alone while it is owned and
evicted per trip, so two trips over the same city took each other's rows: the
second write claimed them, switching the second trip off deleted them for both,
and the first trip's fingerprint still matched so nothing ever refilled them.
Offline search then answered nothing for a trip whose switch read "on". Each
trip keeps its own copies now, and the upgrade clears the fingerprints so the
next sync refills what the rebuild dropped.

The chat composer minted its object URLs inside a state updater, which React
runs twice in development, so one picked image drew two thumbnails and removing
one left a preview for a picture that would not be sent. File and URL are one
record now and every mutator commits a finished list.

import_google_maps_route wrote up to thirty places and their day assignments
under trips:write, which does not imply the places:write that create_place is
gated on, and it was the one non-admin write tool with no demo gate and the
only tool in the repo with no annotations. All three corrected.
2026-09-13 23:49:00 +02:00

56 lines
3.3 KiB
TypeScript

import { describe, it, expect, vi } from 'vitest';
import type { McpContext } from '../../../src/nest-mcp';
import { ChargingMcp } from '../../../src/nest/roadtrip/charging.mcp';
import { RoadtripHazardsMcp } from '../../../src/nest/roadtrip/roadtrip-hazards.mcp';
import { GoogleRouteMcp } from '../../../src/nest/roadtrip/google-route.mcp';
import { DayBoundariesMcp } from '../../../src/nest/roadtrip/day-boundaries.mcp';
const ctx = { userId: 5 } as McpContext;
describe('roadtrip read tools', () => {
it('checks trip access before querying charging and hazards', async () => {
const db = { canAccessTrip: vi.fn(() => false) };
const source = { read: vi.fn(async () => ({ sources: ['public'] })) };
const charging = new ChargingMcp(source as never, db as never, {} as never);
const hazards = new RoadtripHazardsMcp(source as never, db as never, {} as never);
expect((await charging.read({ tripId: 10, placeId: 2 }, ctx)).isError).toBe(true);
expect((await hazards.read({ tripId: 10 }, ctx)).isError).toBe(true);
expect(source.read).not.toHaveBeenCalled();
db.canAccessTrip.mockReturnValue(true);
expect(JSON.stringify(await charging.read({ tripId: 10, placeId: 2 }, ctx))).toContain('public');
expect(source.read).toHaveBeenCalledWith(10, 2);
expect(JSON.stringify(await hazards.read({ tripId: 10 }, ctx))).toContain('public');
});
it('passes reviewed Google stops and the authenticated user to the importer', async () => {
const routes = { preview: vi.fn(async () => ({ stops: [] })), import: vi.fn(() => ({ imported: 2 })) };
const auth = { isDemoUser: vi.fn(() => false) };
const tool = new GoogleRouteMcp(routes as never, auth as never, {} as never);
await tool.preview({ url: 'https://www.google.com/maps/dir/A/B' });
expect(routes.preview).toHaveBeenCalledWith('https://www.google.com/maps/dir/A/B');
const input = { tripId: 10, dayId: 1, stops: [{ name: 'A', lat: 1, lng: 2 }] };
expect(JSON.stringify(tool.import(input, ctx))).toContain('2');
expect(routes.import).toHaveBeenCalledWith(10, 5, input);
});
it('refuses the Google import for the demo account, like every other write tool', () => {
// The one non-admin write tool that had no gate: a demo session could write
// thirty places and their assignments onto the shared demo trip.
const routes = { preview: vi.fn(), import: vi.fn() };
const auth = { isDemoUser: vi.fn(() => true) };
const tool = new GoogleRouteMcp(routes as never, auth as never, {} as never);
const res = tool.import({ tripId: 10, dayId: 1, stops: [{ name: 'A', lat: 1, lng: 2 }] } as never, ctx);
expect(res.isError).toBe(true);
expect(routes.import).not.toHaveBeenCalled();
});
it('does not expose manual boundaries to nonmembers', async () => {
const boundaries = { list: vi.fn(() => [{ day_number: 1 }]) };
const db = { canAccessTrip: vi.fn(() => false) };
const tool = new DayBoundariesMcp(boundaries as never, db as never, {} as never, {} as never, {} as never, {} as never);
expect((await tool.list({ tripId: 10 }, ctx)).isError).toBe(true);
expect(boundaries.list).not.toHaveBeenCalled();
db.canAccessTrip.mockReturnValue(true);
expect(JSON.stringify(await tool.list({ tripId: 10 }, ctx))).toContain('day_number');
expect(boundaries.list).toHaveBeenCalledWith(10);
});
});