import { beforeEach, describe, expect, it, vi } from "vitest"; import { ConflictDetectionService } from "./conflict-detection.service"; import { ConflictDetectionError } from "./types"; // Mock simple-git const mockGit = { fetch: vi.fn(), status: vi.fn(), raw: vi.fn(), revparse: vi.fn(), }; vi.mock("simple-git", () => ({ simpleGit: vi.fn(() => mockGit), })); describe("ConflictDetectionService", () => { let service: ConflictDetectionService; beforeEach(() => { // Reset all mocks vi.clearAllMocks(); // Create service service = new ConflictDetectionService(); }); it("should be defined", () => { expect(service).toBeDefined(); }); describe("checkForConflicts", () => { it("should return no conflicts when branches can merge cleanly", async () => { // Mock successful fetch mockGit.fetch.mockResolvedValue(undefined); // Mock current branch mockGit.revparse.mockResolvedValue("feature-branch"); // Mock merge test - no conflicts mockGit.raw.mockResolvedValue(""); // Mock status - no conflicted files mockGit.status.mockResolvedValue({ conflicted: [], files: [], }); const result = await service.checkForConflicts("/test/repo", { localPath: "/test/repo", remote: "origin", remoteBranch: "develop", strategy: "merge", }); expect(result.hasConflicts).toBe(false); expect(result.conflicts).toHaveLength(0); expect(result.strategy).toBe("merge"); expect(result.remoteBranch).toBe("develop"); expect(mockGit.fetch).toHaveBeenCalledWith("origin", "develop"); }); it("should detect merge conflicts", async () => { // Mock successful fetch mockGit.fetch.mockResolvedValue(undefined); // Mock current branch mockGit.revparse.mockResolvedValue("feature-branch"); // Mock merge test - conflicts detected mockGit.raw.mockRejectedValueOnce(new Error("CONFLICT (content): Merge conflict in file.ts")); // Mock status - show conflicted files mockGit.status.mockResolvedValue({ conflicted: ["src/file.ts", "src/other.ts"], files: [ { path: "src/file.ts", index: "U", working_dir: "U", }, { path: "src/other.ts", index: "U", working_dir: "U", }, ], }); // Mock merge abort (cleanup) mockGit.raw.mockResolvedValue(""); const result = await service.checkForConflicts("/test/repo", { localPath: "/test/repo", remote: "origin", remoteBranch: "develop", strategy: "merge", }); expect(result.hasConflicts).toBe(true); expect(result.conflicts).toHaveLength(2); expect(result.conflicts[0].file).toBe("src/file.ts"); expect(result.conflicts[0].type).toBe("content"); expect(result.canRetry).toBe(true); }); it("should detect rebase conflicts", async () => { // Mock successful fetch mockGit.fetch.mockResolvedValue(undefined); // Mock current branch mockGit.revparse.mockResolvedValue("feature-branch"); // Mock rebase test - conflicts detected mockGit.raw.mockRejectedValueOnce( new Error("CONFLICT (content): Rebase conflict in file.ts") ); // Mock status - show conflicted files mockGit.status.mockResolvedValue({ conflicted: ["src/file.ts"], files: [ { path: "src/file.ts", index: "U", working_dir: "U", }, ], }); // Mock rebase abort (cleanup) mockGit.raw.mockResolvedValue(""); const result = await service.checkForConflicts("/test/repo", { localPath: "/test/repo", remote: "origin", remoteBranch: "develop", strategy: "rebase", }); expect(result.hasConflicts).toBe(true); expect(result.conflicts).toHaveLength(1); expect(result.strategy).toBe("rebase"); }); it("should handle fetch failure", async () => { // Mock fetch failure mockGit.fetch.mockRejectedValue(new Error("Network error")); await expect( service.checkForConflicts("/test/repo", { localPath: "/test/repo", remote: "origin", remoteBranch: "develop", }) ).rejects.toThrow(ConflictDetectionError); }); it("should detect delete conflicts", async () => { // Mock successful fetch mockGit.fetch.mockResolvedValue(undefined); // Mock current branch mockGit.revparse.mockResolvedValue("feature-branch"); // Mock merge test - conflicts detected mockGit.raw.mockRejectedValueOnce( new Error("CONFLICT (delete/modify): file.ts deleted in HEAD") ); // Mock status - show conflicted files with delete mockGit.status.mockResolvedValue({ conflicted: ["src/file.ts"], files: [ { path: "src/file.ts", index: "D", working_dir: "U", }, ], }); // Mock merge abort mockGit.raw.mockResolvedValue(""); const result = await service.checkForConflicts("/test/repo", { localPath: "/test/repo", remote: "origin", remoteBranch: "develop", strategy: "merge", }); expect(result.hasConflicts).toBe(true); expect(result.conflicts[0].type).toBe("delete"); }); it("should detect add conflicts", async () => { // Mock successful fetch mockGit.fetch.mockResolvedValue(undefined); // Mock current branch mockGit.revparse.mockResolvedValue("feature-branch"); // Mock merge test - conflicts detected mockGit.raw.mockRejectedValueOnce(new Error("CONFLICT (add/add): Merge conflict in file.ts")); // Mock status - show conflicted files with add mockGit.status.mockResolvedValue({ conflicted: ["src/file.ts"], files: [ { path: "src/file.ts", index: "A", working_dir: "A", }, ], }); // Mock merge abort mockGit.raw.mockResolvedValue(""); const result = await service.checkForConflicts("/test/repo", { localPath: "/test/repo", remote: "origin", remoteBranch: "develop", strategy: "merge", }); expect(result.hasConflicts).toBe(true); expect(result.conflicts[0].type).toBe("add"); }); it("should use default values for remote and branch", async () => { // Mock successful fetch mockGit.fetch.mockResolvedValue(undefined); // Mock current branch mockGit.revparse.mockResolvedValue("feature-branch"); // Mock merge test - no conflicts mockGit.raw.mockResolvedValue(""); // Mock status - no conflicted files mockGit.status.mockResolvedValue({ conflicted: [], files: [], }); const result = await service.checkForConflicts("/test/repo"); expect(result.remoteBranch).toBe("develop"); expect(mockGit.fetch).toHaveBeenCalledWith("origin", "develop"); }); it("should clean up after conflict detection", async () => { // Mock successful fetch mockGit.fetch.mockResolvedValue(undefined); // Mock current branch mockGit.revparse.mockResolvedValue("feature-branch"); // Mock merge test - conflicts mockGit.raw.mockRejectedValueOnce(new Error("CONFLICT")); // Mock status mockGit.status.mockResolvedValue({ conflicted: ["src/file.ts"], files: [], }); // Track raw calls const rawCalls: string[][] = []; mockGit.raw.mockImplementation((args: string[]) => { rawCalls.push(args); if (args[0] === "merge") { if (args[1] === "--abort") { return Promise.resolve(""); } return Promise.reject(new Error("CONFLICT")); } return Promise.resolve(""); }); await service.checkForConflicts("/test/repo", { localPath: "/test/repo", strategy: "merge", }); // Verify abort was called expect(rawCalls).toContainEqual(["merge", "--abort"]); }); }); describe("fetchRemote", () => { it("should fetch from remote successfully", async () => { mockGit.fetch.mockResolvedValue(undefined); await service.fetchRemote("/test/repo", "origin", "develop"); expect(mockGit.fetch).toHaveBeenCalledWith("origin", "develop"); }); it("should throw ConflictDetectionError on fetch failure", async () => { mockGit.fetch.mockRejectedValue(new Error("Network error")); await expect(service.fetchRemote("/test/repo", "origin", "develop")).rejects.toThrow( ConflictDetectionError ); }); it("should use default remote", async () => { mockGit.fetch.mockResolvedValue(undefined); await service.fetchRemote("/test/repo"); expect(mockGit.fetch).toHaveBeenCalledWith("origin"); }); }); describe("detectConflicts", () => { it("should return empty array when no conflicts", async () => { mockGit.status.mockResolvedValue({ conflicted: [], files: [], }); const conflicts = await service.detectConflicts("/test/repo"); expect(conflicts).toHaveLength(0); }); it("should detect conflicted files", async () => { mockGit.status.mockResolvedValue({ conflicted: ["src/file1.ts", "src/file2.ts"], files: [ { path: "src/file1.ts", index: "U", working_dir: "U", }, { path: "src/file2.ts", index: "U", working_dir: "U", }, ], }); const conflicts = await service.detectConflicts("/test/repo"); expect(conflicts).toHaveLength(2); expect(conflicts[0].file).toBe("src/file1.ts"); expect(conflicts[1].file).toBe("src/file2.ts"); }); it("should determine conflict type from git status", async () => { mockGit.status.mockResolvedValue({ conflicted: ["deleted.ts", "added.ts", "modified.ts"], files: [ { path: "deleted.ts", index: "D", working_dir: "U", }, { path: "added.ts", index: "A", working_dir: "A", }, { path: "modified.ts", index: "U", working_dir: "U", }, ], }); const conflicts = await service.detectConflicts("/test/repo"); expect(conflicts[0].type).toBe("delete"); expect(conflicts[1].type).toBe("add"); expect(conflicts[2].type).toBe("content"); }); it("should throw ConflictDetectionError on git status failure", async () => { mockGit.status.mockRejectedValue(new Error("Git error")); await expect(service.detectConflicts("/test/repo")).rejects.toThrow(ConflictDetectionError); }); }); describe("getCurrentBranch", () => { it("should return current branch name", async () => { mockGit.revparse.mockResolvedValue("feature-branch"); const branch = await service.getCurrentBranch("/test/repo"); expect(branch).toBe("feature-branch"); expect(mockGit.revparse).toHaveBeenCalledWith(["--abbrev-ref", "HEAD"]); }); it("should throw ConflictDetectionError on failure", async () => { mockGit.revparse.mockRejectedValue(new Error("Not a git repository")); await expect(service.getCurrentBranch("/test/repo")).rejects.toThrow(ConflictDetectionError); }); }); });