feat(calendar, contacts, todo): add server API tests with vitest

Calendar: 13 tests (RRULE expansion, ICS parsing, health endpoint).
Contacts: 11 tests (vCard import, avatar upload, health endpoint).
Todo: admin, reminders, and RRULE route tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-01 15:27:58 +02:00
parent 293fd7b63b
commit b684ddeeda
19 changed files with 1553 additions and 353 deletions

View file

@ -7,7 +7,9 @@
"scripts": {
"dev": "bun run --watch src/index.ts",
"start": "bun run src/index.ts",
"type-check": "bun x tsc --noEmit"
"type-check": "bun x tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@manacore/shared-hono": "workspace:*",
@ -19,6 +21,7 @@
},
"devDependencies": {
"@types/bun": "^1.2.0",
"typescript": "^5.9.3"
"typescript": "^5.9.3",
"vitest": "^3.0.0"
}
}

View file

@ -0,0 +1,111 @@
import { describe, it, expect, vi } from 'vitest';
import { Hono } from 'hono';
// Mock drizzle-orm operators
vi.mock('drizzle-orm', () => ({
eq: vi.fn((_col, _val) => ({ type: 'eq' })),
sql: vi.fn((strings: TemplateStringsArray) => strings.join('')),
}));
const mockSelectFromWhere = vi.fn();
const mockDeleteWhere = vi.fn();
vi.mock('../db', () => ({
db: {
select: vi.fn(() => ({
from: vi.fn(() => ({
where: () => mockSelectFromWhere(),
})),
})),
delete: vi.fn(() => ({
where: () => mockDeleteWhere(),
})),
},
tasks: { userId: 'user_id' },
projects: { userId: 'user_id' },
reminders: { userId: 'user_id' },
}));
// Mock serviceAuthMiddleware to pass through
vi.mock('@manacore/shared-hono', () => ({
serviceAuthMiddleware: () => async (_c: unknown, next: () => Promise<void>) => next(),
}));
const { adminRoutes } = await import('./admin');
const app = new Hono();
app.route('/admin', adminRoutes);
function get(path: string) {
return app.request(path);
}
function del(path: string) {
return app.request(path, { method: 'DELETE' });
}
// ─── GET /admin/user-data/:userId ──────────────────────────────
describe('GET /admin/user-data/:userId', () => {
it('returns user data counts', async () => {
mockSelectFromWhere
.mockResolvedValueOnce([{ count: 42 }]) // tasks
.mockResolvedValueOnce([{ count: 3 }]) // projects
.mockResolvedValueOnce([{ count: 5 }]); // reminders
const res = await get('/admin/user-data/user-123');
expect(res.status).toBe(200);
const data = await res.json();
expect(data.userId).toBe('user-123');
expect(data.counts.tasks).toBe(42);
expect(data.counts.projects).toBe(3);
expect(data.counts.reminders).toBe(5);
});
it('returns zero counts for user with no data', async () => {
mockSelectFromWhere
.mockResolvedValueOnce([{ count: 0 }])
.mockResolvedValueOnce([{ count: 0 }])
.mockResolvedValueOnce([{ count: 0 }]);
const res = await get('/admin/user-data/empty-user');
expect(res.status).toBe(200);
const data = await res.json();
expect(data.counts.tasks).toBe(0);
expect(data.counts.projects).toBe(0);
expect(data.counts.reminders).toBe(0);
});
it('handles null count results', async () => {
mockSelectFromWhere
.mockResolvedValueOnce([undefined])
.mockResolvedValueOnce([undefined])
.mockResolvedValueOnce([undefined]);
const res = await get('/admin/user-data/user-x');
expect(res.status).toBe(200);
const data = await res.json();
expect(data.counts.tasks).toBe(0);
expect(data.counts.projects).toBe(0);
expect(data.counts.reminders).toBe(0);
});
});
// ─── DELETE /admin/user-data/:userId ───────────────────────────
describe('DELETE /admin/user-data/:userId', () => {
it('deletes all user data (GDPR)', async () => {
mockDeleteWhere.mockResolvedValue(undefined);
const res = await del('/admin/user-data/user-123');
expect(res.status).toBe(200);
const data = await res.json();
expect(data.userId).toBe('user-123');
expect(data.deleted).toBe(true);
expect(data.message).toBe('All user data deleted');
});
});

View file

@ -0,0 +1,205 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Hono } from 'hono';
// Mock drizzle-orm operators before any imports that use them
vi.mock('drizzle-orm', () => ({
eq: vi.fn((_col, _val) => ({ type: 'eq' })),
and: vi.fn((..._args) => ({ type: 'and' })),
asc: vi.fn((_col) => ({ type: 'asc' })),
}));
const mockFindFirstTask = vi.fn();
const mockFindManyReminders = vi.fn();
const mockInsertReturning = vi.fn();
const mockDeleteWhere = vi.fn();
vi.mock('../db', () => ({
db: {
query: {
tasks: { findFirst: (...args: unknown[]) => mockFindFirstTask(...args) },
reminders: { findMany: (...args: unknown[]) => mockFindManyReminders(...args) },
},
insert: vi.fn(() => ({
values: vi.fn(() => ({
returning: () => mockInsertReturning(),
})),
})),
delete: vi.fn(() => ({
where: () => mockDeleteWhere(),
})),
},
tasks: { id: 'id', userId: 'user_id' },
reminders: {
id: 'id',
taskId: 'task_id',
userId: 'user_id',
minutesBefore: 'minutes_before',
},
}));
// Import AFTER mocks
const { reminderRoutes } = await import('./reminders');
const TEST_USER_ID = 'test-user-id';
function createApp() {
const app = new Hono();
app.use('*', async (c, next) => {
c.set('userId', TEST_USER_ID);
return next();
});
app.route('/', reminderRoutes);
return app;
}
const app = createApp();
function get(path: string) {
return app.request(path);
}
function post(path: string, body: unknown) {
return app.request(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
}
function del(path: string) {
return app.request(path, { method: 'DELETE' });
}
beforeEach(() => {
vi.clearAllMocks();
});
// ─── GET /tasks/:taskId/reminders ──────────────────────────────
describe('GET /tasks/:taskId/reminders', () => {
it('returns reminders for a valid task', async () => {
mockFindFirstTask.mockResolvedValue({ id: 'task-1', userId: TEST_USER_ID });
mockFindManyReminders.mockResolvedValue([
{ id: 'r-1', minutesBefore: 10, type: 'push' },
{ id: 'r-2', minutesBefore: 60, type: 'email' },
]);
const res = await get('/tasks/task-1/reminders');
expect(res.status).toBe(200);
const data = await res.json();
expect(data.reminders).toHaveLength(2);
expect(data.reminders[0].id).toBe('r-1');
});
it('returns 404 if task not found', async () => {
mockFindFirstTask.mockResolvedValue(null);
const res = await get('/tasks/nonexistent/reminders');
expect(res.status).toBe(404);
const data = await res.json();
expect(data.error).toBe('Task not found');
});
});
// ─── POST /tasks/:taskId/reminders ─────────────────────────────
describe('POST /tasks/:taskId/reminders', () => {
it('creates a reminder for a task with due date', async () => {
const dueDate = new Date('2026-06-15T14:00:00Z');
mockFindFirstTask.mockResolvedValue({
id: 'task-1',
userId: TEST_USER_ID,
dueDate: dueDate.toISOString(),
});
mockInsertReturning.mockResolvedValue([
{
id: 'r-new',
taskId: 'task-1',
minutesBefore: 30,
type: 'push',
reminderTime: new Date(dueDate.getTime() - 30 * 60 * 1000).toISOString(),
},
]);
const res = await post('/tasks/task-1/reminders', {
minutesBefore: 30,
type: 'push',
});
expect(res.status).toBe(201);
const data = await res.json();
expect(data.reminder.id).toBe('r-new');
expect(data.reminder.minutesBefore).toBe(30);
});
it('defaults type to push', async () => {
mockFindFirstTask.mockResolvedValue({
id: 'task-1',
userId: TEST_USER_ID,
dueDate: '2026-06-15T14:00:00Z',
});
mockInsertReturning.mockResolvedValue([{ id: 'r-new', type: 'push' }]);
const res = await post('/tasks/task-1/reminders', { minutesBefore: 15 });
expect(res.status).toBe(201);
});
it('returns 404 if task not found', async () => {
mockFindFirstTask.mockResolvedValue(null);
const res = await post('/tasks/nonexistent/reminders', {
minutesBefore: 30,
});
expect(res.status).toBe(404);
});
it('returns 400 if task has no due date', async () => {
mockFindFirstTask.mockResolvedValue({
id: 'task-1',
userId: TEST_USER_ID,
dueDate: null,
});
const res = await post('/tasks/task-1/reminders', { minutesBefore: 30 });
expect(res.status).toBe(400);
const data = await res.json();
expect(data.error).toContain('without due date');
});
});
// ─── DELETE /reminders/:id ─────────────────────────────────────
describe('DELETE /reminders/:id', () => {
it('deletes an existing reminder', async () => {
const mockFindFirstReminder = vi.fn().mockResolvedValue({
id: 'r-1',
userId: TEST_USER_ID,
});
// Override the reminders findFirst for this test
const { db } = await import('../db');
(db.query as Record<string, unknown>).reminders = { findFirst: mockFindFirstReminder };
mockDeleteWhere.mockResolvedValue(undefined);
const res = await del('/reminders/r-1');
expect(res.status).toBe(200);
const data = await res.json();
expect(data.success).toBe(true);
});
it('returns 404 if reminder not found', async () => {
const { db } = await import('../db');
(db.query as Record<string, unknown>).reminders = {
findFirst: vi.fn().mockResolvedValue(null),
};
const res = await del('/reminders/nonexistent');
expect(res.status).toBe(404);
const data = await res.json();
expect(data.error).toBe('Reminder not found');
});
});

View file

@ -0,0 +1,164 @@
import { describe, it, expect } from 'vitest';
import { Hono } from 'hono';
import { rruleRoutes } from './rrule';
const app = new Hono();
app.route('/compute', rruleRoutes);
function post(path: string, body: unknown) {
return app.request(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
}
// ─── POST /compute/next-occurrence ─────────────────────────────
describe('POST /compute/next-occurrence', () => {
it('returns next occurrence for daily RRULE', async () => {
const res = await post('/compute/next-occurrence', {
rrule: 'FREQ=DAILY',
});
expect(res.status).toBe(200);
const data = await res.json();
expect(data.valid).toBe(true);
expect(data.nextDate).toBeDefined();
expect(data.totalOccurrences).toBeGreaterThan(0);
});
it('returns next occurrence for weekly RRULE', async () => {
const res = await post('/compute/next-occurrence', {
rrule: 'FREQ=WEEKLY;BYDAY=MO,WE,FR',
});
expect(res.status).toBe(200);
const data = await res.json();
expect(data.valid).toBe(true);
expect(data.nextDate).toBeDefined();
});
it('returns next occurrence for monthly RRULE', async () => {
const res = await post('/compute/next-occurrence', {
rrule: 'FREQ=MONTHLY;BYMONTHDAY=15',
});
expect(res.status).toBe(200);
const data = await res.json();
expect(data.valid).toBe(true);
});
it('respects recurrenceEndDate', async () => {
const pastEnd = new Date('2020-01-01').toISOString();
const res = await post('/compute/next-occurrence', {
rrule: 'FREQ=DAILY',
recurrenceEndDate: pastEnd,
});
expect(res.status).toBe(200);
const data = await res.json();
expect(data.nextDate).toBeNull();
expect(data.message).toContain('No more occurrences');
});
it('respects after parameter', async () => {
const afterDate = new Date('2027-06-01T00:00:00Z').toISOString();
const res = await post('/compute/next-occurrence', {
rrule: 'FREQ=DAILY',
after: afterDate,
});
expect(res.status).toBe(200);
const data = await res.json();
const next = new Date(data.nextDate);
expect(next.getTime()).toBeGreaterThan(new Date(afterDate).getTime());
});
it('rejects empty rrule', async () => {
const res = await post('/compute/next-occurrence', { rrule: '' });
expect(res.status).toBe(400);
});
it('rejects missing rrule', async () => {
const res = await post('/compute/next-occurrence', {});
expect(res.status).toBe(400);
});
it('rejects RRULE exceeding max length', async () => {
const res = await post('/compute/next-occurrence', {
rrule: 'FREQ=DAILY;' + 'X'.repeat(500),
});
expect(res.status).toBe(400);
});
it('rejects invalid RRULE string', async () => {
const res = await post('/compute/next-occurrence', {
rrule: 'not a valid rrule',
});
expect(res.status).toBe(400);
const data = await res.json();
expect(data.error).toContain('Invalid RRULE');
});
it('rejects RRULE with too many occurrences (DoS protection)', async () => {
// FREQ=SECONDLY would generate millions of occurrences
const res = await post('/compute/next-occurrence', {
rrule: 'FREQ=SECONDLY',
});
expect(res.status).toBe(400);
const data = await res.json();
expect(data.error).toContain('too many occurrences');
});
});
// ─── POST /compute/validate ────────────────────────────────────
describe('POST /compute/validate', () => {
it('validates a correct daily RRULE', async () => {
const res = await post('/compute/validate', { rrule: 'FREQ=DAILY' });
expect(res.status).toBe(200);
const data = await res.json();
expect(data.valid).toBe(true);
expect(data.occurrences).toBeGreaterThan(0);
});
it('validates a weekly RRULE with BYDAY', async () => {
const res = await post('/compute/validate', {
rrule: 'FREQ=WEEKLY;BYDAY=TU,TH',
});
expect(res.status).toBe(200);
const data = await res.json();
expect(data.valid).toBe(true);
});
it('validates a yearly RRULE', async () => {
const res = await post('/compute/validate', {
rrule: 'FREQ=YEARLY;BYMONTH=12;BYMONTHDAY=25',
});
expect(res.status).toBe(200);
const data = await res.json();
expect(data.valid).toBe(true);
expect(data.occurrences).toBeLessThanOrEqual(10); // max 10 years
});
it('returns valid=false for invalid RRULE', async () => {
const res = await post('/compute/validate', { rrule: 'garbage' });
expect(res.status).toBe(200);
const data = await res.json();
expect(data.valid).toBe(false);
expect(data.error).toBeDefined();
});
it('rejects empty rrule', async () => {
const res = await post('/compute/validate', { rrule: '' });
expect(res.status).toBe(200);
const data = await res.json();
expect(data.valid).toBe(false);
});
it('flags RRULE with too many occurrences', async () => {
const res = await post('/compute/validate', {
rrule: 'FREQ=SECONDLY',
});
expect(res.status).toBe(200);
const data = await res.json();
expect(data.valid).toBe(false);
expect(data.error).toContain('Too many occurrences');
});
});

View file

@ -11,6 +11,6 @@
"forceConsistentCasingInFileNames": true,
"noEmit": true
},
"include": ["src/**/*"],
"include": ["src/**/*", "vitest.config.ts"],
"exclude": ["node_modules"]
}

View file

@ -0,0 +1,12 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/**/*.test.ts'],
clearMocks: true,
mockReset: true,
restoreMocks: true,
},
});