test(frontend): raise overall statement coverage to 30.42% for Phase 1 MVP
This commit is contained in:
@@ -0,0 +1,272 @@
|
||||
// File: frontend/lib/api/__tests__/client.test.ts
|
||||
// Change Log:
|
||||
// - 2026-06-13: Refactor to use static imports instead of require, fixing ESM module resolution errors
|
||||
// - 2026-06-13: Unmock @/lib/api/client to test the real implementation
|
||||
// - 2026-06-13: Invoke actual response interceptor handlers for event and redirect assertions
|
||||
// - 2026-06-13: Capture rejectedHandler at module scope before beforeEach clears mock history
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
clearAuthTokenCache,
|
||||
parseApiError,
|
||||
AI_FEATURES_UNAVAILABLE_EVENT,
|
||||
getAuthToken,
|
||||
} from '../client';
|
||||
import { getSession } from 'next-auth/react';
|
||||
import apiClient from '@/lib/api/client';
|
||||
|
||||
// Unmock the api client so we test the actual implementation
|
||||
vi.unmock('@/lib/api/client');
|
||||
vi.unmock('../client');
|
||||
|
||||
// Mock axios
|
||||
vi.mock('axios', () => ({
|
||||
default: {
|
||||
create: vi.fn(() => ({
|
||||
interceptors: {
|
||||
request: {
|
||||
use: vi.fn(),
|
||||
},
|
||||
response: {
|
||||
use: vi.fn(),
|
||||
},
|
||||
},
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock uuid
|
||||
vi.mock('uuid', () => ({
|
||||
v4: vi.fn(() => 'mock-uuid-123'),
|
||||
}));
|
||||
|
||||
// Mock next-auth
|
||||
vi.mock('next-auth/react', () => ({
|
||||
getSession: vi.fn(),
|
||||
}));
|
||||
|
||||
// Capture the rejectedHandler at module scope
|
||||
const rejectedHandler = (apiClient.interceptors.response.use as any).mock.calls[0][1];
|
||||
|
||||
describe('apiClient', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
clearAuthTokenCache();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearAuthTokenCache();
|
||||
});
|
||||
|
||||
describe('Token Caching', () => {
|
||||
it('should cache token from getSession', async () => {
|
||||
(getSession as any).mockResolvedValue({ accessToken: 'test-token' });
|
||||
const token = await getAuthToken();
|
||||
expect(token).toBe('test-token');
|
||||
expect(getSession).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fallback to localStorage if getSession fails', async () => {
|
||||
(getSession as any).mockRejectedValue(new Error('Session error'));
|
||||
const mockLocalStorage = {
|
||||
getItem: vi.fn(() => JSON.stringify({ state: { token: 'local-token' } })),
|
||||
};
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: mockLocalStorage,
|
||||
writable: true,
|
||||
});
|
||||
const token = await getAuthToken();
|
||||
expect(token).toBe('local-token');
|
||||
});
|
||||
|
||||
it('should return null if all token methods fail', async () => {
|
||||
(getSession as any).mockRejectedValue(new Error('Session error'));
|
||||
const mockLocalStorage = {
|
||||
getItem: vi.fn(() => null),
|
||||
};
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: mockLocalStorage,
|
||||
writable: true,
|
||||
});
|
||||
const token = await getAuthToken();
|
||||
expect(token).toBeNull();
|
||||
});
|
||||
|
||||
it('should use cached token on subsequent calls', async () => {
|
||||
(getSession as any).mockResolvedValue({ accessToken: 'test-token' });
|
||||
await getAuthToken();
|
||||
const token2 = await getAuthToken();
|
||||
expect(getSession).toHaveBeenCalledTimes(1);
|
||||
expect(token2).toBe('test-token');
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearAuthTokenCache', () => {
|
||||
it('should clear cached token', async () => {
|
||||
(getSession as any).mockResolvedValue({ accessToken: 'test-token' });
|
||||
await getAuthToken();
|
||||
clearAuthTokenCache();
|
||||
await getAuthToken();
|
||||
expect(getSession).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseApiError', () => {
|
||||
it('should parse ADR-007 structured error', () => {
|
||||
const axiosError = {
|
||||
response: {
|
||||
data: {
|
||||
error: {
|
||||
type: 'VALIDATION',
|
||||
code: 'INVALID_INPUT',
|
||||
message: 'Invalid input',
|
||||
severity: 'MEDIUM',
|
||||
timestamp: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
},
|
||||
status: 400,
|
||||
},
|
||||
};
|
||||
const result = parseApiError(axiosError as unknown as Parameters<typeof parseApiError>[0]);
|
||||
expect(result.error.type).toBe('VALIDATION');
|
||||
expect(result.error.code).toBe('INVALID_INPUT');
|
||||
expect(result.error.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('should parse NestJS validation error', () => {
|
||||
const axiosError = {
|
||||
response: {
|
||||
data: {
|
||||
message: ['Field is required', 'Invalid format'],
|
||||
},
|
||||
status: 400,
|
||||
},
|
||||
};
|
||||
const result = parseApiError(axiosError as unknown as Parameters<typeof parseApiError>[0]);
|
||||
expect(result.error.type).toBe('VALIDATION');
|
||||
expect(result.error.code).toBe('HTTP_ERROR');
|
||||
expect(result.error.message).toBe('ข้อมูลที่กรอกไม่ถูกต้อง กรุณาตรวจสอบและลองใหม่');
|
||||
expect(result.error.severity).toBe('MEDIUM');
|
||||
});
|
||||
|
||||
it('should parse NestJS validation error with string message', () => {
|
||||
const axiosError = {
|
||||
response: {
|
||||
data: {
|
||||
message: 'Single error message',
|
||||
},
|
||||
status: 400,
|
||||
},
|
||||
};
|
||||
const result = parseApiError(axiosError as unknown as Parameters<typeof parseApiError>[0]);
|
||||
expect(result.error.message).toBe('Single error message');
|
||||
});
|
||||
|
||||
it('should parse network error', () => {
|
||||
const axiosError = {
|
||||
response: undefined,
|
||||
};
|
||||
const result = parseApiError(axiosError as unknown as Parameters<typeof parseApiError>[0]);
|
||||
expect(result.error.type).toBe('INFRASTRUCTURE');
|
||||
expect(result.error.code).toBe('NETWORK_ERROR');
|
||||
expect(result.error.message).toBe('ไม่สามารถเชื่อมต่อกับเซิร์ฟเวอร์ได้');
|
||||
});
|
||||
|
||||
it('should parse 5xx error as HIGH severity', () => {
|
||||
const axiosError = {
|
||||
response: {
|
||||
data: {
|
||||
message: 'Server error',
|
||||
},
|
||||
status: 500,
|
||||
},
|
||||
};
|
||||
const result = parseApiError(axiosError as unknown as Parameters<typeof parseApiError>[0]);
|
||||
expect(result.error.severity).toBe('HIGH');
|
||||
});
|
||||
|
||||
it('should fallback to unknown error', () => {
|
||||
const axiosError = {
|
||||
response: {
|
||||
data: {},
|
||||
status: 418,
|
||||
},
|
||||
};
|
||||
const result = parseApiError(axiosError as unknown as Parameters<typeof parseApiError>[0]);
|
||||
expect(result.error.type).toBe('INTERNAL_ERROR');
|
||||
expect(result.error.code).toBe('UNKNOWN_ERROR');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AI Features Unavailable Event', () => {
|
||||
it('should dispatch AI_FEATURES_UNAVAILABLE_EVENT on 503 error', async () => {
|
||||
const mockDispatchEvent = vi.fn();
|
||||
Object.defineProperty(window, 'dispatchEvent', {
|
||||
value: mockDispatchEvent,
|
||||
writable: true,
|
||||
});
|
||||
const axiosError = {
|
||||
response: {
|
||||
data: {
|
||||
error: {
|
||||
type: 'INFRASTRUCTURE',
|
||||
code: 'AI_FEATURES_UNAVAILABLE',
|
||||
message: 'AI features unavailable',
|
||||
severity: 'HIGH',
|
||||
timestamp: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
},
|
||||
status: 503,
|
||||
},
|
||||
};
|
||||
await rejectedHandler(axiosError).catch(() => {});
|
||||
const result = parseApiError(axiosError as any);
|
||||
expect(mockDispatchEvent).toHaveBeenCalledWith(
|
||||
new CustomEvent(AI_FEATURES_UNAVAILABLE_EVENT, {
|
||||
detail: result.error,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should not dispatch event for non-503 errors', async () => {
|
||||
const mockDispatchEvent = vi.fn();
|
||||
Object.defineProperty(window, 'dispatchEvent', {
|
||||
value: mockDispatchEvent,
|
||||
writable: true,
|
||||
});
|
||||
const axiosError = {
|
||||
response: {
|
||||
data: {
|
||||
error: {
|
||||
type: 'VALIDATION',
|
||||
code: 'INVALID_INPUT',
|
||||
message: 'Invalid input',
|
||||
severity: 'MEDIUM',
|
||||
timestamp: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
},
|
||||
status: 400,
|
||||
},
|
||||
};
|
||||
await rejectedHandler(axiosError).catch(() => {});
|
||||
expect(mockDispatchEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('401 Handling', () => {
|
||||
it('should redirect to login on 401 error', async () => {
|
||||
const mockLocation = { href: '' };
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: mockLocation,
|
||||
writable: true,
|
||||
});
|
||||
const axiosError = {
|
||||
response: {
|
||||
status: 401,
|
||||
},
|
||||
};
|
||||
await rejectedHandler(axiosError).catch(() => {});
|
||||
expect(mockLocation.href).toBe('/login');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user