chore: move category backend to proper DI

This commit is contained in:
jubnl
2026-07-26 12:36:30 +02:00
parent c8369bd14b
commit 2ce40724d7
16 changed files with 398 additions and 196 deletions
+3 -11
View File
@@ -9,7 +9,6 @@ import { listReservations } from '../services/reservationService';
import { listNotes as listDayNotes } from '../services/dayNoteService'; import { listNotes as listDayNotes } from '../services/dayNoteService';
import { listNotes as listCollabNotes, listPolls, listMessages } from '../services/collabService'; import { listNotes as listCollabNotes, listPolls, listMessages } from '../services/collabService';
import { listItems as listTodoItems } from '../services/todoService'; import { listItems as listTodoItems } from '../services/todoService';
import { listCategories } from '../services/categoryService';
import { listBucketList, listVisitedCountries, getStats as getAtlasStats, listManuallyVisitedRegions } from '../services/atlasService'; import { listBucketList, listVisitedCountries, getStats as getAtlasStats, listManuallyVisitedRegions } from '../services/atlasService';
import { getNotifications } from '../services/inAppNotifications'; import { getNotifications } from '../services/inAppNotifications';
import { getActivePlanId, getActivePlan, getPlanData, getEntries as getVacayEntries, getHolidays } from '../services/vacayService'; import { getActivePlanId, getActivePlan, getPlanData, getEntries as getVacayEntries, getHolidays } from '../services/vacayService';
@@ -215,16 +214,9 @@ export function registerResources(server: McpServer, userId: number, scopes: str
} }
); );
// All place categories (global, no trip filter) — safe for any authenticated session // The trek://categories resource moved to the DI-discovered
server.registerResource( // src/nest/categories/categories.mcp.ts (@Resource, attached via the
'categories', // nest-mcp registry in registerTools).
'trek://categories',
{ description: 'All available place categories (id, name, color, icon) for use when creating places', mimeType: 'application/json' },
async (uri) => {
const categories = listCategories();
return jsonContent(uri.href, categories);
}
);
// User's bucket list // User's bucket list
if (isAddonEnabled(ADDON_IDS.ATLAS) && canRead(scopes, 'atlas')) server.registerResource( if (isAddonEnabled(ADDON_IDS.ATLAS) && canRead(scopes, 'atlas')) server.registerResource(
+2 -15
View File
@@ -5,7 +5,6 @@ import { isDemoUser } from '../../services/authService';
import { deletePlacesMany, updatePlacesMany, importGoogleList, importNaverList, listPlaces, createPlace, updatePlace, deletePlace, ratePlace } from '../../services/placeService'; import { deletePlacesMany, updatePlacesMany, importGoogleList, importNaverList, listPlaces, createPlace, updatePlace, deletePlace, ratePlace } from '../../services/placeService';
import { createAssignment, dayExists } from '../../services/assignmentService'; import { createAssignment, dayExists } from '../../services/assignmentService';
import { onPlaceDeleted, reconcileTripSkeletons } from '../../services/journeyService'; import { onPlaceDeleted, reconcileTripSkeletons } from '../../services/journeyService';
import { listCategories } from '../../services/categoryService';
import { searchPlaces } from '../../services/mapsService'; import { searchPlaces } from '../../services/mapsService';
import { import {
safeBroadcast, TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, safeBroadcast, TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
@@ -203,20 +202,8 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
} }
); );
// --- CATEGORIES --- // The list_categories tool moved to the DI-discovered
// src/nest/categories/categories.mcp.ts (@McpController).
if (R) server.registerTool(
'list_categories',
{
description: 'List all available place categories with their id, name, icon and color. Use category_id when creating or updating places.',
inputSchema: {},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async () => {
const categories = listCategories();
return ok({ categories });
}
);
// --- SEARCH --- // --- SEARCH ---
+8 -5
View File
@@ -26,8 +26,8 @@ mount to Nest and leaves the sibling trip routes (days, places, ...) on Express.
- **Phase 1 (leaf):** weather, airports, config (public), system-notices, maps, - **Phase 1 (leaf):** weather, airports, config (public), system-notices, maps,
categories, tags, notifications, atlas. categories, tags, notifications, atlas.
- **Phase 2 (trip sub-domains):** vacay (addon), packing, todo. - **Phase 2 (trip sub-domains):** vacay (addon), packing, todo.
- **DI-native services (legacy `src/services/*` deleted):** tags — see the - **DI-native services (legacy `src/services/*` deleted):** tags, categories —
migration recipe below. see the migration recipe below.
## Cross-cutting Foundation pieces ## Cross-cutting Foundation pieces
@@ -94,9 +94,12 @@ at the typed contract (Frontend Track).
## Migrating a legacy `src/services/*` service into its Nest module (recipe) ## Migrating a legacy `src/services/*` service into its Nest module (recipe)
Pilot: **tags** (`services/tagService.ts` → `nest/tags/tags.service.ts` + Pilot: **tags** (`services/tagService.ts` → `nest/tags/tags.service.ts` +
`nest/tags/tags.bridge.ts`). Repeat these steps per service (next up: `nest/tags/tags.bridge.ts`); categories followed the same shape (and piloted the
categoryService, then todoService). This is a **pure relocation** — byte-identical first `@Resource` in `categories.mcp.ts`). Repeat these steps per service (next
SQL, statuses, bodies, and error strings. up: todoService). This is a **pure relocation** — byte-identical
SQL, statuses, bodies, and error strings. The long-run plan for the last
non-Nest consumer (the plugin RPC host, today served via the bridges) is in
`src/nest/plugins/DI-MIGRATION.md`.
1. **Move the SQL** into `<domain>.service.ts` as methods over an injected 1. **Move the SQL** into `<domain>.service.ts` as methods over an injected
`DatabaseService` (`this.db.all<T>/get<T>/run/prepare/transaction`; strict `DatabaseService` (`this.db.all<T>/get<T>/run/prepare/transaction`; strict
@@ -0,0 +1,21 @@
import type { Category } from '@trek/shared';
import { db } from '../../db/database';
import { DatabaseService } from '../database/database.service';
import { CategoriesService } from './categories.service';
/**
* Non-Nest entry point for the categories domain — for code running OUTSIDE
* the Nest container (currently only the plugin RPC host; the MCP tool and
* resource moved to the DI-discovered categories.mcp.ts). Exports only the
* legacy services/categoryService names still consumed outside the container,
* 1:1, so repointing a consumer is an import-path-only diff. Inside the
* container, inject CategoriesService instead.
*
* Module-level construction is safe: `db` is the reinitialize-proof Proxy onto
* the shared better-sqlite3 singleton (same pattern as tags.bridge.ts).
*/
const categories = new CategoriesService(new DatabaseService(db));
export function listCategories(): Category[] {
return categories.list();
}
@@ -0,0 +1,48 @@
import {
McpController, Tool, Resource, type McpContext,
TOOL_ANNOTATIONS_READONLY, ok,
} from '@trek/nest-mcp';
import { CategoriesService } from './categories.service';
/**
* Categories MCP surface — ported 1:1 from the legacy registrars: the
* list_categories tool from src/mcp/tools/places.ts (identical name,
* description, annotations and places-read gating via the declarative access
* marker + trekMcpAccessPolicy) and the trek://categories resource from
* src/mcp/resources.ts (first production @Resource; intentionally ungated —
* the legacy registration was unconditional, safe for any authenticated
* session — and the payload reproduces the legacy jsonContent shape verbatim).
*/
@McpController()
export class CategoriesMcp {
constructor(private readonly categories: CategoriesService) {}
@Tool({
name: 'list_categories',
description: 'List all available place categories with their id, name, icon and color. Use category_id when creating or updating places.',
inputSchema: {},
annotations: TOOL_ANNOTATIONS_READONLY,
access: { group: 'places', mode: 'read' },
})
async listCategories(_args: Record<string, never>, _ctx: McpContext) {
const categories = this.categories.list();
return ok({ categories });
}
@Resource({
name: 'categories',
uri: 'trek://categories',
description: 'All available place categories (id, name, color, icon) for use when creating places',
mimeType: 'application/json',
})
async categoriesResource(uri: URL, _ctx: McpContext) {
const categories = this.categories.list();
return {
contents: [{
uri: uri.href,
mimeType: 'application/json',
text: JSON.stringify(categories, null, 2),
}],
};
}
}
@@ -1,10 +1,11 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { CategoriesController } from './categories.controller'; import { CategoriesController } from './categories.controller';
import { CategoriesMcp } from './categories.mcp';
import { CategoriesService } from './categories.service'; import { CategoriesService } from './categories.service';
/** Categories domain (L4 leaf module). Registered in AppModule. */ /** Categories domain (L4 leaf module). Registered in AppModule. */
@Module({ @Module({
controllers: [CategoriesController], controllers: [CategoriesController],
providers: [CategoriesService], providers: [CategoriesService, CategoriesMcp],
}) })
export class CategoriesModule {} export class CategoriesModule {}
@@ -1,37 +1,57 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import type { Category } from '@trek/shared'; import type { Category } from '@trek/shared';
import { import { DatabaseService } from '../database/database.service';
listCategories,
getCategoryById,
createCategory,
updateCategory,
deleteCategory,
} from '../../services/categoryService';
/** /**
* Thin Nest wrapper around the existing category service. The SQL and the * Categories domain service — owns the category SQL (moved 1:1 from the legacy
* default colour/icon fallbacks stay in categoryService, so behaviour is * services/categoryService.ts: identical statements, the #6366f1/📍 defaults
* unchanged. * and the COALESCE update semantics). Non-Nest consumers (plugin RPC host) go
* through categories.bridge.ts instead of importing this class directly.
*/ */
@Injectable() @Injectable()
export class CategoriesService { export class CategoriesService {
constructor(private readonly db: DatabaseService) {}
list(): Category[] { list(): Category[] {
return listCategories() as Category[]; return this.db.all<Category>('SELECT * FROM categories ORDER BY name ASC');
} }
getById(id: string | number): Category | undefined { getById(id: string | number): Category | undefined {
return getCategoryById(id) as Category | undefined; return this.db.get<Category>('SELECT * FROM categories WHERE id = ?', id);
} }
create(userId: number, name: string, color?: string, icon?: string): Category { create(userId: number, name: string, color?: string, icon?: string): Category {
return createCategory(userId, name, color, icon) as Category; const result = this.db.run(
'INSERT INTO categories (name, color, icon, user_id) VALUES (?, ?, ?, ?)',
name,
color || '#6366f1',
icon || '📍',
userId,
);
return this.db.get<Category>(
'SELECT * FROM categories WHERE id = ?',
result.lastInsertRowid,
) as Category;
} }
update(id: string | number, name?: string, color?: string, icon?: string): Category { update(id: string | number, name?: string, color?: string, icon?: string): Category {
return updateCategory(id, name, color, icon) as Category; this.db.run(
`
UPDATE categories SET
name = COALESCE(?, name),
color = COALESCE(?, color),
icon = COALESCE(?, icon)
WHERE id = ?
`,
name || null,
color || null,
icon || null,
id,
);
return this.db.get<Category>('SELECT * FROM categories WHERE id = ?', id) as Category;
} }
remove(id: string | number): void { remove(id: string | number): void {
deleteCategory(id); this.db.run('DELETE FROM categories WHERE id = ?', id);
} }
} }
@@ -6,7 +6,7 @@ import { listBudgetItems } from '../../../services/budgetService';
import { listItems as listPackingItemsSvc, createItem as createPackingItemSvc, updateItem as updatePackingItemSvc, deleteItem as deletePackingItemSvc, listBags, createBag as createBagSvc, updateBag as updateBagSvc, deleteBag as deleteBagSvc, setBagMembers } from '../../../services/packingService'; import { listItems as listPackingItemsSvc, createItem as createPackingItemSvc, updateItem as updatePackingItemSvc, deleteItem as deletePackingItemSvc, listBags, createBag as createBagSvc, updateBag as updateBagSvc, deleteBag as deleteBagSvc, setBagMembers } from '../../../services/packingService';
import { isUpdateConflict } from '../../../services/conflictResult'; import { isUpdateConflict } from '../../../services/conflictResult';
import { getWeather } from '../../../services/weatherService'; import { getWeather } from '../../../services/weatherService';
import { listCategories } from '../../../services/categoryService'; import { listCategories } from '../../categories/categories.bridge';
import { listTags, createTag, updateTag, deleteTag, getTagByIdAndUser } from '../../tags/tags.bridge'; import { listTags, createTag, updateTag, deleteTag, getTagByIdAndUser } from '../../tags/tags.bridge';
import { listItems as listTodosSvc, createItem as createTodoSvc, updateItem as updateTodoSvc, deleteItem as deleteTodoSvc } from '../../../services/todoService'; import { listItems as listTodosSvc, createItem as createTodoSvc, updateItem as updateTodoSvc, deleteItem as deleteTodoSvc } from '../../../services/todoService';
import { listFiles, createFile, createFileLink, getFileById, updateFile, softDeleteFile, findForeignLinkTarget, resolveFilePath, BLOCKED_EXTENSIONS, filesDir } from '../../../services/fileService'; import { listFiles, createFile, createFileLink, getFileById, updateFile, softDeleteFile, findForeignLinkTarget, resolveFilePath, BLOCKED_EXTENSIONS, filesDir } from '../../../services/fileService';
-31
View File
@@ -1,31 +0,0 @@
import { db } from '../db/database';
export function listCategories() {
return db.prepare('SELECT * FROM categories ORDER BY name ASC').all();
}
export function createCategory(userId: number, name: string, color?: string, icon?: string) {
const result = db.prepare(
'INSERT INTO categories (name, color, icon, user_id) VALUES (?, ?, ?, ?)'
).run(name, color || '#6366f1', icon || '\uD83D\uDCCD', userId);
return db.prepare('SELECT * FROM categories WHERE id = ?').get(result.lastInsertRowid);
}
export function getCategoryById(categoryId: number | string) {
return db.prepare('SELECT * FROM categories WHERE id = ?').get(categoryId);
}
export function updateCategory(categoryId: number | string, name?: string, color?: string, icon?: string) {
db.prepare(`
UPDATE categories SET
name = COALESCE(?, name),
color = COALESCE(?, color),
icon = COALESCE(?, icon)
WHERE id = ?
`).run(name || null, color || null, icon || null, categoryId);
return db.prepare('SELECT * FROM categories WHERE id = ?').get(categoryId);
}
export function deleteCategory(categoryId: number | string) {
db.prepare('DELETE FROM categories WHERE id = ?').run(categoryId);
}
+51 -31
View File
@@ -1,9 +1,11 @@
/** /**
* Categories module e2e — exercises the migrated /api/categories endpoints * Categories module e2e — exercises the migrated /api/categories endpoints
* through the real JwtAuthGuard + AdminGuard against a temp SQLite db seeded * through the real JwtAuthGuard + AdminGuard against a temp SQLite db seeded
* with an admin and a normal user. categoryService is mocked. * with an admin and a normal user. CategoriesService runs its real SQL via
* DatabaseModule (the DATABASE_CONNECTION factory picks up the mocked db
* singleton): listing is open to any authenticated user; writes are admin-only.
*/ */
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from 'vitest';
import request from 'supertest'; import request from 'supertest';
import cookieParser from 'cookie-parser'; import cookieParser from 'cookie-parser';
import type { Server } from 'http'; import type { Server } from 'http';
@@ -17,27 +19,29 @@ const { db } = vi.hoisted(() => {
tmp.exec('PRAGMA journal_mode = WAL'); tmp.exec('PRAGMA journal_mode = WAL');
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL, tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`); email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
tmp.exec(`CREATE TABLE categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
color TEXT DEFAULT '#6366f1',
icon TEXT DEFAULT '📍',
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);`);
return { db: tmp }; return { db: tmp };
}); });
vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} })); vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} }));
const { mocks } = vi.hoisted(() => ({
mocks: {
listCategories: vi.fn(),
createCategory: vi.fn(),
getCategoryById: vi.fn(),
updateCategory: vi.fn(),
deleteCategory: vi.fn(),
},
}));
vi.mock('../../src/services/categoryService', () => mocks);
import { CategoriesModule } from '../../src/nest/categories/categories.module'; import { CategoriesModule } from '../../src/nest/categories/categories.module';
import { DatabaseModule } from '../../src/nest/database/database.module'; import { DatabaseModule } from '../../src/nest/database/database.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
const cat = { id: 1, name: 'Food', color: '#fff', icon: '🍔' }; function insertCategory(name: string, color = '#6366f1', icon = '📍', userId = 1): number {
const res = db
.prepare('INSERT INTO categories (name, color, icon, user_id) VALUES (?, ?, ?, ?)')
.run(name, color, icon, userId);
return Number(res.lastInsertRowid);
}
describe('Categories e2e (real JwtAuthGuard + AdminGuard + temp SQLite)', () => { describe('Categories e2e (real JwtAuthGuard + AdminGuard + temp SQLite)', () => {
let server: Server; let server: Server;
@@ -57,10 +61,10 @@ describe('Categories e2e (real JwtAuthGuard + AdminGuard + temp SQLite)', () =>
seedUser(db as never, { id: 2, role: 'user', email: 'user@example.test' }); seedUser(db as never, { id: 2, role: 'user', email: 'user@example.test' });
app = await build(); app = await build();
server = app.getHttpServer(); server = app.getHttpServer();
mocks.listCategories.mockReturnValue([cat]); });
mocks.createCategory.mockReturnValue(cat);
mocks.getCategoryById.mockImplementation((id: string | number) => (String(id) === '1' ? cat : undefined)); beforeEach(() => {
mocks.updateCategory.mockReturnValue({ ...cat, name: 'Drinks' }); db.exec('DELETE FROM categories');
}); });
afterAll(async () => { afterAll(async () => {
@@ -72,24 +76,32 @@ describe('Categories e2e (real JwtAuthGuard + AdminGuard + temp SQLite)', () =>
expect(res.status).toBe(401); expect(res.status).toBe(401);
}); });
it('200 list for any authenticated user (non-admin allowed)', async () => { it('200 list for any authenticated user (non-admin allowed), ordered by name', async () => {
insertCategory('Zoo');
insertCategory('Aquarium');
const res = await request(server).get('/api/categories').set('Cookie', sessionCookie(2)); const res = await request(server).get('/api/categories').set('Cookie', sessionCookie(2));
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(res.body).toEqual({ categories: [cat] }); expect(res.body.categories.map((c: { name: string }) => c.name)).toEqual(['Aquarium', 'Zoo']);
}); });
it('403 when a non-admin tries to create', async () => { it('403 when a non-admin tries to create', async () => {
const res = await request(server).post('/api/categories').set('Cookie', sessionCookie(2)).send({ name: 'X' }); const res = await request(server).post('/api/categories').set('Cookie', sessionCookie(2)).send({ name: 'X' });
expect(res.status).toBe(403); expect(res.status).toBe(403);
expect(res.body).toEqual({ error: 'Admin access required' }); expect(res.body).toEqual({ error: 'Admin access required' });
expect(mocks.createCategory).not.toHaveBeenCalled(); expect(db.prepare('SELECT COUNT(*) AS n FROM categories').get().n).toBe(0);
}); });
it('201 when an admin creates a category', async () => { it('201 when an admin creates a category, echoing name/color/icon and user_id', async () => {
const res = await request(server).post('/api/categories').set('Cookie', sessionCookie(1)).send({ name: 'Food', color: '#fff', icon: '🍔' }); const res = await request(server).post('/api/categories').set('Cookie', sessionCookie(1)).send({ name: 'Food', color: '#fff', icon: '🍔' });
expect(res.status).toBe(201); expect(res.status).toBe(201);
expect(res.body).toEqual({ category: cat }); expect(res.body.category).toMatchObject({ name: 'Food', color: '#fff', icon: '🍔', user_id: 1 });
expect(mocks.createCategory).toHaveBeenCalledWith(1, 'Food', '#fff', '🍔'); expect(typeof res.body.category.id).toBe('number');
});
it('201 on create with the #6366f1/📍 defaults when color and icon are omitted', async () => {
const res = await request(server).post('/api/categories').set('Cookie', sessionCookie(1)).send({ name: 'Defaults' });
expect(res.status).toBe(201);
expect(res.body.category).toMatchObject({ color: '#6366f1', icon: '📍' });
}); });
it('400 when an admin creates without a name', async () => { it('400 when an admin creates without a name', async () => {
@@ -98,22 +110,30 @@ describe('Categories e2e (real JwtAuthGuard + AdminGuard + temp SQLite)', () =>
expect(res.body).toEqual({ error: 'Category name is required' }); expect(res.body).toEqual({ error: 'Category name is required' });
}); });
it('200 when an admin updates an existing category', async () => { it('200 when an admin updates, COALESCE preserving omitted fields', async () => {
const res = await request(server).put('/api/categories/1').set('Cookie', sessionCookie(1)).send({ name: 'Drinks' }); const id = insertCategory('Food', '#fff', '🍔');
const res = await request(server).put(`/api/categories/${id}`).set('Cookie', sessionCookie(1)).send({ name: 'Drinks' });
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(res.body).toEqual({ category: { ...cat, name: 'Drinks' } }); expect(res.body.category).toMatchObject({ id, name: 'Drinks', color: '#fff', icon: '🍔' });
}); });
it('404 when an admin updates a missing category', async () => { it('404 when an admin updates a missing category', async () => {
const res = await request(server).put('/api/categories/9').set('Cookie', sessionCookie(1)).send({ name: 'X' }); const res = await request(server).put('/api/categories/9999').set('Cookie', sessionCookie(1)).send({ name: 'X' });
expect(res.status).toBe(404); expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Category not found' }); expect(res.body).toEqual({ error: 'Category not found' });
}); });
it('200 when an admin deletes an existing category', async () => { it('200 when an admin deletes an existing category, removing the row', async () => {
const res = await request(server).delete('/api/categories/1').set('Cookie', sessionCookie(1)); const id = insertCategory('ToDelete');
const res = await request(server).delete(`/api/categories/${id}`).set('Cookie', sessionCookie(1));
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(res.body).toEqual({ success: true }); expect(res.body).toEqual({ success: true });
expect(mocks.deleteCategory).toHaveBeenCalledWith('1'); expect(db.prepare('SELECT id FROM categories WHERE id = ?').get(id)).toBeUndefined();
});
it('404 when an admin deletes a missing category', async () => {
const res = await request(server).delete('/api/categories/9999').set('Cookie', sessionCookie(1));
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Category not found' });
}); });
}); });
@@ -1,6 +1,8 @@
import { createTestRegistry, type McpRegistry } from '@trek/nest-mcp'; import { createTestRegistry, type McpRegistry } from '@trek/nest-mcp';
import { db } from '../../src/db/database'; import { db } from '../../src/db/database';
import { trekMcpAccessPolicy } from '../../src/mcp/nest-mcp-policy'; import { trekMcpAccessPolicy } 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 { DatabaseService } from '../../src/nest/database/database.service';
import { TagsMcp } from '../../src/nest/tags/tags.mcp'; import { TagsMcp } from '../../src/nest/tags/tags.mcp';
import { TagsService } from '../../src/nest/tags/tags.service'; import { TagsService } from '../../src/nest/tags/tags.service';
@@ -17,6 +19,7 @@ export function createMcpTestRegistry(): McpRegistry {
return createTestRegistry( return createTestRegistry(
[ [
new TagsMcp(new TagsService(dbService)), new TagsMcp(new TagsService(dbService)),
new CategoriesMcp(new CategoriesService(dbService)),
], ],
{ accessPolicy: trekMcpAccessPolicy }, { accessPolicy: trekMcpAccessPolicy },
); );
+6 -16
View File
@@ -1,6 +1,8 @@
/** /**
* Unit tests for MCP resources (resources.ts). * Unit tests for MCP resources (resources.ts).
* Tests all 14 resources via InMemoryTransport + Client. * Tests all 13 legacy-registrar resources via InMemoryTransport + Client
* (trek://categories moved to the DI-discovered CategoriesMcp — see
* tools-categories.test.ts).
*/ */
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest'; import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
@@ -416,21 +418,9 @@ describe('Resource: trek://trips/{tripId}/collab-notes', () => {
}); });
}); });
describe('Resource: trek://categories', () => { // trek://categories moved to tools-categories.test.ts: it now registers via
it('returns all categories', async () => { // the nest-mcp registry inside registerTools (CategoriesMcp @Resource), which
const { user } = createUser(testDb); // this file's withTools: false harness never attaches.
await withHarness(user.id, async (harness) => {
const result = await harness.client.readResource({ uri: 'trek://categories' });
const categories = parseResourceResult(result) as any[];
expect(categories.length).toBeGreaterThan(0);
expect(categories[0]).toHaveProperty('id');
expect(categories[0]).toHaveProperty('name');
expect(categories[0]).toHaveProperty('color');
expect(categories[0]).toHaveProperty('icon');
});
});
});
describe('Resource: trek://bucket-list', () => { describe('Resource: trek://bucket-list', () => {
it('returns only the current user\'s bucket list items', async () => { it('returns only the current user\'s bucket list items', async () => {
@@ -0,0 +1,155 @@
/**
* Unit tests for the categories MCP surface (CategoriesMcp, DI-discovered):
* the list_categories tool 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
* registerResources fan-out anymore).
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: () => null,
isOwner: () => false,
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
});
afterAll(() => {
testDb.close();
});
async function withHarness(
userId: number,
fn: (h: McpHarness) => Promise<void>,
scopes: string[] | null = null,
) {
const h = await createMcpHarness({ userId, withResources: false, scopes });
try { await fn(h); } finally { await h.cleanup(); }
}
// ---------------------------------------------------------------------------
// list_categories
// ---------------------------------------------------------------------------
describe('Tool: list_categories', () => {
it('returns all categories with id, name, color, icon', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_categories', arguments: {} });
const data = parseToolResult(result) as any;
expect(data.categories).toBeDefined();
expect(data.categories.length).toBeGreaterThan(0);
const cat = data.categories[0];
expect(cat).toHaveProperty('id');
expect(cat).toHaveProperty('name');
expect(cat).toHaveProperty('color');
expect(cat).toHaveProperty('icon');
});
});
it('returns categories from all users, ordered by name', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
testDb.prepare('INSERT INTO categories (name, color, icon, user_id) VALUES (?, ?, ?, ?)').run('Zzz Mine', '#111111', '🅰️', user.id);
testDb.prepare('INSERT INTO categories (name, color, icon, user_id) VALUES (?, ?, ?, ?)').run('Zzz Other', '#222222', '🅱️', other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_categories', arguments: {} });
const names = (parseToolResult(result) as any).categories.map((c: { name: string }) => c.name);
expect(names).toContain('Zzz Mine');
expect(names).toContain('Zzz Other');
expect(names).toEqual([...names].sort());
});
});
});
// ---------------------------------------------------------------------------
// list_categories scope gating (places:read, registration-time)
// ---------------------------------------------------------------------------
describe('Tool: list_categories — scope gating', () => {
async function listToolNames(userId: number, scopes: string[] | null): Promise<string[]> {
const h = await createMcpHarness({ userId, withResources: false, scopes });
try {
return (await h.client.listTools()).tools.map((t) => t.name);
} finally {
await h.cleanup();
}
}
it('registers with null scopes (full access)', async () => {
const { user } = createUser(testDb);
expect(await listToolNames(user.id, null)).toContain('list_categories');
});
it('registers with places:read', async () => {
const { user } = createUser(testDb);
expect(await listToolNames(user.id, ['places:read'])).toContain('list_categories');
});
it('does not register for an unrelated scope', async () => {
const { user } = createUser(testDb);
expect(await listToolNames(user.id, ['budget:read'])).not.toContain('list_categories');
});
});
// ---------------------------------------------------------------------------
// trek://categories resource (first production @Resource)
// ---------------------------------------------------------------------------
describe('Resource: trek://categories', () => {
it('returns all categories', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.readResource({ uri: 'trek://categories' });
const categories = parseResourceResult(result) as any[];
expect(categories.length).toBeGreaterThan(0);
expect(categories[0]).toHaveProperty('id');
expect(categories[0]).toHaveProperty('name');
expect(categories[0]).toHaveProperty('color');
expect(categories[0]).toHaveProperty('icon');
});
});
it('stays readable under restricted non-places scopes (legacy ungated behavior)', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.readResource({ uri: 'trek://categories' });
const categories = parseResourceResult(result) as any[];
expect(Array.isArray(categories)).toBe(true);
expect(categories.length).toBeGreaterThan(0);
}, ['trips:read']);
});
});
+2 -22
View File
@@ -1,5 +1,6 @@
/** /**
* Unit tests for MCP place tools: create_place, update_place, delete_place, list_categories, search_place. * Unit tests for MCP place tools: create_place, update_place, delete_place, search_place.
* (list_categories moved to tools-categories.test.ts with the CategoriesMcp migration.)
*/ */
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
@@ -356,27 +357,6 @@ describe('Tool: create_and_assign_place', () => {
}); });
}); });
// ---------------------------------------------------------------------------
// list_categories
// ---------------------------------------------------------------------------
describe('Tool: list_categories', () => {
it('returns all categories with id, name, color, icon', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_categories', arguments: {} });
const data = parseToolResult(result) as any;
expect(data.categories).toBeDefined();
expect(data.categories.length).toBeGreaterThan(0);
const cat = data.categories[0];
expect(cat).toHaveProperty('id');
expect(cat).toHaveProperty('name');
expect(cat).toHaveProperty('color');
expect(cat).toHaveProperty('icon');
});
});
});
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// search_place // search_place
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -1,6 +1,9 @@
/** /**
* Unit tests for categoryService — CAT-SVC-001 through CAT-SVC-015. * Unit tests for CategoriesService — CAT-SVC-001 through CAT-SVC-016.
* Uses a real in-memory SQLite DB so SQL logic is exercised faithfully. * Uses a real in-memory SQLite DB so SQL logic is exercised faithfully.
* The service is constructed directly (new CategoriesService(new DatabaseService(db)))
* — no Nest container needed. CAT-SVC-016 covers the categories.bridge export
* used by the non-Nest plugin RPC host.
*/ */
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
@@ -34,13 +37,11 @@ import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations'; import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db'; import { resetTestDb } from '../../helpers/test-db';
import { createUser } from '../../helpers/factories'; import { createUser } from '../../helpers/factories';
import { import { DatabaseService } from '../../../src/nest/database/database.service';
listCategories, import { CategoriesService } from '../../../src/nest/categories/categories.service';
createCategory, import { listCategories } from '../../../src/nest/categories/categories.bridge';
getCategoryById,
updateCategory, const svc = new CategoriesService(new DatabaseService(testDb));
deleteCategory,
} from '../../../src/services/categoryService';
beforeAll(() => { beforeAll(() => {
createTables(testDb); createTables(testDb);
@@ -55,22 +56,22 @@ afterAll(() => {
testDb.close(); testDb.close();
}); });
// ── listCategories ──────────────────────────────────────────────────────────── // ── list ──────────────────────────────────────────────────────────────────────
describe('listCategories', () => { describe('list', () => {
it('CAT-SVC-001 — returns an array (seeded defaults are present after migrations)', () => { it('CAT-SVC-001 — returns an array (seeded defaults are present after migrations)', () => {
// Migrations seed default categories, so the list is never empty in a fully initialized DB // Migrations seed default categories, so the list is never empty in a fully initialized DB
const cats = listCategories() as any[]; const cats = svc.list();
expect(Array.isArray(cats)).toBe(true); expect(Array.isArray(cats)).toBe(true);
expect(cats.length).toBeGreaterThan(0); expect(cats.length).toBeGreaterThan(0);
}); });
it('CAT-SVC-002 — results are ordered by name ascending (custom categories sort correctly)', () => { it('CAT-SVC-002 — results are ordered by name ascending (custom categories sort correctly)', () => {
const { user } = createUser(testDb); const { user } = createUser(testDb);
createCategory(user.id, 'Zoo'); svc.create(user.id, 'Zoo');
createCategory(user.id, 'Aquarium'); svc.create(user.id, 'Aquarium');
// Migrations seed default categories; verify ordering by checking our custom ones appear in sorted order // Migrations seed default categories; verify ordering by checking our custom ones appear in sorted order
const names = (listCategories() as any[]).map((c: any) => c.name); const names = svc.list().map((c) => c.name);
const aquariumIdx = names.indexOf('Aquarium'); const aquariumIdx = names.indexOf('Aquarium');
const zooIdx = names.indexOf('Zoo'); const zooIdx = names.indexOf('Zoo');
expect(aquariumIdx).toBeGreaterThanOrEqual(0); expect(aquariumIdx).toBeGreaterThanOrEqual(0);
@@ -81,19 +82,19 @@ describe('listCategories', () => {
it('CAT-SVC-003 — returns categories from all users (including seeded defaults)', () => { it('CAT-SVC-003 — returns categories from all users (including seeded defaults)', () => {
const { user: a } = createUser(testDb); const { user: a } = createUser(testDb);
const { user: b } = createUser(testDb); const { user: b } = createUser(testDb);
const before = (listCategories() as any[]).length; const before = svc.list().length;
createCategory(a.id, 'Cat-A'); svc.create(a.id, 'Cat-A');
createCategory(b.id, 'Cat-B'); svc.create(b.id, 'Cat-B');
expect(listCategories()).toHaveLength(before + 2); expect(svc.list()).toHaveLength(before + 2);
}); });
}); });
// ── createCategory ──────────────────────────────────────────────────────────── // ── create ────────────────────────────────────────────────────────────────────
describe('createCategory', () => { describe('create', () => {
it('CAT-SVC-004 — creates a category with name, color, and icon', () => { it('CAT-SVC-004 — creates a category with name, color, and icon', () => {
const { user } = createUser(testDb); const { user } = createUser(testDb);
const cat = createCategory(user.id, 'Restaurant', '#ff5500', '🍽️') as any; const cat = svc.create(user.id, 'Restaurant', '#ff5500', '🍽️');
expect(cat.name).toBe('Restaurant'); expect(cat.name).toBe('Restaurant');
expect(cat.color).toBe('#ff5500'); expect(cat.color).toBe('#ff5500');
expect(cat.icon).toBe('🍽️'); expect(cat.icon).toBe('🍽️');
@@ -102,55 +103,55 @@ describe('createCategory', () => {
it('CAT-SVC-005 — defaults color to #6366f1 when not provided', () => { it('CAT-SVC-005 — defaults color to #6366f1 when not provided', () => {
const { user } = createUser(testDb); const { user } = createUser(testDb);
const cat = createCategory(user.id, 'Default Color') as any; const cat = svc.create(user.id, 'Default Color');
expect(cat.color).toBe('#6366f1'); expect(cat.color).toBe('#6366f1');
}); });
it('CAT-SVC-006 — defaults icon to 📍 when not provided', () => { it('CAT-SVC-006 — defaults icon to 📍 when not provided', () => {
const { user } = createUser(testDb); const { user } = createUser(testDb);
const cat = createCategory(user.id, 'Default Icon') as any; const cat = svc.create(user.id, 'Default Icon');
expect(cat.icon).toBe('📍'); expect(cat.icon).toBe('📍');
}); });
it('CAT-SVC-007 — returns the inserted row with an id', () => { it('CAT-SVC-007 — returns the inserted row with an id', () => {
const { user } = createUser(testDb); const { user } = createUser(testDb);
const cat = createCategory(user.id, 'WithId') as any; const cat = svc.create(user.id, 'WithId');
expect(typeof cat.id).toBe('number'); expect(typeof cat.id).toBe('number');
expect(cat.id).toBeGreaterThan(0); expect(cat.id).toBeGreaterThan(0);
}); });
}); });
// ── getCategoryById ─────────────────────────────────────────────────────────── // ── getById ───────────────────────────────────────────────────────────────────
describe('getCategoryById', () => { describe('getById', () => {
it('CAT-SVC-008 — returns category for a valid id', () => { it('CAT-SVC-008 — returns category for a valid id', () => {
const { user } = createUser(testDb); const { user } = createUser(testDb);
const created = createCategory(user.id, 'Find Me') as any; const created = svc.create(user.id, 'Find Me');
const found = getCategoryById(created.id) as any; const found = svc.getById(created.id);
expect(found).toBeDefined(); expect(found).toBeDefined();
expect(found.name).toBe('Find Me'); expect(found?.name).toBe('Find Me');
}); });
it('CAT-SVC-009 — returns undefined for non-existent id', () => { it('CAT-SVC-009 — returns undefined for non-existent id', () => {
expect(getCategoryById(99999)).toBeUndefined(); expect(svc.getById(99999)).toBeUndefined();
}); });
it('CAT-SVC-010 — accepts string id (coerced by SQLite)', () => { it('CAT-SVC-010 — accepts string id (coerced by SQLite)', () => {
const { user } = createUser(testDb); const { user } = createUser(testDb);
const created = createCategory(user.id, 'StringId') as any; const created = svc.create(user.id, 'StringId');
const found = getCategoryById(String(created.id)) as any; const found = svc.getById(String(created.id));
expect(found).toBeDefined(); expect(found).toBeDefined();
expect(found.id).toBe(created.id); expect(found?.id).toBe(created.id);
}); });
}); });
// ── updateCategory ──────────────────────────────────────────────────────────── // ── update ────────────────────────────────────────────────────────────────────
describe('updateCategory', () => { describe('update', () => {
it('CAT-SVC-011 — updates name, color, and icon', () => { it('CAT-SVC-011 — updates name, color, and icon', () => {
const { user } = createUser(testDb); const { user } = createUser(testDb);
const cat = createCategory(user.id, 'Old', '#aaaaaa', '❓') as any; const cat = svc.create(user.id, 'Old', '#aaaaaa', '❓');
const updated = updateCategory(cat.id, 'New', '#bbbbbb', '✅') as any; const updated = svc.update(cat.id, 'New', '#bbbbbb', '✅');
expect(updated.name).toBe('New'); expect(updated.name).toBe('New');
expect(updated.color).toBe('#bbbbbb'); expect(updated.color).toBe('#bbbbbb');
expect(updated.icon).toBe('✅'); expect(updated.icon).toBe('✅');
@@ -158,32 +159,44 @@ describe('updateCategory', () => {
it('CAT-SVC-012 — COALESCE: omitting name preserves existing name', () => { it('CAT-SVC-012 — COALESCE: omitting name preserves existing name', () => {
const { user } = createUser(testDb); const { user } = createUser(testDb);
const cat = createCategory(user.id, 'KeepName', '#aaaaaa', '⭐') as any; const cat = svc.create(user.id, 'KeepName', '#aaaaaa', '⭐');
const updated = updateCategory(cat.id, undefined, '#cccccc', '🔥') as any; const updated = svc.update(cat.id, undefined, '#cccccc', '🔥');
expect(updated.name).toBe('KeepName'); expect(updated.name).toBe('KeepName');
expect(updated.color).toBe('#cccccc'); expect(updated.color).toBe('#cccccc');
}); });
it('CAT-SVC-013 — COALESCE: omitting color preserves existing color', () => { it('CAT-SVC-013 — COALESCE: omitting color preserves existing color', () => {
const { user } = createUser(testDb); const { user } = createUser(testDb);
const cat = createCategory(user.id, 'KeepColor', '#dddddd', '⭐') as any; const cat = svc.create(user.id, 'KeepColor', '#dddddd', '⭐');
const updated = updateCategory(cat.id, 'NewName', undefined, '🌟') as any; const updated = svc.update(cat.id, 'NewName', undefined, '🌟');
expect(updated.name).toBe('NewName'); expect(updated.name).toBe('NewName');
expect(updated.color).toBe('#dddddd'); expect(updated.color).toBe('#dddddd');
}); });
}); });
// ── deleteCategory ──────────────────────────────────────────────────────────── // ── remove ────────────────────────────────────────────────────────────────────
describe('deleteCategory', () => { describe('remove', () => {
it('CAT-SVC-014 — deletes the category from the database', () => { it('CAT-SVC-014 — deletes the category from the database', () => {
const { user } = createUser(testDb); const { user } = createUser(testDb);
const cat = createCategory(user.id, 'ToDelete') as any; const cat = svc.create(user.id, 'ToDelete');
deleteCategory(cat.id); svc.remove(cat.id);
expect(getCategoryById(cat.id)).toBeUndefined(); expect(svc.getById(cat.id)).toBeUndefined();
}); });
it('CAT-SVC-015 — deleting a non-existent category does not throw', () => { it('CAT-SVC-015 — deleting a non-existent category does not throw', () => {
expect(() => deleteCategory(99999)).not.toThrow(); expect(() => svc.remove(99999)).not.toThrow();
});
});
// ── categories.bridge (non-Nest entry point) ──────────────────────────────────
describe('categories.bridge', () => {
it('CAT-SVC-016 — listCategories delegates to the service', () => {
const { user } = createUser(testDb);
svc.create(user.id, 'Bridged');
const names = listCategories().map((c) => c.name);
expect(names).toContain('Bridged');
expect(names).toEqual([...names].sort());
}); });
}); });
@@ -136,7 +136,7 @@ vi.mock('../../../src/services/packingService', () => ({
})); }));
vi.mock('../../../src/services/conflictResult', () => ({ isUpdateConflict: (r: unknown) => !!(r as { conflict?: boolean })?.conflict })); vi.mock('../../../src/services/conflictResult', () => ({ isUpdateConflict: (r: unknown) => !!(r as { conflict?: boolean })?.conflict }));
vi.mock('../../../src/services/weatherService', () => ({ getWeather: vi.fn(async (lat: string, lng: string) => ({ lat, lng, temp: 20 })) })); vi.mock('../../../src/services/weatherService', () => ({ getWeather: vi.fn(async (lat: string, lng: string) => ({ lat, lng, temp: 20 })) }));
vi.mock('../../../src/services/categoryService', () => ({ listCategories: vi.fn(() => [{ id: 1, name: 'Food' }]) })); vi.mock('../../../src/nest/categories/categories.bridge', () => ({ listCategories: vi.fn(() => [{ id: 1, name: 'Food' }]) }));
vi.mock('../../../src/nest/tags/tags.bridge', () => ({ vi.mock('../../../src/nest/tags/tags.bridge', () => ({
listTags: vi.fn((uid: number) => [{ id: 1, user_id: uid, name: 'work' }]), listTags: vi.fn((uid: number) => [{ id: 1, user_id: uid, name: 'work' }]),
createTag: vi.fn((uid: number, name: string, color?: string) => ({ id: 9, user_id: uid, name, color })), createTag: vi.fn((uid: number, name: string, color?: string) => ({ id: 9, user_id: uid, name, color })),