diff --git a/src/lib/server/api-utils.test.ts b/src/lib/server/api-utils.test.ts new file mode 100644 index 0000000..2fe3ac4 --- /dev/null +++ b/src/lib/server/api-utils.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { createTestDb } from './db/test-helpers.js'; +import { notes, noteCollaborators, users } from './db/schema.js'; +import type { Db } from './db/index.js'; +import { canAccessNote, requireNoteAccess, requireNoteOwnership, getUserId } from './api-utils.js'; + +let db: Db; +const OWNER_ID = 1; +const COLLAB_ID = 2; +const STRANGER_ID = 3; + +function seedUsers() { + db.insert(users) + .values([ + { email: 'owner@test.com', displayName: 'Owner', role: 'admin', authProvider: 'password', createdAt: new Date() }, + { email: 'collab@test.com', displayName: 'Collab', role: 'user', authProvider: 'password', createdAt: new Date() }, + { email: 'stranger@test.com', displayName: 'Stranger', role: 'user', authProvider: 'password', createdAt: new Date() } + ]) + .run(); +} + +function seedNote(id: string) { + db.insert(notes) + .values({ id, userId: OWNER_ID, title: 'Test', content: '', createdAt: new Date(), updatedAt: new Date() }) + .run(); +} + +function shareNote(noteId: string) { + db.insert(noteCollaborators) + .values({ noteId, userId: COLLAB_ID, addedBy: OWNER_ID, addedAt: new Date() }) + .run(); +} + +beforeEach(() => { + const testDb = createTestDb(); + db = testDb.db; + seedUsers(); +}); + +describe('canAccessNote', () => { + it('should allow owner to access their note', () => { + seedNote('n1'); + const result = canAccessNote(db, 'n1', OWNER_ID); + expect(result).toEqual({ canAccess: true, isOwner: true }); + }); + + it('should allow collaborator to access shared note', () => { + seedNote('n1'); + shareNote('n1'); + const result = canAccessNote(db, 'n1', COLLAB_ID); + expect(result).toEqual({ canAccess: true, isOwner: false }); + }); + + it('should deny non-collaborator access', () => { + seedNote('n1'); + const result = canAccessNote(db, 'n1', STRANGER_ID); + expect(result).toEqual({ canAccess: false, isOwner: false }); + }); + + it('should return false for non-existent note', () => { + const result = canAccessNote(db, 'nonexistent', OWNER_ID); + expect(result).toEqual({ canAccess: false, isOwner: false }); + }); +}); + +describe('requireNoteAccess', () => { + it('should throw 404 for unauthorized user', () => { + seedNote('n1'); + expect(() => requireNoteAccess(db, 'n1', STRANGER_ID)).toThrow(); + }); + + it('should return isOwner for authorized owner', () => { + seedNote('n1'); + expect(requireNoteAccess(db, 'n1', OWNER_ID)).toEqual({ isOwner: true }); + }); +}); + +describe('requireNoteOwnership', () => { + it('should throw 404 for collaborator', () => { + seedNote('n1'); + shareNote('n1'); + expect(() => requireNoteOwnership(db, 'n1', COLLAB_ID)).toThrow(); + }); + + it('should not throw for owner', () => { + seedNote('n1'); + expect(() => requireNoteOwnership(db, 'n1', OWNER_ID)).not.toThrow(); + }); +}); + +describe('getUserId', () => { + it('should throw 401 when no user in locals', () => { + expect(() => getUserId({ locals: {} as App.Locals })).toThrow(); + }); + + it('should return user id from locals', () => { + const id = getUserId({ locals: { user: { id: 42, role: 'user', email: '', displayName: '' } } as App.Locals }); + expect(id).toBe(42); + }); +}); diff --git a/src/lib/server/collaborators.test.ts b/src/lib/server/collaborators.test.ts new file mode 100644 index 0000000..05c6f40 --- /dev/null +++ b/src/lib/server/collaborators.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { createTestDb } from './db/test-helpers.js'; +import { notes, noteCollaborators, noteUserState, users } from './db/schema.js'; +import { eq, and } from 'drizzle-orm'; +import type { Db } from './db/index.js'; +import { fetchCollaboratorsForNotes, addCollaborator, removeCollaborator } from './collaborators.js'; + +let db: Db; +const OWNER_ID = 1; +const COLLAB_ID = 2; + +function seedUsers() { + db.insert(users) + .values([ + { email: 'owner@test.com', displayName: 'Owner', role: 'admin', authProvider: 'password', createdAt: new Date() }, + { email: 'collab@test.com', displayName: 'Collab', role: 'user', authProvider: 'password', createdAt: new Date() } + ]) + .run(); +} + +function seedNote(id: string) { + db.insert(notes) + .values({ id, userId: OWNER_ID, title: 'Test', content: '', createdAt: new Date(), updatedAt: new Date() }) + .run(); +} + +beforeEach(() => { + const testDb = createTestDb(); + db = testDb.db; + seedUsers(); +}); + +describe('fetchCollaboratorsForNotes', () => { + it('should return Map with user details for shared notes', () => { + seedNote('n1'); + addCollaborator(db, 'n1', COLLAB_ID, OWNER_ID); + + const map = fetchCollaboratorsForNotes(db, ['n1']); + const collabs = map.get('n1'); + expect(collabs).toHaveLength(1); + expect(collabs![0].userId).toBe(COLLAB_ID); + expect(collabs![0].displayName).toBe('Collab'); + expect(collabs![0].email).toBe('collab@test.com'); + }); + + it('should return empty Map for notes with no collaborators', () => { + seedNote('n1'); + const map = fetchCollaboratorsForNotes(db, ['n1']); + expect(map.size).toBe(0); + }); +}); + +describe('addCollaborator', () => { + it('should insert collaborator record', () => { + seedNote('n1'); + addCollaborator(db, 'n1', COLLAB_ID, OWNER_ID); + + const rows = db.select().from(noteCollaborators).all(); + expect(rows).toHaveLength(1); + expect(rows[0].userId).toBe(COLLAB_ID); + expect(rows[0].addedBy).toBe(OWNER_ID); + }); +}); + +describe('removeCollaborator', () => { + it('should clean up noteUserState on removal', () => { + seedNote('n1'); + addCollaborator(db, 'n1', COLLAB_ID, OWNER_ID); + db.insert(noteUserState) + .values({ noteId: 'n1', userId: COLLAB_ID, pinned: true, archived: false, sortOrder: 0 }) + .run(); + + removeCollaborator(db, 'n1', COLLAB_ID); + + const collabs = db.select().from(noteCollaborators).all(); + expect(collabs).toHaveLength(0); + + const states = db.select().from(noteUserState) + .where(and(eq(noteUserState.noteId, 'n1'), eq(noteUserState.userId, COLLAB_ID))) + .all(); + expect(states).toHaveLength(0); + }); +}); diff --git a/src/lib/server/db/test-helpers.ts b/src/lib/server/db/test-helpers.ts index 6714faa..ca4d6a7 100644 --- a/src/lib/server/db/test-helpers.ts +++ b/src/lib/server/db/test-helpers.ts @@ -94,6 +94,8 @@ export function createTestDb(options?: { seedUser?: boolean }) { mime_type TEXT NOT NULL, size INTEGER NOT NULL, path TEXT NOT NULL, + thumbnail_path TEXT, + featured INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL ); diff --git a/src/lib/server/notes-service.test.ts b/src/lib/server/notes-service.test.ts new file mode 100644 index 0000000..935bf64 --- /dev/null +++ b/src/lib/server/notes-service.test.ts @@ -0,0 +1,311 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { createTestDb } from './db/test-helpers.js'; +import { notes, noteCollaborators, noteUserState, users } from './db/schema.js'; +import { eq, and } from 'drizzle-orm'; +import type { Db } from './db/index.js'; +import { + listNotes, + getNote, + createNote, + updateNote, + deleteNote, + searchNotes, + reorderNotes +} from './notes-service.js'; + +let db: Db; + +const OWNER_ID = 1; +const COLLAB_ID = 2; + +function seedUsers() { + db.insert(users) + .values([ + { + email: 'owner@test.com', + displayName: 'Owner', + role: 'admin', + authProvider: 'password', + createdAt: new Date() + }, + { + email: 'collab@test.com', + displayName: 'Collab', + role: 'user', + authProvider: 'password', + createdAt: new Date() + } + ]) + .run(); +} + +function seedNote( + id: string, + overrides: Partial = {} +) { + const now = new Date(); + db.insert(notes) + .values({ + id, + userId: OWNER_ID, + title: '', + content: '', + createdAt: now, + updatedAt: now, + ...overrides + }) + .run(); + return id; +} + +function shareNote(noteId: string, userId = COLLAB_ID) { + db.insert(noteCollaborators) + .values({ + noteId, + userId, + addedBy: OWNER_ID, + addedAt: new Date() + }) + .run(); +} + +beforeEach(() => { + const testDb = createTestDb(); + db = testDb.db; + seedUsers(); +}); + +describe('listNotes', () => { + it('should return only active owned notes (excludes archived/trashed)', () => { + seedNote('active', { title: 'Active' }); + seedNote('archived', { title: 'Archived', archived: true }); + seedNote('trashed', { title: 'Trashed', trashed: true }); + + const result = listNotes(db, OWNER_ID); + expect(result).toHaveLength(1); + expect(result[0].title).toBe('Active'); + }); + + it('should include shared notes with per-user state overlay', () => { + seedNote('shared', { title: 'Shared Note' }); + shareNote('shared'); + db.insert(noteUserState) + .values({ noteId: 'shared', userId: COLLAB_ID, pinned: true, archived: false, sortOrder: 5 }) + .run(); + + const result = listNotes(db, COLLAB_ID); + expect(result).toHaveLength(1); + expect(result[0].title).toBe('Shared Note'); + expect(result[0].pinned).toBeTruthy(); + expect(result[0].sortOrder).toBe(5); + }); + + it('should sort pinned first, then by updatedAt desc', () => { + const old = new Date('2024-01-01'); + const recent = new Date('2024-01-02'); + seedNote('old-pinned', { title: 'Old Pinned', pinned: true, updatedAt: old, createdAt: old }); + seedNote('recent', { title: 'Recent', updatedAt: recent, createdAt: recent }); + seedNote('old', { title: 'Old', updatedAt: old, createdAt: old }); + + const result = listNotes(db, OWNER_ID); + expect(result.map((n) => n.title)).toEqual(['Old Pinned', 'Recent', 'Old']); + }); + + it('should return only archived notes with archived filter', () => { + seedNote('active', { title: 'Active' }); + seedNote('archived', { title: 'Archived', archived: true }); + seedNote('shared', { title: 'Shared Archived' }); + shareNote('shared'); + db.insert(noteUserState) + .values({ noteId: 'shared', userId: COLLAB_ID, pinned: false, archived: true, sortOrder: 0 }) + .run(); + + // Owner sees their archived note + const ownerResult = listNotes(db, OWNER_ID, 'archived'); + expect(ownerResult).toHaveLength(1); + expect(ownerResult[0].title).toBe('Archived'); + + // Collaborator sees shared note they archived + const collabResult = listNotes(db, COLLAB_ID, 'archived'); + expect(collabResult).toHaveLength(1); + expect(collabResult[0].title).toBe('Shared Archived'); + }); + + it('should exclude shared notes from trashed filter', () => { + seedNote('trashed', { title: 'Trashed', trashed: true }); + seedNote('shared', { title: 'Shared' }); + shareNote('shared'); + + const result = listNotes(db, COLLAB_ID, 'trashed'); + expect(result).toHaveLength(0); + }); +}); + +describe('getNote', () => { + it('should return null for non-existent note', () => { + expect(getNote(db, OWNER_ID, 'nonexistent')).toBeNull(); + }); + + it('should return null if user has no access', () => { + seedNote('private', { title: 'Private' }); + expect(getNote(db, COLLAB_ID, 'private')).toBeNull(); + }); + + it('should overlay collaborator per-user state', () => { + seedNote('shared', { title: 'Shared', pinned: true, sortOrder: 10 }); + shareNote('shared'); + db.insert(noteUserState) + .values({ noteId: 'shared', userId: COLLAB_ID, pinned: false, archived: true, sortOrder: 5 }) + .run(); + + const note = getNote(db, COLLAB_ID, 'shared'); + expect(note).not.toBeNull(); + expect(note!.pinned).toBeFalsy(); + expect(note!.archived).toBeTruthy(); + expect(note!.sortOrder).toBe(5); + }); +}); + +describe('createNote', () => { + it('should generate UUID and set version=1', () => { + const note = createNote(db, OWNER_ID, { title: 'Test' }); + expect(note.id).toBeDefined(); + expect(note.id.length).toBe(36); // UUID format + expect(note.version).toBe(1); + }); + + it('should use provided ID when given', () => { + const note = createNote(db, OWNER_ID, { id: 'custom-id', title: 'Test' }); + expect(note.id).toBe('custom-id'); + }); + + it('should extract tags from content', () => { + const note = createNote(db, OWNER_ID, { + title: 'My #todo note', + content: 'Some #important content' + }); + expect(note.tags).toContain('todo'); + expect(note.tags).toContain('important'); + }); +}); + +describe('updateNote', () => { + it('should allow owner to update shared fields directly', () => { + seedNote('n1', { title: 'Original' }); + const updated = updateNote(db, OWNER_ID, 'n1', { title: 'Updated' }); + expect(updated).not.toBeNull(); + expect(updated!.title).toBe('Updated'); + }); + + it('should allow collaborator to edit content', () => { + seedNote('n1', { title: 'Original', content: 'Old' }); + shareNote('n1'); + const updated = updateNote(db, COLLAB_ID, 'n1', { content: 'New content' }); + expect(updated).not.toBeNull(); + expect(updated!.content).toBe('New content'); + }); + + it('should route collaborator per-user fields to noteUserState', () => { + seedNote('n1', { title: 'Note', pinned: false }); + shareNote('n1'); + updateNote(db, COLLAB_ID, 'n1', { pinned: true, sortOrder: 7 }); + + // Owner's note pinned should be unchanged + const ownerView = getNote(db, OWNER_ID, 'n1'); + expect(ownerView!.pinned).toBeFalsy(); + + // Collaborator sees their per-user state + const collabView = getNote(db, COLLAB_ID, 'n1'); + expect(collabView!.pinned).toBeTruthy(); + expect(collabView!.sortOrder).toBe(7); + }); + + it('should return null when collaborator tries to trash', () => { + seedNote('n1', { title: 'Note' }); + shareNote('n1'); + const result = updateNote(db, COLLAB_ID, 'n1', { trashed: true }); + expect(result).toBeNull(); + }); + + it('should increment version on update', () => { + seedNote('n1', { title: 'V1' }); + const updated = updateNote(db, OWNER_ID, 'n1', { title: 'V2' }); + expect(updated!.version).toBe(2); + }); + + it('should extract and sync tags on title/content change', () => { + seedNote('n1', { title: 'Original' }); + const updated = updateNote(db, OWNER_ID, 'n1', { title: 'Now with #newtag' }); + expect(updated!.tags).toContain('newtag'); + }); +}); + +describe('deleteNote', () => { + it('should allow owner to permanently delete', () => { + seedNote('n1', { title: 'Delete me' }); + const result = deleteNote(db, OWNER_ID, 'n1'); + expect(result).toBe(true); + expect(getNote(db, OWNER_ID, 'n1')).toBeNull(); + }); + + it('should return false for non-existent or unauthorized', () => { + seedNote('n1', { title: 'Private' }); + expect(deleteNote(db, COLLAB_ID, 'n1')).toBe(false); + expect(deleteNote(db, OWNER_ID, 'nonexistent')).toBe(false); + }); +}); + +describe('searchNotes', () => { + it('should match title and content', () => { + seedNote('n1', { title: 'Meeting notes', content: 'Discuss budget' }); + seedNote('n2', { title: 'Shopping list', content: 'Buy milk' }); + + const results = searchNotes(db, OWNER_ID, 'budget'); + expect(results).toHaveLength(1); + expect(results[0].id).toBe('n1'); + }); + + it('should include shared notes in results', () => { + seedNote('shared', { title: 'Shared doc', content: 'Collaboration' }); + shareNote('shared'); + + const results = searchNotes(db, COLLAB_ID, 'Collaboration'); + expect(results).toHaveLength(1); + expect(results[0].id).toBe('shared'); + }); + + it('should return empty array for empty query', () => { + seedNote('n1', { title: 'Test' }); + expect(searchNotes(db, OWNER_ID, '')).toEqual([]); + }); +}); + +describe('reorderNotes', () => { + it('should update owner sortOrder in notes table', () => { + seedNote('n1', { title: 'A' }); + seedNote('n2', { title: 'B' }); + reorderNotes(db, OWNER_ID, [ + { id: 'n1', sortOrder: 2 }, + { id: 'n2', sortOrder: 1 } + ]); + + const n1 = getNote(db, OWNER_ID, 'n1'); + const n2 = getNote(db, OWNER_ID, 'n2'); + expect(n1!.sortOrder).toBe(2); + expect(n2!.sortOrder).toBe(1); + }); + + it('should update collaborator sortOrder in noteUserState', () => { + seedNote('n1', { title: 'Note', sortOrder: 0 }); + shareNote('n1'); + reorderNotes(db, COLLAB_ID, [{ id: 'n1', sortOrder: 5 }]); + + // Owner's sortOrder unchanged + const ownerView = getNote(db, OWNER_ID, 'n1'); + expect(ownerView!.sortOrder).toBe(0); + + // Collaborator sees their sortOrder + const collabView = getNote(db, COLLAB_ID, 'n1'); + expect(collabView!.sortOrder).toBe(5); + }); +}); diff --git a/src/lib/server/rate-limit.test.ts b/src/lib/server/rate-limit.test.ts new file mode 100644 index 0000000..6380190 --- /dev/null +++ b/src/lib/server/rate-limit.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { createTestDb } from './db/test-helpers.js'; +import { loginAttempts } from './db/schema.js'; +import type { Db } from './db/index.js'; +import { checkRateLimit, recordLoginAttempt, clearOldAttempts } from './rate-limit.js'; + +let db: Db; + +beforeEach(() => { + const testDb = createTestDb(); + db = testDb.db; +}); + +const IP = '192.168.1.1'; +const EMAIL = 'test@test.com'; + +function addFailedAttempts(count: number, now: Date) { + for (let i = 0; i < count; i++) { + recordLoginAttempt(db, IP, EMAIL, false, now); + } +} + +describe('checkRateLimit', () => { + it('should allow first attempt', () => { + const result = checkRateLimit(db, IP, EMAIL); + expect(result.allowed).toBe(true); + }); + + it('should allow up to 4 failed attempts', () => { + const now = new Date(); + addFailedAttempts(4, now); + const result = checkRateLimit(db, IP, EMAIL, now); + expect(result.allowed).toBe(true); + }); + + it('should block after 5 failed attempts within window', () => { + const now = new Date(); + addFailedAttempts(5, now); + const result = checkRateLimit(db, IP, EMAIL, now); + expect(result.allowed).toBe(false); + expect(result.retryAfter).toBeDefined(); + expect(result.retryAfter!).toBeGreaterThan(0); + }); + + it('should return correct retryAfter value', () => { + const now = new Date(); + addFailedAttempts(5, now); + const result = checkRateLimit(db, IP, EMAIL, now); + // Lockout is 15 minutes (900 seconds) + expect(result.retryAfter).toBe(900); + }); + + it('should allow again after lockout expires', () => { + const attemptTime = new Date('2024-01-01T00:00:00Z'); + addFailedAttempts(5, attemptTime); + + // Check at attempt time — should be blocked + const blocked = checkRateLimit(db, IP, EMAIL, attemptTime); + expect(blocked.allowed).toBe(false); + + // Check 16 minutes later — should be allowed + const later = new Date(attemptTime.getTime() + 16 * 60 * 1000); + const allowed = checkRateLimit(db, IP, EMAIL, later); + expect(allowed.allowed).toBe(true); + }); +}); + +describe('recordLoginAttempt', () => { + it('should insert attempt record', () => { + recordLoginAttempt(db, IP, EMAIL, false); + const rows = db.select().from(loginAttempts).all(); + expect(rows).toHaveLength(1); + expect(rows[0].ip).toBe(IP); + expect(rows[0].email).toBe(EMAIL); + }); + + it('should record success/failure flag correctly', () => { + recordLoginAttempt(db, IP, EMAIL, true); + recordLoginAttempt(db, IP, EMAIL, false); + const rows = db.select().from(loginAttempts).all(); + expect(rows[0].success).toBe(true); + expect(rows[1].success).toBe(false); + }); +}); + +describe('clearOldAttempts', () => { + it('should remove attempts older than 2x lockout', () => { + const old = new Date('2024-01-01T00:00:00Z'); + const recent = new Date('2024-01-01T01:00:00Z'); + recordLoginAttempt(db, IP, EMAIL, false, old); + recordLoginAttempt(db, IP, EMAIL, false, recent); + + // Clear at a time where old is > 2x lockout (30 min) ago but recent is not + const clearTime = new Date(old.getTime() + 31 * 60 * 1000); + clearOldAttempts(db, clearTime); + + const rows = db.select().from(loginAttempts).all(); + expect(rows).toHaveLength(1); + }); +}); diff --git a/src/lib/server/rate-limit.ts b/src/lib/server/rate-limit.ts index e11de40..e07c75d 100644 --- a/src/lib/server/rate-limit.ts +++ b/src/lib/server/rate-limit.ts @@ -1,6 +1,6 @@ import type { Db } from './db/index.js'; import { loginAttempts } from './db/schema.js'; -import { and, eq, gte, sql } from 'drizzle-orm'; +import { and, eq, gte, lt, sql } from 'drizzle-orm'; const MAX_ATTEMPTS = 5; const WINDOW_MS = 60 * 1000; // 1 minute @@ -72,6 +72,6 @@ export function recordLoginAttempt(db: Db, ip: string, email: string, success: b export function clearOldAttempts(db: Db, now?: Date): void { const cutoff = new Date((now ?? new Date()).getTime() - LOCKOUT_MS * 2); db.delete(loginAttempts) - .where(sql`${loginAttempts.timestamp} < ${cutoff}`) + .where(lt(loginAttempts.timestamp, cutoff)) .run(); } diff --git a/src/lib/server/tags.test.ts b/src/lib/server/tags.test.ts new file mode 100644 index 0000000..6b9559a --- /dev/null +++ b/src/lib/server/tags.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { createTestDb } from './db/test-helpers.js'; +import { notes, tags, noteTags, users } from './db/schema.js'; +import { eq } from 'drizzle-orm'; +import type { Db } from './db/index.js'; +import { fetchTagsForNotes, syncNoteTags } from './tags.js'; + +let db: Db; +const USER_ID = 1; + +function seedUser() { + db.insert(users) + .values({ email: 'test@test.com', displayName: 'Test', role: 'admin', authProvider: 'password', createdAt: new Date() }) + .run(); +} + +function seedNote(id: string) { + db.insert(notes) + .values({ id, userId: USER_ID, title: '', content: '', createdAt: new Date(), updatedAt: new Date() }) + .run(); +} + +beforeEach(() => { + const testDb = createTestDb(); + db = testDb.db; + seedUser(); +}); + +describe('syncNoteTags', () => { + it('should create new tags and associations', () => { + seedNote('n1'); + syncNoteTags(db, 'n1', ['todo', 'urgent'], USER_ID); + + const tagRows = db.select().from(tags).all(); + expect(tagRows).toHaveLength(2); + + const associations = db.select().from(noteTags).all(); + expect(associations).toHaveLength(2); + }); + + it('should remove old associations and add new ones', () => { + seedNote('n1'); + syncNoteTags(db, 'n1', ['old-tag'], USER_ID); + syncNoteTags(db, 'n1', ['new-tag'], USER_ID); + + const map = fetchTagsForNotes(db, ['n1']); + expect(map.get('n1')).toEqual(['new-tag']); + }); + + it('should handle empty tag list (removes all)', () => { + seedNote('n1'); + syncNoteTags(db, 'n1', ['tag1', 'tag2'], USER_ID); + syncNoteTags(db, 'n1', [], USER_ID); + + const associations = db.select().from(noteTags).where(eq(noteTags.noteId, 'n1')).all(); + expect(associations).toHaveLength(0); + }); + + it('should deduplicate existing tags', () => { + seedNote('n1'); + seedNote('n2'); + syncNoteTags(db, 'n1', ['shared-tag'], USER_ID); + syncNoteTags(db, 'n2', ['shared-tag'], USER_ID); + + // Should reuse the same tag row + const tagRows = db.select().from(tags).all(); + expect(tagRows).toHaveLength(1); + }); +}); + +describe('fetchTagsForNotes', () => { + it('should return Map of noteId to tag names', () => { + seedNote('n1'); + seedNote('n2'); + syncNoteTags(db, 'n1', ['alpha', 'beta'], USER_ID); + syncNoteTags(db, 'n2', ['gamma'], USER_ID); + + const map = fetchTagsForNotes(db, ['n1', 'n2']); + expect(map.get('n1')).toHaveLength(2); + expect(map.get('n1')).toContain('alpha'); + expect(map.get('n1')).toContain('beta'); + expect(map.get('n2')).toEqual(['gamma']); + }); + + it('should return empty map for empty noteIds', () => { + const map = fetchTagsForNotes(db, []); + expect(map.size).toBe(0); + }); +});