/** * Unit tests for client-side functionality. * Tests callAPI function and clientOfAPI factory with different scenarios. */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import axios, { type AxiosInstance, type AxiosResponse, type AxiosRequestConfig } from 'axios' import { z } from 'zod' import { defineAPI } from '../src/def' import { callAPI, clientOfAPI } from '../src/client' import { APIError } from '../src/APIError' import { InvalidDTOError } from '../src/InvalidDTOError' import { InvalidVOError } from '../src/InvalidVOError' describe('Client Functionality', () => { let mockAxios: jest.Mocked beforeEach(() => { // Create a mock axios instance mockAxios = { request: vi.fn(), } as any // Reset all mocks vi.clearAllMocks() }) afterEach(() => { vi.restoreAllMocks() }) describe('callAPI function', () => { describe('PlainAPI', () => { it('should make request to PlainAPI', async () => { const api = defineAPI({ path: '/health' }) const mockResponse: AxiosResponse = { status: 200, data: { success: true, data: undefined }, statusText: 'OK', headers: {}, config: {} as any, request: {} } mockAxios.request.mockResolvedValue(mockResponse) const result = await callAPI({ api, data: undefined, axios: mockAxios }) expect(result).toBeUndefined() expect(mockAxios.request).toHaveBeenCalledTimes(1) expect(mockAxios.request).toHaveBeenCalledWith({ url: '/health', method: 'post', headers: { 'Content-Type': 'application/json' }, data: 'undefined', responseType: 'json' }) }) }) describe('ArgAPI', () => { it('should validate and send DTO data', async () => { const dtoSchema = z.object({ message: z.string().min(1), priority: z.enum(['low', 'medium', 'high']) }) const api = defineAPI({ path: '/log', dtoSchema }) const requestData = { message: 'Test message', priority: 'high' as const } const mockResponse: AxiosResponse = { status: 200, data: { success: true, data: undefined }, statusText: 'OK', headers: {}, config: {} as any, request: {} } mockAxios.request.mockResolvedValue(mockResponse) const result = await callAPI({ api, data: requestData, axios: mockAxios }) expect(result).toBeUndefined() expect(mockAxios.request).toHaveBeenCalledWith({ url: '/log', method: 'post', headers: { 'Content-Type': 'application/json' }, data: JSON.stringify(requestData), responseType: 'json' }) }) it('should throw InvalidDTOError for invalid input', async () => { const dtoSchema = z.object({ email: z.string().email(), age: z.number().min(18) }) const api = defineAPI({ path: '/user', dtoSchema }) const invalidData = { email: 'invalid-email', age: 15 } await expect(callAPI({ api, data: invalidData, axios: mockAxios })).rejects.toThrow(InvalidDTOError) // Should not make HTTP request if validation fails expect(mockAxios.request).not.toHaveBeenCalled() }) }) describe('RetAPI', () => { it('should validate response data', async () => { const voSchema = z.object({ id: z.string().uuid(), timestamp: z.number(), status: z.enum(['online', 'offline']) }) const api = defineAPI({ path: '/status', voSchema }) const responseData = { id: '123e4567-e89b-12d3-a456-426614174000', timestamp: Date.now(), status: 'online' as const } const mockResponse: AxiosResponse = { status: 200, data: { success: true, data: responseData }, statusText: 'OK', headers: {}, config: {} as any, request: {} } mockAxios.request.mockResolvedValue(mockResponse) const result = await callAPI({ api, data: undefined, axios: mockAxios }) expect(result).toEqual(responseData) }) it('should throw InvalidVOError for invalid response', async () => { const voSchema = z.object({ id: z.string().uuid(), count: z.number().int().positive() }) const api = defineAPI({ path: '/data', voSchema }) const invalidResponseData = { id: 'invalid-uuid', count: -1 } const mockResponse: AxiosResponse = { status: 200, data: { success: true, data: invalidResponseData }, statusText: 'OK', headers: {}, config: {} as any, request: {} } mockAxios.request.mockResolvedValue(mockResponse) await expect(callAPI({ api, data: undefined, axios: mockAxios })).rejects.toThrow(InvalidVOError) }) }) describe('StdAPI', () => { it('should handle both input and output validation', async () => { const dtoSchema = z.object({ userId: z.string().uuid() }) const voSchema = z.object({ id: z.string().uuid(), name: z.string(), email: z.string().email() }) const api = defineAPI({ path: '/users/get', dtoSchema, voSchema }) const requestData = { userId: '123e4567-e89b-12d3-a456-426614174000' } const responseData = { id: '123e4567-e89b-12d3-a456-426614174000', name: 'John Doe', email: 'john@example.com' } const mockResponse: AxiosResponse = { status: 200, data: { success: true, data: responseData }, statusText: 'OK', headers: {}, config: {} as any, request: {} } mockAxios.request.mockResolvedValue(mockResponse) const result = await callAPI({ api, data: requestData, axios: mockAxios }) expect(result).toEqual(responseData) expect(mockAxios.request).toHaveBeenCalledWith({ url: '/users/get', method: 'post', headers: { 'Content-Type': 'application/json' }, data: JSON.stringify(requestData), responseType: 'json' }) }) }) describe('HTTP Error Handling', () => { it('should throw APIError for non-200 status codes', async () => { const api = defineAPI({ path: '/test' }) const mockResponse: AxiosResponse = { status: 404, data: 'Not Found', statusText: 'Not Found', headers: {}, config: {} as any, request: {} } mockAxios.request.mockResolvedValue(mockResponse) await expect(callAPI({ api, data: undefined, axios: mockAxios })).rejects.toThrow(APIError) await expect(callAPI({ api, data: undefined, axios: mockAxios })).rejects.toThrow('Unknown error') }) it('should handle network errors', async () => { const api = defineAPI({ path: '/test' }) const networkError = new Error('Network Error') mockAxios.request.mockRejectedValue(networkError) await expect(callAPI({ api, data: undefined, axios: mockAxios })).rejects.toThrow('Network Error') }) }) describe('Response Format Validation', () => { it('should throw APIError for invalid response format', async () => { const api = defineAPI({ path: '/test' }) const mockResponse: AxiosResponse = { status: 200, data: { invalid: 'format' }, // Missing success field statusText: 'OK', headers: {}, config: {} as any, request: {} } mockAxios.request.mockResolvedValue(mockResponse) await expect(callAPI({ api, data: undefined, axios: mockAxios })).rejects.toThrow(APIError) await expect(callAPI({ api, data: undefined, axios: mockAxios })).rejects.toThrow('Invalid response data format') }) it('should handle failed API responses', async () => { const api = defineAPI({ path: '/test' }) const mockResponse: AxiosResponse = { status: 200, data: { success: false, code: 404, msg: 'User not found' }, statusText: 'OK', headers: {}, config: {} as any, request: {} } mockAxios.request.mockResolvedValue(mockResponse) await expect(callAPI({ api, data: undefined, axios: mockAxios })).rejects.toThrow(APIError) const error = await callAPI({ api, data: undefined, axios: mockAxios }).catch(err => err) expect(error).toBeInstanceOf(APIError) expect((error as APIError).code).toBe(404) expect((error as APIError).message).toBe('User not found') }) it('should parse string response data', async () => { const api = defineAPI({ path: '/test' }) const responseData = { success: true, data: 'test' } const mockResponse: AxiosResponse = { status: 200, data: JSON.stringify(responseData), // String response statusText: 'OK', headers: {}, config: {} as any, request: {} } mockAxios.request.mockResolvedValue(mockResponse) const result = await callAPI({ api, data: undefined, axios: mockAxios }) expect(result).toBe('test') }) }) describe('Request Configuration', () => { it('should use default axios when none provided', async () => { const api = defineAPI({ path: '/test' }) // Mock the default axios module const mockDefaultAxios = vi.fn().mockResolvedValue({ status: 200, data: { success: true, data: 'test' } }) vi.doMock('axios', () => ({ default: { request: mockDefaultAxios } })) // This test would require more setup to properly mock the default axios import // For now, we'll skip it as it's complex to test default imports in Vitest }) it('should merge custom request config', async () => { const api = defineAPI({ path: '/test' }) const customConfig: AxiosRequestConfig = { timeout: 5000, headers: { 'Custom-Header': 'custom-value' } } const mockResponse: AxiosResponse = { status: 200, data: { success: true, data: 'test' }, statusText: 'OK', headers: {}, config: {} as any, request: {} } mockAxios.request.mockResolvedValue(mockResponse) await callAPI({ api, data: undefined, axios: mockAxios, requestConfig: customConfig }) expect(mockAxios.request).toHaveBeenCalledWith({ url: '/test', method: 'post', headers: { 'Content-Type': 'application/json', 'Custom-Header': 'custom-value' }, data: 'undefined', responseType: 'json', timeout: 5000 }) }) }) }) describe('clientOfAPI factory', () => { it('should create working client for PlainAPI', async () => { const api = defineAPI({ path: '/health' }) const mockResponse: AxiosResponse = { status: 200, data: { success: true, data: undefined }, statusText: 'OK', headers: {}, config: {} as any, request: {} } mockAxios.request.mockResolvedValue(mockResponse) const client = clientOfAPI({ api, axios: mockAxios }) const result = await client() expect(result).toBeUndefined() }) it('should create working client for ArgAPI', async () => { const dtoSchema = z.object({ message: z.string() }) const api = defineAPI({ path: '/log', dtoSchema }) const mockResponse: AxiosResponse = { status: 200, data: { success: true, data: undefined }, statusText: 'OK', headers: {}, config: {} as any, request: {} } mockAxios.request.mockResolvedValue(mockResponse) const client = clientOfAPI({ api, axios: mockAxios }) const result = await client({ message: 'test' }) expect(result).toBeUndefined() }) it('should create working client for RetAPI', async () => { const voSchema = z.object({ data: z.string() }) const api = defineAPI({ path: '/data', voSchema }) const responseData = { data: 'test-data' } const mockResponse: AxiosResponse = { status: 200, data: { success: true, data: responseData }, statusText: 'OK', headers: {}, config: {} as any, request: {} } mockAxios.request.mockResolvedValue(mockResponse) const client = clientOfAPI({ api, axios: mockAxios }) const result = await client() expect(result).toEqual(responseData) }) it('should create working client for StdAPI', async () => { const dtoSchema = z.object({ id: z.string() }) const voSchema = z.object({ id: z.string(), name: z.string() }) const api = defineAPI({ path: '/users', dtoSchema, voSchema }) const responseData = { id: '123', name: 'John' } const mockResponse: AxiosResponse = { status: 200, data: { success: true, data: responseData }, statusText: 'OK', headers: {}, config: {} as any, request: {} } mockAxios.request.mockResolvedValue(mockResponse) const client = clientOfAPI({ api, axios: mockAxios }) const result = await client({ id: '123' }) expect(result).toEqual(responseData) }) it('should handle errors and call onError callback', async () => { const api = defineAPI({ path: '/error' }) const error = new APIError(500, 'Server error') mockAxios.request.mockRejectedValue(error) const onErrorSpy = vi.fn() const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) const client = clientOfAPI({ api, axios: mockAxios, onError: onErrorSpy }) await expect(client()).rejects.toThrow(APIError) expect(onErrorSpy).toHaveBeenCalledTimes(1) expect(onErrorSpy).toHaveBeenCalledWith(error) expect(consoleErrorSpy).toHaveBeenCalledWith('API client request failed:', error) consoleErrorSpy.mockRestore() }) it('should pass through request config', async () => { const api = defineAPI({ path: '/test' }) const mockResponse: AxiosResponse = { status: 200, data: { success: true, data: 'test' }, statusText: 'OK', headers: {}, config: {} as any, request: {} } mockAxios.request.mockResolvedValue(mockResponse) const requestConfig = { timeout: 3000, headers: { 'Authorization': 'Bearer token' } } const client = clientOfAPI({ api, axios: mockAxios, requestConfig }) await client() expect(mockAxios.request).toHaveBeenCalledWith( expect.objectContaining({ timeout: 3000, headers: expect.objectContaining({ 'Authorization': 'Bearer token' }) }) ) }) }) })