git-commit.tool.test.ts•15.8 kB
/**
 * @fileoverview Unit tests for git-commit tool
 * @module tests/mcp-server/tools/definitions/unit/git-commit.tool.test
 */
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { container } from 'tsyringe';
import { gitCommitTool } from '@/mcp-server/tools/definitions/git-commit.tool.js';
import {
  GitProviderFactory as GitProviderFactoryToken,
  StorageService as StorageServiceToken,
} from '@/container/tokens.js';
import {
  createTestContext,
  createTestSdkContext,
  createMockGitProvider,
  createMockStorageService,
  assertJsonContent,
  assertJsonField,
  parseJsonContent,
  assertLlmFriendlyFormat,
} from '../helpers/index.js';
import type { GitCommitResult, GitStatusResult } from '@/services/git/types.js';
import { GitProviderFactory } from '@/services/git/core/GitProviderFactory.js';
describe('git_commit 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 defaults', () => {
      const input = { path: '.', message: 'Test commit' };
      const result = gitCommitTool.inputSchema.safeParse(input);
      expect(result.success).toBe(true);
      if (result.success) {
        expect(result.data.amend).toBe(false);
        expect(result.data.allowEmpty).toBe(false);
        expect(result.data.forceUnsignedOnFailure).toBe(false);
      }
    });
    it('accepts author override', () => {
      const input = {
        path: '.',
        message: 'Test',
        author: { name: 'Test Author', email: 'test@example.com' },
      };
      const result = gitCommitTool.inputSchema.safeParse(input);
      expect(result.success).toBe(true);
      if (result.success) {
        expect(result.data.author).toEqual({
          name: 'Test Author',
          email: 'test@example.com',
        });
      }
    });
    it('accepts amend flag', () => {
      const input = { path: '.', message: 'Amended', amend: true };
      const result = gitCommitTool.inputSchema.safeParse(input);
      expect(result.success).toBe(true);
      if (result.success) {
        expect(result.data.amend).toBe(true);
      }
    });
    it('accepts filesToStage array', () => {
      const input = {
        path: '.',
        message: 'Test',
        filesToStage: ['file1.txt', 'file2.txt'],
      };
      const result = gitCommitTool.inputSchema.safeParse(input);
      expect(result.success).toBe(true);
      if (result.success) {
        expect(result.data.filesToStage).toEqual(['file1.txt', 'file2.txt']);
      }
    });
    it('rejects invalid message', () => {
      const input = { path: '.', message: '' };
      const result = gitCommitTool.inputSchema.safeParse(input);
      expect(result.success).toBe(false);
    });
    it('rejects invalid author email', () => {
      const input = {
        path: '.',
        message: 'Test',
        author: { name: 'Test', email: 'not-an-email' },
      };
      const result = gitCommitTool.inputSchema.safeParse(input);
      expect(result.success).toBe(false);
    });
  });
  describe('Tool Logic', () => {
    it('creates commit successfully', async () => {
      const mockCommitResult: GitCommitResult = {
        success: true,
        commitHash: 'abc123def456',
        message: 'Test commit',
        author: 'Test User <test@example.com>',
        timestamp: 1234567890,
        filesChanged: ['file1.txt', 'file2.txt'],
      };
      const mockStatusResult: GitStatusResult = {
        currentBranch: 'main',
        isClean: true,
        stagedChanges: {},
        unstagedChanges: {},
        untrackedFiles: [],
        conflictedFiles: [],
      };
      mockProvider.commit.mockResolvedValue(mockCommitResult);
      mockProvider.status.mockResolvedValue(mockStatusResult);
      const parsedInput = gitCommitTool.inputSchema.parse({
        path: '.',
        message: 'Test commit',
      });
      const appContext = createTestContext({ tenantId: 'test-tenant' });
      const sdkContext = createTestSdkContext();
      const result = await gitCommitTool.logic(
        parsedInput,
        appContext,
        sdkContext,
      );
      // Verify commit was called
      expect(mockProvider.commit).toHaveBeenCalledTimes(1);
      const [commitOptions, commitContext] = mockProvider.commit.mock.calls[0]!;
      expect(commitOptions.message).toBe('Test commit');
      expect(commitContext.workingDirectory).toBe('/test/repo');
      // Verify status was called after commit
      expect(mockProvider.status).toHaveBeenCalledTimes(1);
      // Verify output
      expect(result.success).toBe(true);
      expect(result.commitHash).toBe('abc123def456');
      expect(result.status.is_clean).toBe(true);
    });
    it('stages files before committing when filesToStage provided', async () => {
      const mockCommitResult: GitCommitResult = {
        success: true,
        commitHash: 'abc123',
        message: 'Test',
        author: 'Test <test@test.com>',
        timestamp: 123,
        filesChanged: ['file1.txt'],
      };
      const mockStatusResult: GitStatusResult = {
        currentBranch: 'main',
        isClean: true,
        stagedChanges: {},
        unstagedChanges: {},
        untrackedFiles: [],
        conflictedFiles: [],
      };
      mockProvider.add.mockResolvedValue({
        success: true,
        stagedFiles: ['file1.txt', 'file2.txt'],
      });
      mockProvider.commit.mockResolvedValue(mockCommitResult);
      mockProvider.status.mockResolvedValue(mockStatusResult);
      const parsedInput = gitCommitTool.inputSchema.parse({
        path: '.',
        message: 'Test commit',
        filesToStage: ['file1.txt', 'file2.txt'],
      });
      const appContext = createTestContext({ tenantId: 'test-tenant' });
      const sdkContext = createTestSdkContext();
      await gitCommitTool.logic(parsedInput, appContext, sdkContext);
      // Verify add was called before commit
      expect(mockProvider.add).toHaveBeenCalledTimes(1);
      const [addOptions] = mockProvider.add.mock.calls[0]!;
      expect(addOptions.paths).toEqual(['file1.txt', 'file2.txt']);
      // Verify commit was called after add
      expect(mockProvider.commit).toHaveBeenCalledTimes(1);
    });
    it('passes author override to provider', async () => {
      const mockCommitResult: GitCommitResult = {
        success: true,
        commitHash: 'abc123',
        message: 'Test',
        author: 'Custom Author <custom@example.com>',
        timestamp: 123,
        filesChanged: [],
      };
      const mockStatusResult: GitStatusResult = {
        currentBranch: 'main',
        isClean: true,
        stagedChanges: {},
        unstagedChanges: {},
        untrackedFiles: [],
        conflictedFiles: [],
      };
      mockProvider.commit.mockResolvedValue(mockCommitResult);
      mockProvider.status.mockResolvedValue(mockStatusResult);
      const parsedInput = gitCommitTool.inputSchema.parse({
        path: '.',
        message: 'Test commit',
        author: { name: 'Custom Author', email: 'custom@example.com' },
      });
      const appContext = createTestContext({ tenantId: 'test-tenant' });
      const sdkContext = createTestSdkContext();
      await gitCommitTool.logic(parsedInput, appContext, sdkContext);
      const [commitOptions] = mockProvider.commit.mock.calls[0]!;
      expect(commitOptions.author).toEqual({
        name: 'Custom Author',
        email: 'custom@example.com',
      });
    });
    it('passes amend flag to provider', async () => {
      const mockCommitResult: GitCommitResult = {
        success: true,
        commitHash: 'abc123',
        message: 'Amended commit',
        author: 'Test <test@test.com>',
        timestamp: 123,
        filesChanged: [],
      };
      const mockStatusResult: GitStatusResult = {
        currentBranch: 'main',
        isClean: true,
        stagedChanges: {},
        unstagedChanges: {},
        untrackedFiles: [],
        conflictedFiles: [],
      };
      mockProvider.commit.mockResolvedValue(mockCommitResult);
      mockProvider.status.mockResolvedValue(mockStatusResult);
      const parsedInput = gitCommitTool.inputSchema.parse({
        path: '.',
        message: 'Amended commit',
        amend: true,
      });
      const appContext = createTestContext({ tenantId: 'test-tenant' });
      const sdkContext = createTestSdkContext();
      await gitCommitTool.logic(parsedInput, appContext, sdkContext);
      const [commitOptions] = mockProvider.commit.mock.calls[0]!;
      expect(commitOptions.amend).toBe(true);
    });
    it('uses absolute path when provided', async () => {
      const mockCommitResult: GitCommitResult = {
        success: true,
        commitHash: 'abc123',
        message: 'Test',
        author: 'Test <test@test.com>',
        timestamp: 123,
        filesChanged: [],
      };
      const mockStatusResult: GitStatusResult = {
        currentBranch: 'main',
        isClean: true,
        stagedChanges: {},
        unstagedChanges: {},
        untrackedFiles: [],
        conflictedFiles: [],
      };
      mockProvider.commit.mockResolvedValue(mockCommitResult);
      mockProvider.status.mockResolvedValue(mockStatusResult);
      const parsedInput = gitCommitTool.inputSchema.parse({
        path: '/absolute/repo/path',
        message: 'Test commit',
      });
      const appContext = createTestContext({ tenantId: 'test-tenant' });
      const sdkContext = createTestSdkContext();
      await gitCommitTool.logic(parsedInput, appContext, sdkContext);
      const [_options, commitContext] = mockProvider.commit.mock.calls[0]!;
      expect(commitContext.workingDirectory).toBe('/absolute/repo/path');
    });
  });
  describe('Response Formatter', () => {
    it('formats successful commit with clean status', () => {
      const result = {
        success: true,
        commitHash: 'abc123def456789',
        message: 'Add new feature',
        author: 'Test User <test@example.com>',
        timestamp: 1609459200,
        filesChanged: 3,
        committedFiles: ['file1.txt', 'file2.txt', 'file3.txt'],
        status: {
          current_branch: 'main',
          staged_changes: {},
          unstaged_changes: {},
          untracked_files: [],
          conflicted_files: [],
          is_clean: true,
        },
      };
      const content = gitCommitTool.responseFormatter!(result);
      assertJsonContent(content, {
        success: true,
        commitHash: 'abc123def456789',
        message: 'Add new feature',
        filesChanged: 3,
      });
      assertJsonField(content, 'commitHash', 'abc123def456789');
      assertJsonField(content, 'message', 'Add new feature');
      assertJsonField(content, 'filesChanged', 3);
      assertJsonField(content, 'status.is_clean', true);
      assertLlmFriendlyFormat(content);
    });
    it('formats commit with remaining changes', () => {
      const result = {
        success: true,
        commitHash: 'abc123',
        message: 'Partial commit',
        author: 'Test <test@test.com>',
        timestamp: 1609459200,
        filesChanged: 1,
        committedFiles: ['committed.txt'],
        status: {
          current_branch: 'develop',
          staged_changes: { added: ['staged.txt'] },
          unstaged_changes: { modified: ['modified.txt'] },
          untracked_files: ['untracked.txt'],
          conflicted_files: [],
          is_clean: false,
        },
      };
      const content = gitCommitTool.responseFormatter!(result);
      assertJsonContent(content, {
        success: true,
        commitHash: 'abc123',
        message: 'Partial commit',
      });
      assertJsonField(content, 'status.is_clean', false);
      assertJsonField(content, 'status.current_branch', 'develop');
      const parsed = parseJsonContent(content) as {
        status: { untracked_files: string[] };
      };
      expect(parsed.status.untracked_files).toContain('untracked.txt');
    });
    it('formats commit with file statistics', () => {
      const result = {
        success: true,
        commitHash: 'abc123',
        message: 'Test',
        author: 'Test <test@test.com>',
        timestamp: 1609459200,
        filesChanged: 5,
        committedFiles: ['a.txt', 'b.txt', 'c.txt', 'd.txt', 'e.txt'],
        insertions: 100,
        deletions: 50,
        status: {
          current_branch: 'main',
          staged_changes: {},
          unstaged_changes: {},
          untracked_files: [],
          conflicted_files: [],
          is_clean: true,
        },
      };
      const content = gitCommitTool.responseFormatter!(result);
      assertJsonContent(content, {
        success: true,
        commitHash: 'abc123',
        filesChanged: 5,
        insertions: 100,
        deletions: 50,
      });
      assertJsonField(content, 'filesChanged', 5);
      assertJsonField(content, 'insertions', 100);
      assertJsonField(content, 'deletions', 50);
    });
    it('lists all committed files', () => {
      const result = {
        success: true,
        commitHash: 'abc123',
        message: 'Test',
        author: 'Test <test@test.com>',
        timestamp: 1609459200,
        filesChanged: 2,
        committedFiles: ['important.txt', 'feature.js'],
        status: {
          current_branch: 'main',
          staged_changes: {},
          unstaged_changes: {},
          untracked_files: [],
          conflicted_files: [],
          is_clean: true,
        },
      };
      const content = gitCommitTool.responseFormatter!(result);
      assertJsonContent(content, {
        success: true,
        commitHash: 'abc123',
        filesChanged: 2,
      });
      assertJsonField(content, 'committedFiles', [
        'important.txt',
        'feature.js',
      ]);
      const parsed = parseJsonContent(content) as {
        committedFiles: string[];
      };
      expect(parsed.committedFiles).toHaveLength(2);
    });
  });
  describe('Tool Metadata', () => {
    it('has correct tool name', () => {
      expect(gitCommitTool.name).toBe('git_commit');
    });
    it('is marked as write operation', () => {
      expect(gitCommitTool.annotations?.readOnlyHint).toBe(false);
    });
    it('has descriptive title and description', () => {
      expect(gitCommitTool.title).toBe('Git Commit');
      expect(gitCommitTool.description).toBeTruthy();
      expect(gitCommitTool.description).toContain('commit');
    });
    it('has valid input and output schemas', () => {
      expect(gitCommitTool.inputSchema).toBeDefined();
      expect(gitCommitTool.outputSchema).toBeDefined();
      // Verify key input fields
      const inputShape = gitCommitTool.inputSchema.shape;
      expect(inputShape.message).toBeDefined();
      expect(inputShape.path).toBeDefined();
      expect(inputShape.amend).toBeDefined();
      // Verify key output fields
      const outputShape = gitCommitTool.outputSchema.shape;
      expect(outputShape.commitHash).toBeDefined();
      expect(outputShape.success).toBeDefined();
      expect(outputShape.status).toBeDefined();
    });
  });
});