import { describe, it, expect } from 'vitest'; import { getHandler, patchHandler, deleteHandler } from '@/app/api/v1/yachts/[id]/handlers'; import { transferHandler } from '@/app/api/v1/yachts/[id]/transfer/handlers'; import { historyHandler } from '@/app/api/v1/yachts/[id]/ownership-history/handlers'; import { autocompleteHandler } from '@/app/api/v1/yachts/autocomplete/handlers'; import { withPermission } from '@/lib/api/helpers'; import { db } from '@/lib/db'; import { yachts } from '@/lib/db/schema'; import { eq } from 'drizzle-orm'; import { makeMockCtx, makeMockRequest } from '../../helpers/route-tester'; import { makePort, makeClient, makeCompany, makeYacht, makeFullPermissions, makeSalesAgentPermissions, } from '../../helpers/factories'; describe('GET /api/v1/yachts/[id]', () => { it('returns the yacht for valid id + port', async () => { const port = await makePort(); const client = await makeClient({ portId: port.id }); const yacht = await makeYacht({ portId: port.id, ownerType: 'client', ownerId: client.id, name: 'Detail Yacht', }); const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() }); const req = makeMockRequest('GET', `http://localhost/api/v1/yachts/${yacht.id}`); const res = await getHandler(req, ctx, { id: yacht.id }); expect(res.status).toBe(200); const body = await res.json(); expect(body.data.id).toBe(yacht.id); expect(body.data.name).toBe('Detail Yacht'); }); it('returns 404 for wrong port (tenant isolation)', async () => { const portA = await makePort(); const portB = await makePort(); const client = await makeClient({ portId: portA.id }); const yacht = await makeYacht({ portId: portA.id, ownerType: 'client', ownerId: client.id, }); const ctx = makeMockCtx({ portId: portB.id, permissions: makeFullPermissions() }); const req = makeMockRequest('GET', `http://localhost/api/v1/yachts/${yacht.id}`); const res = await getHandler(req, ctx, { id: yacht.id }); expect(res.status).toBe(404); }); it('returns 404 for non-existent id', async () => { const port = await makePort(); const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() }); const req = makeMockRequest('GET', 'http://localhost/api/v1/yachts/does-not-exist'); const res = await getHandler(req, ctx, { id: 'does-not-exist' }); expect(res.status).toBe(404); }); }); describe('PATCH /api/v1/yachts/[id]', () => { it('updates allowed fields', async () => { const port = await makePort(); const client = await makeClient({ portId: port.id }); const yacht = await makeYacht({ portId: port.id, ownerType: 'client', ownerId: client.id, name: 'Before', }); const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() }); const req = makeMockRequest('PATCH', `http://localhost/api/v1/yachts/${yacht.id}`, { body: { name: 'After', notes: 'updated notes' }, }); const res = await patchHandler(req, ctx, { id: yacht.id }); expect(res.status).toBe(200); const body = await res.json(); expect(body.data.name).toBe('After'); expect(body.data.notes).toBe('updated notes'); }); it('returns 400 when attempting to set currentOwnerId (defensive guard)', async () => { const port = await makePort(); const client = await makeClient({ portId: port.id }); const yacht = await makeYacht({ portId: port.id, ownerType: 'client', ownerId: client.id, }); const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() }); // Validator strips owner fields, so we need to bypass it to reach the service's defensive guard. // Test the service layer defense by calling the handler with a payload that the validator // would accept but which also contains an unknown field that matches the forbidden keys. // Actually the validator just omits `owner` — additional keys `currentOwnerId` etc. pass // through Zod's .partial() (which still omits unknown keys by default). // Zod .strip() is default, so unknown keys are dropped: we assert on the service directly. const { updateYacht } = await import('@/lib/services/yachts.service'); await expect( updateYacht( yacht.id, port.id, { currentOwnerId: 'evil' } as unknown as Parameters[2], { userId: ctx.userId, portId: port.id, ipAddress: ctx.ipAddress, userAgent: ctx.userAgent, }, ), ).rejects.toMatchObject({ statusCode: 400 }); }); it('returns 404 for cross-tenant', async () => { const portA = await makePort(); const portB = await makePort(); const client = await makeClient({ portId: portA.id }); const yacht = await makeYacht({ portId: portA.id, ownerType: 'client', ownerId: client.id, }); const ctx = makeMockCtx({ portId: portB.id, permissions: makeFullPermissions() }); const req = makeMockRequest('PATCH', `http://localhost/api/v1/yachts/${yacht.id}`, { body: { name: 'hijack' }, }); const res = await patchHandler(req, ctx, { id: yacht.id }); expect(res.status).toBe(404); }); }); describe('DELETE /api/v1/yachts/[id]', () => { it('archives the yacht (returns 204)', async () => { const port = await makePort(); const client = await makeClient({ portId: port.id }); const yacht = await makeYacht({ portId: port.id, ownerType: 'client', ownerId: client.id, }); const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() }); const req = makeMockRequest('DELETE', `http://localhost/api/v1/yachts/${yacht.id}`); const res = await deleteHandler(req, ctx, { id: yacht.id }); expect(res.status).toBe(204); const [row] = await db.select().from(yachts).where(eq(yachts.id, yacht.id)); expect(row?.archivedAt).not.toBeNull(); }); it('returns 404 for cross-tenant', async () => { const portA = await makePort(); const portB = await makePort(); const client = await makeClient({ portId: portA.id }); const yacht = await makeYacht({ portId: portA.id, ownerType: 'client', ownerId: client.id, }); const ctx = makeMockCtx({ portId: portB.id, permissions: makeFullPermissions() }); const req = makeMockRequest('DELETE', `http://localhost/api/v1/yachts/${yacht.id}`); const res = await deleteHandler(req, ctx, { id: yacht.id }); expect(res.status).toBe(404); }); }); describe('POST /api/v1/yachts/[id]/transfer', () => { it('transfers ownership (200) when caller has yachts.transfer', async () => { const port = await makePort(); const client = await makeClient({ portId: port.id }); const company = await makeCompany({ portId: port.id }); const yacht = await makeYacht({ portId: port.id, ownerType: 'client', ownerId: client.id, }); const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() }); const req = makeMockRequest('POST', `http://localhost/api/v1/yachts/${yacht.id}/transfer`, { body: { newOwner: { type: 'company', id: company.id }, effectiveDate: new Date().toISOString(), transferReason: 'sale', }, }); const res = await transferHandler(req, ctx, { id: yacht.id }); expect(res.status).toBe(200); const body = await res.json(); expect(body.data.currentOwnerType).toBe('company'); expect(body.data.currentOwnerId).toBe(company.id); }); it('returns 403 when caller lacks yachts.transfer (only has yachts.edit)', async () => { const port = await makePort(); const client = await makeClient({ portId: port.id }); const company = await makeCompany({ portId: port.id }); const yacht = await makeYacht({ portId: port.id, ownerType: 'client', ownerId: client.id, }); const gated = withPermission('yachts', 'transfer', transferHandler); const ctx = makeMockCtx({ portId: port.id, permissions: makeSalesAgentPermissions() }); const req = makeMockRequest('POST', `http://localhost/api/v1/yachts/${yacht.id}/transfer`, { body: { newOwner: { type: 'company', id: company.id }, effectiveDate: new Date().toISOString(), }, }); const res = await gated(req, ctx, { id: yacht.id }); expect(res.status).toBe(403); }); it('returns 400 when newOwner === currentOwner', async () => { const port = await makePort(); const client = await makeClient({ portId: port.id }); const yacht = await makeYacht({ portId: port.id, ownerType: 'client', ownerId: client.id, }); const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() }); const req = makeMockRequest('POST', `http://localhost/api/v1/yachts/${yacht.id}/transfer`, { body: { newOwner: { type: 'client', id: client.id }, effectiveDate: new Date().toISOString(), }, }); const res = await transferHandler(req, ctx, { id: yacht.id }); expect(res.status).toBe(400); }); }); describe('GET /api/v1/yachts/[id]/ownership-history', () => { it('returns full history sorted by startDate DESC', async () => { const port = await makePort(); const client = await makeClient({ portId: port.id }); const company = await makeCompany({ portId: port.id }); const yacht = await makeYacht({ portId: port.id, ownerType: 'client', ownerId: client.id, }); // Transfer to create a second history row const ctxFull = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() }); const transferReq = makeMockRequest( 'POST', `http://localhost/api/v1/yachts/${yacht.id}/transfer`, { body: { newOwner: { type: 'company', id: company.id }, effectiveDate: new Date().toISOString(), transferReason: 'sale', }, }, ); const transferRes = await transferHandler(transferReq, ctxFull, { id: yacht.id }); expect(transferRes.status).toBe(200); const req = makeMockRequest( 'GET', `http://localhost/api/v1/yachts/${yacht.id}/ownership-history`, ); const res = await historyHandler(req, ctxFull, { id: yacht.id }); expect(res.status).toBe(200); const body = await res.json(); expect(body.data).toHaveLength(2); // Sorted DESC by startDate — newest first const firstStart = new Date(body.data[0].startDate).getTime(); const secondStart = new Date(body.data[1].startDate).getTime(); expect(firstStart).toBeGreaterThanOrEqual(secondStart); }); it('returns 404 for cross-tenant yacht', async () => { const portA = await makePort(); const portB = await makePort(); const client = await makeClient({ portId: portA.id }); const yacht = await makeYacht({ portId: portA.id, ownerType: 'client', ownerId: client.id, }); const ctx = makeMockCtx({ portId: portB.id, permissions: makeFullPermissions() }); const req = makeMockRequest( 'GET', `http://localhost/api/v1/yachts/${yacht.id}/ownership-history`, ); const res = await historyHandler(req, ctx, { id: yacht.id }); expect(res.status).toBe(404); }); }); describe('GET /api/v1/yachts/autocomplete', () => { it('returns matching yachts by name', async () => { const port = await makePort(); const client = await makeClient({ portId: port.id }); await makeYacht({ portId: port.id, ownerType: 'client', ownerId: client.id, name: 'AutoCompleteMatch One', }); await makeYacht({ portId: port.id, ownerType: 'client', ownerId: client.id, name: 'OtherName', }); const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() }); const req = makeMockRequest( 'GET', 'http://localhost/api/v1/yachts/autocomplete?q=AutoCompleteMatch', ); const res = await autocompleteHandler(req, ctx, {}); expect(res.status).toBe(200); const body = await res.json(); expect(body.data.length).toBeGreaterThanOrEqual(1); expect(body.data.every((y: { name: string }) => y.name.includes('AutoCompleteMatch'))).toBe( true, ); }); it('returns empty array when q is missing', async () => { const port = await makePort(); const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() }); const req = makeMockRequest('GET', 'http://localhost/api/v1/yachts/autocomplete'); const res = await autocompleteHandler(req, ctx, {}); expect(res.status).toBe(200); const body = await res.json(); expect(body.data).toEqual([]); }); it('is tenant-scoped', async () => { const portA = await makePort(); const portB = await makePort(); const clientA = await makeClient({ portId: portA.id }); await makeYacht({ portId: portA.id, ownerType: 'client', ownerId: clientA.id, name: 'TenantScopedYachtXYZ', }); const ctx = makeMockCtx({ portId: portB.id, permissions: makeFullPermissions() }); const req = makeMockRequest( 'GET', 'http://localhost/api/v1/yachts/autocomplete?q=TenantScopedYachtXYZ', ); const res = await autocompleteHandler(req, ctx, {}); expect(res.status).toBe(200); const body = await res.json(); expect(body.data).toEqual([]); }); });