git-status.tool.test.ts•12.5 kB
/**
* @fileoverview Unit tests for git-status tool
* @module tests/mcp-server/tools/definitions/unit/git-status.tool.test
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { container } from 'tsyringe';
import { gitStatusTool } from '@/mcp-server/tools/definitions/git-status.tool.js';
import {
GitProviderFactory as GitProviderFactoryToken,
StorageService as StorageServiceToken,
} from '@/container/tokens.js';
import {
createTestContext,
createTestSdkContext,
createMockGitProvider,
createMockStorageService,
assertJsonContent,
assertJsonField,
parseJsonContent,
assertProviderCalledWithContext,
assertLlmFriendlyFormat,
} from '../helpers/index.js';
import type { GitStatusResult } from '@/services/git/types.js';
import { GitProviderFactory } from '@/services/git/core/GitProviderFactory.js';
describe('git_status tool', () => {
const mockProvider = createMockGitProvider();
const mockStorage = createMockStorageService();
const mockFactory = {
getProvider: vi.fn(async () => mockProvider),
} as unknown as GitProviderFactory;
beforeEach(() => {
// Reset mocks
mockProvider.resetMocks();
mockStorage.clearAll();
// Register mock dependencies
container.clearInstances();
container.register(GitProviderFactoryToken, { useValue: mockFactory });
container.register(StorageServiceToken, { useValue: mockStorage });
// Set up default session working directory
const tenantId = 'test-tenant';
const context = createTestContext({ tenantId });
mockStorage.set(`session:workingDir:${tenantId}`, '/test/repo', context);
});
describe('Input Schema', () => {
it('validates correct input with default values', () => {
const input = { path: '.' };
const result = gitStatusTool.inputSchema.safeParse(input);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.includeUntracked).toBe(true); // default
}
});
it('accepts absolute path', () => {
const input = { path: '/absolute/path/to/repo' };
const result = gitStatusTool.inputSchema.safeParse(input);
expect(result.success).toBe(true);
});
it('accepts includeUntracked option', () => {
const input = { path: '.', includeUntracked: false };
const result = gitStatusTool.inputSchema.safeParse(input);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.includeUntracked).toBe(false);
}
});
it('rejects invalid input types', () => {
const input = { path: 123 }; // Invalid: path should be string
const result = gitStatusTool.inputSchema.safeParse(input);
expect(result.success).toBe(false);
});
});
describe('Tool Logic', () => {
it('executes status operation successfully with session path', async () => {
const mockStatusResult: GitStatusResult = {
currentBranch: 'main',
isClean: false,
stagedChanges: {
added: ['file1.txt'],
modified: ['file2.txt'],
},
unstagedChanges: {
modified: ['file3.txt'],
},
untrackedFiles: ['file4.txt'],
conflictedFiles: [],
};
mockProvider.status.mockResolvedValue(mockStatusResult);
// Parse input through schema to get defaults
const parsedInput = gitStatusTool.inputSchema.parse({ path: '.' });
const appContext = createTestContext({ tenantId: 'test-tenant' });
const sdkContext = createTestSdkContext();
const result = await gitStatusTool.logic(
parsedInput,
appContext,
sdkContext,
);
// Verify provider was called correctly
expect(mockProvider.status).toHaveBeenCalledTimes(1);
assertProviderCalledWithContext(
mockProvider.status.mock.calls[0] as unknown[],
'/test/repo',
'test-tenant',
);
// Verify output structure
expect(result).toMatchObject({
currentBranch: 'main',
isClean: false,
stagedChanges: {
added: ['file1.txt'],
modified: ['file2.txt'],
},
unstagedChanges: {
modified: ['file3.txt'],
},
untrackedFiles: ['file4.txt'],
conflictedFiles: [],
});
});
it('executes status operation with absolute path', async () => {
const mockStatusResult: GitStatusResult = {
currentBranch: 'develop',
isClean: true,
stagedChanges: {},
unstagedChanges: {},
untrackedFiles: [],
conflictedFiles: [],
};
mockProvider.status.mockResolvedValue(mockStatusResult);
const parsedInput = gitStatusTool.inputSchema.parse({
path: '/absolute/repo/path',
});
const appContext = createTestContext({ tenantId: 'test-tenant' });
const sdkContext = createTestSdkContext();
const result = await gitStatusTool.logic(
parsedInput,
appContext,
sdkContext,
);
// Verify provider was called with absolute path
expect(mockProvider.status).toHaveBeenCalledTimes(1);
const [_options, context] = mockProvider.status.mock.calls[0] as [
unknown,
{ workingDirectory: string },
];
expect(context.workingDirectory).toBe('/absolute/repo/path');
expect(result.isClean).toBe(true);
});
it('applies graceful tenantId default when missing', async () => {
const mockStatusResult: GitStatusResult = {
currentBranch: 'main',
isClean: true,
stagedChanges: {},
unstagedChanges: {},
untrackedFiles: [],
conflictedFiles: [],
};
mockProvider.status.mockResolvedValue(mockStatusResult);
// Context without tenantId (development mode)
const appContext = createTestContext();
const sdkContext = createTestSdkContext();
// Set up default tenant storage
mockStorage.set(
'session:workingDir:default-tenant',
'/default/repo',
appContext,
);
const parsedInput = gitStatusTool.inputSchema.parse({ path: '.' });
await gitStatusTool.logic(parsedInput, appContext, sdkContext);
// Verify default tenant was used
expect(mockProvider.status).toHaveBeenCalledTimes(1);
const [_options, context] = mockProvider.status.mock.calls[0] as [
unknown,
{ tenantId: string },
];
expect(context.tenantId).toBe('default-tenant');
});
it('passes includeUntracked option to provider', async () => {
const mockStatusResult: GitStatusResult = {
currentBranch: 'main',
isClean: true,
stagedChanges: {},
unstagedChanges: {},
untrackedFiles: [],
conflictedFiles: [],
};
mockProvider.status.mockResolvedValue(mockStatusResult);
const input = { path: '.', includeUntracked: false };
const appContext = createTestContext({ tenantId: 'test-tenant' });
const sdkContext = createTestSdkContext();
await gitStatusTool.logic(input, appContext, sdkContext);
// Verify option was passed to provider
expect(mockProvider.status).toHaveBeenCalledTimes(1);
const [options, _context] = mockProvider.status.mock.calls[0]!;
expect(options.includeUntracked).toBe(false);
});
});
describe('Response Formatter', () => {
it('formats clean repository status correctly', () => {
const result = {
success: true,
currentBranch: 'main',
isClean: true,
stagedChanges: {},
unstagedChanges: {},
untrackedFiles: [],
conflictedFiles: [],
};
const content = gitStatusTool.responseFormatter!(result);
assertJsonContent(content, {
success: true,
currentBranch: 'main',
isClean: true,
});
assertJsonField(content, 'currentBranch', 'main');
assertJsonField(content, 'isClean', true);
assertLlmFriendlyFormat(content, 30);
});
it('formats status with changes correctly', () => {
const result = {
success: true,
currentBranch: 'feature-branch',
isClean: false,
stagedChanges: {
added: ['new-file.txt'],
modified: ['existing-file.txt'],
},
unstagedChanges: {
modified: ['changed-file.txt'],
},
untrackedFiles: ['untracked.txt'],
conflictedFiles: [],
};
const content = gitStatusTool.responseFormatter!(result);
assertJsonContent(content, {
success: true,
currentBranch: 'feature-branch',
isClean: false,
});
assertJsonField(content, 'currentBranch', 'feature-branch');
assertJsonField(content, 'isClean', false);
const parsed = parseJsonContent(content) as {
stagedChanges: { added: string[]; modified: string[] };
unstagedChanges: { modified: string[] };
untrackedFiles: string[];
};
expect(parsed.stagedChanges.added).toContain('new-file.txt');
expect(parsed.stagedChanges.modified).toContain('existing-file.txt');
expect(parsed.unstagedChanges.modified).toContain('changed-file.txt');
expect(parsed.untrackedFiles).toContain('untracked.txt');
assertLlmFriendlyFormat(content);
});
it('formats status with conflicts', () => {
const result = {
success: true,
currentBranch: 'main',
isClean: false,
stagedChanges: {},
unstagedChanges: {},
untrackedFiles: [],
conflictedFiles: ['conflict1.txt', 'conflict2.txt'],
};
const content = gitStatusTool.responseFormatter!(result);
assertJsonContent(content, {
success: true,
currentBranch: 'main',
isClean: false,
});
assertJsonField(content, 'conflictedFiles', [
'conflict1.txt',
'conflict2.txt',
]);
const parsed = parseJsonContent(content) as {
conflictedFiles: string[];
};
expect(parsed.conflictedFiles).toHaveLength(2);
});
it('formats status without branch (detached HEAD)', () => {
const result = {
success: true,
currentBranch: null,
isClean: true,
stagedChanges: {},
unstagedChanges: {},
untrackedFiles: [],
conflictedFiles: [],
};
const content = gitStatusTool.responseFormatter!(result);
assertJsonContent(content, {
success: true,
currentBranch: null,
isClean: true,
});
assertJsonField(content, 'currentBranch', null);
assertJsonField(content, 'isClean', true);
});
it('includes counts for each category', () => {
const result = {
success: true,
currentBranch: 'main',
isClean: false,
stagedChanges: {
added: ['file1.txt', 'file2.txt'],
modified: ['file3.txt'],
},
unstagedChanges: {
modified: ['file4.txt', 'file5.txt', 'file6.txt'],
},
untrackedFiles: ['file7.txt'],
conflictedFiles: [],
};
const content = gitStatusTool.responseFormatter!(result);
assertJsonContent(content, {
success: true,
currentBranch: 'main',
isClean: false,
});
const parsed = parseJsonContent(content) as {
stagedChanges: { added: string[]; modified: string[] };
unstagedChanges: { modified: string[] };
untrackedFiles: string[];
};
// Check for counts
expect(parsed.stagedChanges.added).toHaveLength(2);
expect(parsed.stagedChanges.modified).toHaveLength(1);
expect(parsed.unstagedChanges.modified).toHaveLength(3);
expect(parsed.untrackedFiles).toHaveLength(1);
});
});
describe('Tool Metadata', () => {
it('has correct tool name', () => {
expect(gitStatusTool.name).toBe('git_status');
});
it('has correct read-only annotation', () => {
expect(gitStatusTool.annotations?.readOnlyHint).toBe(true);
});
it('has descriptive title and description', () => {
expect(gitStatusTool.title).toBeTruthy();
expect(gitStatusTool.description).toBeTruthy();
expect(gitStatusTool.description.length).toBeGreaterThan(20);
});
it('has valid input and output schemas', () => {
expect(gitStatusTool.inputSchema).toBeDefined();
expect(gitStatusTool.outputSchema).toBeDefined();
});
});
});