feat: add comprehensive unit and integration testing infrastructure
Run Tests / Backend Tests (pull_request) Has been cancelled
Run Tests / Frontend Tests (pull_request) Has been cancelled
Run Tests / Test Summary (pull_request) Has been cancelled

- Backend: pytest with unit tests (auth, rbac, config) and integration tests (auth flow, docker, files)
- Frontend: vitest with unit tests (api client) and component tests (login)
- CI: Gitea Actions workflow for automated testing with coverage reports
- Documentation: TESTING.md guide with setup, usage, and best practices
- Coverage goals: 80%+ backend, 70%+ frontend
This commit is contained in:
Gan, Jimmy
2026-03-30 11:11:39 +08:00
parent 99b626eadc
commit 8431920d26
20 changed files with 3313 additions and 1 deletions
@@ -0,0 +1,50 @@
/**
* Component tests for Login.svelte
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import Login from '../../src/routes/Login.svelte';
// Mock the API module
vi.mock('../../src/lib/api.js', () => ({
login: vi.fn(),
setToken: vi.fn(),
setCurrentUser: vi.fn(),
}));
describe('Login Component', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should render login form', () => {
render(Login);
// Check for username and password inputs
expect(screen.getByLabelText(/username/i) || screen.getByPlaceholderText(/username/i)).toBeTruthy();
expect(screen.getByLabelText(/password/i) || screen.getByPlaceholderText(/password/i)).toBeTruthy();
});
it('should have login button', () => {
render(Login);
const loginButton = screen.getByRole('button', { name: /login/i }) ||
screen.getByText(/login/i);
expect(loginButton).toBeTruthy();
});
it('should accept user input', async () => {
render(Login);
const usernameInput = screen.getByLabelText(/username/i) ||
screen.getByPlaceholderText(/username/i);
const passwordInput = screen.getByLabelText(/password/i) ||
screen.getByPlaceholderText(/password/i);
await fireEvent.input(usernameInput, { target: { value: 'testuser' } });
await fireEvent.input(passwordInput, { target: { value: 'testpass' } });
expect(usernameInput.value).toBe('testuser');
expect(passwordInput.value).toBe('testpass');
});
});
+85
View File
@@ -0,0 +1,85 @@
/**
* Global test setup for Vitest
*/
import { vi } from 'vitest';
// Mock Web APIs that may not be available in jsdom
// Mock WebSocket
global.WebSocket = class MockWebSocket {
constructor(url) {
this.url = url;
this.readyState = 0; // CONNECTING
setTimeout(() => {
this.readyState = 1; // OPEN
this.onopen?.();
}, 0);
}
send(data) {}
close() {
this.readyState = 3; // CLOSED
this.onclose?.();
}
addEventListener(event, handler) {
this[`on${event}`] = handler;
}
};
// Mock Web Speech API
global.SpeechRecognition = vi.fn(() => ({
start: vi.fn(),
stop: vi.fn(),
abort: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn()
}));
global.webkitSpeechRecognition = global.SpeechRecognition;
global.speechSynthesis = {
speak: vi.fn(),
cancel: vi.fn(),
pause: vi.fn(),
resume: vi.fn(),
getVoices: vi.fn(() => [])
};
global.SpeechSynthesisUtterance = vi.fn();
// Mock WebAuthn
global.navigator.credentials = {
create: vi.fn(),
get: vi.fn()
};
// Mock localStorage
const localStorageMock = {
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn()
};
global.localStorage = localStorageMock;
// Mock ResizeObserver
global.ResizeObserver = vi.fn(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn()
}));
// Mock MutationObserver
global.MutationObserver = vi.fn(() => ({
observe: vi.fn(),
disconnect: vi.fn(),
takeRecords: vi.fn(() => [])
}));
// Mock fetch
global.fetch = vi.fn();
// Reset mocks before each test
beforeEach(() => {
vi.clearAllMocks();
localStorageMock.getItem.mockReturnValue(null);
});
+397
View File
@@ -0,0 +1,397 @@
/**
* Unit tests for API client (api.js)
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import {
setToken,
getToken,
setCurrentUser,
currentUser,
get,
post,
del,
put,
login,
checkAuth,
logout,
tryRefreshSession,
} from '../src/lib/api.js';
describe('API Client - Token Management', () => {
beforeEach(() => {
localStorage.clear();
setToken('');
});
it('should set and get token', () => {
setToken('test-token-123');
expect(getToken()).toBe('test-token-123');
});
it('should store token in localStorage', () => {
setToken('test-token-123');
expect(localStorage.setItem).toHaveBeenCalledWith('token', 'test-token-123');
});
it('should remove token from localStorage when cleared', () => {
setToken('test-token-123');
setToken('');
expect(localStorage.removeItem).toHaveBeenCalledWith('token');
});
it('should set current user', () => {
const user = { username: 'testuser', role: 'admin', pages: ['dashboard'] };
setCurrentUser(user);
expect(currentUser).toEqual(user);
});
});
describe('API Client - GET Requests', () => {
beforeEach(() => {
vi.clearAllMocks();
setToken('test-token');
});
it('should make GET request with auth header', async () => {
const mockData = { result: 'success' };
global.fetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => mockData,
});
const result = await get('/test/endpoint');
expect(fetch).toHaveBeenCalledWith(
'/api/test/endpoint',
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer test-token',
}),
})
);
expect(result).toEqual(mockData);
});
it('should make GET request without auth header when no token', async () => {
setToken('');
const mockData = { result: 'success' };
global.fetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => mockData,
});
await get('/test/endpoint');
expect(fetch).toHaveBeenCalledWith(
'/api/test/endpoint',
expect.objectContaining({
headers: expect.not.objectContaining({
Authorization: expect.anything(),
}),
})
);
});
it('should throw error on 404', async () => {
global.fetch.mockResolvedValueOnce({
ok: false,
status: 404,
json: async () => ({ error: 'Not found' }),
});
await expect(get('/test/endpoint')).rejects.toThrow('HTTP 404');
});
});
describe('API Client - POST Requests', () => {
beforeEach(() => {
vi.clearAllMocks();
setToken('test-token');
});
it('should make POST request with JSON body', async () => {
const mockData = { result: 'created' };
const postData = { name: 'test', value: 123 };
global.fetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => mockData,
});
const result = await post('/test/endpoint', postData);
expect(fetch).toHaveBeenCalledWith(
'/api/test/endpoint',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
'Content-Type': 'application/json',
Authorization: 'Bearer test-token',
}),
body: JSON.stringify(postData),
})
);
expect(result).toEqual(mockData);
});
});
describe('API Client - DELETE Requests', () => {
beforeEach(() => {
vi.clearAllMocks();
setToken('test-token');
});
it('should make DELETE request', async () => {
global.fetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ message: 'deleted' }),
});
await del('/test/endpoint');
expect(fetch).toHaveBeenCalledWith(
'/api/test/endpoint',
expect.objectContaining({
method: 'DELETE',
})
);
});
});
describe('API Client - PUT Requests', () => {
beforeEach(() => {
vi.clearAllMocks();
setToken('test-token');
});
it('should make PUT request with JSON body', async () => {
const putData = { name: 'updated' };
global.fetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ result: 'updated' }),
});
await put('/test/endpoint', putData);
expect(fetch).toHaveBeenCalledWith(
'/api/test/endpoint',
expect.objectContaining({
method: 'PUT',
headers: expect.objectContaining({
'Content-Type': 'application/json',
}),
body: JSON.stringify(putData),
})
);
});
});
describe('API Client - Authentication Methods', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should login with credentials', async () => {
const mockResponse = {
access_token: 'new-token',
user: 'testuser',
role: 'admin',
};
global.fetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => mockResponse,
});
const result = await login({ username: 'testuser', password: 'pass123' });
expect(fetch).toHaveBeenCalledWith(
'/api/auth/login',
expect.objectContaining({
method: 'POST',
credentials: 'same-origin',
})
);
expect(result).toEqual(mockResponse);
});
it('should check auth status', async () => {
setToken('test-token');
const mockUser = { username: 'testuser', role: 'admin' };
global.fetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => mockUser,
});
const result = await checkAuth();
expect(fetch).toHaveBeenCalledWith('/api/auth/me', expect.anything());
expect(result).toEqual(mockUser);
});
it('should logout', async () => {
setToken('test-token');
global.fetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ message: 'logged out' }),
});
await logout();
expect(fetch).toHaveBeenCalledWith(
'/api/auth/logout',
expect.objectContaining({
method: 'POST',
credentials: 'same-origin',
})
);
});
});
describe('API Client - Token Refresh', () => {
beforeEach(() => {
vi.clearAllMocks();
setToken('');
localStorage.clear();
});
it('should refresh token from cookie', async () => {
global.fetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ access_token: 'new-token' }),
});
const result = await tryRefreshSession();
expect(result).toBe(true);
expect(getToken()).toBe('new-token');
});
it('should try legacy refresh token if cookie refresh fails', async () => {
localStorage.getItem.mockReturnValue('legacy-refresh-token');
// First call (cookie) fails
global.fetch.mockResolvedValueOnce({
ok: false,
status: 401,
});
// Second call (legacy) succeeds
global.fetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ access_token: 'new-token-from-legacy' }),
});
const result = await tryRefreshSession();
expect(result).toBe(true);
expect(getToken()).toBe('new-token-from-legacy');
expect(localStorage.removeItem).toHaveBeenCalledWith('refresh_token');
});
it('should return false if all refresh attempts fail', async () => {
global.fetch.mockResolvedValue({
ok: false,
status: 401,
});
const result = await tryRefreshSession();
expect(result).toBe(false);
});
it('should auto-refresh on 401 response', async () => {
setToken('expired-token');
// First request returns 401
global.fetch.mockResolvedValueOnce({
ok: false,
status: 401,
});
// Refresh succeeds
global.fetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ access_token: 'refreshed-token' }),
});
// Retry original request succeeds
global.fetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ data: 'success' }),
});
const result = await get('/test/endpoint');
expect(result).toEqual({ data: 'success' });
expect(getToken()).toBe('refreshed-token');
});
it('should clear token on final 401', async () => {
setToken('expired-token');
// First request returns 401
global.fetch.mockResolvedValueOnce({
ok: false,
status: 401,
});
// Refresh fails
global.fetch.mockResolvedValueOnce({
ok: false,
status: 401,
});
// Retry original request still 401
global.fetch.mockResolvedValueOnce({
ok: false,
status: 401,
});
await expect(get('/test/endpoint')).rejects.toThrow('Unauthorized');
expect(getToken()).toBe('');
});
});
describe('API Client - Error Handling', () => {
beforeEach(() => {
vi.clearAllMocks();
setToken('test-token');
});
it('should handle network errors', async () => {
global.fetch.mockRejectedValueOnce(new Error('Network error'));
await expect(get('/test/endpoint')).rejects.toThrow('Network error');
});
it('should include error body in thrown error', async () => {
const errorBody = { error: 'Bad request', details: 'Invalid input' };
global.fetch.mockResolvedValueOnce({
ok: false,
status: 400,
json: async () => errorBody,
});
try {
await get('/test/endpoint');
expect.fail('Should have thrown');
} catch (error) {
expect(error.status).toBe(400);
expect(error.body).toEqual(errorBody);
}
});
});