refactor: migrate backend to pnpm monorepo with Hono module architecture

- Replace Bun with pnpm 9 + Turborepo + tsx; apps/api renamed to apps/backend
- Split backend into http/, modules/, lib/ layers mirroring tai-specguard
- Add use-case + Result pattern, Problem Details RFC 7807, basePath /api/v1
- Mount Better Auth at /api/v1/auth, keep session-auth whitelist
- Split Prisma schema into prisma/models/*, generate into generated/
- Rework packages/shared into lib/ + schemas/ with pagination DTOs
- Implement health, groups, students, payments, waitlist modules
- Add vitest suite with prisma mocks (21 tests), biome lint
- Point web client to /api/v1/auth and /api/v1/groups/from-organization
This commit is contained in:
Jose Selesan
2026-09-14 09:18:40 -03:00
parent b41fffa40a
commit 4b1f356fab
101 changed files with 7152 additions and 1456 deletions

View File

@@ -0,0 +1,208 @@
import { Hono } from 'hono';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { db } = vi.hoisted(() => {
return {
db: {
group: {
findMany: vi.fn(),
findFirst: vi.fn(),
count: vi.fn(),
create: vi.fn(),
},
groupMember: {
create: vi.fn(),
},
organization: {
findUnique: vi.fn(),
},
} as {
group: {
findMany: ReturnType<typeof vi.fn>;
findFirst: ReturnType<typeof vi.fn>;
count: ReturnType<typeof vi.fn>;
create: ReturnType<typeof vi.fn>;
};
groupMember: { create: ReturnType<typeof vi.fn> };
organization: { findUnique: ReturnType<typeof vi.fn> };
},
};
});
vi.mock('@/lib/prisma', () => ({
default: db,
getPrismaClient: vi.fn(),
UnitOfWork: class {
executeResult = vi.fn(
async (cb: (tx: unknown) => Promise<unknown>) => cb(db),
);
execute = vi.fn(
async (cb: (tx: unknown) => Promise<unknown>) => cb(db),
);
},
}));
import prisma from '@/lib/prisma';
import { groupsRoutes } from '@/modules/groups';
const userId = 'user-1';
const group = {
id: 'group-1',
name: 'Cuadrilla Alfa',
description: null,
createdById: userId,
createdAt: new Date('2026-08-01T10:00:00.000Z'),
updatedAt: new Date('2026-08-01T10:00:00.000Z'),
};
function makeApp(userValue: unknown) {
const app = new Hono();
app.use('*', async (c, next) => {
c.set('user', userValue as never);
await next();
});
app.route('/groups', groupsRoutes);
return app;
}
describe('groups routes', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('lists groups scoped to the current user', async () => {
vi.mocked(prisma.group.findMany).mockResolvedValue([group]);
vi.mocked(prisma.group.count).mockResolvedValue(1);
const res = await makeApp({ id: userId }).request('/groups');
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
data: [
{
...group,
createdAt: group.createdAt.toISOString(),
updatedAt: group.updatedAt.toISOString(),
},
],
pagination: { page: 1, pageSize: 10, total: 1, totalPages: 1 },
});
const where = {
OR: [{ createdById: userId }, { members: { some: { userId } } }],
};
expect(prisma.group.findMany).toHaveBeenCalledWith({
where,
skip: 0,
take: 10,
orderBy: { createdAt: 'desc' },
});
expect(prisma.group.count).toHaveBeenCalledWith({ where });
});
it('rejects listing groups without a session', async () => {
const res = await makeApp(null).request('/groups');
expect(res.status).toBe(401);
expect(await res.json()).toMatchObject({ code: 'unauthorized' });
});
it('rejects invalid pagination query parameters', async () => {
const res = await makeApp({ id: userId }).request('/groups?pageSize=101');
expect(res.status).toBe(400);
});
it('creates a group from an owned organization', async () => {
const organization = {
id: 'org-1',
name: 'Escuela Alfa',
members: [{ userId, role: 'owner' }],
};
vi.mocked(prisma.organization.findUnique).mockResolvedValue(organization as never);
vi.mocked(prisma.group.findFirst).mockResolvedValue(null);
vi.mocked(prisma.group.create).mockResolvedValue(group);
const res = await makeApp({ id: userId }).request('/groups/from-organization', {
method: 'POST',
body: JSON.stringify({ organizationId: 'org-1' }),
headers: { 'Content-Type': 'application/json' },
});
expect(res.status).toBe(201);
expect(await res.json()).toEqual({
group: {
...group,
createdAt: group.createdAt.toISOString(),
updatedAt: group.updatedAt.toISOString(),
},
alreadyExists: false,
});
expect(prisma.group.create).toHaveBeenCalledWith({
data: { name: 'Escuela Alfa', createdById: userId },
});
});
it('reuses an existing group with the same name', async () => {
const organization = {
id: 'org-1',
name: 'Escuela Alfa',
members: [{ userId, role: 'owner' }],
};
vi.mocked(prisma.organization.findUnique).mockResolvedValue(organization as never);
vi.mocked(prisma.group.findFirst).mockResolvedValue(group);
const res = await makeApp({ id: userId }).request('/groups/from-organization', {
method: 'POST',
body: JSON.stringify({ organizationId: 'org-1' }),
headers: { 'Content-Type': 'application/json' },
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.alreadyExists).toBe(true);
expect(prisma.group.create).not.toHaveBeenCalled();
});
it('rejects creating a group for an unknown organization', async () => {
vi.mocked(prisma.organization.findUnique).mockResolvedValue(null);
const res = await makeApp({ id: userId }).request('/groups/from-organization', {
method: 'POST',
body: JSON.stringify({ organizationId: 'missing' }),
headers: { 'Content-Type': 'application/json' },
});
expect(res.status).toBe(404);
expect(await res.json()).toMatchObject({ code: 'organization_not_found' });
expect(prisma.group.create).not.toHaveBeenCalled();
});
it('rejects creating a group when the user is not the owner', async () => {
const organization = {
id: 'org-1',
name: 'Escuela Alfa',
members: [{ userId: 'other-user', role: 'owner' }],
};
vi.mocked(prisma.organization.findUnique).mockResolvedValue(organization as never);
const res = await makeApp({ id: userId }).request('/groups/from-organization', {
method: 'POST',
body: JSON.stringify({ organizationId: 'org-1' }),
headers: { 'Content-Type': 'application/json' },
});
expect(res.status).toBe(403);
expect(await res.json()).toMatchObject({ code: 'group_owner_required' });
expect(prisma.group.create).not.toHaveBeenCalled();
});
it('rejects creating a group without a session', async () => {
const res = await makeApp(null).request('/groups/from-organization', {
method: 'POST',
body: JSON.stringify({ organizationId: 'org-1' }),
headers: { 'Content-Type': 'application/json' },
});
expect(res.status).toBe(401);
});
});

View File

@@ -0,0 +1,45 @@
import { Hono } from 'hono';
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('@/lib/prisma', () => ({
default: {
$queryRaw: vi.fn(),
},
getPrismaClient: vi.fn(),
UnitOfWork: class {},
}));
import prisma from '@/lib/prisma';
import { healthCheckRoutes } from '@/modules/health-check';
const app = new Hono();
app.route('/health', healthCheckRoutes);
describe('health check routes', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('reports ok when the database answers', async () => {
vi.mocked(prisma.$queryRaw).mockResolvedValue([{ '?column?': 1 }]);
const res = await app.request('/health');
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toMatchObject({ status: 'ok', checks: { database: 'ok' } });
expect(typeof body.timestamp).toBe('string');
});
it('reports degraded when the database is unreachable', async () => {
vi.mocked(prisma.$queryRaw).mockRejectedValue(new Error('connection refused'));
const res = await app.request('/health');
expect(res.status).toBe(503);
expect(await res.json()).toMatchObject({
code: 'database_unavailable',
status: 503,
});
});
});

View File

@@ -0,0 +1,75 @@
import { Hono } from 'hono';
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('@/lib/prisma', () => ({
default: {
payment: {
findMany: vi.fn(),
count: vi.fn(),
},
},
getPrismaClient: vi.fn(),
UnitOfWork: class {},
}));
import prisma from '@/lib/prisma';
import { paymentsRoutes } from '@/modules/payments';
const app = new Hono();
app.route('/payments', paymentsRoutes);
const payment = {
id: 'payment-1',
groupId: 'group-1',
studentId: 'student-1',
amount: { toString: () => '150.50' } as { toString(): string },
currency: 'MXN',
status: 'pending',
dueDate: new Date('2026-09-01T10:00:00.000Z'),
paidAt: null,
createdAt: new Date('2026-08-01T10:00:00.000Z'),
updatedAt: new Date('2026-08-01T10:00:00.000Z'),
};
describe('payments routes', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('lists payments with pagination and converts Decimal amounts', async () => {
vi.mocked(prisma.payment.findMany).mockResolvedValue([payment]);
vi.mocked(prisma.payment.count).mockResolvedValue(1);
const res = await app.request('/payments');
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
data: [
{
id: 'payment-1',
groupId: 'group-1',
studentId: 'student-1',
amount: 150.5,
currency: 'MXN',
status: 'pending',
dueDate: payment.dueDate.toISOString(),
paidAt: null,
createdAt: payment.createdAt.toISOString(),
updatedAt: payment.updatedAt.toISOString(),
},
],
pagination: { page: 1, pageSize: 10, total: 1, totalPages: 1 },
});
expect(prisma.payment.findMany).toHaveBeenCalledWith({
skip: 0,
take: 10,
orderBy: { createdAt: 'desc' },
});
});
it('rejects invalid pagination query parameters', async () => {
const res = await app.request('/payments?pageSize=101');
expect(res.status).toBe(400);
});
});

View File

@@ -0,0 +1,80 @@
import { Hono } from 'hono';
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('@/lib/prisma', () => ({
default: {
$queryRaw: vi.fn(),
},
getPrismaClient: vi.fn(),
UnitOfWork: class {},
}));
vi.mock('@/modules/auth/auth', () => ({
auth: {
api: {
getSession: vi.fn(),
},
},
}));
import {
isPublicApiRequest,
sessionAuthMiddleware,
} from '@/http/session-auth';
import { auth } from '@/modules/auth/auth';
describe('session auth', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('classifies public API requests', () => {
expect(isPublicApiRequest('OPTIONS', '/api/v1/groups')).toBe(true);
expect(isPublicApiRequest('GET', '/api/v1/health')).toBe(true);
expect(isPublicApiRequest('POST', '/api/v1/auth/sign-in/email')).toBe(true);
expect(isPublicApiRequest('GET', '/api/v1/groups')).toBe(false);
expect(isPublicApiRequest('GET', '/api/v1/students')).toBe(false);
});
it('allows public requests without a session', async () => {
const app = new Hono();
app.use('*', sessionAuthMiddleware);
app.get('/api/v1/health', (c) => c.json({ status: 'ok' }));
const res = await app.request('/api/v1/health');
expect(res.status).toBe(200);
expect(auth.api.getSession).not.toHaveBeenCalled();
});
it('rejects protected requests without a session', async () => {
vi.mocked(auth.api.getSession).mockResolvedValue(null);
const app = new Hono();
app.use('*', sessionAuthMiddleware);
app.get('/api/v1/groups', (c) => c.json({}));
const res = await app.request('/api/v1/groups');
expect(res.status).toBe(401);
expect(await res.json()).toMatchObject({ code: 'unauthorized' });
});
it('sets the user and session for authenticated requests', async () => {
const session = {
user: { id: 'user-1', name: 'Ana' },
session: { id: 'session-1' },
};
vi.mocked(auth.api.getSession).mockResolvedValue(session as never);
const app = new Hono();
app.use('*', sessionAuthMiddleware);
app.get('/api/v1/groups', (c) => c.json({ userId: c.get('user').id }));
const res = await app.request('/api/v1/groups');
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ userId: 'user-1' });
expect(auth.api.getSession).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,83 @@
import { Hono } from 'hono';
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('@/lib/prisma', () => ({
default: {
student: {
findMany: vi.fn(),
count: vi.fn(),
},
},
getPrismaClient: vi.fn(),
UnitOfWork: class {},
}));
import prisma from '@/lib/prisma';
import { studentsRoutes } from '@/modules/students';
const app = new Hono();
app.route('/students', studentsRoutes);
const student = {
id: 'student-1',
groupId: 'group-1',
fullName: 'Ana Pérez',
email: 'ana@example.com',
phone: null,
guardianName: 'Luis Pérez',
guardianPhone: null,
notes: null,
createdAt: new Date('2026-08-01T10:00:00.000Z'),
updatedAt: new Date('2026-08-01T10:00:00.000Z'),
};
describe('students routes', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('lists students with pagination', async () => {
vi.mocked(prisma.student.findMany).mockResolvedValue([student]);
vi.mocked(prisma.student.count).mockResolvedValue(21);
const res = await app.request('/students?page=2&pageSize=10');
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
data: [
{
...student,
createdAt: student.createdAt.toISOString(),
updatedAt: student.updatedAt.toISOString(),
},
],
pagination: { page: 2, pageSize: 10, total: 21, totalPages: 3 },
});
expect(prisma.student.findMany).toHaveBeenCalledWith({
skip: 10,
take: 10,
orderBy: { createdAt: 'desc' },
});
expect(prisma.student.count).toHaveBeenCalledWith();
});
it('uses default pagination', async () => {
vi.mocked(prisma.student.findMany).mockResolvedValue([]);
vi.mocked(prisma.student.count).mockResolvedValue(0);
const res = await app.request('/students');
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
data: [],
pagination: { page: 1, pageSize: 10, total: 0, totalPages: 0 },
});
});
it('rejects invalid pagination query parameters', async () => {
const res = await app.request('/students?page=0&pageSize=25');
expect(res.status).toBe(400);
expect(await res.json()).toMatchObject({ status: 400, title: 'Bad Request' });
});
});

View File

@@ -0,0 +1,60 @@
import { Hono } from 'hono';
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('@/lib/prisma', () => ({
default: {
waitlistEntry: {
findMany: vi.fn(),
count: vi.fn(),
},
},
getPrismaClient: vi.fn(),
UnitOfWork: class {},
}));
import prisma from '@/lib/prisma';
import { waitlistRoutes } from '@/modules/waitlist';
const app = new Hono();
app.route('/waitlist', waitlistRoutes);
const entry = {
id: 'waitlist-1',
email: 'caro@example.com',
name: 'Caro',
status: 'pending',
createdAt: new Date('2026-08-01T10:00:00.000Z'),
updatedAt: new Date('2026-08-01T10:00:00.000Z'),
};
describe('waitlist routes', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('lists waitlist entries with pagination', async () => {
vi.mocked(prisma.waitlistEntry.findMany).mockResolvedValue([entry]);
vi.mocked(prisma.waitlistEntry.count).mockResolvedValue(1);
const res = await app.request('/waitlist');
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
data: [
{
...entry,
createdAt: entry.createdAt.toISOString(),
updatedAt: entry.updatedAt.toISOString(),
},
],
pagination: { page: 1, pageSize: 10, total: 1, totalPages: 1 },
});
});
it('rejects invalid pagination query parameters', async () => {
const res = await app.request('/waitlist?page=0');
expect(res.status).toBe(400);
expect(await res.json()).toMatchObject({ status: 400, title: 'Bad Request' });
});
});