![google-labs-jules[bot]](/assets/img/avatar_default.png)
This commit introduces a suite of unit tests for the Vue.js frontend, significantly improving code coverage and reliability. Key areas covered: - **Setup**: Configured Vitest and @vue/test-utils. - **Core UI Components**: Added tests for SocialLoginButtons and NotificationDisplay. - **Pinia Stores**: Implemented tests for auth, notifications, and offline stores, including detailed testing of actions, getters, and state management. Offline store tests were adapted to its event-driven design. - **Services**: - `api.ts`: Tested Axios client config, interceptors (auth token refresh), and wrapper methods. - `choreService.ts` & `groupService.ts`: Tested all existing service functions for CRUD operations, mocking API interactions. - **Pages**: - `AccountPage.vue`: Tested rendering, data fetching, form submissions (profile, password, preferences), and error handling. - `ChoresPage.vue`: Tested rendering, chore display (personal & grouped), CRUD modals, and state handling (loading, error, empty). - `LoginPage.vue`: Verified existing comprehensive tests. These tests provide a solid foundation for frontend testing. The next planned step is to enhance E2E tests using Playwright.
292 lines
13 KiB
TypeScript
292 lines
13 KiB
TypeScript
import axios, { type AxiosInstance, type AxiosRequestConfig, type InternalAxiosRequestConfig } from 'axios';
|
|
import { apiClient, api as configuredApiInstance, API_ENDPOINTS } from '../api'; // Adjust path
|
|
import { API_BASE_URL as MOCK_API_BASE_URL } from '@/config/api-config';
|
|
import { useAuthStore } from '@/stores/auth';
|
|
import router from '@/router';
|
|
|
|
// --- Mocks ---
|
|
vi.mock('axios', async (importOriginal) => {
|
|
const actualAxios = await importOriginal<typeof axios>();
|
|
return {
|
|
...actualAxios, // Spread actual axios to get CancelToken, isCancel, etc.
|
|
create: vi.fn(), // Mock axios.create
|
|
// Mocking static methods if needed by the module under test, though not directly by api.ts
|
|
// get: vi.fn(),
|
|
// post: vi.fn(),
|
|
};
|
|
});
|
|
|
|
vi.mock('@/stores/auth', () => ({
|
|
useAuthStore: vi.fn(() => ({
|
|
refreshToken: null,
|
|
setTokens: vi.fn(),
|
|
clearTokens: vi.fn(),
|
|
})),
|
|
}));
|
|
|
|
vi.mock('@/router', () => ({
|
|
default: {
|
|
push: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
// Mock localStorage
|
|
const localStorageMock = (() => {
|
|
let store: Record<string, string> = {};
|
|
return {
|
|
getItem: (key: string) => store[key] || null,
|
|
setItem: (key: string, value: string) => { store[key] = value.toString(); },
|
|
removeItem: (key: string) => { delete store[key]; },
|
|
clear: () => { store = {}; },
|
|
};
|
|
})();
|
|
Object.defineProperty(window, 'localStorage', { value: localStorageMock });
|
|
|
|
|
|
// --- Test Suite ---
|
|
describe('API Service (api.ts)', () => {
|
|
let mockAxiosInstance: Partial<AxiosInstance>;
|
|
let requestInterceptor: (config: InternalAxiosRequestConfig) => InternalAxiosRequestConfig | Promise<InternalAxiosRequestConfig>;
|
|
let responseInterceptorSuccess: (response: any) => any;
|
|
let responseInterceptorError: (error: any) => Promise<any>;
|
|
let mockAuthStore: ReturnType<typeof useAuthStore>;
|
|
|
|
beforeEach(() => {
|
|
// Reset all mocks
|
|
vi.clearAllMocks();
|
|
localStorageMock.clear();
|
|
|
|
// Setup mock axios instance that axios.create will return
|
|
mockAxiosInstance = {
|
|
get: vi.fn(),
|
|
post: vi.fn(),
|
|
put: vi.fn(),
|
|
patch: vi.fn(),
|
|
delete: vi.fn(),
|
|
interceptors: {
|
|
request: { use: vi.fn((successCallback) => { requestInterceptor = successCallback; }) },
|
|
response: { use: vi.fn((successCallback, errorCallback) => {
|
|
responseInterceptorSuccess = successCallback;
|
|
responseInterceptorError = errorCallback;
|
|
})},
|
|
},
|
|
defaults: { headers: { common: {} } } as any, // Mock defaults if accessed
|
|
};
|
|
(axios.create as vi.Mock).mockReturnValue(mockAxiosInstance);
|
|
|
|
// Re-evaluate api.ts by importing it or re-triggering its setup
|
|
// This is tricky as modules are cached. For simplicity, we assume the interceptors
|
|
// are captured correctly when api.ts was first imported by the test runner.
|
|
// The configuredApiInstance is the one created in api.ts
|
|
// We need to ensure our mockAxiosInstance is what it uses.
|
|
// The `api` export from `../api` is the axios instance.
|
|
// We can't easily swap it post-import if api.ts doesn't export a factory.
|
|
// However, by mocking axios.create before api.ts is first processed by Jest/Vitest,
|
|
// configuredApiInstance *should* be our mockAxiosInstance.
|
|
|
|
mockAuthStore = useAuthStore();
|
|
(useAuthStore as vi.Mock).mockReturnValue(mockAuthStore); // Ensure this instance is used
|
|
|
|
// Manually call the interceptor setup functions from api.ts if they were exported
|
|
// Or, rely on the fact that they were called when api.ts was imported.
|
|
// The interceptors are set up on `configuredApiInstance` which should be `mockAxiosInstance`.
|
|
// So, `requestInterceptor` and `responseInterceptorError` should be populated.
|
|
// This part is a bit of a dance with module imports.
|
|
// To be absolutely sure, `api.ts` could export its setup functions or be more DI-friendly.
|
|
// For now, we assume the interceptors on `mockAxiosInstance` got registered.
|
|
});
|
|
|
|
describe('Axios Instance Configuration', () => {
|
|
it('should create an axios instance with correct baseURL and default headers', () => {
|
|
// This test relies on axios.create being called when api.ts is imported.
|
|
// The call to axios.create() happens when api.ts is loaded.
|
|
expect(axios.create).toHaveBeenCalledWith({
|
|
baseURL: MOCK_API_BASE_URL,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
withCredentials: true,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Request Interceptor', () => {
|
|
it('should add Authorization header if token exists in localStorage', () => {
|
|
localStorageMock.setItem('token', 'test-token');
|
|
const config: InternalAxiosRequestConfig = { headers: {} } as InternalAxiosRequestConfig;
|
|
|
|
// configuredApiInstance is the instance from api.ts, which should have the interceptor
|
|
// We need to ensure our mockAxiosInstance.interceptors.request.use captured the callback
|
|
// Then we call it manually.
|
|
const processedConfig = requestInterceptor(config);
|
|
|
|
expect(processedConfig.headers.Authorization).toBe('Bearer test-token');
|
|
});
|
|
|
|
it('should not add Authorization header if token does not exist', () => {
|
|
const config: InternalAxiosRequestConfig = { headers: {} } as InternalAxiosRequestConfig;
|
|
const processedConfig = requestInterceptor(config);
|
|
expect(processedConfig.headers.Authorization).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('Response Interceptor (401 Refresh Logic)', () => {
|
|
const originalRequestConfig: InternalAxiosRequestConfig = { headers: {}, _retry: false } as InternalAxiosRequestConfig;
|
|
|
|
it('successful token refresh and retry', async () => {
|
|
localStorageMock.setItem('token', 'old-token'); // For the initial failed request
|
|
mockAuthStore.refreshToken = 'valid-refresh-token';
|
|
const error = { config: originalRequestConfig, response: { status: 401 } };
|
|
|
|
(mockAxiosInstance.post as vi.Mock).mockResolvedValueOnce({ // for refresh call
|
|
data: { access_token: 'new-access-token', refresh_token: 'new-refresh-token' },
|
|
});
|
|
(mockAxiosInstance as any).mockResolvedValueOnce({ data: 'retried request data' }); // for retried original request
|
|
|
|
const result = await responseInterceptorError(error);
|
|
|
|
expect(mockAxiosInstance.post).toHaveBeenCalledWith('/auth/jwt/refresh', { refresh_token: 'valid-refresh-token' });
|
|
expect(mockAuthStore.setTokens).toHaveBeenCalledWith({ access_token: 'new-access-token', refresh_token: 'new-refresh-token' });
|
|
expect(originalRequestConfig.headers.Authorization).toBe('Bearer new-access-token');
|
|
expect(result.data).toBe('retried request data');
|
|
});
|
|
|
|
it('failed token refresh redirects to login', async () => {
|
|
mockAuthStore.refreshToken = 'valid-refresh-token';
|
|
const error = { config: originalRequestConfig, response: { status: 401 } };
|
|
const refreshError = new Error('Refresh failed');
|
|
(mockAxiosInstance.post as vi.Mock).mockRejectedValueOnce(refreshError); // refresh call fails
|
|
|
|
await expect(responseInterceptorError(error)).rejects.toThrow('Refresh failed');
|
|
|
|
expect(mockAxiosInstance.post).toHaveBeenCalledWith('/auth/jwt/refresh', { refresh_token: 'valid-refresh-token' });
|
|
expect(mockAuthStore.clearTokens).toHaveBeenCalled();
|
|
expect(router.push).toHaveBeenCalledWith('/auth/login');
|
|
});
|
|
|
|
it('no refresh token, redirects to login', async () => {
|
|
mockAuthStore.refreshToken = null;
|
|
const error = { config: originalRequestConfig, response: { status: 401 } };
|
|
|
|
await expect(responseInterceptorError(error)).rejects.toEqual(error); // Original error rejected
|
|
|
|
expect(mockAuthStore.clearTokens).toHaveBeenCalled();
|
|
expect(router.push).toHaveBeenCalledWith('/auth/login');
|
|
expect(mockAxiosInstance.post).not.toHaveBeenCalledWith('/auth/jwt/refresh', expect.anything());
|
|
});
|
|
|
|
it('should not retry if _retry is already true', async () => {
|
|
const error = { config: { ...originalRequestConfig, _retry: true }, response: { status: 401 } };
|
|
await expect(responseInterceptorError(error)).rejects.toEqual(error);
|
|
expect(mockAxiosInstance.post).not.toHaveBeenCalledWith('/auth/jwt/refresh', expect.anything());
|
|
});
|
|
|
|
it('passes through non-401 errors', async () => {
|
|
const error = { config: originalRequestConfig, response: { status: 500, data: 'Server Error' } };
|
|
await expect(responseInterceptorError(error)).rejects.toEqual(error);
|
|
expect(mockAxiosInstance.post).not.toHaveBeenCalledWith('/auth/jwt/refresh', expect.anything());
|
|
});
|
|
});
|
|
|
|
describe('apiClient methods', () => {
|
|
// Note: API_BASE_URL is part of getApiUrl, which prepends it.
|
|
// The mockAxiosInstance is already configured with baseURL.
|
|
// So, api.get(URL) will have URL = MOCK_API_BASE_URL + endpoint.
|
|
// However, the apiClient methods call getApiUrl(endpoint) which results in MOCK_API_BASE_URL + endpoint.
|
|
// This means the final URL passed to the mockAxiosInstance.get (etc.) will be effectively MOCK_API_BASE_URL + MOCK_API_BASE_URL + endpoint
|
|
// This is a slight duplication issue in the original api.ts's apiClient if not careful.
|
|
// For testing, we'll assume the passed endpoint to apiClient methods is relative (e.g., "/users")
|
|
// and getApiUrl correctly forms the full path once.
|
|
|
|
const testEndpoint = '/test'; // Example, will be combined with API_ENDPOINTS
|
|
const fullTestEndpoint = MOCK_API_BASE_URL + API_ENDPOINTS.AUTH.LOGIN; // Using a concrete endpoint
|
|
const responseData = { message: 'success' };
|
|
const requestData = { foo: 'bar' };
|
|
|
|
beforeEach(() => {
|
|
// Reset mockAxiosInstance calls for each apiClient method test
|
|
(mockAxiosInstance.get as vi.Mock).mockClear();
|
|
(mockAxiosInstance.post as vi.Mock).mockClear();
|
|
(mockAxiosInstance.put as vi.Mock).mockClear();
|
|
(mockAxiosInstance.patch as vi.Mock).mockClear();
|
|
(mockAxiosInstance.delete as vi.Mock).mockClear();
|
|
});
|
|
|
|
it('apiClient.get calls configuredApiInstance.get with full URL', async () => {
|
|
(mockAxiosInstance.get as vi.Mock).mockResolvedValue({ data: responseData });
|
|
const result = await apiClient.get(API_ENDPOINTS.AUTH.LOGIN);
|
|
expect(mockAxiosInstance.get).toHaveBeenCalledWith(fullTestEndpoint, undefined);
|
|
expect(result.data).toEqual(responseData);
|
|
});
|
|
|
|
it('apiClient.post calls configuredApiInstance.post with full URL and data', async () => {
|
|
(mockAxiosInstance.post as vi.Mock).mockResolvedValue({ data: responseData });
|
|
const result = await apiClient.post(API_ENDPOINTS.AUTH.LOGIN, requestData);
|
|
expect(mockAxiosInstance.post).toHaveBeenCalledWith(fullTestEndpoint, requestData, undefined);
|
|
expect(result.data).toEqual(responseData);
|
|
});
|
|
|
|
it('apiClient.put calls configuredApiInstance.put with full URL and data', async () => {
|
|
(mockAxiosInstance.put as vi.Mock).mockResolvedValue({ data: responseData });
|
|
const result = await apiClient.put(API_ENDPOINTS.AUTH.LOGIN, requestData);
|
|
expect(mockAxiosInstance.put).toHaveBeenCalledWith(fullTestEndpoint, requestData, undefined);
|
|
expect(result.data).toEqual(responseData);
|
|
});
|
|
|
|
it('apiClient.patch calls configuredApiInstance.patch with full URL and data', async () => {
|
|
(mockAxiosInstance.patch as vi.Mock).mockResolvedValue({ data: responseData });
|
|
const result = await apiClient.patch(API_ENDPOINTS.AUTH.LOGIN, requestData);
|
|
expect(mockAxiosInstance.patch).toHaveBeenCalledWith(fullTestEndpoint, requestData, undefined);
|
|
expect(result.data).toEqual(responseData);
|
|
});
|
|
|
|
it('apiClient.delete calls configuredApiInstance.delete with full URL', async () => {
|
|
(mockAxiosInstance.delete as vi.Mock).mockResolvedValue({ data: responseData });
|
|
const result = await apiClient.delete(API_ENDPOINTS.AUTH.LOGIN);
|
|
expect(mockAxiosInstance.delete).toHaveBeenCalledWith(fullTestEndpoint, undefined);
|
|
expect(result.data).toEqual(responseData);
|
|
});
|
|
|
|
it('apiClient.get propagates errors', async () => {
|
|
const error = new Error('Network Error');
|
|
(mockAxiosInstance.get as vi.Mock).mockRejectedValue(error);
|
|
await expect(apiClient.get(API_ENDPOINTS.AUTH.LOGIN)).rejects.toThrow('Network Error');
|
|
});
|
|
});
|
|
|
|
describe('Interceptor Registration on Actual Instance', () => {
|
|
// This is more of an integration test for the interceptor setup itself.
|
|
it('should have registered interceptors on the true configuredApiInstance', () => {
|
|
// configuredApiInstance is the actual instance from api.ts
|
|
// We check if its interceptors.request.use was called (which our mock does)
|
|
// This relies on the mockAxiosInstance being the one that was used.
|
|
expect(mockAxiosInstance.interceptors?.request.use).toHaveBeenCalled();
|
|
expect(mockAxiosInstance.interceptors?.response.use).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
});
|
|
|
|
// Mock actual config values
|
|
vi.mock('@/config/api-config', () => ({
|
|
API_BASE_URL: 'http://mockapi.com/api/v1',
|
|
API_VERSION: 'v1',
|
|
API_ENDPOINTS: {
|
|
AUTH: {
|
|
LOGIN: '/auth/login/',
|
|
SIGNUP: '/auth/signup/',
|
|
REFRESH: '/auth/jwt/refresh/',
|
|
LOGOUT: '/auth/logout/',
|
|
},
|
|
USERS: {
|
|
PROFILE: '/users/me/',
|
|
// Add other user-related endpoints here
|
|
},
|
|
LISTS: {
|
|
BASE: '/lists/',
|
|
BY_ID: (id: string | number) => `/lists/${id}/`,
|
|
ITEMS: (listId: string | number) => `/lists/${listId}/items/`,
|
|
ITEM: (listId: string | number, itemId: string | number) => `/lists/${listId}/items/${itemId}/`,
|
|
}
|
|
// Add other resource endpoints here
|
|
},
|
|
}));
|