From 53e884ad55e6d0b4bbdc542ab2166f40f7e058d6 Mon Sep 17 00:00:00 2001 From: jubnl Date: Sat, 25 Jul 2026 18:36:17 +0200 Subject: [PATCH] refactor(nest): migrate tagService into TagsService DI (pilot) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the tag SQL from src/services/tagService.ts into TagsService over an injected DatabaseService (identical statements, #10b981 default, COALESCE semantics). Non-Nest consumers (MCP tools, plugin RPC host) go through the new tags.bridge.ts, which exports the legacy function names 1:1 from a module-level instance over the shared db Proxy — the settled pattern for the remaining legacy-service migrations (recipe documented in src/nest/README.md). Tests are DI-native: direct service construction in unit (with bridge delegation cases for the coverage gate) and DatabaseModule + real SQL in e2e. --- server/src/mcp/tools/tags.ts | 2 +- server/src/nest/README.md | 49 +++++++ .../src/nest/plugins/host/create-rpc-host.ts | 2 +- server/src/nest/tags/tags.bridge.ts | 36 +++++ server/src/nest/tags/tags.service.ts | 38 +++-- server/src/services/tagService.ts | 26 ---- server/tests/e2e/tags.e2e.test.ts | 91 +++++++----- .../tags.service.test.ts} | 134 ++++++++++++------ .../unit/plugins/create-rpc-host.test.ts | 2 +- 9 files changed, 260 insertions(+), 120 deletions(-) create mode 100644 server/src/nest/tags/tags.bridge.ts delete mode 100644 server/src/services/tagService.ts rename server/tests/unit/{services/tagService.test.ts => nest/tags.service.test.ts} (54%) diff --git a/server/src/mcp/tools/tags.ts b/server/src/mcp/tools/tags.ts index 70f4fe8e8..9cb040907 100644 --- a/server/src/mcp/tools/tags.ts +++ b/server/src/mcp/tools/tags.ts @@ -1,7 +1,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; import { z } from 'zod'; import { isDemoUser } from '../../services/authService'; -import { listTags, createTag, getTagByIdAndUser, updateTag, deleteTag } from '../../services/tagService'; +import { listTags, createTag, getTagByIdAndUser, updateTag, deleteTag } from '../../nest/tags/tags.bridge'; import { TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT, diff --git a/server/src/nest/README.md b/server/src/nest/README.md index 3bea1a736..5c107f1b8 100644 --- a/server/src/nest/README.md +++ b/server/src/nest/README.md @@ -26,6 +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, categories, tags, notifications, atlas. - **Phase 2 (trip sub-domains):** vacay (addon), packing, todo. +- **DI-native services (legacy `src/services/*` deleted):** tags — see the + migration recipe below. ## Cross-cutting Foundation pieces @@ -88,3 +90,50 @@ validation/error parity → unit + parity + e2e tests over the gate → prefix t Nest → parity verified on the demo DB → **then** decommission the old Express route/service (separate step, after the toggle is confirmed in prod) → frontend points at the typed contract (Frontend Track). + +## Migrating a legacy `src/services/*` service into its Nest module (recipe) + +Pilot: **tags** (`services/tagService.ts` → `nest/tags/tags.service.ts` + +`nest/tags/tags.bridge.ts`). Repeat these steps per service (next up: +categoryService, then todoService). This is a **pure relocation** — byte-identical +SQL, statuses, bodies, and error strings. + +1. **Move the SQL** into `.service.ts` as methods over an injected + `DatabaseService` (`this.db.all/get/run/prepare/transaction`; strict + constructor injection, no `@Optional()`). Preserve every quirk: falsy-coercion + defaults (`x || fallback`, never `??`), post-insert/post-update re-selects (no + RETURNING), `COALESCE` semantics. If a controller already wraps the legacy + functions, do not change the service's method surface. The module needs no + `imports: [DatabaseModule]` — it's `@Global`. +2. **Add `.bridge.ts`** next to the service **only if non-Nest consumers + exist** (MCP tools, plugin RPC host, scheduler, websocket). It builds a + module-level instance over the shared connection Proxy — + `new XService(new DatabaseService(db))`, reinitialize-proof, same pattern as + `plugins/host/create-rpc-host.ts` — and exports the legacy function names 1:1. + Container code injects the service; only outside-container code imports the + bridge. *(Design decision, settled with the tags pilot: MCP tools stay outside + the container and use the bridge. The alternative — handing the Nest app to the + MCP layer via `app.get(XService)` — was rejected: it would thread the container + through `mcpHandler` + every tool registrar and force a Nest bootstrap into the + container-less `tests/helpers/mcp-harness.ts` used by ~25 MCP suites.)* +3. **Repoint non-Nest consumers** — import-path-only diffs from + `services/Service` to `nest//.bridge`; call sites unchanged. +4. **Delete the legacy service** once `grep -rn "services/Service" src tests` + only hits the tests you're rewriting. +5. **Tests:** + - Move `tests/unit/services/Service.test.ts` → + `tests/unit/nest/.service.test.ts`, preserving case IDs. Construct the + service directly — `new XService(new DatabaseService(testDb))` — no + TestingModule, no `overrideProvider` (repo convention). Add one delegation + case per bridge export: the bridge sits under `src/nest/**`, inside the ≥80% + coverage gate, and these cases pin it deterministically. + - Convert the module e2e to the DI-native pattern (exemplar + `tests/e2e/trips.e2e.test.ts`): temp-db DDL for the domain's tables, + `imports: [DatabaseModule, Module]`, real SQL assertions. Keep only + the `vi.mock('../../src/db/database', …)` — the auth guard still reads users + through the singleton, and `DatabaseModule`'s factory picks up the same + mocked db. Drop the legacy-service mock entirely. + - Suites that mocked the legacy module (e.g. `create-rpc-host.test.ts`) mock + the bridge path instead — same factory shape, path-only change. +6. **Verify** (from `server/`): `npm run typecheck`, `test:unit`, + `test:integration`, `test:e2e`, `lint:check`, `test:coverage`. diff --git a/server/src/nest/plugins/host/create-rpc-host.ts b/server/src/nest/plugins/host/create-rpc-host.ts index 3ca509276..4fec02536 100644 --- a/server/src/nest/plugins/host/create-rpc-host.ts +++ b/server/src/nest/plugins/host/create-rpc-host.ts @@ -7,7 +7,7 @@ import { listItems as listPackingItemsSvc, createItem as createPackingItemSvc, u import { isUpdateConflict } from '../../../services/conflictResult'; import { getWeather } from '../../../services/weatherService'; import { listCategories } from '../../../services/categoryService'; -import { listTags, createTag, updateTag, deleteTag, getTagByIdAndUser } from '../../../services/tagService'; +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 { listFiles, createFile, createFileLink, getFileById, updateFile, softDeleteFile, findForeignLinkTarget, resolveFilePath, BLOCKED_EXTENSIONS, filesDir } from '../../../services/fileService'; import { createNote as createCollabNoteSvc, createPoll as createCollabPollSvc, votePoll as voteCollabPollSvc, createMessage as createCollabMessageSvc, listNotes as listCollabNotesSvc, listPolls as listCollabPollsSvc, listMessages as listCollabMessagesSvc } from '../../../services/collabService'; diff --git a/server/src/nest/tags/tags.bridge.ts b/server/src/nest/tags/tags.bridge.ts new file mode 100644 index 000000000..19425efe1 --- /dev/null +++ b/server/src/nest/tags/tags.bridge.ts @@ -0,0 +1,36 @@ +import type { Tag } from '@trek/shared'; +import { db } from '../../db/database'; +import { DatabaseService } from '../database/database.service'; +import { TagsService } from './tags.service'; + +/** + * Non-Nest entry point for the tags domain — for code running OUTSIDE the Nest + * container (MCP tools, plugin RPC host). Exports the legacy + * services/tagService function names 1:1 so repointing a consumer is an + * import-path-only diff. Inside the container, inject TagsService instead. + * + * Module-level construction is safe: `db` is the reinitialize-proof Proxy onto + * the shared better-sqlite3 singleton (same pattern as + * plugins/host/create-rpc-host.ts). + */ +const tags = new TagsService(new DatabaseService(db)); + +export function listTags(userId: number): Tag[] { + return tags.list(userId); +} + +export function createTag(userId: number, name: string, color?: string): Tag { + return tags.create(userId, name, color); +} + +export function getTagByIdAndUser(tagId: number | string, userId: number): Tag | undefined { + return tags.getByIdAndUser(tagId, userId); +} + +export function updateTag(tagId: number | string, name?: string, color?: string): Tag | undefined { + return tags.update(tagId, name, color); +} + +export function deleteTag(tagId: number | string): void { + tags.remove(tagId); +} diff --git a/server/src/nest/tags/tags.service.ts b/server/src/nest/tags/tags.service.ts index 468b4bd64..6d7e059c7 100644 --- a/server/src/nest/tags/tags.service.ts +++ b/server/src/nest/tags/tags.service.ts @@ -1,36 +1,46 @@ import { Injectable } from '@nestjs/common'; import type { Tag } from '@trek/shared'; -import { - listTags, - createTag, - getTagByIdAndUser, - updateTag, - deleteTag, -} from '../../services/tagService'; +import { DatabaseService } from '../database/database.service'; /** - * Thin Nest wrapper around the existing tag service. Ownership scoping and the - * default colour fallback stay in tagService, so behaviour is unchanged. + * Tags domain service — owns the tag SQL (moved 1:1 from the legacy + * services/tagService.ts: identical statements, the #10b981 default and the + * COALESCE update semantics). Non-Nest consumers (MCP tools, plugin RPC host) + * go through tags.bridge.ts instead of importing this class directly. */ @Injectable() export class TagsService { + constructor(private readonly db: DatabaseService) {} + list(userId: number): Tag[] { - return listTags(userId) as Tag[]; + return this.db.all('SELECT * FROM tags WHERE user_id = ? ORDER BY name ASC', userId); } getByIdAndUser(id: string | number, userId: number): Tag | undefined { - return getTagByIdAndUser(id, userId) as Tag | undefined; + return this.db.get('SELECT * FROM tags WHERE id = ? AND user_id = ?', id, userId); } create(userId: number, name: string, color?: string): Tag { - return createTag(userId, name, color) as Tag; + const result = this.db.run( + 'INSERT INTO tags (user_id, name, color) VALUES (?, ?, ?)', + userId, + name, + color || '#10b981', + ); + return this.db.get('SELECT * FROM tags WHERE id = ?', result.lastInsertRowid) as Tag; } update(id: string | number, name?: string, color?: string): Tag { - return updateTag(id, name, color) as Tag; + this.db.run( + 'UPDATE tags SET name = COALESCE(?, name), color = COALESCE(?, color) WHERE id = ?', + name || null, + color || null, + id, + ); + return this.db.get('SELECT * FROM tags WHERE id = ?', id) as Tag; } remove(id: string | number): void { - deleteTag(id); + this.db.run('DELETE FROM tags WHERE id = ?', id); } } diff --git a/server/src/services/tagService.ts b/server/src/services/tagService.ts deleted file mode 100644 index ef17b5887..000000000 --- a/server/src/services/tagService.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { db } from '../db/database'; - -export function listTags(userId: number) { - return db.prepare('SELECT * FROM tags WHERE user_id = ? ORDER BY name ASC').all(userId); -} - -export function createTag(userId: number, name: string, color?: string) { - const result = db.prepare( - 'INSERT INTO tags (user_id, name, color) VALUES (?, ?, ?)' - ).run(userId, name, color || '#10b981'); - return db.prepare('SELECT * FROM tags WHERE id = ?').get(result.lastInsertRowid); -} - -export function getTagByIdAndUser(tagId: number | string, userId: number) { - return db.prepare('SELECT * FROM tags WHERE id = ? AND user_id = ?').get(tagId, userId); -} - -export function updateTag(tagId: number | string, name?: string, color?: string) { - db.prepare('UPDATE tags SET name = COALESCE(?, name), color = COALESCE(?, color) WHERE id = ?') - .run(name || null, color || null, tagId); - return db.prepare('SELECT * FROM tags WHERE id = ?').get(tagId); -} - -export function deleteTag(tagId: number | string) { - db.prepare('DELETE FROM tags WHERE id = ?').run(tagId); -} diff --git a/server/tests/e2e/tags.e2e.test.ts b/server/tests/e2e/tags.e2e.test.ts index f2e939386..4eed950aa 100644 --- a/server/tests/e2e/tags.e2e.test.ts +++ b/server/tests/e2e/tags.e2e.test.ts @@ -1,9 +1,11 @@ /** * Tags module e2e — exercises the migrated /api/tags endpoints through the real - * JwtAuthGuard against a temp SQLite db. tagService is mocked; tags are - * user-scoped (no admin gate), so a normal authenticated user can do everything. + * JwtAuthGuard against a temp SQLite db. TagsService runs its real SQL via + * DatabaseModule (the DATABASE_CONNECTION factory picks up the mocked db + * singleton); tags are user-scoped (no admin gate), so a normal authenticated + * user can do everything. */ -import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from 'vitest'; import request from 'supertest'; import cookieParser from 'cookie-parser'; import type { Server } from 'http'; @@ -17,33 +19,33 @@ const { db } = vi.hoisted(() => { tmp.exec('PRAGMA journal_mode = WAL'); 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);`); + tmp.exec(`CREATE TABLE tags ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + color TEXT DEFAULT '#10b981', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + );`); return { db: tmp }; }); vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} })); -const { mocks } = vi.hoisted(() => ({ - mocks: { - listTags: vi.fn(), - createTag: vi.fn(), - getTagByIdAndUser: vi.fn(), - updateTag: vi.fn(), - deleteTag: vi.fn(), - }, -})); -vi.mock('../../src/services/tagService', () => mocks); - +import { DatabaseModule } from '../../src/nest/database/database.module'; import { TagsModule } from '../../src/nest/tags/tags.module'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; -const tag = { id: 1, user_id: 1, name: 'Beach', color: '#10b981' }; +function insertTag(userId: number, name: string, color = '#10b981'): number { + const res = db.prepare('INSERT INTO tags (user_id, name, color) VALUES (?, ?, ?)').run(userId, name, color); + return Number(res.lastInsertRowid); +} describe('Tags e2e (real auth guard + temp SQLite)', () => { let server: Server; let app: Awaited>; async function build() { - const moduleRef = await Test.createTestingModule({ imports: [TagsModule] }).compile(); + const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, TagsModule] }).compile(); const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); nest.useGlobalFilters(new TrekExceptionFilter()); @@ -53,12 +55,13 @@ describe('Tags e2e (real auth guard + temp SQLite)', () => { beforeAll(async () => { seedUser(db as never, { id: 1 }); + seedUser(db as never, { id: 2, email: 'e2e-2@example.test' }); app = await build(); server = app.getHttpServer(); - mocks.listTags.mockReturnValue([tag]); - mocks.createTag.mockReturnValue(tag); - mocks.getTagByIdAndUser.mockImplementation((id: string | number) => (String(id) === '1' ? tag : undefined)); - mocks.updateTag.mockReturnValue({ ...tag, name: 'Hike' }); + }); + + beforeEach(() => { + db.exec('DELETE FROM tags'); }); afterAll(async () => { @@ -70,18 +73,27 @@ describe('Tags e2e (real auth guard + temp SQLite)', () => { expect(res.status).toBe(401); }); - it('200 list scoped to the user', async () => { + it('200 list scoped to the user, ordered by name', async () => { + insertTag(1, 'Zebra'); + insertTag(1, 'Apple'); + insertTag(2, 'Other-User-Tag'); const res = await request(server).get('/api/tags').set('Cookie', sessionCookie(1)); expect(res.status).toBe(200); - expect(res.body).toEqual({ tags: [tag] }); - expect(mocks.listTags).toHaveBeenCalledWith(1); + expect(res.body.tags.map((t: { name: string }) => t.name)).toEqual(['Apple', 'Zebra']); + expect(res.body.tags.every((t: { user_id: number }) => t.user_id === 1)).toBe(true); }); - it('201 on create', async () => { - const res = await request(server).post('/api/tags').set('Cookie', sessionCookie(1)).send({ name: 'Beach', color: '#10b981' }); + it('201 on create, echoing the provided color', async () => { + const res = await request(server).post('/api/tags').set('Cookie', sessionCookie(1)).send({ name: 'Beach', color: '#ff0000' }); expect(res.status).toBe(201); - expect(res.body).toEqual({ tag }); - expect(mocks.createTag).toHaveBeenCalledWith(1, 'Beach', '#10b981'); + expect(res.body.tag).toMatchObject({ user_id: 1, name: 'Beach', color: '#ff0000' }); + expect(typeof res.body.tag.id).toBe('number'); + }); + + it('201 on create with the #10b981 default color when omitted', async () => { + const res = await request(server).post('/api/tags').set('Cookie', sessionCookie(1)).send({ name: 'Default' }); + expect(res.status).toBe(201); + expect(res.body.tag.color).toBe('#10b981'); }); it('400 on create without a name', async () => { @@ -90,22 +102,33 @@ describe('Tags e2e (real auth guard + temp SQLite)', () => { expect(res.body).toEqual({ error: 'Tag name is required' }); }); - it('200 on update of an owned tag', async () => { - const res = await request(server).put('/api/tags/1').set('Cookie', sessionCookie(1)).send({ name: 'Hike' }); + it('200 on update of an owned tag, COALESCE preserving the color', async () => { + const id = insertTag(1, 'Beach', '#ff0000'); + const res = await request(server).put(`/api/tags/${id}`).set('Cookie', sessionCookie(1)).send({ name: 'Hike' }); expect(res.status).toBe(200); - expect(res.body).toEqual({ tag: { ...tag, name: 'Hike' } }); + expect(res.body.tag).toMatchObject({ id, name: 'Hike', color: '#ff0000' }); }); it('404 on update of a tag the user does not own', async () => { - const res = await request(server).put('/api/tags/9').set('Cookie', sessionCookie(1)).send({ name: 'X' }); + const id = insertTag(2, 'Not-Yours'); + const res = await request(server).put(`/api/tags/${id}`).set('Cookie', sessionCookie(1)).send({ name: 'X' }); expect(res.status).toBe(404); expect(res.body).toEqual({ error: 'Tag not found' }); }); - it('200 on delete of an owned tag', async () => { - const res = await request(server).delete('/api/tags/1').set('Cookie', sessionCookie(1)); + it('200 on delete of an owned tag, removing the row', async () => { + const id = insertTag(1, 'ToDelete'); + const res = await request(server).delete(`/api/tags/${id}`).set('Cookie', sessionCookie(1)); expect(res.status).toBe(200); expect(res.body).toEqual({ success: true }); - expect(mocks.deleteTag).toHaveBeenCalledWith('1'); + expect(db.prepare('SELECT id FROM tags WHERE id = ?').get(id)).toBeUndefined(); + }); + + it('404 on delete of a tag the user does not own', async () => { + const id = insertTag(2, 'Not-Yours'); + const res = await request(server).delete(`/api/tags/${id}`).set('Cookie', sessionCookie(1)); + expect(res.status).toBe(404); + expect(res.body).toEqual({ error: 'Tag not found' }); + expect(db.prepare('SELECT id FROM tags WHERE id = ?').get(id)).toBeDefined(); }); }); diff --git a/server/tests/unit/services/tagService.test.ts b/server/tests/unit/nest/tags.service.test.ts similarity index 54% rename from server/tests/unit/services/tagService.test.ts rename to server/tests/unit/nest/tags.service.test.ts index 0cfa16bf6..708931862 100644 --- a/server/tests/unit/services/tagService.test.ts +++ b/server/tests/unit/nest/tags.service.test.ts @@ -1,6 +1,9 @@ /** - * Unit tests for tagService — TAG-SVC-001 through TAG-SVC-015. + * Unit tests for TagsService — TAG-SVC-001 through TAG-SVC-020. * Uses a real in-memory SQLite DB so SQL logic is exercised faithfully. + * The service is constructed directly (new TagsService(new DatabaseService(db))) + * — no Nest container needed. TAG-SVC-016..020 cover the tags.bridge exports + * used by non-Nest consumers (MCP tools, plugin RPC host). */ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; @@ -34,7 +37,11 @@ import { createTables } from '../../../src/db/schema'; import { runMigrations } from '../../../src/db/migrations'; import { resetTestDb } from '../../helpers/test-db'; import { createUser } from '../../helpers/factories'; -import { listTags, createTag, getTagByIdAndUser, updateTag, deleteTag } from '../../../src/services/tagService'; +import { DatabaseService } from '../../../src/nest/database/database.service'; +import { TagsService } from '../../../src/nest/tags/tags.service'; +import { listTags, createTag, getTagByIdAndUser, updateTag, deleteTag } from '../../../src/nest/tags/tags.bridge'; + +const svc = new TagsService(new DatabaseService(testDb)); beforeAll(() => { createTables(testDb); @@ -49,40 +56,40 @@ afterAll(() => { testDb.close(); }); -// ── listTags ────────────────────────────────────────────────────────────────── +// ── list ────────────────────────────────────────────────────────────────────── -describe('listTags', () => { +describe('list', () => { it('TAG-SVC-001 — returns empty array when user has no tags', () => { const { user } = createUser(testDb); - expect(listTags(user.id)).toEqual([]); + expect(svc.list(user.id)).toEqual([]); }); it('TAG-SVC-002 — returns only tags belonging to the user', () => { const { user: a } = createUser(testDb); const { user: b } = createUser(testDb); - createTag(a.id, 'A-Tag'); - createTag(b.id, 'B-Tag'); - const tags = listTags(a.id) as any[]; + svc.create(a.id, 'A-Tag'); + svc.create(b.id, 'B-Tag'); + const tags = svc.list(a.id); expect(tags).toHaveLength(1); expect(tags[0].name).toBe('A-Tag'); }); it('TAG-SVC-003 — results are ordered by name ascending', () => { const { user } = createUser(testDb); - createTag(user.id, 'Zebra'); - createTag(user.id, 'Apple'); - createTag(user.id, 'Mango'); - const names = (listTags(user.id) as any[]).map((t: any) => t.name); + svc.create(user.id, 'Zebra'); + svc.create(user.id, 'Apple'); + svc.create(user.id, 'Mango'); + const names = svc.list(user.id).map((t) => t.name); expect(names).toEqual(['Apple', 'Mango', 'Zebra']); }); }); -// ── createTag ───────────────────────────────────────────────────────────────── +// ── create ──────────────────────────────────────────────────────────────────── -describe('createTag', () => { +describe('create', () => { it('TAG-SVC-004 — creates a tag with provided name and color', () => { const { user } = createUser(testDb); - const tag = createTag(user.id, 'Beach', '#ff0000') as any; + const tag = svc.create(user.id, 'Beach', '#ff0000'); expect(tag.name).toBe('Beach'); expect(tag.color).toBe('#ff0000'); expect(tag.user_id).toBe(user.id); @@ -90,91 +97,132 @@ describe('createTag', () => { it('TAG-SVC-005 — defaults to #10b981 when no color provided', () => { const { user } = createUser(testDb); - const tag = createTag(user.id, 'Default') as any; + const tag = svc.create(user.id, 'Default'); expect(tag.color).toBe('#10b981'); }); it('TAG-SVC-006 — returns the inserted row with an id', () => { const { user } = createUser(testDb); - const tag = createTag(user.id, 'WithId') as any; + const tag = svc.create(user.id, 'WithId'); expect(typeof tag.id).toBe('number'); expect(tag.id).toBeGreaterThan(0); }); }); -// ── getTagByIdAndUser ───────────────────────────────────────────────────────── +// ── getByIdAndUser ──────────────────────────────────────────────────────────── -describe('getTagByIdAndUser', () => { +describe('getByIdAndUser', () => { it('TAG-SVC-007 — returns the tag when id and user_id match', () => { const { user } = createUser(testDb); - const created = createTag(user.id, 'Find Me') as any; - const found = getTagByIdAndUser(created.id, user.id) as any; + const created = svc.create(user.id, 'Find Me'); + const found = svc.getByIdAndUser(created.id, user.id); expect(found).toBeDefined(); - expect(found.name).toBe('Find Me'); + expect(found?.name).toBe('Find Me'); }); it('TAG-SVC-008 — returns undefined when tag belongs to different user', () => { const { user: a } = createUser(testDb); const { user: b } = createUser(testDb); - const tag = createTag(a.id, 'Private') as any; - expect(getTagByIdAndUser(tag.id, b.id)).toBeUndefined(); + const tag = svc.create(a.id, 'Private'); + expect(svc.getByIdAndUser(tag.id, b.id)).toBeUndefined(); }); it('TAG-SVC-009 — returns undefined for non-existent tag id', () => { const { user } = createUser(testDb); - expect(getTagByIdAndUser(99999, user.id)).toBeUndefined(); + expect(svc.getByIdAndUser(99999, user.id)).toBeUndefined(); }); }); -// ── updateTag ───────────────────────────────────────────────────────────────── +// ── update ──────────────────────────────────────────────────────────────────── -describe('updateTag', () => { +describe('update', () => { it('TAG-SVC-010 — updates both name and color', () => { const { user } = createUser(testDb); - const tag = createTag(user.id, 'Old', '#aaaaaa') as any; - const updated = updateTag(tag.id, 'New', '#bbbbbb') as any; + const tag = svc.create(user.id, 'Old', '#aaaaaa'); + const updated = svc.update(tag.id, 'New', '#bbbbbb'); expect(updated.name).toBe('New'); expect(updated.color).toBe('#bbbbbb'); }); it('TAG-SVC-011 — COALESCE: omitting name preserves existing name', () => { const { user } = createUser(testDb); - const tag = createTag(user.id, 'KeepMe', '#aaaaaa') as any; - const updated = updateTag(tag.id, undefined, '#cccccc') as any; + const tag = svc.create(user.id, 'KeepMe', '#aaaaaa'); + const updated = svc.update(tag.id, undefined, '#cccccc'); expect(updated.name).toBe('KeepMe'); expect(updated.color).toBe('#cccccc'); }); it('TAG-SVC-012 — COALESCE: omitting color preserves existing color', () => { const { user } = createUser(testDb); - const tag = createTag(user.id, 'ColorKeep', '#dddddd') as any; - const updated = updateTag(tag.id, 'NewName', undefined) as any; + const tag = svc.create(user.id, 'ColorKeep', '#dddddd'); + const updated = svc.update(tag.id, 'NewName', undefined); expect(updated.name).toBe('NewName'); expect(updated.color).toBe('#dddddd'); }); }); -// ── deleteTag ───────────────────────────────────────────────────────────────── +// ── remove ──────────────────────────────────────────────────────────────────── -describe('deleteTag', () => { +describe('remove', () => { it('TAG-SVC-013 — deletes the tag from the database', () => { const { user } = createUser(testDb); - const tag = createTag(user.id, 'ToDelete') as any; - deleteTag(tag.id); - expect(getTagByIdAndUser(tag.id, user.id)).toBeUndefined(); + const tag = svc.create(user.id, 'ToDelete'); + svc.remove(tag.id); + expect(svc.getByIdAndUser(tag.id, user.id)).toBeUndefined(); }); it('TAG-SVC-014 — deleting a non-existent tag does not throw', () => { - expect(() => deleteTag(99999)).not.toThrow(); + expect(() => svc.remove(99999)).not.toThrow(); }); it('TAG-SVC-015 — deleting one tag does not affect other tags', () => { const { user } = createUser(testDb); - const t1 = createTag(user.id, 'Keep') as any; - const t2 = createTag(user.id, 'Remove') as any; - deleteTag(t2.id); - const remaining = listTags(user.id) as any[]; + const t1 = svc.create(user.id, 'Keep'); + const t2 = svc.create(user.id, 'Remove'); + svc.remove(t2.id); + const remaining = svc.list(user.id); expect(remaining).toHaveLength(1); expect(remaining[0].id).toBe(t1.id); }); }); + +// ── tags.bridge (non-Nest entry point) ──────────────────────────────────────── + +describe('tags.bridge', () => { + it('TAG-SVC-016 — listTags delegates to the service', () => { + const { user } = createUser(testDb); + createTag(user.id, 'Bravo'); + createTag(user.id, 'Alpha'); + expect(listTags(user.id).map((t) => t.name)).toEqual(['Alpha', 'Bravo']); + }); + + it('TAG-SVC-017 — createTag applies the default color', () => { + const { user } = createUser(testDb); + const tag = createTag(user.id, 'Bridged'); + expect(tag.color).toBe('#10b981'); + expect(tag.user_id).toBe(user.id); + }); + + it('TAG-SVC-018 — getTagByIdAndUser scopes to the owner', () => { + const { user: a } = createUser(testDb); + const { user: b } = createUser(testDb); + const tag = createTag(a.id, 'Mine'); + expect(getTagByIdAndUser(tag.id, a.id)?.name).toBe('Mine'); + expect(getTagByIdAndUser(tag.id, b.id)).toBeUndefined(); + }); + + it('TAG-SVC-019 — updateTag keeps COALESCE semantics', () => { + const { user } = createUser(testDb); + const tag = createTag(user.id, 'Before', '#eeeeee'); + const updated = updateTag(tag.id, 'After', undefined); + expect(updated?.name).toBe('After'); + expect(updated?.color).toBe('#eeeeee'); + }); + + it('TAG-SVC-020 — deleteTag removes the row', () => { + const { user } = createUser(testDb); + const tag = createTag(user.id, 'Gone'); + deleteTag(tag.id); + expect(getTagByIdAndUser(tag.id, user.id)).toBeUndefined(); + }); +}); diff --git a/server/tests/unit/plugins/create-rpc-host.test.ts b/server/tests/unit/plugins/create-rpc-host.test.ts index 56c41354f..c51dfec26 100644 --- a/server/tests/unit/plugins/create-rpc-host.test.ts +++ b/server/tests/unit/plugins/create-rpc-host.test.ts @@ -137,7 +137,7 @@ vi.mock('../../../src/services/packingService', () => ({ 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/categoryService', () => ({ listCategories: vi.fn(() => [{ id: 1, name: 'Food' }]) })); -vi.mock('../../../src/services/tagService', () => ({ +vi.mock('../../../src/nest/tags/tags.bridge', () => ({ 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 })), getTagByIdAndUser: vi.fn((tagId: number, _uid: number) => (Number(tagId) === 404 ? undefined : { id: Number(tagId) })),