feat: add comprehensive unit and integration testing infrastructure
- 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:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user