/** * Unit tests for server-side implementation (Express integration). * Tests the implApi function with different API patterns and error scenarios. */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import express, { type Express } from 'express' import request from 'supertest' import { z } from 'zod' import { defineAPI } from '../src/def' import { implApi } from '../src/server' import { APIError } from '../src/APIError' import { InvalidDTOError } from '../src/InvalidDTOError' describe('Server Implementation', () => { let app: Express let onErrorSpy: ReturnType beforeEach(() => { app = express() app.use(express.json()) onErrorSpy = vi.fn() }) afterEach(() => { vi.restoreAllMocks() }) describe('implApi with PlainAPI', () => { it('should handle PlainAPI (no input/output)', async () => { const api = defineAPI({ path: '/health' }) const handlerSpy = vi.fn().mockResolvedValue(undefined) implApi({ express: app, api, handler: handlerSpy, onError: onErrorSpy }) const response = await request(app) .post('/health') .expect(200) .expect('Content-Type', /json/) expect(response.body).toEqual({ success: true, data: undefined }) expect(handlerSpy).toHaveBeenCalledTimes(1) expect(handlerSpy).toHaveBeenCalledWith({ request: expect.any(Object), response: expect.any(Object), dto: {} }) expect(onErrorSpy).not.toHaveBeenCalled() }) it('should handle PlainAPI with direct parameter syntax', async () => { const api = defineAPI({ path: '/ping' }) const handlerSpy = vi.fn().mockResolvedValue(undefined) // Test direct parameter syntax implApi(app, api, handlerSpy, onErrorSpy) const response = await request(app) .post('/ping') .expect(200) expect(response.body.success).toBe(true) expect(handlerSpy).toHaveBeenCalledTimes(1) expect(onErrorSpy).not.toHaveBeenCalled() }) }) describe('implApi with RetAPI', () => { it('should handle RetAPI (output only)', async () => { const voSchema = z.object({ timestamp: z.number(), server: z.string(), version: z.string() }) const api = defineAPI({ path: '/status', voSchema }) const responseData = { timestamp: Date.now(), server: 'fuapi-test', version: '1.0.0' } const handlerSpy = vi.fn().mockResolvedValue(responseData) implApi({ express: app, api, handler: handlerSpy }) const response = await request(app) .post('/status') .expect(200) expect(response.body).toEqual({ success: true, data: responseData }) expect(handlerSpy).toHaveBeenCalledTimes(1) expect(handlerSpy).toHaveBeenCalledWith({ request: expect.any(Object), response: expect.any(Object), dto: {} }) }) }) describe('implApi with ArgAPI', () => { it('should handle ArgAPI (input only)', async () => { const dtoSchema = z.object({ message: z.string().min(1), level: z.enum(['info', 'warn', 'error']) }) const api = defineAPI({ path: '/log', dtoSchema }) const handlerSpy = vi.fn().mockResolvedValue(undefined) implApi({ express: app, api, handler: handlerSpy }) const requestData = { message: 'Test log message', level: 'info' as const } const response = await request(app) .post('/log') .send(requestData) .set('Content-Type', 'application/json') .expect(200) expect(response.body).toEqual({ success: true, data: undefined }) expect(handlerSpy).toHaveBeenCalledTimes(1) expect(handlerSpy).toHaveBeenCalledWith({ request: expect.any(Object), response: expect.any(Object), dto: requestData }) }) it('should reject invalid input data', async () => { const dtoSchema = z.object({ name: z.string().min(1), age: z.number().min(0) }) const api = defineAPI({ path: '/user', dtoSchema }) const handlerSpy = vi.fn() implApi({ express: app, api, handler: handlerSpy, onError: onErrorSpy }) const invalidData = { name: '', // Invalid: empty string age: -5 // Invalid: negative number } const response = await request(app) .post('/user') .send(invalidData) .set('Content-Type', 'application/json') .expect(200) // fuapi wraps all responses, even errors expect(response.body.success).toBe(false) expect(response.body.code).toBeDefined() expect(response.body.msg).toBeDefined() expect(handlerSpy).not.toHaveBeenCalled() expect(onErrorSpy).toHaveBeenCalledTimes(1) }) }) describe('implApi with StdAPI', () => { it('should handle StdAPI (both input and output)', async () => { const dtoSchema = z.object({ id: z.string().uuid() }) const voSchema = z.object({ id: z.string().uuid(), name: z.string(), email: z.string().email(), createdAt: z.string() }) const api = defineAPI({ path: '/users/get', dtoSchema, voSchema }) const responseData = { id: '123e4567-e89b-12d3-a456-426614174000', name: 'John Doe', email: 'john@example.com', createdAt: '2023-01-01T00:00:00.000Z' } const handlerSpy = vi.fn().mockResolvedValue(responseData) implApi({ express: app, api, handler: handlerSpy }) const requestData = { id: '123e4567-e89b-12d3-a456-426614174000' } const response = await request(app) .post('/users/get') .send(requestData) .set('Content-Type', 'application/json') .expect(200) expect(response.body).toEqual({ success: true, data: responseData }) expect(handlerSpy).toHaveBeenCalledTimes(1) expect(handlerSpy).toHaveBeenCalledWith({ request: expect.any(Object), response: expect.any(Object), dto: requestData }) }) }) describe('Error Handling', () => { it('should handle APIError thrown from handler', async () => { const api = defineAPI({ path: '/error-test' }) const apiError = new APIError(404, 'Resource not found') const handlerSpy = vi.fn().mockRejectedValue(apiError) implApi({ express: app, api, handler: handlerSpy, onError: onErrorSpy }) const response = await request(app) .post('/error-test') .expect(200) // fuapi wraps all responses expect(response.body).toEqual({ success: false, code: 404, msg: 'Resource not found' }) expect(onErrorSpy).toHaveBeenCalledTimes(1) expect(onErrorSpy).toHaveBeenCalledWith(apiError) }) it('should handle standard Error thrown from handler', async () => { const api = defineAPI({ path: '/error-test' }) const standardError = new Error('Something went wrong') const handlerSpy = vi.fn().mockRejectedValue(standardError) implApi({ express: app, api, handler: handlerSpy, onError: onErrorSpy }) const response = await request(app) .post('/error-test') .expect(200) expect(response.body).toEqual({ success: false, code: 1, msg: 'Something went wrong' }) expect(onErrorSpy).toHaveBeenCalledTimes(1) expect(onErrorSpy).toHaveBeenCalledWith(standardError) }) it('should handle string error thrown from handler', async () => { const api = defineAPI({ path: '/error-test' }) const handlerSpy = vi.fn().mockRejectedValue('String error') implApi({ express: app, api, handler: handlerSpy, onError: onErrorSpy }) const response = await request(app) .post('/error-test') .expect(200) expect(response.body).toEqual({ success: false, code: 1, msg: 'String error' }) expect(onErrorSpy).toHaveBeenCalledTimes(1) expect(onErrorSpy).toHaveBeenCalledWith(expect.any(Error)) }) it('should handle unknown error thrown from handler', async () => { const api = defineAPI({ path: '/error-test' }) const unknownError = { message: 'Unknown error object' } const handlerSpy = vi.fn().mockRejectedValue(unknownError) implApi({ express: app, api, handler: handlerSpy, onError: onErrorSpy }) const response = await request(app) .post('/error-test') .expect(200) expect(response.body).toEqual({ success: false, code: 1, msg: 'Unknown error' }) expect(onErrorSpy).toHaveBeenCalledTimes(1) expect(onErrorSpy).toHaveBeenCalledWith(expect.any(Error)) }) it('should provide default error handler when none provided', async () => { const api = defineAPI({ path: '/error-test' }) const handlerSpy = vi.fn().mockRejectedValue(new Error('Test error')) // Don't provide onError handler implApi({ express: app, api, handler: handlerSpy }) const response = await request(app) .post('/error-test') .expect(200) expect(response.body.success).toBe(false) // Should not throw, even without error handler }) }) describe('Path Validation', () => { it('should reject non-lowercase paths', () => { const api = defineAPI({ path: '/Users/Create' // Mixed case - should be rejected }) const handler = vi.fn() expect(() => { implApi({ express: app, api, handler }) }).toThrow('API path must be all lowercase') }) it('should accept lowercase paths', () => { const validPaths = [ '/users/create', '/api/v1/health', '/admin-panel/settings', '/data_export' ] validPaths.forEach(path => { const api = defineAPI({ path }) const handler = vi.fn() expect(() => { implApi({ express: app, api, handler }) }).not.toThrow() }) }) }) describe('Request Body Processing', () => { it('should handle missing Content-Type header', async () => { const dtoSchema = z.object({ name: z.string() }) const api = defineAPI({ path: '/test', dtoSchema }) const handlerSpy = vi.fn().mockResolvedValue(undefined) implApi({ express: app, api, handler: handlerSpy }) // Send request without Content-Type header await request(app) .post('/test') .send({ name: 'test' }) .expect(200) // Handler should still be called even without proper content-type expect(handlerSpy).toHaveBeenCalledWith({ request: expect.any(Object), response: expect.any(Object), dto: {} }) }) it('should process JSON request body correctly', async () => { const dtoSchema = z.object({ data: z.object({ nested: z.string() }), array: z.array(z.number()), optional: z.string().optional() }) const api = defineAPI({ path: '/complex', dtoSchema }) const handlerSpy = vi.fn().mockResolvedValue(undefined) implApi({ express: app, api, handler: handlerSpy }) const requestData = { data: { nested: 'value' }, array: [1, 2, 3], optional: 'included' } await request(app) .post('/complex') .send(requestData) .set('Content-Type', 'application/json') .expect(200) expect(handlerSpy).toHaveBeenCalledWith({ request: expect.any(Object), response: expect.any(Object), dto: requestData }) }) }) describe('Response Formatting', () => { it('should set correct response headers', async () => { const api = defineAPI({ path: '/headers-test' }) const handlerSpy = vi.fn().mockResolvedValue('test data') implApi({ express: app, api, handler: handlerSpy }) const response = await request(app) .post('/headers-test') .expect(200) .expect('Content-Type', /application\/json/) expect(response.body).toEqual({ success: true, data: 'test data' }) }) it('should serialize response as JSON', async () => { const api = defineAPI({ path: '/json-test' }) const complexData = { string: 'value', number: 123, boolean: true, array: [1, 'two', { three: 3 }], nested: { deep: { value: 'nested' } }, nullValue: null } const handlerSpy = vi.fn().mockResolvedValue(complexData) implApi({ express: app, api, handler: handlerSpy }) const response = await request(app) .post('/json-test') .expect(200) expect(response.body).toEqual({ success: true, data: complexData }) // Ensure it's valid JSON expect(() => JSON.parse(JSON.stringify(response.body))).not.toThrow() }) }) })