diff --git a/src/app/api/v1/yachts/route.ts b/src/app/api/v1/yachts/route.ts new file mode 100644 index 0000000..7ed967d --- /dev/null +++ b/src/app/api/v1/yachts/route.ts @@ -0,0 +1,47 @@ +import { NextResponse } from 'next/server'; + +import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers'; +import { parseQuery, parseBody } from '@/lib/api/route-helpers'; +import { errorResponse } from '@/lib/errors'; +import { listYachts, createYacht } from '@/lib/services/yachts.service'; +import { listYachtsSchema, createYachtSchema } from '@/lib/validators/yachts'; + +export const listHandler: RouteHandler = async (req, ctx) => { + try { + const query = parseQuery(req, listYachtsSchema); + const result = await listYachts(ctx.portId, query); + const { page, limit } = query; + const totalPages = Math.ceil(result.total / limit); + return NextResponse.json({ + data: result.data, + pagination: { + page, + pageSize: limit, + total: result.total, + totalPages, + hasNextPage: page < totalPages, + hasPreviousPage: page > 1, + }, + }); + } catch (error) { + return errorResponse(error); + } +}; + +export const createHandler: RouteHandler = async (req, ctx) => { + try { + const body = await parseBody(req, createYachtSchema); + const yacht = await createYacht(ctx.portId, body, { + userId: ctx.userId, + portId: ctx.portId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }); + return NextResponse.json({ data: yacht }, { status: 201 }); + } catch (error) { + return errorResponse(error); + } +}; + +export const GET = withAuth(withPermission('yachts', 'view', listHandler)); +export const POST = withAuth(withPermission('yachts', 'create', createHandler)); diff --git a/src/lib/api/helpers.ts b/src/lib/api/helpers.ts index 2c183fb..10ac87c 100644 --- a/src/lib/api/helpers.ts +++ b/src/lib/api/helpers.ts @@ -3,12 +3,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { auth } from '@/lib/auth'; import { db } from '@/lib/db'; -import { - portRoleOverrides, - ports, - userPortRoles, - userProfiles, -} from '@/lib/db/schema'; +import { portRoleOverrides, ports, userPortRoles, userProfiles } from '@/lib/db/schema'; import { type RolePermissions } from '@/lib/db/schema/users'; import { createAuditLog } from '@/lib/audit'; import { errorResponse } from '@/lib/errors'; @@ -40,7 +35,7 @@ export interface AuthContext { userAgent: string; } -type RouteHandler = ( +export type RouteHandler = ( req: NextRequest, ctx: AuthContext, params: Record, @@ -133,10 +128,7 @@ export function withAuth( if (!profile.isSuperAdmin && portId) { const portRole = await db.query.userPortRoles.findFirst({ - where: and( - eq(userPortRoles.userId, profile.userId), - eq(userPortRoles.portId, portId), - ), + where: and(eq(userPortRoles.userId, profile.userId), eq(userPortRoles.portId, portId)), with: { role: true, port: true, @@ -182,8 +174,7 @@ export function withAuth( email: session.user.email, name: session.user.name, }, - ipAddress: - req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown', + ipAddress: req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown', userAgent: req.headers.get('user-agent') ?? 'unknown', }; @@ -213,9 +204,7 @@ export function withPermission( ): RouteHandler { return async (req, ctx, params) => { if (!ctx.isSuperAdmin) { - const resourcePerms = ctx.permissions?.[resource] as - | Record - | undefined; + const resourcePerms = ctx.permissions?.[resource] as Record | undefined; if (!resourcePerms || !resourcePerms[action]) { logger.warn({ userId: ctx.userId, resource, action }, 'Permission denied'); diff --git a/tests/helpers/route-tester.ts b/tests/helpers/route-tester.ts new file mode 100644 index 0000000..94f4da8 --- /dev/null +++ b/tests/helpers/route-tester.ts @@ -0,0 +1,42 @@ +/** + * Helper to invoke route inner-handlers directly, bypassing withAuth. + * Route files must export the inner handler (in addition to the withAuth-wrapped HTTP method). + */ +import { NextRequest } from 'next/server'; +import type { AuthContext } from '@/lib/api/helpers'; +import type { RolePermissions } from '@/lib/db/schema/users'; + +export interface MockCtxOptions { + portId: string; + isSuperAdmin?: boolean; + permissions?: RolePermissions | null; + userId?: string; +} + +export function makeMockCtx(opts: MockCtxOptions): AuthContext { + return { + userId: opts.userId ?? 'test-user', + portId: opts.portId, + portSlug: 'test-port', + isSuperAdmin: opts.isSuperAdmin ?? false, + permissions: opts.permissions ?? null, + user: { email: 'test@example.com', name: 'Test User' }, + ipAddress: '127.0.0.1', + userAgent: 'vitest/1.0', + }; +} + +export function makeMockRequest( + method: string, + url: string, + opts: { body?: unknown; headers?: Record } = {}, +): NextRequest { + const init: { method: string; headers: Record; body?: string } = { + method, + headers: { 'content-type': 'application/json', ...(opts.headers ?? {}) }, + }; + if (opts.body !== undefined) { + init.body = JSON.stringify(opts.body); + } + return new NextRequest(url, init); +} diff --git a/tests/integration/api/yachts.test.ts b/tests/integration/api/yachts.test.ts new file mode 100644 index 0000000..677251f --- /dev/null +++ b/tests/integration/api/yachts.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect } from 'vitest'; + +import { listHandler, createHandler, POST } from '@/app/api/v1/yachts/route'; +import { withPermission } from '@/lib/api/helpers'; +import { makeMockCtx, makeMockRequest } from '../../helpers/route-tester'; +import { + makePort, + makeClient, + makeYacht, + makeFullPermissions, + makeViewerPermissions, +} from '../../helpers/factories'; + +describe('POST /api/v1/yachts (createHandler)', () => { + it('creates a yacht and returns 201', async () => { + const port = await makePort(); + const client = await makeClient({ portId: port.id }); + const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() }); + const req = makeMockRequest('POST', 'http://localhost/api/v1/yachts', { + body: { name: 'Sea Breeze', owner: { type: 'client', id: client.id } }, + }); + const res = await createHandler(req, ctx, {}); + expect(res.status).toBe(201); + const body = await res.json(); + expect(body.data.name).toBe('Sea Breeze'); + expect(body.data.currentOwnerId).toBe(client.id); + }); + + it('returns 400 on invalid body (empty name)', async () => { + const port = await makePort(); + const client = await makeClient({ portId: port.id }); + const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() }); + const req = makeMockRequest('POST', 'http://localhost/api/v1/yachts', { + body: { name: '', owner: { type: 'client', id: client.id } }, + }); + const res = await createHandler(req, ctx, {}); + expect(res.status).toBe(400); + }); + + it('returns 400 when owner.id does not exist', async () => { + const port = await makePort(); + const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() }); + const req = makeMockRequest('POST', 'http://localhost/api/v1/yachts', { + body: { name: 'Phantom', owner: { type: 'client', id: 'nonexistent' } }, + }); + const res = await createHandler(req, ctx, {}); + expect(res.status).toBe(400); + }); +}); + +describe('GET /api/v1/yachts (listHandler)', () => { + it('returns tenant-scoped yachts with pagination metadata', async () => { + const port = await makePort(); + const client = await makeClient({ portId: port.id }); + await makeYacht({ + portId: port.id, + ownerType: 'client', + ownerId: client.id, + name: 'Listed', + }); + const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() }); + const req = makeMockRequest('GET', 'http://localhost/api/v1/yachts?page=1&limit=20&order=desc'); + const res = await listHandler(req, ctx, {}); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data.some((y: { name: string }) => y.name === 'Listed')).toBe(true); + expect(body.pagination.page).toBe(1); + expect(body.pagination.pageSize).toBe(20); + expect(typeof body.pagination.total).toBe('number'); + }); + + it('returns 400 for invalid query params (non-numeric page)', async () => { + const port = await makePort(); + const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() }); + const req = makeMockRequest( + 'GET', + 'http://localhost/api/v1/yachts?page=abc&limit=20&order=desc', + ); + const res = await listHandler(req, ctx, {}); + expect(res.status).toBe(400); + }); +}); + +describe('POST /api/v1/yachts — permission gate', () => { + it('viewer (no yachts.create) receives 403 through full pipeline', async () => { + const port = await makePort(); + const client = await makeClient({ portId: port.id }); + const gated = withPermission('yachts', 'create', createHandler); + const ctx = makeMockCtx({ portId: port.id, permissions: makeViewerPermissions() }); + const req = makeMockRequest('POST', 'http://localhost/api/v1/yachts', { + body: { name: 'X', owner: { type: 'client', id: client.id } }, + }); + const res = await gated(req, ctx, {}); + expect(res.status).toBe(403); + // Sanity check that the withAuth-wrapped HTTP export exists. + expect(POST).toBeDefined(); + }); +});