Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,4 @@ node_modules
.DS_Store
*.tsbuildinfo
.vercel
.claude/
186 changes: 186 additions & 0 deletions __tests__/api/routes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { NextRequest } from 'next/server'

vi.mock('@/lib/auth0', () => ({
auth0: {
getAccessToken: vi.fn(),
getSession: vi.fn(),
},
}))

vi.mock('@/lib/api', () => ({
fetchBackend: vi.fn(),
}))

import { GET as getDevToken } from '@/app/api/dev-token/route'
import { GET as getUser } from '@/app/api/user/route'
import { GET as getApplications, POST as createApplication } from '@/app/api/applications/route'
import { GET as getAppById, PUT as updateAppById, DELETE as deleteAppById } from '@/app/api/applications/[id]/route'
import { POST as acceptInvitation } from '@/app/api/invitations/[token]/accept/route'
import { auth0 } from '@/lib/auth0'
import { fetchBackend } from '@/lib/api'

describe('app/api Routes (Endpoints Internos)', () => {
const originalEnv = process.env.VERCEL_ENV

beforeEach(() => {
vi.resetAllMocks()
delete process.env.VERCEL_ENV
})

afterEach(() => {
if (originalEnv !== undefined) {
process.env.VERCEL_ENV = originalEnv
} else {
delete process.env.VERCEL_ENV
}
})

describe('GET /api/dev-token', () => {
it('should return 404 if running on Vercel environment', async () => {
process.env.VERCEL_ENV = 'production'
const res = await getDevToken()
expect(res.status).toBe(404)
})

it('should return 401 if access token is not available', async () => {
vi.mocked(auth0.getAccessToken).mockResolvedValueOnce(null as any)
const res = await getDevToken()
expect(res.status).toBe(401)
const body = await res.json()
expect(body.error).toBe('No access token available')
})

it('should return token if user is authenticated locally', async () => {
vi.mocked(auth0.getAccessToken).mockResolvedValueOnce('secret-dev-token' as any)
const res = await getDevToken()
expect(res.status).toBe(200)
const body = await res.json()
expect(body).toBe('secret-dev-token')
})
})

describe('GET /api/user', () => {
it('should return 401 if no active session', async () => {
vi.mocked(auth0.getSession).mockResolvedValueOnce(null as any)
const res = await getUser()
expect(res.status).toBe(401)
const body = await res.json()
expect(body.error).toBe('Not authenticated')
})

it('should return user session data when authenticated', async () => {
const mockUser = { sub: 'auth0|123', name: 'John Doe', email: 'john@example.com' }
vi.mocked(auth0.getSession).mockResolvedValueOnce({ user: mockUser } as any)

const res = await getUser()
expect(res.status).toBe(200)
const body = await res.json()
expect(body.user).toEqual(mockUser)
})
})

describe('/api/applications', () => {
it('GET should call fetchBackend with pagination params and return backend json', async () => {
const mockBackendResponse = new Response(
JSON.stringify({ content: [{ id: 'app-1', name: 'payment-sync' }] }),
{ status: 200 }
)
vi.mocked(fetchBackend).mockResolvedValueOnce(mockBackendResponse)

const req = new NextRequest('http://localhost:3000/api/applications?page=0&size=10')
const res = await getApplications(req)

expect(res.status).toBe(200)
expect(fetchBackend).toHaveBeenCalledWith('/v1/applications?page=0&size=10&sort=name%2Casc')
const body = await res.json()
expect(body.content).toHaveLength(1)
})

it('POST should forward payload to backend and return created application', async () => {
const payload = { name: 'new-app', description: 'Test app' }
const mockBackendResponse = new Response(
JSON.stringify({ id: 'app-99', ...payload }),
{ status: 201 }
)
vi.mocked(fetchBackend).mockResolvedValueOnce(mockBackendResponse)

const req = new NextRequest('http://localhost:3000/api/applications', {
method: 'POST',
body: JSON.stringify(payload),
})
const res = await createApplication(req)

expect(res.status).toBe(200)
expect(fetchBackend).toHaveBeenCalledWith('/v1/applications', {
method: 'POST',
body: JSON.stringify(payload),
})
const body = await res.json()
expect(body.id).toBe('app-99')
})
})

describe('/api/applications/[id]', () => {
it('GET /api/applications/[id] should fetch specific application', async () => {
const mockBackendResponse = new Response(
JSON.stringify({ id: 'app-123', name: 'my-app' }),
{ status: 200 }
)
vi.mocked(fetchBackend).mockResolvedValueOnce(mockBackendResponse)

const req = new NextRequest('http://localhost:3000/api/applications/app-123')
const res = await getAppById(req, { params: Promise.resolve({ id: 'app-123' }) })

expect(res.status).toBe(200)
expect(fetchBackend).toHaveBeenCalledWith('/v1/applications/app-123')
const body = await res.json()
expect(body.id).toBe('app-123')
})

it('PUT /api/applications/[id] should update application', async () => {
const updateData = { name: 'updated-name' }
const mockBackendResponse = new Response(
JSON.stringify({ id: 'app-123', ...updateData }),
{ status: 200 }
)
vi.mocked(fetchBackend).mockResolvedValueOnce(mockBackendResponse)

const req = new NextRequest('http://localhost:3000/api/applications/app-123', {
method: 'PUT',
body: JSON.stringify(updateData),
})
const res = await updateAppById(req, { params: Promise.resolve({ id: 'app-123' }) })

expect(res.status).toBe(200)
expect(fetchBackend).toHaveBeenCalledWith('/v1/applications/app-123', {
method: 'PUT',
body: JSON.stringify(updateData),
})
})

it('DELETE /api/applications/[id] should delete application and return 204', async () => {
const mockBackendResponse = new Response(null, { status: 204 })
vi.mocked(fetchBackend).mockResolvedValueOnce(mockBackendResponse)

const req = new NextRequest('http://localhost:3000/api/applications/app-123', { method: 'DELETE' })
const res = await deleteAppById(req, { params: Promise.resolve({ id: 'app-123' }) })

expect(res.status).toBe(204)
expect(fetchBackend).toHaveBeenCalledWith('/v1/applications/app-123', { method: 'DELETE' })
})
})

describe('/api/invitations/[token]/accept', () => {
it('POST /api/invitations/[token]/accept should call backend accept endpoint', async () => {
const mockBackendResponse = new Response(null, { status: 200 })
vi.mocked(fetchBackend).mockResolvedValueOnce(mockBackendResponse)

const req = new NextRequest('http://localhost:3000/api/invitations/token-abc/accept', { method: 'POST' })
const res = await acceptInvitation(req, { params: Promise.resolve({ token: 'token-abc' }) })

expect(res.status).toBe(200)
expect(fetchBackend).toHaveBeenCalledWith('/v1/invitations/token-abc/accept', { method: 'POST' })
})
})
})
81 changes: 81 additions & 0 deletions __tests__/lib/api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'

vi.mock('@/lib/auth0', () => ({
auth0: {
getAccessToken: vi.fn(),
},
}))

import { getBackendHeaders, fetchBackend } from '@/lib/api'
import { auth0 } from '@/lib/auth0'

describe('lib/api', () => {
const originalFetch = global.fetch

beforeEach(() => {
vi.resetAllMocks()
global.fetch = vi.fn()
})

afterEach(() => {
global.fetch = originalFetch
})

describe('getBackendHeaders', () => {
it('should return headers with Bearer token when token is available', async () => {
vi.mocked(auth0.getAccessToken).mockResolvedValueOnce({ token: 'mock-token-123' } as any)

const headers = await getBackendHeaders()
expect(headers).toEqual({
'Content-Type': 'application/json',
Authorization: 'Bearer mock-token-123',
})
})

it('should throw Unauthorized error when token is missing', async () => {
vi.mocked(auth0.getAccessToken).mockResolvedValueOnce(null as any)

await expect(getBackendHeaders()).rejects.toThrow('Unauthorized: Could not retrieve access token')
})

it('should throw error when getAccessToken rejects', async () => {
vi.mocked(auth0.getAccessToken).mockRejectedValueOnce(new Error('Auth error'))

await expect(getBackendHeaders()).rejects.toThrow('Unauthorized: Could not retrieve access token')
})
})

describe('fetchBackend', () => {
it('should call fetch with backend URL and authorization headers', async () => {
vi.mocked(auth0.getAccessToken).mockResolvedValueOnce({ token: 'mock-token-abc' } as any)
const mockResponse = new Response(JSON.stringify({ ok: true }), { status: 200 })
vi.mocked(global.fetch).mockResolvedValueOnce(mockResponse)

const response = await fetchBackend('/v1/test', { method: 'POST' })

expect(global.fetch).toHaveBeenCalledTimes(1)
const [url, init] = vi.mocked(global.fetch).mock.calls[0]
expect(url).toContain('/v1/test')
expect(init?.method).toBe('POST')

const headers = init?.headers as Headers
expect(headers.get('Authorization')).toBe('Bearer mock-token-abc')
expect(headers.get('Content-Type')).toBe('application/json')
expect(response).toBe(mockResponse)
})

it('should merge custom headers properly', async () => {
vi.mocked(auth0.getAccessToken).mockResolvedValueOnce({ token: 'mock-token-xyz' } as any)
vi.mocked(global.fetch).mockResolvedValueOnce(new Response('{}', { status: 200 }))

await fetchBackend('/v1/custom', {
headers: { 'X-Custom-Header': 'CustomValue' },
})

const [, init] = vi.mocked(global.fetch).mock.calls[0]
const headers = init?.headers as Headers
expect(headers.get('X-Custom-Header')).toBe('CustomValue')
expect(headers.get('Authorization')).toBe('Bearer mock-token-xyz')
})
})
})
51 changes: 51 additions & 0 deletions __tests__/lib/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { describe, it, expect } from 'vitest'
import { cn, getEnvColors } from '@/lib/utils'

describe('lib/utils', () => {
describe('cn', () => {
it('should merge class names correctly', () => {
expect(cn('bg-red-500', 'text-white')).toBe('bg-red-500 text-white')
})

it('should handle conditional classes', () => {
expect(cn('base-class', true && 'active', false && 'disabled')).toBe('base-class active')
})

it('should resolve tailwind conflicts using tailwind-merge', () => {
expect(cn('px-2 py-1', 'p-4')).toBe('p-4')
expect(cn('text-red-500', 'text-blue-500')).toBe('text-blue-500')
})
})

describe('getEnvColors', () => {
it('should return dev colors for dev/development or unknown environment', () => {
const devColors = getEnvColors('dev')
expect(devColors.dot).toBe('bg-blue-500')
expect(devColors.text).toBe('text-blue-400')

const developmentColors = getEnvColors('DEVELOPMENT')
expect(developmentColors.dot).toBe('bg-blue-500')

const nullColors = getEnvColors(null)
expect(nullColors.dot).toBe('bg-blue-500')
})

it('should return staging colors for staging/stage environment', () => {
const stagingColors = getEnvColors('staging')
expect(stagingColors.dot).toBe('bg-yellow-500')
expect(stagingColors.text).toBe('text-yellow-400')

const stageColors = getEnvColors('STAGE')
expect(stageColors.dot).toBe('bg-yellow-500')
})

it('should return prod colors for prod/production environment', () => {
const prodColors = getEnvColors('prod')
expect(prodColors.dot).toBe('bg-green-500')
expect(prodColors.text).toBe('text-green-400')

const productionColors = getEnvColors('PRODUCTION')
expect(productionColors.dot).toBe('bg-green-500')
})
})
})
56 changes: 56 additions & 0 deletions __tests__/proxy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextRequest, NextResponse } from 'next/server'

vi.mock('@/lib/auth0', () => ({
auth0: {
middleware: vi.fn((req: any) => Promise.resolve(NextResponse.next())),
getSession: vi.fn(),
},
}))

import { proxy } from '@/proxy'
import { auth0 } from '@/lib/auth0'

describe('proxy.ts (Middleware de Red y Seguridad)', () => {
beforeEach(() => {
vi.resetAllMocks()
vi.mocked(auth0.middleware).mockImplementation(() => NextResponse.next())
})

it('should redirect unauthenticated users accessing /dashboard to /auth/login', async () => {
vi.mocked(auth0.getSession).mockResolvedValueOnce(null)

const req = new NextRequest('http://localhost:3000/dashboard/applications')
const res = await proxy(req)

expect(res.status).toBe(307) // Next.js NextResponse.redirect default status
expect(res.headers.get('location')).toBe('http://localhost:3000/auth/login')
})

it('should allow authenticated users accessing /dashboard', async () => {
vi.mocked(auth0.getSession).mockResolvedValueOnce({ user: { sub: 'user-123' } } as any)

const req = new NextRequest('http://localhost:3000/dashboard/applications')
const res = await proxy(req)

expect(res.headers.get('location')).toBeNull()
})

it('should redirect unauthenticated users accessing /accept-invite to /auth/login with returnTo', async () => {
vi.mocked(auth0.getSession).mockResolvedValueOnce(null)

const req = new NextRequest('http://localhost:3000/accept-invite?token=invite-token-abc')
const res = await proxy(req)

expect(res.status).toBe(307)
const expectedReturnTo = encodeURIComponent('/accept-invite?token=invite-token-abc')
expect(res.headers.get('location')).toBe(`http://localhost:3000/auth/login?returnTo=${expectedReturnTo}`)
})

it('should pass through requests for public routes like landing page', async () => {
const req = new NextRequest('http://localhost:3000/')
const res = await proxy(req)

expect(res.headers.get('location')).toBeNull()
})
})
Loading