mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
refactor(nest): migrate tagService into TagsService DI (pilot)
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.
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 `<domain>.service.ts` as methods over an injected
|
||||
`DatabaseService` (`this.db.all<T>/get<T>/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 `<domain>.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/<x>Service` to `nest/<domain>/<domain>.bridge`; call sites unchanged.
|
||||
4. **Delete the legacy service** once `grep -rn "services/<x>Service" src tests`
|
||||
only hits the tests you're rewriting.
|
||||
5. **Tests:**
|
||||
- Move `tests/unit/services/<x>Service.test.ts` →
|
||||
`tests/unit/nest/<domain>.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, <Domain>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`.
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<Tag>('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<Tag>('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<Tag>('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<Tag>('SELECT * FROM tags WHERE id = ?', id) as Tag;
|
||||
}
|
||||
|
||||
remove(id: string | number): void {
|
||||
deleteTag(id);
|
||||
this.db.run('DELETE FROM tags WHERE id = ?', id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<ReturnType<typeof build>>;
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
+91
-43
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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) })),
|
||||
|
||||
Reference in New Issue
Block a user