git-add.tool.test.ts•11.8 kB
/**
* @fileoverview Unit tests for git-add tool
* @module tests/mcp-server/tools/definitions/unit/git-add.tool.test
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { container } from 'tsyringe';
import { gitAddTool } from '@/mcp-server/tools/definitions/git-add.tool.js';
import {
GitProviderFactory as GitProviderFactoryToken,
StorageService as StorageServiceToken,
} from '@/container/tokens.js';
import {
createTestContext,
createTestSdkContext,
createMockGitProvider,
createMockStorageService,
assertJsonContent,
assertJsonField,
assertLlmFriendlyFormat,
} from '../helpers/index.js';
import type { GitAddResult } from '@/services/git/types.js';
import { GitProviderFactory } from '@/services/git/core/GitProviderFactory.js';
describe('git_add tool', () => {
const mockProvider = createMockGitProvider();
const mockStorage = createMockStorageService();
const mockFactory = {
getProvider: vi.fn(async () => mockProvider),
} as unknown as GitProviderFactory;
beforeEach(() => {
mockProvider.resetMocks();
mockStorage.clearAll();
container.clearInstances();
container.register(GitProviderFactoryToken, { useValue: mockFactory });
container.register(StorageServiceToken, { useValue: mockStorage });
const tenantId = 'test-tenant';
const context = createTestContext({ tenantId });
mockStorage.set(`session:workingDir:${tenantId}`, '/test/repo', context);
});
describe('Input Schema', () => {
it('validates correct input with defaults', () => {
const input = { path: '.', files: ['file.txt'] };
const result = gitAddTool.inputSchema.safeParse(input);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.update).toBe(false);
expect(result.data.all).toBe(false);
expect(result.data.force).toBe(false);
}
});
it('accepts multiple files', () => {
const input = { path: '.', files: ['file1.txt', 'file2.txt', 'dir/'] };
const result = gitAddTool.inputSchema.safeParse(input);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.files).toHaveLength(3);
}
});
it('accepts all files shorthand', () => {
const input = { path: '.', files: ['.'] };
const result = gitAddTool.inputSchema.safeParse(input);
expect(result.success).toBe(true);
});
it('accepts update flag', () => {
const input = { path: '.', files: ['.'], update: true };
const result = gitAddTool.inputSchema.safeParse(input);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.update).toBe(true);
}
});
it('rejects empty files array', () => {
const input = { path: '.', files: [] };
const result = gitAddTool.inputSchema.safeParse(input);
expect(result.success).toBe(false);
});
});
describe('Tool Logic', () => {
it('stages single file successfully', async () => {
const mockResult: GitAddResult = {
success: true,
stagedFiles: ['file.txt'],
};
const mockStatusResult = {
currentBranch: 'main',
stagedChanges: { modified: ['file.txt'] },
unstagedChanges: {},
untrackedFiles: [],
conflictedFiles: [],
isClean: false,
};
mockProvider.add.mockResolvedValue(mockResult);
mockProvider.status.mockResolvedValue(mockStatusResult);
const parsedInput = gitAddTool.inputSchema.parse({
path: '.',
files: ['file.txt'],
});
const appContext = createTestContext({ tenantId: 'test-tenant' });
const sdkContext = createTestSdkContext();
const result = await gitAddTool.logic(
parsedInput,
appContext,
sdkContext,
);
expect(mockProvider.add).toHaveBeenCalledTimes(1);
const [addOptions, addContext] = mockProvider.add.mock.calls[0]!;
expect(addOptions.paths).toEqual(['file.txt']);
expect(addContext.workingDirectory).toBe('/test/repo');
expect(result.success).toBe(true);
expect(result.stagedFiles).toEqual(['file.txt']);
expect(result.totalFiles).toBe(1);
});
it('stages multiple files', async () => {
const mockResult: GitAddResult = {
success: true,
stagedFiles: ['file1.txt', 'file2.txt', 'dir/file3.txt'],
};
const mockStatusResult = {
currentBranch: 'main',
stagedChanges: {
modified: ['file1.txt', 'file2.txt', 'dir/file3.txt'],
},
unstagedChanges: {},
untrackedFiles: [],
conflictedFiles: [],
isClean: false,
};
mockProvider.add.mockResolvedValue(mockResult);
mockProvider.status.mockResolvedValue(mockStatusResult);
const parsedInput = gitAddTool.inputSchema.parse({
path: '.',
files: ['file1.txt', 'file2.txt', 'dir/file3.txt'],
});
const appContext = createTestContext({ tenantId: 'test-tenant' });
const sdkContext = createTestSdkContext();
const result = await gitAddTool.logic(
parsedInput,
appContext,
sdkContext,
);
expect(result.totalFiles).toBe(3);
expect(result.stagedFiles).toHaveLength(3);
});
it('passes update flag to provider', async () => {
const mockResult: GitAddResult = {
success: true,
stagedFiles: ['modified.txt'],
};
const mockStatusResult = {
currentBranch: 'main',
stagedChanges: { modified: ['modified.txt'] },
unstagedChanges: {},
untrackedFiles: [],
conflictedFiles: [],
isClean: false,
};
mockProvider.add.mockResolvedValue(mockResult);
mockProvider.status.mockResolvedValue(mockStatusResult);
const parsedInput = gitAddTool.inputSchema.parse({
path: '.',
files: ['.'],
update: true,
});
const appContext = createTestContext({ tenantId: 'test-tenant' });
const sdkContext = createTestSdkContext();
await gitAddTool.logic(parsedInput, appContext, sdkContext);
const [addOptions] = mockProvider.add.mock.calls[0]!;
expect(addOptions.update).toBe(true);
});
it('passes force flag to provider', async () => {
const mockResult: GitAddResult = {
success: true,
stagedFiles: ['ignored.txt'],
};
const mockStatusResult = {
currentBranch: 'main',
stagedChanges: { added: ['ignored.txt'] },
unstagedChanges: {},
untrackedFiles: [],
conflictedFiles: [],
isClean: false,
};
mockProvider.add.mockResolvedValue(mockResult);
mockProvider.status.mockResolvedValue(mockStatusResult);
const parsedInput = gitAddTool.inputSchema.parse({
path: '.',
files: ['ignored.txt'],
force: true,
});
const appContext = createTestContext({ tenantId: 'test-tenant' });
const sdkContext = createTestSdkContext();
await gitAddTool.logic(parsedInput, appContext, sdkContext);
const [addOptions] = mockProvider.add.mock.calls[0]!;
expect(addOptions.force).toBe(true);
});
it('uses absolute path when provided', async () => {
const mockResult: GitAddResult = {
success: true,
stagedFiles: ['file.txt'],
};
const mockStatusResult = {
currentBranch: 'main',
stagedChanges: { modified: ['file.txt'] },
unstagedChanges: {},
untrackedFiles: [],
conflictedFiles: [],
isClean: false,
};
mockProvider.add.mockResolvedValue(mockResult);
mockProvider.status.mockResolvedValue(mockStatusResult);
const parsedInput = gitAddTool.inputSchema.parse({
path: '/absolute/path',
files: ['file.txt'],
});
const appContext = createTestContext({ tenantId: 'test-tenant' });
const sdkContext = createTestSdkContext();
await gitAddTool.logic(parsedInput, appContext, sdkContext);
const [_options, addContext] = mockProvider.add.mock.calls[0]!;
expect(addContext.workingDirectory).toBe('/absolute/path');
});
});
describe('Response Formatter', () => {
const mockStatus = {
current_branch: 'main',
staged_changes: { modified: ['README.md'] },
unstaged_changes: {},
untracked_files: [],
conflicted_files: [],
is_clean: false,
};
it('formats single file staging', () => {
const result = {
success: true,
stagedFiles: ['README.md'],
totalFiles: 1,
status: mockStatus,
};
const content = gitAddTool.responseFormatter!(result);
// Should return JSON format with expected structure
assertJsonContent(content, {
success: true,
stagedFiles: ['README.md'],
totalFiles: 1,
status: {
current_branch: 'main',
is_clean: false,
},
});
assertJsonField(content, 'stagedFiles', ['README.md']);
assertJsonField(content, 'totalFiles', 1);
assertLlmFriendlyFormat(content);
});
it('formats multiple files staging', () => {
const result = {
success: true,
stagedFiles: ['file1.txt', 'file2.txt', 'src/index.ts'],
totalFiles: 3,
status: {
...mockStatus,
staged_changes: {
modified: ['file1.txt', 'file2.txt', 'src/index.ts'],
},
},
};
const content = gitAddTool.responseFormatter!(result);
assertJsonContent(content, {
success: true,
stagedFiles: ['file1.txt', 'file2.txt', 'src/index.ts'],
totalFiles: 3,
});
assertJsonField(content, 'totalFiles', 3);
assertJsonField(content, 'stagedFiles', [
'file1.txt',
'file2.txt',
'src/index.ts',
]);
});
it('lists all staged files', () => {
const result = {
success: true,
stagedFiles: [
'package.json',
'src/main.ts',
'tests/main.test.ts',
'docs/README.md',
],
totalFiles: 4,
status: {
...mockStatus,
staged_changes: {
modified: [
'package.json',
'src/main.ts',
'tests/main.test.ts',
'docs/README.md',
],
},
},
};
const content = gitAddTool.responseFormatter!(result);
assertJsonContent(content, {
success: true,
totalFiles: 4,
});
assertJsonField(content, 'stagedFiles', [
'package.json',
'src/main.ts',
'tests/main.test.ts',
'docs/README.md',
]);
});
});
describe('Tool Metadata', () => {
it('has correct tool name', () => {
expect(gitAddTool.name).toBe('git_add');
});
it('is marked as write operation', () => {
expect(gitAddTool.annotations?.readOnlyHint).toBe(false);
});
it('has descriptive title and description', () => {
expect(gitAddTool.title).toBe('Git Add');
expect(gitAddTool.description).toBeTruthy();
expect(gitAddTool.description.toLowerCase()).toContain('stage');
});
it('has valid schemas', () => {
expect(gitAddTool.inputSchema).toBeDefined();
expect(gitAddTool.outputSchema).toBeDefined();
const inputShape = gitAddTool.inputSchema.shape;
expect(inputShape.files).toBeDefined();
expect(inputShape.update).toBeDefined();
const outputShape = gitAddTool.outputSchema.shape;
expect(outputShape.success).toBeDefined();
expect(outputShape.stagedFiles).toBeDefined();
expect(outputShape.totalFiles).toBeDefined();
});
});
});