test(git): implement backend and frontend test coverage for git integration

This commit is contained in:
2026-03-08 11:01:46 +03:00
parent e864a9e08b
commit eb7305ecda
4 changed files with 769 additions and 4 deletions

View File

@@ -0,0 +1,209 @@
// [DEF:frontend.src.routes.settings.git.__tests__.git_settings_page_ux_test:Module]
// @RELATION: VERIFIES -> ../+page.svelte
// @PURPOSE: Test UX states and transitions for the Git Settings page
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
import GitSettingsPage from '../+page.svelte';
import { gitService } from '../../../../services/gitService';
import { addToast } from '$lib/toasts';
vi.mock('../../../../services/gitService', () => ({
gitService: {
getConfigs: vi.fn(),
createConfig: vi.fn(),
updateConfig: vi.fn(),
deleteConfig: vi.fn(),
testConnection: vi.fn(),
listGiteaRepositories: vi.fn(),
createGiteaRepository: vi.fn(),
deleteGiteaRepository: vi.fn()
}
}));
vi.mock('$lib/toasts', () => ({
addToast: vi.fn()
}));
vi.mock('$lib/i18n', () => ({
t: {
subscribe: (fn) => {
fn({
settings: {
configured_servers: 'Configured Servers',
add_git_server: 'Add Git Server',
edit_git_server: 'Edit Git Server',
display_name: 'Display Name',
server_url: 'Server URL',
personal_access_token: 'Personal Access Token',
default_repository_optional: 'Default Repository (Optional)',
default_branch: 'Default Branch',
save_configuration: 'Save Configuration',
update_configuration: 'Update Configuration',
connection_success: 'Connection successful',
connection_failed_short: 'Connection failed',
git_config_saved: 'Git configuration saved',
git_config_updated: 'Git configuration updated',
git_delete_confirm: 'Are you sure?',
git_config_deleted: 'Git configuration deleted'
},
llm: { type: 'Type', test: 'Test' },
nav: { settings_git: 'Git Settings' },
git: { no_servers_configured: 'No servers configured' },
common: { edit: 'Edit', delete: 'Delete', cancel: 'Cancel' }
});
return () => { };
}
},
_: vi.fn((key) => key)
}));
// Mock window.scrollTo
Object.defineProperty(window, 'scrollTo', { value: vi.fn(), writable: true });
describe('GitSettingsPage UX Contracts', () => {
const mockConfigs = [
{
id: 'conf-1',
name: 'Dev GitLab',
provider: 'GITLAB',
url: 'https://gitlab.com',
pat: '********',
status: 'CONNECTED'
}
];
beforeEach(() => {
vi.clearAllMocks();
// Default confirm to always accept for tests
global.confirm = vi.fn(() => true);
});
// @UX_STATE: Initial Load
it('should display configured servers on mount', async () => {
gitService.getConfigs.mockResolvedValue(mockConfigs);
render(GitSettingsPage);
await waitFor(() => {
expect(screen.getByText('Dev GitLab')).toBeTruthy();
expect(screen.getByText('GITLAB')).toBeTruthy();
expect(screen.getByText('https://gitlab.com')).toBeTruthy();
});
});
it('should show empty state when no servers configured', async () => {
gitService.getConfigs.mockResolvedValue([]);
render(GitSettingsPage);
await waitFor(() => {
expect(screen.getByText('No servers configured')).toBeTruthy();
});
});
// @UX_FEEDBACK: Connection testing feedback
it('should test connection successfully and show success toast', async () => {
gitService.getConfigs.mockResolvedValue([]);
gitService.testConnection.mockResolvedValue({ status: 'success' });
render(GitSettingsPage);
await waitFor(() => expect(screen.getByText('Add Git Server')).toBeTruthy());
// Fill form
await fireEvent.input(screen.getByLabelText('Display Name'), { target: { value: 'New Gitea' } });
await fireEvent.input(screen.getByLabelText('Server URL'), { target: { value: 'https://gitea.local' } });
await fireEvent.input(screen.getByLabelText('Personal Access Token'), { target: { value: 'token123' } });
// Click Test
await fireEvent.click(screen.getByText('Test'));
await waitFor(() => {
expect(gitService.testConnection).toHaveBeenCalledWith(expect.objectContaining({
name: 'New Gitea',
url: 'https://gitea.local',
pat: 'token123'
}));
expect(addToast).toHaveBeenCalledWith('Connection successful', 'success');
});
});
// @UX_FEEDBACK: Save configuration
it('should save configuration and show success toast', async () => {
gitService.getConfigs.mockResolvedValue([]);
gitService.createConfig.mockResolvedValue({
id: 'conf-2',
name: 'New Server',
provider: 'GITHUB',
url: 'https://github.com',
pat: '********',
status: 'CONNECTED'
});
render(GitSettingsPage);
await waitFor(() => expect(screen.getByText('Add Git Server')).toBeTruthy());
await fireEvent.input(screen.getByLabelText('Display Name'), { target: { value: 'New Server' } });
await fireEvent.click(screen.getByText('Save Configuration'));
await waitFor(() => {
expect(gitService.createConfig).toHaveBeenCalled();
expect(addToast).toHaveBeenCalledWith('Git configuration saved', 'success');
// Check that it's added to the list
expect(screen.getByText('New Server')).toBeTruthy();
});
});
// @UX_STATE: Delete configuration
it('should delete configuration upon confirmation', async () => {
gitService.getConfigs.mockResolvedValue([...mockConfigs]);
gitService.deleteConfig.mockResolvedValue({ status: 'success' });
render(GitSettingsPage);
await waitFor(() => expect(screen.getByText('Dev GitLab')).toBeTruthy());
// Click delete button
const deleteButton = screen.getByTitle('Delete');
await fireEvent.click(deleteButton);
await waitFor(() => {
expect(global.confirm).toHaveBeenCalledWith('Are you sure?');
expect(gitService.deleteConfig).toHaveBeenCalledWith('conf-1');
expect(addToast).toHaveBeenCalledWith('Git configuration deleted', 'success');
expect(screen.queryByText('Dev GitLab')).toBeNull(); // Should be removed from DOM
});
});
// @UX_STATE: Editing form state
it('should load config into form when edit is clicked', async () => {
gitService.getConfigs.mockResolvedValue([...mockConfigs]);
render(GitSettingsPage);
await waitFor(() => expect(screen.getByText('Dev GitLab')).toBeTruthy());
// Click edit button
const editButton = screen.getByTitle('Edit');
await fireEvent.click(editButton);
await waitFor(() => {
expect(screen.getByText('Edit Git Server')).toBeTruthy();
const nameInput = screen.getByLabelText('Display Name');
expect(nameInput.value).toBe('Dev GitLab');
});
// Save changes
gitService.updateConfig.mockResolvedValue({
...mockConfigs[0],
name: 'Updated GitLab'
});
await fireEvent.input(screen.getByLabelText('Display Name'), { target: { value: 'Updated GitLab' } });
await fireEvent.click(screen.getByText('Update Configuration'));
await waitFor(() => {
expect(gitService.updateConfig).toHaveBeenCalledWith('conf-1', expect.objectContaining({
name: 'Updated GitLab'
}));
expect(screen.getByText('Updated GitLab')).toBeTruthy();
});
});
});
// [/DEF:frontend.src.routes.settings.git.__tests__.git_settings_page_ux_test:Module]

View File

@@ -0,0 +1,245 @@
// [DEF:frontend.src.services.__tests__.gitService_test:Module]
// @RELATION: VERIFIES -> ../gitService.js
// @PURPOSE: API client tests ensuring correct endpoints are called per contract
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { gitService } from '../gitService';
import { requestApi } from '../../lib/api';
vi.mock('../../lib/api', () => ({
requestApi: vi.fn()
}));
describe('gitService', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('Config Management', () => {
// @PRE User authenticated
// @POST Returns list of Git configs
it('getConfigs calls /git/config', async () => {
requestApi.mockResolvedValue([]);
const result = await gitService.getConfigs();
expect(requestApi).toHaveBeenCalledWith('/git/config');
expect(result).toEqual([]);
});
// @PRE Config object must be valid
// @POST New config created and returned
it('createConfig calls POST /git/config', async () => {
const config = { name: 'Test', provider: 'GITHUB' };
requestApi.mockResolvedValue({ id: '1', ...config });
const result = await gitService.createConfig(config);
expect(requestApi).toHaveBeenCalledWith('/git/config', 'POST', config);
expect(result.id).toBe('1');
});
// @PRE configId must exist
// @POST Config deleted
it('deleteConfig calls DELETE /git/config/:id', async () => {
requestApi.mockResolvedValue({ status: 'success' });
await gitService.deleteConfig('config-123');
expect(requestApi).toHaveBeenCalledWith('/git/config/config-123', 'DELETE');
});
// @PRE configId must exist
// @POST Config updated
it('updateConfig calls PUT /git/config/:id', async () => {
const config = { name: 'Updated' };
requestApi.mockResolvedValue({ id: 'config-123', ...config });
await gitService.updateConfig('config-123', config);
expect(requestApi).toHaveBeenCalledWith('/git/config/config-123', 'PUT', config);
});
// @PRE Config must contain valid URL and PAT
// @POST Returns connection status
it('testConnection calls POST /git/config/test', async () => {
const config = { url: 'https://github.com', pat: 'secret' };
requestApi.mockResolvedValue({ status: 'success' });
await gitService.testConnection(config);
expect(requestApi).toHaveBeenCalledWith('/git/config/test', 'POST', config);
});
});
describe('Remote Repository Management', () => {
// @PRE configId references GITEA
// @POST Returns repo metadata
it('listGiteaRepositories calls GET /git/config/:id/gitea/repos', async () => {
requestApi.mockResolvedValue([]);
await gitService.listGiteaRepositories('conf-1');
expect(requestApi).toHaveBeenCalledWith('/git/config/conf-1/gitea/repos');
});
// @PRE configId references GITEA
// @POST Repo created on Gitea
it('createGiteaRepository calls POST /git/config/:id/gitea/repos', async () => {
const payload = { name: 'new-repo' };
requestApi.mockResolvedValue({ name: 'new-repo' });
await gitService.createGiteaRepository('conf-1', payload);
expect(requestApi).toHaveBeenCalledWith('/git/config/conf-1/gitea/repos', 'POST', payload);
});
// @PRE configId references supported provider
// @POST Remote repo created
it('createRemoteRepository calls POST /git/config/:id/repositories', async () => {
const payload = { name: 'new-remote' };
requestApi.mockResolvedValue({ name: 'new-remote' });
await gitService.createRemoteRepository('conf-1', payload);
expect(requestApi).toHaveBeenCalledWith('/git/config/conf-1/repositories', 'POST', payload);
});
// @PRE configId references GITEA
// @POST Repo deleted on Gitea
it('deleteGiteaRepository calls DELETE with encoded owner and repo', async () => {
requestApi.mockResolvedValue({ status: 'success' });
await gitService.deleteGiteaRepository('conf-1', 'my-org', 'my-repo');
expect(requestApi).toHaveBeenCalledWith('/git/config/conf-1/gitea/repos/my-org/my-repo', 'DELETE');
});
});
describe('Dashboard Repository Operations', () => {
// @PRE Dashboard exists and config_id valid
// @POST Repo initialized
it('initRepository calls POST with remote_url and config_id', async () => {
requestApi.mockResolvedValue({});
await gitService.initRepository(123, 'conf-1', 'http://remote.git');
expect(requestApi).toHaveBeenCalledWith('/git/repositories/123/init', 'POST', {
config_id: 'conf-1', remote_url: 'http://remote.git'
});
});
// @PRE Repo initialized
// @POST Returns binding
it('getRepositoryBinding calls GET', async () => {
requestApi.mockResolvedValue({});
await gitService.getRepositoryBinding('dash-slug');
expect(requestApi).toHaveBeenCalledWith('/git/repositories/dash-slug');
});
// @PRE Repo initialized
// @POST Returns branches
it('getBranches calls GET /branches', async () => {
requestApi.mockResolvedValue([]);
await gitService.getBranches(123);
expect(requestApi).toHaveBeenCalledWith('/git/repositories/123/branches');
});
// @PRE Source branch exists
// @POST New branch created
it('createBranch calls POST /branches with name and from_branch', async () => {
requestApi.mockResolvedValue({});
await gitService.createBranch(123, 'feature', 'main');
expect(requestApi).toHaveBeenCalledWith('/git/repositories/123/branches', 'POST', {
name: 'feature', from_branch: 'main'
});
});
// @PRE Target branch exists
// @POST Repo checked out
it('checkoutBranch calls POST /checkout', async () => {
requestApi.mockResolvedValue({});
await gitService.checkoutBranch(123, 'feature');
expect(requestApi).toHaveBeenCalledWith('/git/repositories/123/checkout', 'POST', { name: 'feature' });
});
// @PRE Message not empty
// @POST Changes committed
it('commit calls POST /commit with message and files', async () => {
requestApi.mockResolvedValue({});
await gitService.commit(123, 'Initial commit', ['file.txt']);
expect(requestApi).toHaveBeenCalledWith('/git/repositories/123/commit', 'POST', {
message: 'Initial commit', files: ['file.txt']
});
});
// @PRE Remote configured
// @POST Remote updated
it('push calls POST /push', async () => {
requestApi.mockResolvedValue({});
await gitService.push(123);
expect(requestApi).toHaveBeenCalledWith('/git/repositories/123/push', 'POST');
});
// @PRE Dashboard ref valid
// @POST Repo deleted
it('deleteRepository calls DELETE', async () => {
requestApi.mockResolvedValue({});
await gitService.deleteRepository(123);
expect(requestApi).toHaveBeenCalledWith('/git/repositories/123', 'DELETE');
});
// @PRE Remote configured
// @POST Local repo updated
it('pull calls POST /pull', async () => {
requestApi.mockResolvedValue({});
await gitService.pull(123);
expect(requestApi).toHaveBeenCalledWith('/git/repositories/123/pull', 'POST');
});
});
describe('Other Operations', () => {
// @POST Returns list of environments
it('getEnvironments calls GET /environments', async () => {
requestApi.mockResolvedValue([]);
await gitService.getEnvironments();
expect(requestApi).toHaveBeenCalledWith('/git/environments');
});
// @PRE Environment active
// @POST Dashboard deployed
it('deploy calls POST /deploy with environment_id', async () => {
requestApi.mockResolvedValue({});
await gitService.deploy(123, 'env-1');
expect(requestApi).toHaveBeenCalledWith('/git/repositories/123/deploy', 'POST', { environment_id: 'env-1' });
});
// @POST Returns commit history
it('getHistory calls GET /history with limit', async () => {
requestApi.mockResolvedValue([]);
await gitService.getHistory(123, 10);
expect(requestApi).toHaveBeenCalledWith('/git/repositories/123/history?limit=10');
});
// @POST Syncs local and remote
it('sync calls POST /sync with optional params', async () => {
requestApi.mockResolvedValue({});
await gitService.sync(123, 'env-source', 'env-target');
expect(requestApi).toHaveBeenCalledWith('/git/repositories/123/sync?source_env_id=env-source&env_id=env-target', 'POST');
});
// @PRE Dashboard id valid
// @POST Returns status
it('getStatus calls GET /status', async () => {
requestApi.mockResolvedValue({});
await gitService.getStatus(123);
expect(requestApi).toHaveBeenCalledWith('/git/repositories/123/status');
});
// @PRE Dashboard ids array
// @POST Returns batch statuses
it('getStatusesBatch calls POST /status/batch', async () => {
requestApi.mockResolvedValue({});
await gitService.getStatusesBatch([1, 2, 3]);
expect(requestApi).toHaveBeenCalledWith('/git/repositories/status/batch', 'POST', { dashboard_ids: [1, 2, 3] });
});
// @PRE Dashboard id valid
// @POST Returns diff
it('getDiff calls GET /diff with params', async () => {
requestApi.mockResolvedValue('');
await gitService.getDiff(123, 'file.txt', true, 'env-1');
expect(requestApi).toHaveBeenCalledWith('/git/repositories/123/diff?file_path=file.txt&staged=true&env_id=env-1');
});
// @PRE Repo initialized
// @POST Returns promotion metadata
it('promote calls POST /promote with payload', async () => {
const payload = { from_branch: 'dev', to_branch: 'main', mode: 'mr' };
requestApi.mockResolvedValue({});
await gitService.promote(123, payload);
expect(requestApi).toHaveBeenCalledWith('/git/repositories/123/promote', 'POST', payload);
});
});
});
// [/DEF:frontend.src.services.__tests__.gitService_test:Module]