/** * Integration tests for end-to-end API communication. * Tests the complete flow from client request to server response with real HTTP communication. */ import { describe, it, expect, beforeAll, afterAll } from 'vitest' import express, { type Express } from 'express' import axios, { type AxiosInstance } from 'axios' import { z } from 'zod' import { defineAPI } from '../src/def' import { implApi } from '../src/server' import { clientOfAPI, callAPI } from '../src/client' import { APIError } from '../src/APIError' import type { Server } from 'http' describe('Integration Tests', () => { let app: Express let server: Server let axiosInstance: AxiosInstance let baseURL: string beforeAll(async () => { // Set up Express app app = express() app.use(express.json()) // Start server on random port server = app.listen(0) const address = server.address() const port = typeof address === 'object' && address ? address.port : 3000 baseURL = `http://localhost:${port}` // Create axios instance axiosInstance = axios.create({ baseURL, timeout: 5000 }) }) afterAll(async () => { if (server) { await new Promise((resolve) => { server.close(() => resolve()) }) } }) describe('PlainAPI Integration', () => { it('should handle PlainAPI end-to-end', async () => { // Define API const healthApi = defineAPI({ path: '/health' }) // Implement server endpoint implApi({ express: app, api: healthApi, handler: async () => { // Return undefined for PlainAPI } }) // Create client const healthClient = clientOfAPI({ api: healthApi, axios: axiosInstance }) // Make request const result = await healthClient() expect(result).toBeUndefined() }) it('should handle PlainAPI with direct callAPI', async () => { const pingApi = defineAPI({ path: '/ping' }) implApi({ express: app, api: pingApi, handler: async () => { // Plain API handler } }) const result = await callAPI({ api: pingApi, data: undefined, axios: axiosInstance }) expect(result).toBeUndefined() }) }) describe('RetAPI Integration', () => { it('should handle RetAPI end-to-end', async () => { const serverInfoSchema = z.object({ name: z.string(), version: z.string(), timestamp: z.number(), uptime: z.number() }) const serverInfoApi = defineAPI({ path: '/server-info', voSchema: serverInfoSchema }) // Implement server endpoint implApi({ express: app, api: serverInfoApi, handler: async () => { return { name: 'fuapi-test-server', version: '1.0.0', timestamp: Date.now(), uptime: process.uptime() } } }) // Create client const serverInfoClient = clientOfAPI({ api: serverInfoApi, axios: axiosInstance }) // Make request const result = await serverInfoClient() expect(result).toMatchObject({ name: 'fuapi-test-server', version: '1.0.0', timestamp: expect.any(Number), uptime: expect.any(Number) }) expect(result.name).toBe('fuapi-test-server') expect(result.version).toBe('1.0.0') expect(typeof result.timestamp).toBe('number') expect(typeof result.uptime).toBe('number') }) }) describe('ArgAPI Integration', () => { it('should handle ArgAPI end-to-end', async () => { const logEntrySchema = z.object({ level: z.enum(['debug', 'info', 'warn', 'error']), message: z.string().min(1), timestamp: z.number().optional(), metadata: z.record(z.any()).optional() }) const logApi = defineAPI({ path: '/log-entry', dtoSchema: logEntrySchema }) const receivedLogs: Array> = [] // Implement server endpoint implApi({ express: app, api: logApi, handler: async ({ dto }) => { receivedLogs.push(dto) // ArgAPI returns void } }) // Create client const logClient = clientOfAPI({ api: logApi, axios: axiosInstance }) // Make requests const logData1 = { level: 'info' as const, message: 'User logged in', timestamp: Date.now(), metadata: { userId: '123', ip: '192.168.1.1' } } const logData2 = { level: 'error' as const, message: 'Database connection failed' } await logClient(logData1) await logClient(logData2) expect(receivedLogs).toHaveLength(2) expect(receivedLogs[0]).toEqual(logData1) expect(receivedLogs[1]).toEqual(logData2) }) it('should reject invalid input data', async () => { const userSchema = z.object({ name: z.string().min(2), email: z.string().email(), age: z.number().int().min(0).max(150) }) const createUserApi = defineAPI({ path: '/create-user', dtoSchema: userSchema }) implApi({ express: app, api: createUserApi, handler: async ({ dto }) => { // This shouldn't be reached due to validation failure expect.fail('Handler should not be called with invalid data') } }) const createUserClient = clientOfAPI({ api: createUserApi, axios: axiosInstance }) // Try to create user with invalid data const invalidData = { name: 'A', // Too short email: 'invalid-email', age: -5 // Negative age } await expect(createUserClient(invalidData as any)).rejects.toThrow() }) }) describe('StdAPI Integration', () => { it('should handle StdAPI end-to-end', async () => { const getUserRequestSchema = z.object({ id: z.string().uuid() }) const userResponseSchema = z.object({ id: z.string().uuid(), name: z.string(), email: z.string().email(), role: z.enum(['user', 'admin', 'moderator']), createdAt: z.string(), isActive: z.boolean() }) const getUserApi = defineAPI({ path: '/get-user', dtoSchema: getUserRequestSchema, voSchema: userResponseSchema }) // Mock user database const mockUsers = new Map([ ['123e4567-e89b-12d3-a456-426614174000', { id: '123e4567-e89b-12d3-a456-426614174000', name: 'John Doe', email: 'john@example.com', role: 'user' as const, createdAt: '2023-01-01T00:00:00.000Z', isActive: true }], ['987fcdeb-51a2-43d1-9b87-123456789abc', { id: '987fcdeb-51a2-43d1-9b87-123456789abc', name: 'Jane Admin', email: 'jane@example.com', role: 'admin' as const, createdAt: '2023-01-02T00:00:00.000Z', isActive: true }] ]) // Implement server endpoint implApi({ express: app, api: getUserApi, handler: async ({ dto }) => { const user = mockUsers.get(dto.id) if (!user) { throw new APIError(404, 'User not found') } return user } }) // Create client const getUserClient = clientOfAPI({ api: getUserApi, axios: axiosInstance }) // Test successful request const userId = '123e4567-e89b-12d3-a456-426614174000' const result = await getUserClient({ id: userId }) expect(result).toEqual({ id: userId, name: 'John Doe', email: 'john@example.com', role: 'user', createdAt: '2023-01-01T00:00:00.000Z', isActive: true }) // Test error case const nonExistentUserId = '111e1111-e11b-11d1-a111-111111111111' await expect(getUserClient({ id: nonExistentUserId })) .rejects.toThrow(APIError) }) it('should handle complex data structures', async () => { const searchRequestSchema = z.object({ query: z.string().min(1), filters: z.object({ category: z.array(z.string()).optional(), priceRange: z.object({ min: z.number().min(0), max: z.number().min(0) }).optional(), tags: z.array(z.string()).optional() }).optional(), pagination: z.object({ page: z.number().int().min(1).default(1), limit: z.number().int().min(1).max(100).default(10) }).optional(), sortBy: z.enum(['relevance', 'price', 'date', 'rating']).default('relevance') }) const searchResponseSchema = z.object({ results: z.array(z.object({ id: z.string(), title: z.string(), description: z.string(), price: z.number(), category: z.string(), tags: z.array(z.string()), rating: z.number().min(0).max(5), createdAt: z.string() })), pagination: z.object({ page: z.number(), limit: z.number(), total: z.number(), totalPages: z.number() }), query: z.string(), executionTime: z.number() }) const searchApi = defineAPI({ path: '/search', dtoSchema: searchRequestSchema, voSchema: searchResponseSchema }) implApi({ express: app, api: searchApi, handler: async ({ dto }) => { // Simulate search logic const startTime = Date.now() const mockResults = [ { id: '1', title: 'Product 1', description: 'Description for product 1', price: 29.99, category: 'electronics', tags: ['gadget', 'popular'], rating: 4.5, createdAt: '2023-01-01T00:00:00.000Z' }, { id: '2', title: 'Product 2', description: 'Description for product 2', price: 49.99, category: 'electronics', tags: ['premium', 'new'], rating: 4.8, createdAt: '2023-01-02T00:00:00.000Z' } ] return { results: mockResults, pagination: { page: dto.pagination?.page ?? 1, limit: dto.pagination?.limit ?? 10, total: mockResults.length, totalPages: 1 }, query: dto.query, executionTime: Date.now() - startTime } } }) const searchClient = clientOfAPI({ api: searchApi, axios: axiosInstance }) const searchRequest = { query: 'electronics', filters: { category: ['electronics'], priceRange: { min: 20, max: 100 }, tags: ['gadget'] }, pagination: { page: 1, limit: 20 }, sortBy: 'price' as const } const result = await searchClient(searchRequest) expect(result.results).toHaveLength(2) expect(result.query).toBe('electronics') expect(result.pagination.page).toBe(1) expect(result.pagination.limit).toBe(20) expect(result.pagination.total).toBe(2) expect(typeof result.executionTime).toBe('number') expect(result.results[0]).toMatchObject({ id: '1', title: 'Product 1', price: 29.99, category: 'electronics' }) }) }) describe('Error Handling Integration', () => { it('should propagate APIError from server to client', async () => { const errorTestApi = defineAPI({ path: '/error-test' }) implApi({ express: app, api: errorTestApi, handler: async () => { throw new APIError(422, 'Unprocessable Entity') } }) const errorTestClient = clientOfAPI({ api: errorTestApi, axios: axiosInstance }) await expect(errorTestClient()).rejects.toThrow(APIError) try { await errorTestClient() } catch (error) { expect(error).toBeInstanceOf(APIError) expect((error as APIError).code).toBe(422) expect((error as APIError).message).toBe('Unprocessable Entity') } }) it('should handle server validation errors', async () => { const validationTestSchema = z.object({ value: z.number().positive() }) const validationTestApi = defineAPI({ path: '/validation-test', dtoSchema: validationTestSchema }) implApi({ express: app, api: validationTestApi, handler: async ({ dto }) => { // This handler should not be reached due to validation failure } }) const validationTestClient = clientOfAPI({ api: validationTestApi, axios: axiosInstance }) // The client-side validation should catch this before sending await expect(validationTestClient({ value: -1 } as any)) .rejects.toThrow() }) it('should handle network timeouts', async () => { const timeoutApi = defineAPI({ path: '/timeout-test' }) implApi({ express: app, api: timeoutApi, handler: async () => { // Simulate slow operation await new Promise(resolve => setTimeout(resolve, 100)) return 'completed' } }) const fastTimeoutClient = clientOfAPI({ api: timeoutApi, axios: axiosInstance, requestConfig: { timeout: 10 // Very short timeout } }) // This might be flaky depending on system performance // In a real test, you'd want a more reliable way to test timeouts await expect(fastTimeoutClient()).rejects.toThrow() }) }) describe('Type Safety Integration', () => { it('should maintain type safety across client-server boundary', async () => { // This test demonstrates compile-time type safety // The actual assertions are at compile time, not runtime const typedApi = defineAPI({ path: '/typed-api', dtoSchema: z.object({ stringField: z.string(), numberField: z.number(), booleanField: z.boolean(), arrayField: z.array(z.string()), nestedField: z.object({ subField: z.string() }) }), voSchema: z.object({ id: z.string(), processed: z.boolean(), results: z.array(z.number()) }) }) implApi({ express: app, api: typedApi, handler: async ({ dto }) => { // TypeScript should infer correct types for dto const stringValue: string = dto.stringField const numberValue: number = dto.numberField const booleanValue: boolean = dto.booleanField const arrayValue: string[] = dto.arrayField const nestedValue: string = dto.nestedField.subField // Return properly typed response return { id: 'processed-' + stringValue, processed: true, results: [numberValue * 2, numberValue * 3] } } }) const typedClient = clientOfAPI({ api: typedApi, axios: axiosInstance }) const requestData = { stringField: 'test', numberField: 42, booleanField: true, arrayField: ['a', 'b', 'c'], nestedField: { subField: 'nested-value' } } const result = await typedClient(requestData) // TypeScript should infer correct return type const id: string = result.id const processed: boolean = result.processed const results: number[] = result.results expect(result).toEqual({ id: 'processed-test', processed: true, results: [84, 126] }) }) }) })