test(frontend): raise overall statement coverage to 30.42% for Phase 1 MVP

This commit is contained in:
2026-06-13 22:33:11 +07:00
parent 190b9a3af5
commit 9c5df0abdb
37 changed files with 6128 additions and 24 deletions
+272
View File
@@ -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');
});
});
});