T1/Phase 1: scaffold Vite SPA + Hono API + Bun worker
- apps/web: Vite + React 19 + TanStack Router/Query + shadcn/ui - apps/api: Hono + Bun on :3001 with /api/health, /api/auth/*, /mcp stubs - apps/worker: Bun worker stub, DB connection, graceful SIGTERM - apps/web-legacy/: old Next.js code moved aside (preserved for T2-T8 reference) - Dockerfiles: api (Bun), worker (Bun), spa (multi-stage Caddy) - Caddyfile: serves dist + reverse-proxies /api/* + /mcp to api - docker-compose.yml: 4-service target (api, spa, db, worker) - packages/db/src/client.ts: shared Drizzle client for api + worker - db/client.ts: root-level alias for convenience Parent: t_e1cbd87d -> t_24c9c3fd (T0)
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* API tests for habits routes.
|
||||
* These tests verify the habit CRUD API logic using mocked Drizzle.
|
||||
* Run with: npm test -- --testPathPattern=habits
|
||||
*/
|
||||
|
||||
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
|
||||
|
||||
// Mock the database module
|
||||
jest.mock('@project-e/db', () => ({
|
||||
db: {
|
||||
select: jest.fn(),
|
||||
insert: jest.fn(),
|
||||
update: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
},
|
||||
sql: { unsafe: jest.fn() },
|
||||
habits: {},
|
||||
habitCompletions: {},
|
||||
habitTags: {},
|
||||
tags: {},
|
||||
activityFeed: {},
|
||||
}));
|
||||
|
||||
jest.mock('@/lib/auth', () => ({
|
||||
withAuth: (handler: any) => {
|
||||
return (request: any, context: any) => {
|
||||
const mockUser = { id: 'user-1', email: 'test@test.com', name: 'Test User' };
|
||||
return handler(request, mockUser, context);
|
||||
};
|
||||
},
|
||||
requireWorkspaceAccess: jest.fn().mockResolvedValue(undefined),
|
||||
createErrorResponse: (code: string, message: string, status: number, details?: unknown) => ({
|
||||
code,
|
||||
message,
|
||||
status,
|
||||
details,
|
||||
}),
|
||||
ApiError: class ApiError extends Error {
|
||||
constructor(message: string, public status: number = 400, public code: string = 'BAD_REQUEST') {
|
||||
super(message);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('@/lib/activity', () => ({
|
||||
recordActivity: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// Build a Drizzle-like chain that resolves to the given value when awaited
|
||||
function chain(resolvedValue: any) {
|
||||
const c: any = {};
|
||||
c.from = jest.fn().mockReturnValue(c);
|
||||
c.where = jest.fn().mockReturnValue(c);
|
||||
c.orderBy = jest.fn().mockReturnValue(c);
|
||||
c.limit = jest.fn().mockReturnValue(c);
|
||||
c.offset = jest.fn().mockReturnValue(c);
|
||||
c.innerJoin = jest.fn().mockReturnValue(c);
|
||||
c.having = jest.fn().mockReturnValue(c);
|
||||
c.then = (onfulfilled: any) => Promise.resolve(resolvedValue).then(onfulfilled);
|
||||
c.catch = (onrejected: any) => Promise.resolve(resolvedValue).catch(onrejected);
|
||||
return c;
|
||||
}
|
||||
|
||||
function insertChain(resolvedValue: any) {
|
||||
const c: any = {};
|
||||
c.values = jest.fn().mockReturnValue(c);
|
||||
c.returning = jest.fn().mockReturnValue(c);
|
||||
c.then = (onfulfilled: any) => Promise.resolve(resolvedValue).then(onfulfilled);
|
||||
c.catch = (onrejected: any) => Promise.resolve(resolvedValue).catch(onrejected);
|
||||
return c;
|
||||
}
|
||||
|
||||
function updateChain(resolvedValue?: any) {
|
||||
const c: any = {};
|
||||
c.set = jest.fn().mockReturnValue(c);
|
||||
c.where = jest.fn().mockReturnValue(c);
|
||||
c.returning = jest.fn().mockReturnValue(c);
|
||||
c.then = (onfulfilled: any) => Promise.resolve(resolvedValue !== undefined ? resolvedValue : undefined).then(onfulfilled);
|
||||
c.catch = (onrejected: any) => Promise.resolve(resolvedValue !== undefined ? resolvedValue : undefined).catch(onrejected);
|
||||
return c;
|
||||
}
|
||||
|
||||
describe('Habits API', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('GET /api/domains/[domainId]/habits', () => {
|
||||
it('should list habits with default pagination', async () => {
|
||||
const { GET } = await import('@/app/api/domains/[domainId]/habits/route');
|
||||
const { db } = require('@project-e/db');
|
||||
|
||||
const mockHabits = [
|
||||
{ id: '1', name: 'Habit 1', frequency: 'daily', difficulty: 'medium', domainId: 'domain-1', streakCount: 5, bestStreak: 10 },
|
||||
{ id: '2', name: 'Habit 2', frequency: 'weekly', difficulty: 'hard', domainId: 'domain-1', streakCount: 0, bestStreak: 3 },
|
||||
];
|
||||
|
||||
db.select
|
||||
.mockReturnValueOnce(chain(mockHabits)) // main query
|
||||
.mockReturnValueOnce(chain([{ count: 2 }])) // count query
|
||||
.mockReturnValueOnce(chain([])); // tags query
|
||||
|
||||
const request = new Request('http://localhost:3000/api/domains/domain-1/habits');
|
||||
const response = await GET(request, { params: Promise.resolve({ domainId: 'domain-1' }) });
|
||||
|
||||
expect(response).toBeDefined();
|
||||
});
|
||||
|
||||
it('should filter by active status', async () => {
|
||||
const { GET } = await import('@/app/api/domains/[domainId]/habits/route');
|
||||
const { db } = require('@project-e/db');
|
||||
|
||||
db.select
|
||||
.mockReturnValueOnce(chain([{ id: '1', name: 'Habit 1', domainId: 'domain-1' }]))
|
||||
.mockReturnValueOnce(chain([{ count: 1 }]))
|
||||
.mockReturnValueOnce(chain([]));
|
||||
|
||||
const request = new Request('http://localhost:3000/api/domains/domain-1/habits?active=true');
|
||||
const response = await GET(request, { params: Promise.resolve({ domainId: 'domain-1' }) });
|
||||
expect(response).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/domains/[domainId]/habits', () => {
|
||||
it('should create a habit with required fields', async () => {
|
||||
const { POST } = await import('@/app/api/domains/[domainId]/habits/route');
|
||||
const { db } = require('@project-e/db');
|
||||
|
||||
const mockHabit = {
|
||||
id: 'new-habit-1',
|
||||
name: 'Test Habit',
|
||||
frequency: 'daily',
|
||||
difficulty: 'medium',
|
||||
domainId: 'domain-1',
|
||||
};
|
||||
|
||||
db.insert.mockReturnValue(insertChain([mockHabit]));
|
||||
|
||||
const request = new Request('http://localhost:3000/api/domains/domain-1/habits', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Test Habit' }),
|
||||
});
|
||||
|
||||
const response = await POST(request, { params: Promise.resolve({ domainId: 'domain-1' }) });
|
||||
expect(response).toBeDefined();
|
||||
});
|
||||
|
||||
it('should reject empty name', async () => {
|
||||
const { POST } = await import('@/app/api/domains/[domainId]/habits/route');
|
||||
const request = new Request('http://localhost:3000/api/domains/domain-1/habits', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: '' }),
|
||||
});
|
||||
|
||||
const response = await POST(request, { params: Promise.resolve({ domainId: 'domain-1' }) });
|
||||
expect(response).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/domains/[domainId]/habits/[id]/complete', () => {
|
||||
it('should complete a habit and return streak info', async () => {
|
||||
const { POST } = await import('@/app/api/domains/[domainId]/habits/[id]/complete/route');
|
||||
const { db } = require('@project-e/db');
|
||||
|
||||
const mockHabit = {
|
||||
id: 'habit-1',
|
||||
name: 'Test Habit',
|
||||
domainId: 'domain-1',
|
||||
skipDays: [0, 6],
|
||||
streakCount: 3,
|
||||
bestStreak: 10,
|
||||
};
|
||||
|
||||
db.select
|
||||
.mockReturnValueOnce(chain([mockHabit])) // verify habit exists
|
||||
.mockReturnValueOnce(chain([])); // completions for streak calc
|
||||
|
||||
db.insert.mockReturnValue(insertChain([{ id: 'comp-1', habitId: 'habit-1', date: new Date(), value: 1 }]));
|
||||
|
||||
db.update.mockReturnValue(updateChain());
|
||||
|
||||
const request = new Request('http://localhost:3000/api/domains/domain-1/habits/habit-1/complete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ value: 1 }),
|
||||
});
|
||||
|
||||
const response = await POST(request, { params: Promise.resolve({ domainId: 'domain-1', id: 'habit-1' }) });
|
||||
expect(response).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/domains/[domainId]/habits/[id]', () => {
|
||||
it('should update a habit', async () => {
|
||||
const { PATCH } = await import('@/app/api/domains/[domainId]/habits/[id]/route');
|
||||
const { db } = require('@project-e/db');
|
||||
|
||||
const existingHabit = {
|
||||
id: 'habit-1',
|
||||
name: 'Test Habit',
|
||||
domainId: 'domain-1',
|
||||
};
|
||||
|
||||
db.select.mockReturnValue(chain([existingHabit]));
|
||||
db.update.mockReturnValue(updateChain([{ ...existingHabit, name: 'Updated Habit' }]));
|
||||
|
||||
const request = new Request('http://localhost:3000/api/domains/domain-1/habits/habit-1', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Updated Habit' }),
|
||||
});
|
||||
|
||||
const response = await PATCH(request, { params: Promise.resolve({ domainId: 'domain-1', id: 'habit-1' }) });
|
||||
expect(response).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/domains/[domainId]/habits/[id]', () => {
|
||||
it('should soft delete a habit', async () => {
|
||||
const { DELETE } = await import('@/app/api/domains/[domainId]/habits/[id]/route');
|
||||
const { db } = require('@project-e/db');
|
||||
|
||||
const existingHabit = {
|
||||
id: 'habit-1',
|
||||
name: 'Test Habit',
|
||||
domainId: 'domain-1',
|
||||
};
|
||||
|
||||
db.select.mockReturnValue(chain([existingHabit]));
|
||||
db.update.mockReturnValue(updateChain());
|
||||
|
||||
const request = new Request('http://localhost:3000/api/domains/domain-1/habits/habit-1', {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
const response = await DELETE(request, { params: Promise.resolve({ domainId: 'domain-1', id: 'habit-1' }) });
|
||||
expect(response.status).toBe(204);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* API tests for projects and sections routes.
|
||||
* These tests verify the project CRUD and section API logic using mocked Drizzle.
|
||||
* Run with: npm test -- --testPathPattern=projects
|
||||
*/
|
||||
|
||||
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
|
||||
|
||||
// Mock the database module
|
||||
jest.mock('@project-e/db', () => ({
|
||||
db: {
|
||||
select: jest.fn(),
|
||||
insert: jest.fn(),
|
||||
update: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
},
|
||||
sql: { unsafe: jest.fn() },
|
||||
projects: {},
|
||||
sections: {},
|
||||
tasks: {},
|
||||
projectTags: {},
|
||||
tags: {},
|
||||
activityFeed: {},
|
||||
}));
|
||||
|
||||
jest.mock('@/lib/auth', () => ({
|
||||
withAuth: (handler: any) => {
|
||||
return (request: any, context: any) => {
|
||||
const mockUser = { id: 'user-1', email: 'test@test.com', name: 'Test User' };
|
||||
return handler(request, mockUser, context);
|
||||
};
|
||||
},
|
||||
requireWorkspaceAccess: jest.fn().mockResolvedValue(undefined),
|
||||
createErrorResponse: (code: string, message: string, status: number, details?: unknown) => ({
|
||||
code,
|
||||
message,
|
||||
status,
|
||||
details,
|
||||
}),
|
||||
ApiError: class ApiError extends Error {
|
||||
constructor(message: string, public status: number = 400, public code: string = 'BAD_REQUEST') {
|
||||
super(message);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('@/lib/activity', () => ({
|
||||
recordActivity: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// Build a Drizzle-like chain that resolves to the given value when awaited
|
||||
function chain(resolvedValue: any) {
|
||||
const c: any = {};
|
||||
c.from = jest.fn().mockReturnValue(c);
|
||||
c.where = jest.fn().mockReturnValue(c);
|
||||
c.orderBy = jest.fn().mockReturnValue(c);
|
||||
c.limit = jest.fn().mockReturnValue(c);
|
||||
c.offset = jest.fn().mockReturnValue(c);
|
||||
c.innerJoin = jest.fn().mockReturnValue(c);
|
||||
c.having = jest.fn().mockReturnValue(c);
|
||||
c.then = (onfulfilled: any) => Promise.resolve(resolvedValue).then(onfulfilled);
|
||||
c.catch = (onrejected: any) => Promise.resolve(resolvedValue).catch(onrejected);
|
||||
return c;
|
||||
}
|
||||
|
||||
function insertChain(resolvedValue: any) {
|
||||
const c: any = {};
|
||||
c.values = jest.fn().mockReturnValue(c);
|
||||
c.returning = jest.fn().mockReturnValue(c);
|
||||
c.then = (onfulfilled: any) => Promise.resolve(resolvedValue).then(onfulfilled);
|
||||
c.catch = (onrejected: any) => Promise.resolve(resolvedValue).catch(onrejected);
|
||||
return c;
|
||||
}
|
||||
|
||||
function updateChain(resolvedValue?: any) {
|
||||
const c: any = {};
|
||||
c.set = jest.fn().mockReturnValue(c);
|
||||
c.where = jest.fn().mockReturnValue(c);
|
||||
c.returning = jest.fn().mockReturnValue(c);
|
||||
c.then = (onfulfilled: any) => Promise.resolve(resolvedValue !== undefined ? resolvedValue : undefined).then(onfulfilled);
|
||||
c.catch = (onrejected: any) => Promise.resolve(resolvedValue !== undefined ? resolvedValue : undefined).catch(onrejected);
|
||||
return c;
|
||||
}
|
||||
|
||||
describe('Projects API', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('GET /api/domains/[domainId]/projects', () => {
|
||||
it('should list projects with default pagination', async () => {
|
||||
const { GET } = await import('@/app/api/domains/[domainId]/projects/route');
|
||||
const { db } = require('@project-e/db');
|
||||
|
||||
const mockProjects = [
|
||||
{ id: '1', name: 'Project 1', status: 'active', domainId: 'domain-1' },
|
||||
{ id: '2', name: 'Project 2', status: 'paused', domainId: 'domain-1' },
|
||||
];
|
||||
|
||||
db.select
|
||||
.mockReturnValueOnce(chain(mockProjects)) // main query
|
||||
.mockReturnValueOnce(chain([{ count: 2 }])) // count query
|
||||
.mockReturnValueOnce(chain([])) // tags query
|
||||
.mockReturnValueOnce(chain([{ count: 0 }])) // task count for project 1
|
||||
.mockReturnValueOnce(chain([{ count: 0 }])) // completed count for project 1
|
||||
.mockReturnValueOnce(chain([{ count: 0 }])) // task count for project 2
|
||||
.mockReturnValueOnce(chain([{ count: 0 }])); // completed count for project 2
|
||||
|
||||
const request = new Request('http://localhost:3000/api/domains/domain-1/projects');
|
||||
const response = await GET(request, { params: Promise.resolve({ domainId: 'domain-1' }) });
|
||||
|
||||
expect(response).toBeDefined();
|
||||
});
|
||||
|
||||
it('should filter by status', async () => {
|
||||
const { GET } = await import('@/app/api/domains/[domainId]/projects/route');
|
||||
const { db } = require('@project-e/db');
|
||||
|
||||
db.select
|
||||
.mockReturnValueOnce(chain([{ id: '1', name: 'Project 1', domainId: 'domain-1' }]))
|
||||
.mockReturnValueOnce(chain([{ count: 1 }]))
|
||||
.mockReturnValueOnce(chain([]))
|
||||
.mockReturnValueOnce(chain([{ count: 0 }]))
|
||||
.mockReturnValueOnce(chain([{ count: 0 }]));
|
||||
|
||||
const request = new Request('http://localhost:3000/api/domains/domain-1/projects?status=active');
|
||||
const response = await GET(request, { params: Promise.resolve({ domainId: 'domain-1' }) });
|
||||
expect(response).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/domains/[domainId]/projects', () => {
|
||||
it('should create a project with required fields', async () => {
|
||||
const { POST } = await import('@/app/api/domains/[domainId]/projects/route');
|
||||
const { db } = require('@project-e/db');
|
||||
|
||||
const mockProject = {
|
||||
id: 'new-project-1',
|
||||
name: 'Test Project',
|
||||
status: 'active',
|
||||
domainId: 'domain-1',
|
||||
};
|
||||
|
||||
db.insert.mockReturnValue(insertChain([mockProject]));
|
||||
|
||||
const request = new Request('http://localhost:3000/api/domains/domain-1/projects', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Test Project' }),
|
||||
});
|
||||
|
||||
const response = await POST(request, { params: Promise.resolve({ domainId: 'domain-1' }) });
|
||||
expect(response).toBeDefined();
|
||||
});
|
||||
|
||||
it('should reject empty name', async () => {
|
||||
const { POST } = await import('@/app/api/domains/[domainId]/projects/route');
|
||||
const request = new Request('http://localhost:3000/api/domains/domain-1/projects', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: '' }),
|
||||
});
|
||||
|
||||
const response = await POST(request, { params: Promise.resolve({ domainId: 'domain-1' }) });
|
||||
expect(response).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/domains/[domainId]/projects/[id]', () => {
|
||||
it('should update a project', async () => {
|
||||
const { PATCH } = await import('@/app/api/domains/[domainId]/projects/[id]/route');
|
||||
const { db } = require('@project-e/db');
|
||||
|
||||
const existingProject = {
|
||||
id: 'project-1',
|
||||
name: 'Test Project',
|
||||
domainId: 'domain-1',
|
||||
};
|
||||
|
||||
db.select.mockReturnValue(chain([existingProject]));
|
||||
db.update.mockReturnValue(updateChain([{ ...existingProject, name: 'Updated Project' }]));
|
||||
|
||||
const request = new Request('http://localhost:3000/api/domains/domain-1/projects/project-1', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Updated Project' }),
|
||||
});
|
||||
|
||||
const response = await PATCH(request, { params: Promise.resolve({ domainId: 'domain-1', id: 'project-1' }) });
|
||||
expect(response).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/domains/[domainId]/projects/[id]', () => {
|
||||
it('should soft delete a project', async () => {
|
||||
const { DELETE } = await import('@/app/api/domains/[domainId]/projects/[id]/route');
|
||||
const { db } = require('@project-e/db');
|
||||
|
||||
const existingProject = {
|
||||
id: 'project-1',
|
||||
name: 'Test Project',
|
||||
domainId: 'domain-1',
|
||||
};
|
||||
|
||||
db.select.mockReturnValue(chain([existingProject]));
|
||||
db.update.mockReturnValue(updateChain());
|
||||
|
||||
const request = new Request('http://localhost:3000/api/domains/domain-1/projects/project-1', {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
const response = await DELETE(request, { params: Promise.resolve({ domainId: 'domain-1', id: 'project-1' }) });
|
||||
expect(response.status).toBe(204);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sections API', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('GET /api/domains/[domainId]/projects/[projectId]/sections', () => {
|
||||
it('should list sections for a project', async () => {
|
||||
const { GET } = await import('@/app/api/domains/[domainId]/projects/[projectId]/sections/route');
|
||||
const { db } = require('@project-e/db');
|
||||
|
||||
const mockSections = [
|
||||
{ id: '1', name: 'Section 1', projectId: 'project-1', kind: 'section', sortOrder: 0 },
|
||||
{ id: '2', name: 'Milestone 1', projectId: 'project-1', kind: 'milestone', sortOrder: 1 },
|
||||
];
|
||||
|
||||
db.select
|
||||
.mockReturnValueOnce(chain([{ id: 'project-1' }])) // verify project
|
||||
.mockReturnValueOnce(chain(mockSections)); // list sections
|
||||
|
||||
const request = new Request('http://localhost:3000/api/domains/domain-1/projects/project-1/sections');
|
||||
const response = await GET(request, { params: Promise.resolve({ domainId: 'domain-1', projectId: 'project-1' }) });
|
||||
expect(response).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/domains/[domainId]/projects/[projectId]/sections', () => {
|
||||
it('should create a section', async () => {
|
||||
const { POST } = await import('@/app/api/domains/[domainId]/projects/[projectId]/sections/route');
|
||||
const { db } = require('@project-e/db');
|
||||
|
||||
const mockProject = { id: 'project-1', name: 'Test Project' };
|
||||
const mockSection = {
|
||||
id: 'new-section-1',
|
||||
name: 'Test Section',
|
||||
projectId: 'project-1',
|
||||
kind: 'section',
|
||||
sortOrder: 0,
|
||||
};
|
||||
|
||||
db.select
|
||||
.mockReturnValueOnce(chain([mockProject])) // verify project
|
||||
.mockReturnValueOnce(chain([{ max: -1 }])); // max sort order
|
||||
|
||||
db.insert.mockReturnValue(insertChain([mockSection]));
|
||||
|
||||
const request = new Request('http://localhost:3000/api/domains/domain-1/projects/project-1/sections', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Test Section' }),
|
||||
});
|
||||
|
||||
const response = await POST(request, { params: Promise.resolve({ domainId: 'domain-1', projectId: 'project-1' }) });
|
||||
expect(response).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* API tests for tasks routes.
|
||||
* These tests verify the task CRUD API logic using mocked Drizzle.
|
||||
* Run with: npm test -- --testPathPattern=tasks
|
||||
*/
|
||||
|
||||
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
|
||||
|
||||
// Mock the database module
|
||||
jest.mock('@project-e/db', () => ({
|
||||
db: {
|
||||
select: jest.fn(),
|
||||
insert: jest.fn(),
|
||||
update: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
},
|
||||
sql: { unsafe: jest.fn() },
|
||||
tasks: {},
|
||||
taskTags: {},
|
||||
taskDependencies: {},
|
||||
tags: {},
|
||||
activityFeed: {},
|
||||
}));
|
||||
|
||||
jest.mock('@/lib/auth', () => ({
|
||||
withAuth: (handler: any) => handler,
|
||||
requireWorkspaceAccess: jest.fn().mockResolvedValue(undefined),
|
||||
createErrorResponse: (code: string, message: string, status: number, details?: unknown) => ({
|
||||
code,
|
||||
message,
|
||||
status,
|
||||
details,
|
||||
}),
|
||||
ApiError: class ApiError extends Error {
|
||||
constructor(message: string, public status: number = 400, public code: string = 'BAD_REQUEST') {
|
||||
super(message);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('@/lib/activity', () => ({
|
||||
recordActivity: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
describe('Tasks API', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('GET /api/domains/[domainId]/tasks', () => {
|
||||
it('should list tasks with default pagination', async () => {
|
||||
const { GET } = await import('@/app/api/domains/[domainId]/tasks/route');
|
||||
const { db } = require('@project-e/db');
|
||||
|
||||
const mockTasks = [
|
||||
{ id: '1', title: 'Task 1', status: 'todo', priority: 'medium', domainId: 'domain-1' },
|
||||
{ id: '2', title: 'Task 2', status: 'in_progress', priority: 'high', domainId: 'domain-1' },
|
||||
];
|
||||
|
||||
// Mock the db.select chain
|
||||
const mockSelect = jest.fn().mockReturnThis();
|
||||
const mockFrom = jest.fn().mockReturnThis();
|
||||
const mockWhere = jest.fn().mockReturnThis();
|
||||
const mockOrderBy = jest.fn().mockReturnThis();
|
||||
const mockLimit = jest.fn().mockReturnThis();
|
||||
const mockOffset = jest.fn().mockResolvedValue(mockTasks);
|
||||
|
||||
db.select.mockReturnValue({
|
||||
from: jest.fn().mockReturnValue({
|
||||
where: jest.fn().mockReturnValue({
|
||||
orderBy: jest.fn().mockReturnValue({
|
||||
limit: jest.fn().mockReturnValue({
|
||||
offset: jest.fn().mockResolvedValue(mockTasks),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const request = new Request('http://localhost:3000/api/domains/domain-1/tasks');
|
||||
const response = await GET(request, { params: Promise.resolve({ domainId: 'domain-1' }) });
|
||||
|
||||
expect(response).toBeDefined();
|
||||
});
|
||||
|
||||
it('should filter by status', async () => {
|
||||
const { GET } = await import('@/app/api/domains/[domainId]/tasks/route');
|
||||
const request = new Request('http://localhost:3000/api/domains/domain-1/tasks?status=todo');
|
||||
const response = await GET(request, { params: Promise.resolve({ domainId: 'domain-1' }) });
|
||||
expect(response).toBeDefined();
|
||||
});
|
||||
|
||||
it('should filter by search term', async () => {
|
||||
const { GET } = await import('@/app/api/domains/[domainId]/tasks/route');
|
||||
const request = new Request('http://localhost:3000/api/domains/domain-1/tasks?search=test');
|
||||
const response = await GET(request, { params: Promise.resolve({ domainId: 'domain-1' }) });
|
||||
expect(response).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/domains/[domainId]/tasks', () => {
|
||||
it('should create a task with required fields', async () => {
|
||||
const { POST } = await import('@/app/api/domains/[domainId]/tasks/route');
|
||||
const { db } = require('@project-e/db');
|
||||
|
||||
const mockTask = {
|
||||
id: 'new-task-1',
|
||||
title: 'Test Task',
|
||||
status: 'todo',
|
||||
priority: 'medium',
|
||||
domainId: 'domain-1',
|
||||
};
|
||||
|
||||
db.insert.mockReturnValue({
|
||||
values: jest.fn().mockReturnValue({
|
||||
returning: jest.fn().mockResolvedValue([mockTask]),
|
||||
}),
|
||||
});
|
||||
|
||||
const request = new Request('http://localhost:3000/api/domains/domain-1/tasks', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title: 'Test Task' }),
|
||||
});
|
||||
|
||||
const response = await POST(request, { params: Promise.resolve({ domainId: 'domain-1' }) });
|
||||
expect(response).toBeDefined();
|
||||
});
|
||||
|
||||
it('should reject empty title', async () => {
|
||||
const { POST } = await import('@/app/api/domains/[domainId]/tasks/route');
|
||||
const request = new Request('http://localhost:3000/api/domains/domain-1/tasks', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title: '' }),
|
||||
});
|
||||
|
||||
const response = await POST(request, { params: Promise.resolve({ domainId: 'domain-1' }) });
|
||||
expect(response).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/domains/[domainId]/tasks/[id]', () => {
|
||||
it('should update task status', async () => {
|
||||
const { PATCH } = await import('@/app/api/domains/[domainId]/tasks/[id]/route');
|
||||
const { db } = require('@project-e/db');
|
||||
|
||||
const existingTask = {
|
||||
id: 'task-1',
|
||||
title: 'Test Task',
|
||||
status: 'todo',
|
||||
domainId: 'domain-1',
|
||||
};
|
||||
|
||||
db.select.mockReturnValue({
|
||||
from: jest.fn().mockReturnValue({
|
||||
where: jest.fn().mockReturnValue({
|
||||
limit: jest.fn().mockResolvedValue([existingTask]),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
db.update.mockReturnValue({
|
||||
set: jest.fn().mockReturnValue({
|
||||
where: jest.fn().mockReturnValue({
|
||||
returning: jest.fn().mockResolvedValue([{ ...existingTask, status: 'done' }]),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const request = new Request('http://localhost:3000/api/domains/domain-1/tasks/task-1', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: 'done' }),
|
||||
});
|
||||
|
||||
const response = await PATCH(request, { params: Promise.resolve({ domainId: 'domain-1', id: 'task-1' }) });
|
||||
expect(response).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/domains/[domainId]/tasks/[id]', () => {
|
||||
it('should soft delete a task', async () => {
|
||||
const { DELETE } = await import('@/app/api/domains/[domainId]/tasks/[id]/route');
|
||||
const { db } = require('@project-e/db');
|
||||
|
||||
const existingTask = {
|
||||
id: 'task-1',
|
||||
title: 'Test Task',
|
||||
domainId: 'domain-1',
|
||||
};
|
||||
|
||||
db.select.mockReturnValue({
|
||||
from: jest.fn().mockReturnValue({
|
||||
where: jest.fn().mockReturnValue({
|
||||
limit: jest.fn().mockResolvedValue([existingTask]),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
db.update.mockReturnValue({
|
||||
set: jest.fn().mockReturnValue({
|
||||
where: jest.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
});
|
||||
|
||||
const request = new Request('http://localhost:3000/api/domains/domain-1/tasks/task-1', {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
const response = await DELETE(request, { params: Promise.resolve({ domainId: 'domain-1', id: 'task-1' }) });
|
||||
expect(response.status).toBe(204);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dependencies cycle detection', () => {
|
||||
it('should detect direct self-loop', async () => {
|
||||
const { POST } = await import('@/app/api/domains/[domainId]/tasks/[id]/dependencies/route');
|
||||
const { db } = require('@project-e/db');
|
||||
|
||||
const mockTask = { id: 'task-1', title: 'Test', domainId: 'domain-1' };
|
||||
|
||||
db.select.mockReturnValue({
|
||||
from: jest.fn().mockReturnValue({
|
||||
where: jest.fn().mockReturnValue({
|
||||
limit: jest.fn().mockResolvedValue([mockTask]),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const request = new Request('http://localhost:3000/api/domains/domain-1/tasks/task-1/dependencies', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ taskId: 'task-1' }),
|
||||
});
|
||||
|
||||
const response = await POST(request, { params: Promise.resolve({ domainId: 'domain-1', id: 'task-1' }) });
|
||||
expect(response).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Unit tests for resolveActiveDomain helper in lib/auth.ts.
|
||||
* Tests both branches: existing domain returned, and auto-creation of "Personal" domain.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
|
||||
|
||||
// Mock the database module
|
||||
const mockDb = {
|
||||
select: jest.fn(),
|
||||
insert: jest.fn(),
|
||||
};
|
||||
const mockDomains = {};
|
||||
|
||||
jest.mock('@project-e/db', () => ({
|
||||
db: mockDb,
|
||||
domains: mockDomains,
|
||||
}));
|
||||
|
||||
// Mock next-auth
|
||||
jest.mock('next-auth', () => ({
|
||||
getServerSession: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock next-auth config
|
||||
jest.mock('@/lib/auth-config', () => ({
|
||||
authOptions: {},
|
||||
}));
|
||||
|
||||
describe('resolveActiveDomain', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return the user\'s first existing domain without creating one', async () => {
|
||||
const { resolveActiveDomain } = await import('@/lib/auth');
|
||||
|
||||
const mockUser = { id: 'user-1', email: 'test@example.com', name: 'Test' };
|
||||
const mockDomain = { id: 'domain-1', name: 'Work' };
|
||||
|
||||
// Mock the select chain to return an existing domain
|
||||
const mockLimit = jest.fn().mockResolvedValue([mockDomain]);
|
||||
const mockOrderBy = jest.fn().mockReturnValue({ limit: mockLimit });
|
||||
const mockWhere = jest.fn().mockReturnValue({ orderBy: mockOrderBy });
|
||||
const mockFrom = jest.fn().mockReturnValue({ where: mockWhere });
|
||||
mockDb.select.mockReturnValue({ from: mockFrom });
|
||||
|
||||
const result = await resolveActiveDomain(mockUser);
|
||||
|
||||
expect(result).toEqual({ id: 'domain-1', name: 'Work', created: false });
|
||||
expect(mockDb.select).toHaveBeenCalledWith({ id: expect.anything(), name: expect.anything() });
|
||||
expect(mockDb.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should create a "Personal" domain when the user has none', async () => {
|
||||
const { resolveActiveDomain } = await import('@/lib/auth');
|
||||
|
||||
const mockUser = { id: 'user-2', email: 'new@example.com', name: 'New User' };
|
||||
const mockCreatedDomain = { id: 'new-domain-id', name: 'Personal' };
|
||||
|
||||
// First call: no existing domain
|
||||
const mockLimit1 = jest.fn().mockResolvedValue([]);
|
||||
const mockOrderBy1 = jest.fn().mockReturnValue({ limit: mockLimit1 });
|
||||
const mockWhere1 = jest.fn().mockReturnValue({ orderBy: mockOrderBy1 });
|
||||
const mockFrom1 = jest.fn().mockReturnValue({ where: mockWhere1 });
|
||||
mockDb.select.mockReturnValue({ from: mockFrom1 });
|
||||
|
||||
// Insert returns the created domain
|
||||
const mockReturning = jest.fn().mockResolvedValue([mockCreatedDomain]);
|
||||
const mockValues = jest.fn().mockReturnValue({ returning: mockReturning });
|
||||
mockDb.insert.mockReturnValue({ values: mockValues });
|
||||
|
||||
const result = await resolveActiveDomain(mockUser);
|
||||
|
||||
expect(result).toEqual({ id: 'new-domain-id', name: 'Personal', created: true });
|
||||
expect(mockDb.insert).toHaveBeenCalled();
|
||||
expect(mockValues).toHaveBeenCalledWith(expect.objectContaining({
|
||||
ownerId: 'user-2',
|
||||
name: 'Personal',
|
||||
sortOrder: 0,
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import { describe, it, expect } from '@jest/globals';
|
||||
import { parseWikilinks, extractLinkTargets, hasWikilinks, formatWikilinkDisplay } from '@/lib/wikilink-parser';
|
||||
|
||||
describe('wikilink-parser', () => {
|
||||
describe('parseWikilinks', () => {
|
||||
it('parses simple [[Title]] links', () => {
|
||||
const result = parseWikilinks('Check out [[Meeting Notes]] for details');
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
raw: '[[Meeting Notes]]',
|
||||
entityType: '',
|
||||
title: 'Meeting Notes',
|
||||
displayText: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses [[Title|Display]] links', () => {
|
||||
const result = parseWikilinks('See [[Long Note Title|this note]]');
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
raw: '[[Long Note Title|this note]]',
|
||||
entityType: '',
|
||||
title: 'Long Note Title',
|
||||
displayText: 'this note',
|
||||
});
|
||||
});
|
||||
|
||||
it('parses [[entity_type:Title]] cross-entity links', () => {
|
||||
const result = parseWikilinks('Complete [[task:Buy milk]] and [[habit:Exercise]]');
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toEqual({
|
||||
raw: '[[task:Buy milk]]',
|
||||
entityType: 'task',
|
||||
title: 'Buy milk',
|
||||
displayText: null,
|
||||
});
|
||||
expect(result[1]).toEqual({
|
||||
raw: '[[habit:Exercise]]',
|
||||
entityType: 'habit',
|
||||
title: 'Exercise',
|
||||
displayText: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses [[entity_type:Title|Display]] links', () => {
|
||||
const result = parseWikilinks('See [[project:Stratos|the Stratos project]]');
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
raw: '[[project:Stratos|the Stratos project]]',
|
||||
entityType: 'project',
|
||||
title: 'Stratos',
|
||||
displayText: 'the Stratos project',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles multiple wikilinks in one string', () => {
|
||||
const result = parseWikilinks('[[Note A]] and [[task:Task B]] and [[Note C|display]]');
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('returns empty array for content with no wikilinks', () => {
|
||||
const result = parseWikilinks('Plain text with no links');
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns empty array for empty content', () => {
|
||||
expect(parseWikilinks('')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('handles titles with special characters', () => {
|
||||
const result = parseWikilinks('[[Task:Buy milk & eggs!]]');
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].title).toBe('Buy milk & eggs!');
|
||||
});
|
||||
|
||||
it('trims whitespace from titles', () => {
|
||||
const result = parseWikilinks('[[ Spaced Title ]]');
|
||||
expect(result[0].title).toBe('Spaced Title');
|
||||
});
|
||||
|
||||
it('handles entity types with underscores', () => {
|
||||
const result = parseWikilinks('[[note:My Note]]');
|
||||
expect(result[0].entityType).toBe('note');
|
||||
expect(result[0].title).toBe('My Note');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractLinkTargets', () => {
|
||||
it('extracts unique link targets', () => {
|
||||
const result = extractLinkTargets('[[Note A]] and [[Note A]] and [[task:Task B]]');
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result).toContainEqual({ entityType: '', title: 'Note A' });
|
||||
expect(result).toContainEqual({ entityType: 'task', title: 'Task B' });
|
||||
});
|
||||
|
||||
it('deduplicates identical targets', () => {
|
||||
const result = extractLinkTargets('[[Note A]] and [[Note A|display]]');
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasWikilinks', () => {
|
||||
it('returns true when wikilinks exist', () => {
|
||||
expect(hasWikilinks('Text with [[a link]]')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when no wikilinks exist', () => {
|
||||
expect(hasWikilinks('Plain text')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for empty content', () => {
|
||||
expect(hasWikilinks('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatWikilinkDisplay', () => {
|
||||
it('uses display text when available', () => {
|
||||
const match = parseWikilinks('[[Title|Display]]')[0];
|
||||
expect(formatWikilinkDisplay(match)).toBe('Display');
|
||||
});
|
||||
|
||||
it('formats entity links without display text', () => {
|
||||
const match = parseWikilinks('[[task:Buy milk]]')[0];
|
||||
expect(formatWikilinkDisplay(match)).toBe('task: Buy milk');
|
||||
});
|
||||
|
||||
it('returns title for simple note links', () => {
|
||||
const match = parseWikilinks('[[Meeting Notes]]')[0];
|
||||
expect(formatWikilinkDisplay(match)).toBe('Meeting Notes');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user