mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(mcp): note colours, note order, a note website, and category CRUD
A day note could not be coloured or positioned, so the service default of 9999 pinned every note an assistant wrote to the bottom of the timeline. A collab note could not carry the website the card renders. Categories could be assigned by bulk_update_places but never created: the whole file was one read tool and a resource. The three new tools are admin-only, matching the REST routes exactly.
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
/**
|
||||
* Unit tests for the categories MCP surface (CategoriesMcp, DI-discovered):
|
||||
* the list_categories tool and the trek://categories resource.
|
||||
* the list_categories tool, the create/update/delete_category admin tools and
|
||||
* the trek://categories resource.
|
||||
*
|
||||
* Both attach via the nest-mcp registry inside registerTools, so every harness
|
||||
* here keeps withTools on (the resource is NOT registered by the legacy
|
||||
* They all attach via the nest-mcp registry inside registerTools, so every
|
||||
* harness here keeps withTools on (the resource is NOT registered by the legacy
|
||||
* registerResources fan-out anymore).
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
@@ -32,11 +33,31 @@ vi.mock('../../../src/config', () => ({
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index';
|
||||
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory';
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser } from '../../helpers/factories';
|
||||
import { createUser, createAdmin } from '../../helpers/factories';
|
||||
import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness';
|
||||
import { createTestRegistry } from '../../../src/nest-mcp';
|
||||
import { trekMcpAccessPolicy, trekMcpValidateAccess } from '../../../src/mcp/nest-mcp-policy';
|
||||
import { CategoriesMcp } from '../../../src/nest/categories/categories.mcp';
|
||||
import { CategoriesService } from '../../../src/nest/categories/categories.service';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
|
||||
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
|
||||
import { McpToolGuardsService } from '../../../src/nest/mcp-shared/mcp-tool-guards.service';
|
||||
import { AuthService } from '../../../src/nest/auth/auth.service';
|
||||
import { BudgetService } from '../../../src/nest/budget/budget.service';
|
||||
import { ExchangeRatesService } from '../../../src/nest/budget/exchange-rates.service';
|
||||
import { TripMembershipService } from '../../../src/nest/trip-membership/trip-membership.service';
|
||||
import { WebauthnConfigService } from '../../../src/nest/auth/webauthn-config.service';
|
||||
import { UserCleanupService } from '../../../src/nest/auth/user-cleanup.service';
|
||||
import { MailerService } from '../../../src/nest/notifications/mailer/mailer.service';
|
||||
import { EphemeralTokenService } from '../../../src/nest/auth/ephemeral-token.service';
|
||||
import { AllowedFileTypesService } from '../../../src/nest/files/allowed-file-types.service';
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
@@ -45,6 +66,7 @@ beforeAll(() => {
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
delete process.env.DEMO_MODE;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
@@ -60,6 +82,46 @@ async function withHarness(
|
||||
try { await fn(h); } finally { await h.cleanup(); }
|
||||
}
|
||||
|
||||
// The write tools reach the auth and guard collaborators, so they run against a
|
||||
// controller wired here rather than through the shared harness. Everything
|
||||
// points at this file's DB, which is what lets the admin gate and the demo gate
|
||||
// be driven from the users table instead of from a stub.
|
||||
const categoriesDb = new DatabaseService(testDb);
|
||||
const categoriesMcp = new CategoriesMcp(
|
||||
new CategoriesService(categoriesDb),
|
||||
new AuthService(
|
||||
categoriesDb,
|
||||
new PermissionsService(categoriesDb),
|
||||
new TripMembershipService(categoriesDb),
|
||||
new WebauthnConfigService(categoriesDb),
|
||||
new UserCleanupService(categoriesDb, new BudgetService(categoriesDb, new PermissionsService(categoriesDb), new ExchangeRatesService(), new RealtimeService())),
|
||||
new MailerService(categoriesDb),
|
||||
new EphemeralTokenService(),
|
||||
new AllowedFileTypesService(categoriesDb),
|
||||
),
|
||||
new McpToolGuardsService(categoriesDb, new PermissionsService(categoriesDb), new RealtimeService()),
|
||||
);
|
||||
|
||||
async function withWriteHarness(userId: number, fn: (client: Client) => Promise<void>) {
|
||||
const server = new McpServer({ name: 'trek-test', version: '1.0.0' });
|
||||
createTestRegistry([categoriesMcp], { accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess })
|
||||
.attach(server, { userId, scopes: null, isStaticToken: false });
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
const client = new Client({ name: 'test-client', version: '1.0.0' });
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
try {
|
||||
await fn(client);
|
||||
} finally {
|
||||
try { await client.close(); } catch { /* ignore */ }
|
||||
try { await server.close(); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
function insertCategory(name: string, color = '#111111', icon = '🅰️'): number {
|
||||
return Number(testDb.prepare('INSERT INTO categories (name, color, icon) VALUES (?, ?, ?)').run(name, color, icon).lastInsertRowid);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// list_categories
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -96,10 +158,191 @@ describe('Tool: list_categories', () => {
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// list_categories scope gating (places:read, registration-time)
|
||||
// create_category (admin only, mirroring POST /api/categories)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: list_categories — scope gating', () => {
|
||||
describe('Tool: create_category', () => {
|
||||
it('persists the new category and credits the admin who minted it', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
await withWriteHarness(admin.id, async (client) => {
|
||||
const result = await client.callTool({
|
||||
name: 'create_category',
|
||||
arguments: { name: 'Street food', color: '#16a34a', icon: '🍜' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
const row = testDb.prepare('SELECT name, color, icon, user_id FROM categories WHERE id = ?').get(data.category.id) as any;
|
||||
expect(row).toEqual({ name: 'Street food', color: '#16a34a', icon: '🍜', user_id: admin.id });
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the palette defaults when color and icon are omitted', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
await withWriteHarness(admin.id, async (client) => {
|
||||
const result = await client.callTool({ name: 'create_category', arguments: { name: 'Bare minimum' } });
|
||||
const data = parseToolResult(result) as any;
|
||||
const row = testDb.prepare('SELECT color, icon FROM categories WHERE id = ?').get(data.category.id) as any;
|
||||
expect(row).toEqual({ color: '#6366f1', icon: '📍' });
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses a non-admin and writes nothing', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withWriteHarness(user.id, async (client) => {
|
||||
const result = await client.callTool({ name: 'create_category', arguments: { name: 'Sneaky' } });
|
||||
expect(result.isError).toBe(true);
|
||||
expect(testDb.prepare('SELECT id FROM categories WHERE name = ?').get('Sneaky')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks demo user', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const { user } = createAdmin(testDb, { email: 'demo@trek.app' });
|
||||
await withWriteHarness(user.id, async (client) => {
|
||||
const result = await client.callTool({ name: 'create_category', arguments: { name: 'Demo made this' } });
|
||||
expect(result.isError).toBe(true);
|
||||
expect(testDb.prepare('SELECT id FROM categories WHERE name = ?').get('Demo made this')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses a color that is not a hex value', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
await withWriteHarness(admin.id, async (client) => {
|
||||
const result = await client.callTool({ name: 'create_category', arguments: { name: 'Bad colour', color: 'rebeccapurple' } });
|
||||
expect(result.isError).toBe(true);
|
||||
expect(testDb.prepare('SELECT id FROM categories WHERE name = ?').get('Bad colour')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses an empty name', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
await withWriteHarness(admin.id, async (client) => {
|
||||
const result = await client.callTool({ name: 'create_category', arguments: { name: '' } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// update_category (admin only, mirroring PUT /api/categories/:id)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: update_category', () => {
|
||||
it('renames and recolours an existing category', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const id = insertCategory('Old name', '#111111', '🅰️');
|
||||
await withWriteHarness(admin.id, async (client) => {
|
||||
await client.callTool({ name: 'update_category', arguments: { categoryId: id, name: 'New name', color: '#dc2626' } });
|
||||
const row = testDb.prepare('SELECT name, color, icon FROM categories WHERE id = ?').get(id) as any;
|
||||
expect(row).toEqual({ name: 'New name', color: '#dc2626', icon: '🅰️' });
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves the fields it was not given alone', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const id = insertCategory('Keep me', '#0891b2', '🚕');
|
||||
await withWriteHarness(admin.id, async (client) => {
|
||||
await client.callTool({ name: 'update_category', arguments: { categoryId: id, icon: '🚗' } });
|
||||
const row = testDb.prepare('SELECT name, color, icon FROM categories WHERE id = ?').get(id) as any;
|
||||
expect(row).toEqual({ name: 'Keep me', color: '#0891b2', icon: '🚗' });
|
||||
});
|
||||
});
|
||||
|
||||
it('reports an unknown category', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
await withWriteHarness(admin.id, async (client) => {
|
||||
const result = await client.callTool({ name: 'update_category', arguments: { categoryId: 999999, name: 'Nope' } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses a non-admin and leaves the row untouched', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const id = insertCategory('Not yours', '#9333ea', '🔒');
|
||||
await withWriteHarness(user.id, async (client) => {
|
||||
const result = await client.callTool({ name: 'update_category', arguments: { categoryId: id, name: 'Hijacked' } });
|
||||
expect(result.isError).toBe(true);
|
||||
expect((testDb.prepare('SELECT name FROM categories WHERE id = ?').get(id) as any).name).toBe('Not yours');
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks demo user', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const { user } = createAdmin(testDb, { email: 'demo@trek.app' });
|
||||
const id = insertCategory('Demo untouchable', '#ea580c', '🙅');
|
||||
await withWriteHarness(user.id, async (client) => {
|
||||
const result = await client.callTool({ name: 'update_category', arguments: { categoryId: id, name: 'Changed' } });
|
||||
expect(result.isError).toBe(true);
|
||||
expect((testDb.prepare('SELECT name FROM categories WHERE id = ?').get(id) as any).name).toBe('Demo untouchable');
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses a color that is not a hex value', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const id = insertCategory('Colour guard', '#2563eb', '🎨');
|
||||
await withWriteHarness(admin.id, async (client) => {
|
||||
const result = await client.callTool({ name: 'update_category', arguments: { categoryId: id, color: 'goldenrod' } });
|
||||
expect(result.isError).toBe(true);
|
||||
expect((testDb.prepare('SELECT color FROM categories WHERE id = ?').get(id) as any).color).toBe('#2563eb');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// delete_category (admin only, mirroring DELETE /api/categories/:id)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: delete_category', () => {
|
||||
it('removes the category and unassigns the places that carried it', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const id = insertCategory('Doomed', '#d97706', '💀');
|
||||
const trip = Number(testDb.prepare('INSERT INTO trips (user_id, title) VALUES (?, ?)').run(admin.id, 'Trip').lastInsertRowid);
|
||||
const place = Number(testDb.prepare('INSERT INTO places (trip_id, name, category_id) VALUES (?, ?, ?)').run(trip, 'Somewhere', id).lastInsertRowid);
|
||||
await withWriteHarness(admin.id, async (client) => {
|
||||
const result = await client.callTool({ name: 'delete_category', arguments: { categoryId: id } });
|
||||
expect((parseToolResult(result) as any).success).toBe(true);
|
||||
expect(testDb.prepare('SELECT id FROM categories WHERE id = ?').get(id)).toBeUndefined();
|
||||
expect((testDb.prepare('SELECT category_id FROM places WHERE id = ?').get(place) as any).category_id).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('reports an unknown category', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
await withWriteHarness(admin.id, async (client) => {
|
||||
const result = await client.callTool({ name: 'delete_category', arguments: { categoryId: 999999 } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses a non-admin and keeps the row', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const id = insertCategory('Survivor', '#16a34a', '🌿');
|
||||
await withWriteHarness(user.id, async (client) => {
|
||||
const result = await client.callTool({ name: 'delete_category', arguments: { categoryId: id } });
|
||||
expect(result.isError).toBe(true);
|
||||
expect(testDb.prepare('SELECT id FROM categories WHERE id = ?').get(id)).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks demo user', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const { user } = createAdmin(testDb, { email: 'demo@trek.app' });
|
||||
const id = insertCategory('Demo survivor', '#0891b2', '🛟');
|
||||
await withWriteHarness(user.id, async (client) => {
|
||||
const result = await client.callTool({ name: 'delete_category', arguments: { categoryId: id } });
|
||||
expect(result.isError).toBe(true);
|
||||
expect(testDb.prepare('SELECT id FROM categories WHERE id = ?').get(id)).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scope gating (places:read for the listing, places:write for the palette
|
||||
// writes, both registration-time via the declarative access markers)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Category tools: scope gating', () => {
|
||||
const WRITE_TOOLS = ['create_category', 'update_category', 'delete_category'];
|
||||
|
||||
async function listToolNames(userId: number, scopes: string[] | null): Promise<string[]> {
|
||||
const h = await createMcpHarness({ userId, withResources: false, scopes });
|
||||
try {
|
||||
@@ -111,7 +354,9 @@ describe('Tool: list_categories — scope gating', () => {
|
||||
|
||||
it('registers with null scopes (full access)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
expect(await listToolNames(user.id, null)).toContain('list_categories');
|
||||
const names = await listToolNames(user.id, null);
|
||||
expect(names).toContain('list_categories');
|
||||
for (const tool of WRITE_TOOLS) expect(names).toContain(tool);
|
||||
});
|
||||
|
||||
it('registers with places:read', async () => {
|
||||
@@ -119,9 +364,23 @@ describe('Tool: list_categories — scope gating', () => {
|
||||
expect(await listToolNames(user.id, ['places:read'])).toContain('list_categories');
|
||||
});
|
||||
|
||||
it('registers no palette writes with places:read only', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const names = await listToolNames(user.id, ['places:read']);
|
||||
for (const tool of WRITE_TOOLS) expect(names).not.toContain(tool);
|
||||
});
|
||||
|
||||
it('registers the palette writes with places:write', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const names = await listToolNames(user.id, ['places:write']);
|
||||
for (const tool of WRITE_TOOLS) expect(names).toContain(tool);
|
||||
});
|
||||
|
||||
it('does not register for an unrelated scope', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
expect(await listToolNames(user.id, ['budget:read'])).not.toContain('list_categories');
|
||||
const names = await listToolNames(user.id, ['budget:read']);
|
||||
expect(names).not.toContain('list_categories');
|
||||
for (const tool of WRITE_TOOLS) expect(names).not.toContain(tool);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -115,6 +115,52 @@ describe('Tool: create_day_note', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('persists a palette color and an explicit position', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'create_day_note',
|
||||
arguments: { tripId: trip.id, dayId: day.id, text: 'Ferry leaves early', color: '#dc2626', sort_order: 2 },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
const row = testDb.prepare('SELECT color, sort_order FROM day_notes WHERE id = ?').get(data.note.id) as any;
|
||||
expect(row.color).toBe('#dc2626');
|
||||
expect(row.sort_order).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
it('stores no color and appends at the bottom when neither is given', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'create_day_note',
|
||||
arguments: { tripId: trip.id, dayId: day.id, text: 'A note' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
const row = testDb.prepare('SELECT color, sort_order FROM day_notes WHERE id = ?').get(data.note.id) as any;
|
||||
expect(row.color).toBeNull();
|
||||
expect(row.sort_order).toBe(9999);
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses a color outside the note palette and writes nothing', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'create_day_note',
|
||||
arguments: { tripId: trip.id, dayId: day.id, text: 'A note', color: '#ff0000' },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
expect(testDb.prepare('SELECT COUNT(*) AS n FROM day_notes WHERE day_id = ?').get(day.id)).toEqual({ n: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
it('returns error when day does not belong to trip', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip1 = createTrip(testDb, user.id);
|
||||
@@ -175,6 +221,69 @@ describe('Tool: update_day_note', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('sets color and moves the note within the day', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
const note = createDayNote(testDb, day.id, trip.id, { sort_order: 9999 });
|
||||
await withHarness(user.id, async (h) => {
|
||||
await h.client.callTool({
|
||||
name: 'update_day_note',
|
||||
arguments: { tripId: trip.id, dayId: day.id, noteId: note.id, color: '#16a34a', sort_order: 1.5 },
|
||||
});
|
||||
const row = testDb.prepare('SELECT color, sort_order FROM day_notes WHERE id = ?').get(note.id) as any;
|
||||
expect(row.color).toBe('#16a34a');
|
||||
expect(row.sort_order).toBe(1.5);
|
||||
});
|
||||
});
|
||||
|
||||
it('clears the color with an explicit null', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
const note = createDayNote(testDb, day.id, trip.id);
|
||||
testDb.prepare('UPDATE day_notes SET color = ? WHERE id = ?').run('#2563eb', note.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
await h.client.callTool({
|
||||
name: 'update_day_note',
|
||||
arguments: { tripId: trip.id, dayId: day.id, noteId: note.id, color: null },
|
||||
});
|
||||
expect((testDb.prepare('SELECT color FROM day_notes WHERE id = ?').get(note.id) as any).color).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the stored color and position when neither is sent', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
const note = createDayNote(testDb, day.id, trip.id, { sort_order: 3 });
|
||||
testDb.prepare('UPDATE day_notes SET color = ? WHERE id = ?').run('#9333ea', note.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
await h.client.callTool({
|
||||
name: 'update_day_note',
|
||||
arguments: { tripId: trip.id, dayId: day.id, noteId: note.id, text: 'Only the text changed' },
|
||||
});
|
||||
const row = testDb.prepare('SELECT color, sort_order FROM day_notes WHERE id = ?').get(note.id) as any;
|
||||
expect(row.color).toBe('#9333ea');
|
||||
expect(row.sort_order).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses a color outside the note palette', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
const note = createDayNote(testDb, day.id, trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'update_day_note',
|
||||
arguments: { tripId: trip.id, dayId: day.id, noteId: note.id, color: 'red' },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
expect((testDb.prepare('SELECT color FROM day_notes WHERE id = ?').get(note.id) as any).color).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('broadcasts dayNote:updated event', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
@@ -371,6 +480,31 @@ describe('Tool: create_collab_note', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('persists the website 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_collab_note',
|
||||
arguments: { tripId: trip.id, title: 'Boat tour', website: 'https://example.com/tour' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.note.website).toBe('https://example.com/tour');
|
||||
expect((testDb.prepare('SELECT website FROM collab_notes WHERE id = ?').get(data.note.id) as any).website)
|
||||
.toBe('https://example.com/tour');
|
||||
});
|
||||
});
|
||||
|
||||
it('stores no website when none is given', 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_collab_note', arguments: { tripId: trip.id, title: 'Plain note' } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect((testDb.prepare('SELECT website FROM collab_notes WHERE id = ?').get(data.note.id) as any).website).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('broadcasts collab:note:created event', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
@@ -412,6 +546,57 @@ describe('Tool: update_collab_note', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('sets the website link on an existing note', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const note = createCollabNote(testDb, trip.id, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
await h.client.callTool({
|
||||
name: 'update_collab_note',
|
||||
arguments: { tripId: trip.id, noteId: note.id, website: 'https://example.com/museum' },
|
||||
});
|
||||
expect((testDb.prepare('SELECT website FROM collab_notes WHERE id = ?').get(note.id) as any).website)
|
||||
.toBe('https://example.com/museum');
|
||||
});
|
||||
});
|
||||
|
||||
it('clears the website with an explicit null', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const note = createCollabNote(testDb, trip.id, user.id);
|
||||
testDb.prepare('UPDATE collab_notes SET website = ? WHERE id = ?').run('https://example.com/old', note.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
await h.client.callTool({ name: 'update_collab_note', arguments: { tripId: trip.id, noteId: note.id, website: null } });
|
||||
expect((testDb.prepare('SELECT website FROM collab_notes WHERE id = ?').get(note.id) as any).website).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the stored website when the field is not sent', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const note = createCollabNote(testDb, trip.id, user.id);
|
||||
testDb.prepare('UPDATE collab_notes SET website = ? WHERE id = ?').run('https://example.com/keep', note.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
await h.client.callTool({ name: 'update_collab_note', arguments: { tripId: trip.id, noteId: note.id, title: 'Renamed' } });
|
||||
expect((testDb.prepare('SELECT website FROM collab_notes WHERE id = ?').get(note.id) as any).website)
|
||||
.toBe('https://example.com/keep');
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses a website past the tool cap, the way the sibling text fields are capped', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const note = createCollabNote(testDb, trip.id, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'update_collab_note',
|
||||
arguments: { tripId: trip.id, noteId: note.id, website: `https://example.com/${'x'.repeat(600)}` },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
expect((testDb.prepare('SELECT website FROM collab_notes WHERE id = ?').get(note.id) as any).website).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('broadcasts collab:note:updated event', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
Reference in New Issue
Block a user