Add comprehensive unit and E2E tests for API endpoints

Co-authored-by: johndoe6345789 <224850594+johndoe6345789@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-01-08 04:06:33 +00:00
parent 9667e55324
commit 3047d6b881
3 changed files with 570 additions and 0 deletions

View File

@@ -0,0 +1,174 @@
/**
* E2E tests for CRUD operations via API
*
* Tests the complete lifecycle of entity operations through the API
*/
import { test, expect } from '@playwright/test'
test.describe('API CRUD Operations', () => {
const baseURL = 'http://localhost:3000'
const tenant = 'test-tenant'
const packageId = 'test-package'
const entity = 'test-entity'
let createdEntityId: string
test.describe('List Entities (GET)', () => {
test('should list entities with default pagination', async ({ request }) => {
const response = await request.get(`${baseURL}/api/v1/${tenant}/${packageId}/${entity}`)
// Expect either 200 (success) or appropriate error for missing package
expect([200, 404, 401, 403]).toContain(response.status())
if (response.status() === 200) {
const data = await response.json()
expect(data).toBeDefined()
expect(Array.isArray(data) || Array.isArray(data.data)).toBeTruthy()
}
})
test('should list entities with pagination parameters', async ({ request }) => {
const response = await request.get(
`${baseURL}/api/v1/${tenant}/${packageId}/${entity}?page=1&limit=10`
)
if (response.status() === 200) {
const data = await response.json()
expect(data).toBeDefined()
}
})
test('should list entities with filters', async ({ request }) => {
const filter = JSON.stringify({ published: true })
const response = await request.get(
`${baseURL}/api/v1/${tenant}/${packageId}/${entity}?filter=${encodeURIComponent(filter)}`
)
if (response.status() === 200) {
const data = await response.json()
expect(data).toBeDefined()
}
})
test('should list entities with sorting', async ({ request }) => {
const response = await request.get(
`${baseURL}/api/v1/${tenant}/${packageId}/${entity}?sort=-createdAt`
)
if (response.status() === 200) {
const data = await response.json()
expect(data).toBeDefined()
}
})
})
test.describe('Create Entity (POST)', () => {
test('should create a new entity or return appropriate error', async ({ request }) => {
const newEntity = {
name: 'Test Entity',
description: 'Created by E2E test',
createdAt: new Date().toISOString(),
}
const response = await request.post(`${baseURL}/api/v1/${tenant}/${packageId}/${entity}`, {
headers: {
'Content-Type': 'application/json',
},
data: newEntity,
})
// Expect either success or appropriate error
if (response.status() === 201) {
const data = await response.json()
expect(data).toBeDefined()
expect(data.id).toBeDefined()
createdEntityId = data.id
} else {
const error = await response.json()
console.log('Create entity error:', error)
}
})
test('should reject invalid entity data', async ({ request }) => {
const invalidEntity = {}
const response = await request.post(`${baseURL}/api/v1/${tenant}/${packageId}/${entity}`, {
headers: {
'Content-Type': 'application/json',
},
data: invalidEntity,
})
expect([400, 404, 401, 403]).toContain(response.status())
})
})
test.describe('Read Entity (GET)', () => {
test('should get entity by ID or return not found', async ({ request }) => {
const testId = 'test-id-123'
const response = await request.get(`${baseURL}/api/v1/${tenant}/${packageId}/${entity}/${testId}`)
expect([200, 404, 401, 403]).toContain(response.status())
if (response.status() === 200) {
const data = await response.json()
expect(data).toBeDefined()
}
})
})
test.describe('Update Entity (PUT)', () => {
test('should update an existing entity or return error', async ({ request }) => {
const testId = 'test-id-123'
const updates = {
name: 'Updated Entity Name',
updatedAt: new Date().toISOString(),
}
const response = await request.put(`${baseURL}/api/v1/${tenant}/${packageId}/${entity}/${testId}`, {
headers: {
'Content-Type': 'application/json',
},
data: updates,
})
expect([200, 404, 401, 403]).toContain(response.status())
})
})
test.describe('Delete Entity (DELETE)', () => {
test('should delete an existing entity or return error', async ({ request }) => {
const testId = 'test-id-to-delete'
const response = await request.delete(`${baseURL}/api/v1/${tenant}/${packageId}/${entity}/${testId}`)
expect([200, 204, 404, 401, 403]).toContain(response.status())
})
})
test.describe('Error Handling', () => {
test('should return proper error for invalid route', async ({ request }) => {
const response = await request.get(`${baseURL}/api/v1/invalid`)
expect([400, 404]).toContain(response.status())
})
test('should handle missing package gracefully', async ({ request }) => {
const response = await request.get(`${baseURL}/api/v1/${tenant}/non-existent-package/entity`)
expect([404, 403]).toContain(response.status())
const error = await response.json()
expect(error.error).toBeDefined()
})
test('should return JSON error responses', async ({ request }) => {
const response = await request.get(`${baseURL}/api/v1/invalid/route`)
const contentType = response.headers()['content-type']
if (contentType) {
expect(contentType).toContain('application/json')
}
})
})
})

View File

@@ -39,6 +39,7 @@
"devDependencies": {
"@eslint/js": "^9.39.2",
"@tanstack/react-query": "^5.90.16",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.1",
"@types/better-sqlite3": "^7.6.12",
"@types/node": "^25.0.3",

View File

@@ -0,0 +1,395 @@
/**
* Tests for RESTful API route
*
* Comprehensive test coverage for /api/v1/{tenant}/{package}/{entity} endpoints
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextRequest } from 'next/server'
import { GET, POST, PUT, DELETE } from './route'
// Mock dependencies
vi.mock('@/lib/routing', () => ({
errorResponse: vi.fn((message: string, status: number) => ({
json: async () => ({ error: message }),
status,
})),
executeDbalOperation: vi.fn(),
executePackageAction: vi.fn(),
getSessionUser: vi.fn(),
parseRestfulRequest: vi.fn(),
STATUS: {
OK: 200,
CREATED: 201,
BAD_REQUEST: 400,
UNAUTHORIZED: 401,
FORBIDDEN: 403,
NOT_FOUND: 404,
INTERNAL_ERROR: 500,
},
successResponse: vi.fn((data: unknown, status: number) => ({
json: async () => data,
status,
})),
validatePackageRoute: vi.fn(),
validateTenantAccess: vi.fn(),
}))
vi.mock('@/lib/logging', () => ({
logger: {
error: vi.fn(),
},
apiError: vi.fn((error: unknown) => String(error)),
}))
describe('API Route /api/v1/[...slug]', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('GET method', () => {
it.each([
{
slug: ['tenant1', 'package1', 'entity1'],
expected: { operation: 'list' },
},
{
slug: ['tenant1', 'package1', 'entity1', 'id123'],
expected: { operation: 'read' },
},
])('should handle GET request for $slug', async ({ slug }) => {
const { parseRestfulRequest, getSessionUser, validatePackageRoute, validateTenantAccess, executeDbalOperation, successResponse } = await import('@/lib/routing')
vi.mocked(parseRestfulRequest).mockReturnValue({
route: { tenant: slug[0], package: slug[1], entity: slug[2], id: slug[3] },
operation: slug.length === 4 ? 'read' : 'list',
dbalOp: { entity: slug[2], operation: slug.length === 4 ? 'get' : 'list' },
})
vi.mocked(getSessionUser).mockResolvedValue({
user: { id: 'user1', role: 'user', tenantId: 'tenant1' },
})
vi.mocked(validatePackageRoute).mockReturnValue({
allowed: true,
package: { minLevel: 1 },
})
vi.mocked(validateTenantAccess).mockResolvedValue({
allowed: true,
tenant: { id: 'tenant1', name: 'Tenant 1' },
})
vi.mocked(executeDbalOperation).mockResolvedValue({
success: true,
data: [{ id: '1', name: 'Test' }],
})
vi.mocked(successResponse).mockReturnValue({
json: async () => [{ id: '1', name: 'Test' }],
status: 200,
} as any)
const request = new NextRequest(`http://localhost:3000/api/v1/${slug.join('/')}`)
const params = { params: Promise.resolve({ slug }) }
await GET(request, params)
expect(parseRestfulRequest).toHaveBeenCalled()
expect(getSessionUser).toHaveBeenCalled()
expect(validatePackageRoute).toHaveBeenCalled()
expect(validateTenantAccess).toHaveBeenCalled()
expect(executeDbalOperation).toHaveBeenCalled()
})
})
describe('POST method', () => {
it.each([
{
slug: ['tenant1', 'package1', 'entity1'],
body: { name: 'Test Entity' },
expected: { status: 201 },
},
])('should handle POST request for creating entity', async ({ slug, body }) => {
const { parseRestfulRequest, getSessionUser, validatePackageRoute, validateTenantAccess, executeDbalOperation, successResponse, STATUS } = await import('@/lib/routing')
vi.mocked(parseRestfulRequest).mockReturnValue({
route: { tenant: slug[0], package: slug[1], entity: slug[2] },
operation: 'create',
dbalOp: { entity: slug[2], operation: 'create' },
})
vi.mocked(getSessionUser).mockResolvedValue({
user: { id: 'user1', role: 'user', tenantId: 'tenant1' },
})
vi.mocked(validatePackageRoute).mockReturnValue({
allowed: true,
package: { minLevel: 1 },
})
vi.mocked(validateTenantAccess).mockResolvedValue({
allowed: true,
tenant: { id: 'tenant1', name: 'Tenant 1' },
})
vi.mocked(executeDbalOperation).mockResolvedValue({
success: true,
data: { id: 'new-id', ...body },
})
vi.mocked(successResponse).mockReturnValue({
json: async () => ({ id: 'new-id', ...body }),
status: STATUS.CREATED,
} as any)
const request = new NextRequest(`http://localhost:3000/api/v1/${slug.join('/')}`, {
method: 'POST',
body: JSON.stringify(body),
})
const params = { params: Promise.resolve({ slug }) }
await POST(request, params)
expect(executeDbalOperation).toHaveBeenCalled()
expect(successResponse).toHaveBeenCalledWith(
expect.objectContaining({ id: 'new-id' }),
STATUS.CREATED
)
})
})
describe('PUT method', () => {
it.each([
{
slug: ['tenant1', 'package1', 'entity1', 'id123'],
body: { name: 'Updated Entity' },
expected: { status: 200 },
},
])('should handle PUT request for updating entity', async ({ slug, body }) => {
const { parseRestfulRequest, getSessionUser, validatePackageRoute, validateTenantAccess, executeDbalOperation, successResponse, STATUS } = await import('@/lib/routing')
vi.mocked(parseRestfulRequest).mockReturnValue({
route: { tenant: slug[0], package: slug[1], entity: slug[2], id: slug[3] },
operation: 'update',
dbalOp: { entity: slug[2], operation: 'update', id: slug[3] },
})
vi.mocked(getSessionUser).mockResolvedValue({
user: { id: 'user1', role: 'user', tenantId: 'tenant1' },
})
vi.mocked(validatePackageRoute).mockReturnValue({
allowed: true,
package: { minLevel: 1 },
})
vi.mocked(validateTenantAccess).mockResolvedValue({
allowed: true,
tenant: { id: 'tenant1', name: 'Tenant 1' },
})
vi.mocked(executeDbalOperation).mockResolvedValue({
success: true,
data: { id: slug[3], ...body },
})
vi.mocked(successResponse).mockReturnValue({
json: async () => ({ id: slug[3], ...body }),
status: STATUS.OK,
} as any)
const request = new NextRequest(`http://localhost:3000/api/v1/${slug.join('/')}`, {
method: 'PUT',
body: JSON.stringify(body),
})
const params = { params: Promise.resolve({ slug }) }
await PUT(request, params)
expect(executeDbalOperation).toHaveBeenCalled()
})
})
describe('DELETE method', () => {
it.each([
{
slug: ['tenant1', 'package1', 'entity1', 'id123'],
expected: { status: 200 },
},
])('should handle DELETE request for deleting entity', async ({ slug }) => {
const { parseRestfulRequest, getSessionUser, validatePackageRoute, validateTenantAccess, executeDbalOperation, successResponse, STATUS } = await import('@/lib/routing')
vi.mocked(parseRestfulRequest).mockReturnValue({
route: { tenant: slug[0], package: slug[1], entity: slug[2], id: slug[3] },
operation: 'delete',
dbalOp: { entity: slug[2], operation: 'delete', id: slug[3] },
})
vi.mocked(getSessionUser).mockResolvedValue({
user: { id: 'user1', role: 'admin', tenantId: 'tenant1' },
})
vi.mocked(validatePackageRoute).mockReturnValue({
allowed: true,
package: { minLevel: 1 },
})
vi.mocked(validateTenantAccess).mockResolvedValue({
allowed: true,
tenant: { id: 'tenant1', name: 'Tenant 1' },
})
vi.mocked(executeDbalOperation).mockResolvedValue({
success: true,
data: { success: true },
})
vi.mocked(successResponse).mockReturnValue({
json: async () => ({ success: true }),
status: STATUS.OK,
} as any)
const request = new NextRequest(`http://localhost:3000/api/v1/${slug.join('/')}`)
const params = { params: Promise.resolve({ slug }) }
await DELETE(request, params)
expect(executeDbalOperation).toHaveBeenCalled()
})
})
describe('Error handling', () => {
it.each([
{
scenario: 'unauthorized user',
user: null,
expectedStatus: 401,
},
{
scenario: 'insufficient permissions',
user: { id: 'user1', role: 'user', tenantId: 'tenant1' },
validateResult: { allowed: false, reason: 'Insufficient permissions' },
expectedStatus: 403,
},
])('should return $expectedStatus for $scenario', async ({ user, validateResult, expectedStatus }) => {
const { parseRestfulRequest, getSessionUser, validatePackageRoute, errorResponse } = await import('@/lib/routing')
vi.mocked(parseRestfulRequest).mockReturnValue({
route: { tenant: 'tenant1', package: 'package1', entity: 'entity1' },
operation: 'list',
dbalOp: { entity: 'entity1', operation: 'list' },
})
vi.mocked(getSessionUser).mockResolvedValue({ user })
vi.mocked(validatePackageRoute).mockReturnValue(
validateResult ?? { allowed: true, package: { minLevel: 1 } }
)
vi.mocked(errorResponse).mockReturnValue({
json: async () => ({ error: 'Access denied' }),
status: expectedStatus,
} as any)
const request = new NextRequest('http://localhost:3000/api/v1/tenant1/package1/entity1')
const params = { params: Promise.resolve({ slug: ['tenant1', 'package1', 'entity1'] }) }
await GET(request, params)
if (validateResult?.allowed === false) {
expect(errorResponse).toHaveBeenCalledWith(
expect.any(String),
expectedStatus
)
}
})
it('should handle invalid JSON body', async () => {
const { parseRestfulRequest, getSessionUser, validatePackageRoute, validateTenantAccess, errorResponse, STATUS } = await import('@/lib/routing')
vi.mocked(parseRestfulRequest).mockReturnValue({
route: { tenant: 'tenant1', package: 'package1', entity: 'entity1' },
operation: 'create',
dbalOp: { entity: 'entity1', operation: 'create' },
})
vi.mocked(getSessionUser).mockResolvedValue({
user: { id: 'user1', role: 'user', tenantId: 'tenant1' },
})
vi.mocked(validatePackageRoute).mockReturnValue({
allowed: true,
package: { minLevel: 1 },
})
vi.mocked(validateTenantAccess).mockResolvedValue({
allowed: true,
tenant: { id: 'tenant1', name: 'Tenant 1' },
})
vi.mocked(errorResponse).mockReturnValue({
json: async () => ({ error: 'Invalid JSON body' }),
status: STATUS.BAD_REQUEST,
} as any)
const request = new NextRequest('http://localhost:3000/api/v1/tenant1/package1/entity1', {
method: 'POST',
body: 'invalid json{',
})
const params = { params: Promise.resolve({ slug: ['tenant1', 'package1', 'entity1'] }) }
await POST(request, params)
expect(errorResponse).toHaveBeenCalledWith('Invalid JSON body', STATUS.BAD_REQUEST)
})
})
describe('Multi-tenant isolation', () => {
it.each([
{
scenario: 'user accessing their tenant',
user: { id: 'user1', role: 'user', tenantId: 'tenant1' },
requestTenant: 'tenant1',
allowed: true,
},
{
scenario: 'god user accessing any tenant',
user: { id: 'god1', role: 'god', tenantId: 'tenant1' },
requestTenant: 'tenant2',
allowed: true,
},
])('should handle $scenario', async ({ user, requestTenant, allowed }) => {
const { parseRestfulRequest, getSessionUser, validatePackageRoute, validateTenantAccess } = await import('@/lib/routing')
vi.mocked(parseRestfulRequest).mockReturnValue({
route: { tenant: requestTenant, package: 'package1', entity: 'entity1' },
operation: 'list',
dbalOp: { entity: 'entity1', operation: 'list' },
})
vi.mocked(getSessionUser).mockResolvedValue({ user })
vi.mocked(validatePackageRoute).mockReturnValue({
allowed: true,
package: { minLevel: 1 },
})
vi.mocked(validateTenantAccess).mockResolvedValue({
allowed,
tenant: allowed ? { id: requestTenant, name: 'Tenant' } : null,
})
const request = new NextRequest(`http://localhost:3000/api/v1/${requestTenant}/package1/entity1`)
const params = { params: Promise.resolve({ slug: [requestTenant, 'package1', 'entity1'] }) }
await GET(request, params)
expect(validateTenantAccess).toHaveBeenCalledWith(
user,
requestTenant,
1
)
})
})
})