test: add 52 unit tests for notes-service, api-utils, rate-limit, tags, collaborators

- notes-service.test.ts (24 tests): listNotes, getNote, createNote, updateNote,
  deleteNote, searchNotes, reorderNotes with owner/collaborator patterns
- api-utils.test.ts (10 tests): canAccessNote, requireNoteAccess, requireNoteOwnership, getUserId
- rate-limit.test.ts (8 tests): checkRateLimit, recordLoginAttempt, clearOldAttempts
- tags.test.ts (6 tests): syncNoteTags, fetchTagsForNotes
- collaborators.test.ts (4 tests): fetchCollaboratorsForNotes, addCollaborator, removeCollaborator

Also fixes test-helpers.ts missing thumbnail_path/featured columns
and rate-limit.ts raw SQL Date binding issue.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Frederic Cabassut
2026-03-10 10:14:09 +01:00
committed by Fred Cabassut
co-authored by Claude Opus 4.6
parent e794d09978
commit 914b4fc772
7 changed files with 687 additions and 2 deletions
+100
View File
@@ -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);
});
});
+83
View File
@@ -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);
});
});
+2
View File
@@ -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
);
+311
View File
@@ -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<typeof notes.$inferInsert> = {}
) {
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);
});
});
+100
View File
@@ -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);
});
});
+2 -2
View File
@@ -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();
}
+89
View File
@@ -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);
});
});