// frontend/src/lib/models/__tests__/GitManagerModel.test.ts // #region GitManagerModelTests [C:3] [TYPE Module] [SEMANTICS test,model,git-manager] // @BRIEF L1 unit tests for GitManagerModel — no DOM render, mocks all API + toast + i18n. // @RELATION BINDS_TO -> [GitManagerModel] // @TEST_INVARIANT: derived-computations -> VERIFIED_BY: [test_hasWorkspaceChanges_true_when_changes, test_changedFilesCount_aggregates_all_categories, test_derived_no_workspaceStatus] // @TEST_INVARIANT: loading-state-lifecycle -> VERIFIED_BY: [test_checkStatus_sets_checkingStatus_lifecycle, test_handleCommit_sets_committing_lifecycle] // @TEST_INVARIANT: mutual-exclusion -> VERIFIED_BY: [test_mutual_exclusion_defaults_all_false] // @TEST_INVARIANT: error-handling -> VERIFIED_BY: [test_handleCommit_sets_gitError_on_api_failure, test_handleCommit_clears_gitError_before_operation] // @TEST_INVARIANT: commit-calls-api -> VERIFIED_BY: [test_handleCommit_calls_gitService_commit_with_correct_args, test_handleCommit_calls_push_when_autoPushAfterCommit] // @TEST_INVARIANT: promote-calls-api -> VERIFIED_BY: [test_handlePromote_calls_gitService_promote, test_handlePromote_validates_branches] // @TEST_INVARIANT: pull-calls-api -> VERIFIED_BY: [test_handlePull_calls_gitService_pull] // @TEST_INVARIANT: clearGitError -> VERIFIED_BY: [test_clearGitError_resets_both_fields] // @TEST_EDGE: missing_field -> empty commitMessage blocks handleCommit // @TEST_EDGE: invalid_type -> numeric dashboard id blocks checkStatus // @TEST_EDGE: external_fail -> API throws on handlePull import { describe, it, expect, vi, beforeEach } from "vitest"; // ── Mocks ──────────────────────────────────────────────────────── vi.mock("../../../services/gitService.js", () => ({ gitService: { getConfigs: vi.fn(), getBranches: vi.fn(), getStatus: vi.fn(), getDiff: vi.fn(), commit: vi.fn(), push: vi.fn(), pull: vi.fn(), promote: vi.fn(), sync: vi.fn(), getMergeStatus: vi.fn(), getMergeConflicts: vi.fn(), resolveMergeConflicts: vi.fn(), abortMerge: vi.fn(), continueMerge: vi.fn(), getRepositoryBinding: vi.fn(), initRepository: vi.fn(), createRemoteRepository: vi.fn() }, })); vi.mock("$lib/api.js", () => ({ api: { getEnvironmentsList: vi.fn(), postApi: vi.fn() } })); vi.mock('$lib/toasts.svelte.js', () => ({ addToast: vi.fn() })); vi.mock('$lib/i18n/index.svelte.js', () => ({ t: { subscribe: vi.fn() }, _: vi.fn(), getT: vi.fn(() => ({ git: { sync_success: "Synced", commit_success: "Committed", commit_and_push_success: "Commit & Push OK", commit_message_generated: "Generated", pull_success: "Pulled", push_success: "Pushed", init_success: "Init OK", init_validation_error: "Fill all fields", no_servers_configured: "No servers", repo_already_exists: "Already exists" } })) })); vi.mock("$lib/cot-logger", () => ({ log: vi.fn() })); vi.mock("svelte/store", () => ({ get: vi.fn(() => ({ git: {} })) })); import { GitManagerModel } from "../GitManagerModel.svelte.ts"; import { gitService } from "../../../services/gitService.js"; import { api } from "$lib/api.js"; import { addToast } from "$lib/toasts.svelte.js"; interface WorkspaceStatusFixture { is_dirty?: boolean; staged_files?: string[]; modified_files?: string[]; untracked_files?: string[]; current_branch?: string; } function createModel(overrides: Partial<{ dashboardId: string; envId: string | null; dashboardTitle: string }> = {}): GitManagerModel { return new GitManagerModel({ dashboardId: "test-dashboard", envId: "env-123", dashboardTitle: "Test Dashboard", ...overrides }); } function makeWs(overrides: Partial = {}): WorkspaceStatusFixture { return { is_dirty: false, staged_files: [], modified_files: [], untracked_files: [], current_branch: "main", ...overrides }; } describe("GitManagerModel — L1 invariants (no render)", () => { let model: GitManagerModel; beforeEach(() => { vi.clearAllMocks(); model = createModel(); }); describe("constructor", () => { it("stores props", () => { expect(model.dashboardId).toBe("test-dashboard"); expect(model.envId).toBe("env-123"); expect(model.dashboardTitle).toBe("Test Dashboard"); }); it("defaults when called empty", () => { const m = new GitManagerModel(); expect(m.dashboardId).toBe(""); expect(m.envId).toBeNull(); }); }); describe("derived state", () => { it("hasWorkspaceChanges false when status null", () => { model.workspaceStatus = null; expect(model.hasWorkspaceChanges).toBe(false); }); it("hasWorkspaceChanges true when staged", () => { model.workspaceStatus = makeWs({ staged_files: ["x"] }); expect(model.hasWorkspaceChanges).toBe(true); }); it("hasWorkspaceChanges true when modified", () => { model.workspaceStatus = makeWs({ modified_files: ["x"] }); expect(model.hasWorkspaceChanges).toBe(true); }); it("hasWorkspaceChanges true when untracked", () => { model.workspaceStatus = makeWs({ untracked_files: ["x"] }); expect(model.hasWorkspaceChanges).toBe(true); }); it("hasWorkspaceChanges false when empty", () => { model.workspaceStatus = makeWs(); expect(model.hasWorkspaceChanges).toBe(false); }); it("changedFilesCount aggregates all", () => { model.workspaceStatus = makeWs({ staged_files: ["a", "b"], modified_files: ["c"], untracked_files: ["d", "e"] }); expect(model.changedFilesCount).toBe(5); }); it("changedFilesCount 0 when null", () => { model.workspaceStatus = null; expect(model.changedFilesCount).toBe(0); }); it("changedFilesCount 0 when empty", () => { model.workspaceStatus = makeWs(); expect(model.changedFilesCount).toBe(0); }); it("resolvedEnvId returns envId", () => { model.envId = "env-456"; expect(model.resolvedEnvId).toBe("env-456"); }); it("hasOriginConfigMismatch false when same host", () => { model.repositoryBindingRemoteUrl = "https://git.example.com/repo.git"; model.repositoryConfigUrl = "https://git.example.com/config"; expect(model.hasOriginConfigMismatch).toBe(false); }); it("pushProviderLabel default is git", () => { expect(model.pushProviderLabel).toBe("git"); }); }); describe("mutual exclusion — defaults", () => { it("all false except checkingStatus", () => { expect(model.checkingStatus).toBe(true); expect(model.loading).toBe(false); expect(model.workspaceLoading).toBe(false); expect(model.committing).toBe(false); expect(model.isPulling).toBe(false); expect(model.isPushing).toBe(false); expect(model.generatingMessage).toBe(false); expect(model.promoting).toBe(false); expect(model.mergeResolveInProgress).toBe(false); expect(model.mergeAbortInProgress).toBe(false); expect(model.mergeContinueInProgress).toBe(false); expect(model.creatingRemoteRepo).toBe(false); }); }); describe("error handling", () => { it("clearGitError resets error", () => { model.gitError = { message: "Broke", status: 500, error_code: null, files: [], next_steps: [], detail: null }; model.clearGitError(); expect(model.gitError).toBeNull(); }); it("handleCommit sets gitError on failure", async () => { model.commitMessage = "fix"; model.workspaceStatus = makeWs({ staged_files: ["x"] }); vi.mocked(gitService.commit).mockRejectedValue(new Error("Fail")); await model.handleCommit(); expect(model.gitError).not.toBeNull(); }); it("handleCommit clears before operation", async () => { model.commitMessage = "fix"; model.workspaceStatus = makeWs({ staged_files: ["x"] }); model.gitError = { message: "Old", status: 500, error_code: null, files: [], next_steps: [], detail: null }; vi.mocked(gitService.commit).mockRejectedValue(new Error("New")); await model.handleCommit(); expect(model.gitError!.message).toBe("New"); }); }); describe("loading state lifecycle", () => { it("checkStatus transitions checkingStatus", async () => { vi.mocked(gitService.getBranches).mockResolvedValue(["main"]); vi.mocked(gitService.getStatus).mockResolvedValue(makeWs()); vi.mocked(gitService.getDiff).mockResolvedValue(""); vi.mocked(gitService.getDiff).mockResolvedValue(""); expect(model.checkingStatus).toBe(true); await model.checkStatus(); expect(model.checkingStatus).toBe(false); expect(model.initialized).toBe(true); }); it("checkStatus sets false on failure", async () => { vi.mocked(gitService.getBranches).mockRejectedValue(new Error("No repo")); await model.checkStatus(); expect(model.initialized).toBe(false); expect(model.checkingStatus).toBe(false); }); it("handleCommit sets committing lifecycle", async () => { model.commitMessage = "fix"; model.workspaceStatus = makeWs({ staged_files: ["x"] }); vi.mocked(gitService.commit).mockImplementation(() => new Promise((r) => setTimeout(r, 10))); const p = model.handleCommit(); expect(model.committing).toBe(true); await p; expect(model.committing).toBe(false); }); }); describe("guard conditions", () => { it("handleCommit returns early when message empty", async () => { await model.handleCommit(); expect(gitService.commit).not.toHaveBeenCalled(); }); it("handleCommit returns early when no changes", async () => { model.commitMessage = "fix"; await model.handleCommit(); expect(gitService.commit).not.toHaveBeenCalled(); }); it("handlePromote validates branches different", async () => { model.promoteFromBranch = "main"; model.promoteToBranch = "main"; await model.handlePromote(); expect(gitService.promote).not.toHaveBeenCalled(); }); it("handlePromote validates reason in direct mode", async () => { model.promoteFromBranch = "dev"; model.promoteToBranch = "main"; model.promoteMode = "direct"; await model.handlePromote(); expect(gitService.promote).not.toHaveBeenCalled(); }); }); describe("handleCommit — API", () => { it("calls commit with correct args", async () => { model.commitMessage = "fix: x"; model.workspaceStatus = makeWs({ staged_files: ["x"] }); model.autoPushAfterCommit = false; vi.mocked(gitService.commit).mockResolvedValue({}); await model.handleCommit(); expect(gitService.commit).toHaveBeenCalledWith("test-dashboard", "fix: x", [], "env-123"); }); it("calls push when autoPushAfterCommit", async () => { model.commitMessage = "fix"; model.workspaceStatus = makeWs({ staged_files: ["x"] }); model.autoPushAfterCommit = true; vi.mocked(gitService.commit).mockResolvedValue({}); vi.mocked(gitService.push).mockResolvedValue({}); await model.handleCommit(); expect(gitService.push).toHaveBeenCalled(); }); it("does not push when autoPushAfterCommit false", async () => { model.commitMessage = "fix"; model.workspaceStatus = makeWs({ staged_files: ["x"] }); model.autoPushAfterCommit = false; vi.mocked(gitService.commit).mockResolvedValue({}); await model.handleCommit(); expect(gitService.push).not.toHaveBeenCalled(); }); }); describe("handlePromote — API", () => { it("calls promote with correct args in MR mode", async () => { model.promoteFromBranch = "dev"; model.promoteToBranch = "main"; model.promoteMode = "mr"; vi.mocked(gitService.promote).mockResolvedValue({ url: "https://example.com/mr/1" }); await model.handlePromote(); expect(gitService.promote).toHaveBeenCalledWith("test-dashboard", expect.objectContaining({ mode: "mr" }), "env-123"); }); it("calls promote with reason in direct mode", async () => { model.promoteFromBranch = "dev"; model.promoteToBranch = "preprod"; model.promoteMode = "direct"; model.promoteReason = "Hotfix"; vi.mocked(gitService.promote).mockResolvedValue({}); await model.handlePromote(); expect(gitService.promote).toHaveBeenCalledWith("test-dashboard", expect.objectContaining({ reason: "Hotfix" }), "env-123"); }); }); describe("handlePull — API", () => { it("calls pull with correct args", async () => { vi.mocked(gitService.pull).mockResolvedValue({}); vi.mocked(gitService.getStatus).mockResolvedValue(makeWs()); vi.mocked(gitService.getDiff).mockResolvedValue(""); vi.mocked(gitService.getDiff).mockResolvedValue(""); await model.handlePull(); expect(gitService.pull).toHaveBeenCalledWith("test-dashboard", "env-123"); expect(model.isPulling).toBe(false); }); it("opens merge dialog on 409", async () => { const err = new Error("Unfinished merge"); (err as any).status = 409; (err as any).detail = { error_code: "GIT_UNFINISHED_MERGE", message: "Merge in progress", repository_path: "/repo", git_dir: "/repo/.git", current_branch: "main", merge_head: "feature", manual_commands: ["git merge --abort"], next_steps: ["Abort"] }; vi.mocked(gitService.pull).mockRejectedValue(err); vi.mocked(gitService.getMergeStatus).mockResolvedValue({ has_unfinished_merge: true }); await model.handlePull(); expect(model.showUnfinishedMergeDialog).toBe(true); }); it("sets gitError on non-merge failure", async () => { vi.mocked(gitService.pull).mockRejectedValue(new Error("Network")); await model.handlePull(); expect(model.gitError).not.toBeNull(); }); }); describe("handlePush — API", () => { it("calls push", async () => { vi.mocked(gitService.push).mockResolvedValue({}); vi.mocked(gitService.getStatus).mockResolvedValue(makeWs()); vi.mocked(gitService.getDiff).mockResolvedValue(""); vi.mocked(gitService.getDiff).mockResolvedValue(""); await model.handlePush(); expect(gitService.push).toHaveBeenCalledWith("test-dashboard", "env-123"); }); it("sets gitError on failure", async () => { vi.mocked(gitService.push).mockRejectedValue(new Error("Rejected")); await model.handlePush(); expect(model.gitError).not.toBeNull(); }); }); describe("handleSync — API", () => { it("calls sync", async () => { vi.mocked(gitService.sync).mockResolvedValue({}); vi.mocked(gitService.getStatus).mockResolvedValue(makeWs()); vi.mocked(gitService.getDiff).mockResolvedValue(""); vi.mocked(gitService.getDiff).mockResolvedValue(""); await model.handleSync(); expect(gitService.sync).toHaveBeenCalledWith("test-dashboard", "env-123", "env-123"); }); it("sets gitError on failure", async () => { vi.mocked(gitService.sync).mockRejectedValue(new Error("Sync fail")); await model.handleSync(); expect(model.gitError).not.toBeNull(); }); }); describe("handleGenerateMessage — API", () => { it("calls postApi and sets message", async () => { vi.mocked(api.postApi).mockResolvedValue({ message: "AI message" }); await model.handleGenerateMessage(); expect(api.postApi).toHaveBeenCalledWith(expect.stringContaining("/generate-message"), undefined, { suppressToast: true }); expect(model.commitMessage).toBe("AI message"); expect(model.generatingMessage).toBe(false); }); it("sets gitError on failure", async () => { vi.mocked(api.postApi).mockRejectedValue(new Error("Gen failed")); await model.handleGenerateMessage(); expect(model.gitError).not.toBeNull(); expect(model.generatingMessage).toBe(false); }); }); describe("checkStatus — guard paths", () => { it("blocks numeric dashboardId", async () => { model.dashboardId = "12345"; await model.checkStatus(); expect(model.initialized).toBe(false); expect(addToast).toHaveBeenCalledWith(expect.stringContaining("Numeric ID"), "error"); }); it("sets false when no resolvedEnvId", async () => { const m = createModel({ envId: null }); await m.checkStatus(); expect(m.initialized).toBe(false); expect(m.checkingStatus).toBe(false); }); }); describe("loadWorkspace", () => { it("returns early when not initialized", async () => { model.initialized = false; await model.loadWorkspace(); expect(gitService.getStatus).not.toHaveBeenCalled(); }); it("loads workspace on success", async () => { model.initialized = true; vi.mocked(gitService.getStatus).mockResolvedValue(makeWs({ staged_files: ["x"] })); vi.mocked(gitService.getDiff).mockResolvedValueOnce("staged"); vi.mocked(gitService.getDiff).mockResolvedValueOnce("unstaged"); await model.loadWorkspace(); expect(model.workspaceStatus).toEqual(makeWs({ staged_files: ["x"] })); expect(model.workspaceDiff).toContain("staged"); }); it("sets gitError on failure", async () => { model.initialized = true; vi.mocked(gitService.getStatus).mockRejectedValue(new Error("Fail")); await model.loadWorkspace(); expect(model.gitError).not.toBeNull(); }); }); describe("handleInit", () => { it("returns early when missing config or url", async () => { await model.handleInit(); expect(gitService.initRepository).not.toHaveBeenCalled(); }); it("calls init on success", async () => { model.selectedConfigId = "cfg-1"; model.remoteUrl = "https://example.com/repo.git"; vi.mocked(gitService.initRepository).mockResolvedValue({}); await model.handleInit(); expect(gitService.initRepository).toHaveBeenCalledWith("test-dashboard", "cfg-1", "https://example.com/repo.git", "env-123"); }); }); describe("handleCreateRemoteRepo", () => { it("returns early when no config", async () => { await model.handleCreateRemoteRepo(); expect(model.showCreateRepoDialog).toBe(false); expect(gitService.createRemoteRepository).not.toHaveBeenCalled(); }); it("handles 409 already exists error", async () => { model.configs = [{ id: "cfg-1", provider: "github" }]; model.selectedConfigId = "cfg-1"; model.dashboardTitle = "Test"; await model.handleCreateRemoteRepo(); expect(model.showCreateRepoDialog).toBe(true); model.pendingRepoName = "my-repo"; const err = new Error("already exists"); (err as any).status = 409; vi.mocked(gitService.createRemoteRepository).mockRejectedValue(err); await model.confirmCreateRemoteRepo(); expect(addToast).toHaveBeenCalledWith("Already exists", "warning", 0); }); it("handles other error via _setGitError", async () => { model.configs = [{ id: "cfg-1", provider: "github" }]; model.selectedConfigId = "cfg-1"; await model.handleCreateRemoteRepo(); expect(model.showCreateRepoDialog).toBe(true); model.pendingRepoName = "my-repo"; vi.mocked(gitService.createRemoteRepository).mockRejectedValue(new Error("Server error")); await model.confirmCreateRemoteRepo(); expect(model.gitError).not.toBeNull(); expect(model.creatingRemoteRepo).toBe(false); }); it("returns early when user cancels prompt", async () => { model.configs = [{ id: "cfg-1", provider: "github" }]; await model.handleCreateRemoteRepo(); expect(model.showCreateRepoDialog).toBe(true); expect(gitService.createRemoteRepository).not.toHaveBeenCalled(); // Close dialog without confirming model.showCreateRepoDialog = false; model.pendingRepoName = ""; }); }); describe("handleInit — guard paths", () => { it("blocks when missing resolvedEnvId and non-numeric", async () => { model.selectedConfigId = "cfg-1"; model.remoteUrl = "https://example.com/repo.git"; const m = createModel({ envId: null }); m.selectedConfigId = "cfg-1"; m.remoteUrl = "https://example.com/repo.git"; await m.handleInit(); expect(addToast).toHaveBeenCalledWith(expect.stringContaining("Environment must be selected"), "error"); expect(gitService.initRepository).not.toHaveBeenCalled(); }); }); describe("initialize", () => { it("loads configs and checks status", async () => { vi.mocked(gitService.getConfigs).mockResolvedValue([{ id: "cfg-1", provider: "github" }]); vi.mocked(gitService.getBranches).mockResolvedValue(["main"]); vi.mocked(gitService.getStatus).mockResolvedValue(makeWs()); vi.mocked(gitService.getDiff).mockResolvedValue(""); vi.mocked(api.getEnvironmentsList).mockResolvedValue([{ id: "env-123", name: "Dev" }]); vi.mocked(gitService.getRepositoryBinding).mockResolvedValue({ provider: "github", remote_url: "https://example.com/repo.git", config_id: "cfg-1" }); await model.initialize(); expect(model.configs).toHaveLength(1); expect(model.loading).toBe(false); expect(model.initialized).toBe(true); }); it("handles config fetch error", async () => { vi.mocked(gitService.getConfigs).mockRejectedValue(new Error("Fail")); vi.mocked(gitService.getBranches).mockResolvedValue(["main"]); vi.mocked(gitService.getStatus).mockResolvedValue(makeWs()); vi.mocked(gitService.getDiff).mockResolvedValue(""); vi.mocked(api.getEnvironmentsList).mockResolvedValue([]); await model.initialize(); expect(model.configs).toEqual([]); expect(model.loading).toBe(false); }); }); describe("loadCurrentEnvironmentStage", () => { it("fetches env stage and sets defaults", async () => { vi.mocked(api.getEnvironmentsList).mockResolvedValue([{ id: "env-123", name: "Dev" }]); await model.loadCurrentEnvironmentStage(); expect(model.currentEnvStage).toBe("DEV"); }); it("handles error gracefully", async () => { const m = createModel({ envId: "env-99" }); vi.mocked(api.getEnvironmentsList).mockRejectedValue(new Error("API error")); const { log } = await import("$lib/cot-logger"); await m.loadCurrentEnvironmentStage(); expect(log).toHaveBeenCalledWith( expect.any(String), "EXPLORE", expect.any(String), expect.any(Object), expect.any(String), ); }); it("returns early when no envId", async () => { const m = createModel({ envId: null }); await m.loadCurrentEnvironmentStage(); expect(api.getEnvironmentsList).not.toHaveBeenCalled(); }); }); describe("openDeployModal", () => { it("opens modal when stage is not PROD", () => { model.currentEnvStage = "DEV"; model.openDeployModal(); expect(model.showDeployModal).toBe(true); }); it("requires confirmation for PROD", () => { model.currentEnvStage = "PROD"; model.dashboardId = "test-dashboard"; model.openDeployModal(); expect(model.showDeployModal).toBe(false); expect(model.showDeployConfirm).toBe(true); expect(model.deployConfirmSlug).toBe("test-dashboard"); }); it("opens modal when PROD confirmed correctly", () => { model.currentEnvStage = "PROD"; model.dashboardId = "test-dashboard"; model.openDeployModal(); model.confirmDeploy("test-dashboard"); expect(model.showDeployModal).toBe(true); expect(model.showDeployConfirm).toBe(false); }); it("keeps deploy modal closed when PROD confirmation fails", () => { model.currentEnvStage = "PROD"; model.dashboardId = "test-dashboard"; model.openDeployModal(); model.confirmDeploy("wrong-slug"); expect(model.showDeployModal).toBe(false); expect(model.showDeployConfirm).toBe(false); expect(addToast).toHaveBeenCalledWith(expect.stringContaining("PROD"), "error"); }); }); describe("handleSync — numeric guard", () => { it("blocks numeric dashboardId", async () => { model.dashboardId = "12345"; await model.handleSync(); expect(gitService.sync).not.toHaveBeenCalled(); }); }); describe("derived — origin/config host mismatch", () => { it("hasOriginConfigMismatch true when hosts differ", () => { model.repositoryBindingRemoteUrl = "https://host1.example.com/repo.git"; model.repositoryConfigUrl = "https://host2.example.com/config"; expect(model.hasOriginConfigMismatch).toBe(true); }); it("hasOriginConfigMismatch false when one host missing", () => { model.repositoryBindingRemoteUrl = ""; model.repositoryConfigUrl = "https://host2.example.com/config"; expect(model.hasOriginConfigMismatch).toBe(false); }); it("originHost extracts http host from URL", () => { model.repositoryBindingRemoteUrl = "https://gitlab.com/org/repo.git"; expect(model.originHost).toBe("gitlab.com"); }); }); describe("merge recovery — extended", () => { it("loadMergeRecoveryState loads and updates context", async () => { vi.mocked(gitService.getMergeStatus).mockResolvedValue({ has_unfinished_merge: true, repository_path: "/repo", git_dir: "/repo/.git", current_branch: "main", merge_head: "feature", merge_message_preview: "Merge msg", conflicts_count: 2, }); model.unfinishedMergeContext = { nextSteps: ["Step 1"], commands: ["cmd1"] } as any; await model.loadMergeRecoveryState(); expect(model.unfinishedMergeContext?.repositoryPath).toBe("/repo"); expect(model.unfinishedMergeContext?.conflictsCount).toBe(2); expect(model.mergeRecoveryLoading).toBe(false); }); it("loadMergeRecoveryState closes dialog when no unfinished merge", async () => { model.showUnfinishedMergeDialog = true; vi.mocked(gitService.getMergeStatus).mockResolvedValue({ has_unfinished_merge: false }); await model.loadMergeRecoveryState(); expect(model.showUnfinishedMergeDialog).toBe(false); }); it("loadMergeRecoveryState sets gitError on failure", async () => { vi.mocked(gitService.getMergeStatus).mockRejectedValue(new Error("Merge error")); await model.loadMergeRecoveryState(); expect(model.gitError).not.toBeNull(); }); it("handleOpenConflictResolver loads conflicts", async () => { vi.mocked(gitService.getMergeConflicts).mockResolvedValue([{ file_path: "file1.ts" }]); await model.handleOpenConflictResolver(); expect(model.mergeConflicts).toHaveLength(1); expect(model.showConflictResolver).toBe(true); expect(model.mergeRecoveryLoading).toBe(false); }); it("handleOpenConflictResolver shows info when no conflicts", async () => { vi.mocked(gitService.getMergeConflicts).mockResolvedValue([]); await model.handleOpenConflictResolver(); expect(addToast).toHaveBeenCalledWith(expect.stringContaining("No unresolved"), "info"); }); it("handleOpenConflictResolver sets gitError on failure", async () => { vi.mocked(gitService.getMergeConflicts).mockRejectedValue(new Error("Fail")); await model.handleOpenConflictResolver(); expect(model.gitError).not.toBeNull(); }); it("handleResolveConflicts processes event detail", async () => { vi.mocked(gitService.resolveMergeConflicts).mockResolvedValue({}); vi.mocked(gitService.getMergeStatus).mockResolvedValue({ has_unfinished_merge: false }); vi.mocked(gitService.getStatus).mockResolvedValue(makeWs()); vi.mocked(gitService.getDiff).mockResolvedValue(""); await model.handleResolveConflicts(new CustomEvent("resolve", { detail: { "file1.ts": "ours" } })); expect(gitService.resolveMergeConflicts).toHaveBeenCalled(); expect(model.mergeResolveInProgress).toBe(false); }); it("handleResolveConflicts warns on empty detail", async () => { await model.handleResolveConflicts(new CustomEvent("resolve", { detail: {} })); expect(addToast).toHaveBeenCalledWith(expect.stringContaining("resolutions"), "warning"); expect(gitService.resolveMergeConflicts).not.toHaveBeenCalled(); }); it("handleResolveConflicts sets gitError on failure", async () => { vi.mocked(gitService.resolveMergeConflicts).mockRejectedValue(new Error("Resolve fail")); await model.handleResolveConflicts(new CustomEvent("resolve", { detail: { "x": "y" } })); expect(model.gitError).not.toBeNull(); }); it("handleAbortUnfinishedMerge calls abort and resets", async () => { vi.mocked(gitService.abortMerge).mockResolvedValue({}); vi.mocked(gitService.getStatus).mockResolvedValue(makeWs()); vi.mocked(gitService.getDiff).mockResolvedValue(""); await model.handleAbortUnfinishedMerge(); expect(gitService.abortMerge).toHaveBeenCalledWith("test-dashboard", "env-123"); expect(model.mergeAbortInProgress).toBe(false); }); it("handleAbortUnfinishedMerge sets gitError on failure", async () => { vi.mocked(gitService.abortMerge).mockRejectedValue(new Error("Abort fail")); await model.handleAbortUnfinishedMerge(); expect(model.gitError).not.toBeNull(); }); it("handleContinueUnfinishedMerge calls continue", async () => { vi.mocked(gitService.continueMerge).mockResolvedValue({}); vi.mocked(gitService.getStatus).mockResolvedValue(makeWs()); vi.mocked(gitService.getDiff).mockResolvedValue(""); await model.handleContinueUnfinishedMerge(); expect(gitService.continueMerge).toHaveBeenCalledWith("test-dashboard", "", "env-123"); expect(model.mergeContinueInProgress).toBe(false); }); it("handleContinueUnfinishedMerge loads recovery on failure", async () => { vi.mocked(gitService.continueMerge).mockRejectedValue(new Error("Continue fail")); vi.mocked(gitService.getMergeStatus).mockResolvedValue({ has_unfinished_merge: true }); await model.handleContinueUnfinishedMerge(); expect(model.mergeContinueInProgress).toBe(false); // _setGitError sets gitError before loadMergeRecoveryState clears it // but we verify the loading flag is reset }); it("handleCopyUnfinishedMergeCommands copies to clipboard", async () => { Object.assign(navigator, { clipboard: { writeText: vi.fn().mockResolvedValue(undefined) } }); const writeTextSpy = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue(); model.unfinishedMergeContext = { commands: ["git merge --abort"] } as any; await model.handleCopyUnfinishedMergeCommands(); expect(writeTextSpy).toHaveBeenCalledWith("git merge --abort"); expect(model.copyingUnfinishedMergeCommands).toBe(false); writeTextSpy.mockRestore(); }); it("handleCopyUnfinishedMergeCommands warns when no commands", async () => { model.unfinishedMergeContext = { commands: [] } as any; await model.handleCopyUnfinishedMergeCommands(); expect(addToast).toHaveBeenCalledWith(expect.stringContaining("копирования"), "warning"); }); it("handleCopyUnfinishedMergeCommands handles clipboard error", async () => { Object.assign(navigator, { clipboard: { writeText: vi.fn().mockRejectedValue(new Error("Clipboard blocked")) } }); model.unfinishedMergeContext = { commands: ["git status"] } as any; await model.handleCopyUnfinishedMergeCommands(); expect(addToast).toHaveBeenCalledWith(expect.stringContaining('скопировать'), "error"); expect(model.copyingUnfinishedMergeCommands).toBe(false); }); }); describe("openUnfinishedMergeDialogFromError", () => { it("returns true when context found", () => { const err = new Error("Unfinished merge"); (err as any).status = 409; (err as any).detail = { error_code: "GIT_UNFINISHED_MERGE", repository_path: "/repo", git_dir: "/repo/.git", current_branch: "main", merge_head: "feature", manual_commands: ["git merge --abort"], next_steps: ["Abort"] }; const result = model.openUnfinishedMergeDialogFromError(err); expect(result).toBe(true); expect(model.showUnfinishedMergeDialog).toBe(true); expect(model.unfinishedMergeContext).not.toBeNull(); }); }); describe("coverage — remaining uncovered paths", () => { it("buildGitErrorFromException handles falsy error", () => { (model as any)._setGitError(null); expect(model.gitError).not.toBeNull(); expect(model.gitError!.message).toBe('Неизвестная ошибка Git'); }); it("_snapshot returns helper object and calls resolveEnvId", () => { const snap = model._snapshot(); const envId = snap.resolveEnvId(); expect(typeof envId).toBe("string"); expect(typeof snap.normalizeEnvStageFn).toBe("function"); }); it("initialize handles getRepositoryBinding failure", async () => { vi.mocked(gitService.getConfigs).mockResolvedValue([{ id: "cfg-1" }]); vi.mocked(gitService.getBranches).mockResolvedValue(["main"]); vi.mocked(gitService.getStatus).mockResolvedValue(makeWs()); vi.mocked(gitService.getDiff).mockResolvedValue(""); vi.mocked(api.getEnvironmentsList).mockResolvedValue([{ id: "env-123" }]); vi.mocked(gitService.getRepositoryBinding).mockRejectedValue(new Error("Binding fail")); await model.initialize(); expect(model.repositoryProvider).toBe(""); expect(model.repositoryBindingRemoteUrl).toBe(""); }); it("handlePromote sets error on API failure", async () => { model.promoteFromBranch = "dev"; model.promoteToBranch = "main"; model.promoteMode = "mr"; vi.mocked(gitService.promote).mockRejectedValue(new Error("Promote fail")); await model.handlePromote(); expect(model.gitError).not.toBeNull(); }); it("handleInit sets error on init failure", async () => { model.selectedConfigId = "cfg-1"; model.remoteUrl = "https://example.com/repo.git"; vi.mocked(gitService.initRepository).mockRejectedValue(new Error("Init fail")); await model.handleInit(); expect(model.gitError).not.toBeNull(); }); it("handleCreateRemoteRepo succeeds with clone_url", async () => { model.configs = [{ id: "cfg-1", provider: "github" }]; model.selectedConfigId = "cfg-1"; model.dashboardTitle = "Test Dash"; await model.handleCreateRemoteRepo(); expect(model.showCreateRepoDialog).toBe(true); model.pendingRepoName = "my-repo"; vi.mocked(gitService.createRemoteRepository).mockResolvedValue({ clone_url: "https://github.com/org/repo.git" }); await model.confirmCreateRemoteRepo(); expect(model.remoteUrl).toBe("https://github.com/org/repo.git"); }); it("handleCreateRemoteRepo succeeds with html_url", async () => { model.configs = [{ id: "cfg-1", provider: "gitlab" }]; model.selectedConfigId = "cfg-1"; model.dashboardTitle = "Test Dash"; await model.handleCreateRemoteRepo(); expect(model.showCreateRepoDialog).toBe(true); model.pendingRepoName = "my-repo"; vi.mocked(gitService.createRemoteRepository).mockResolvedValue({ html_url: "https://gitlab.com/org/repo" }); await model.confirmCreateRemoteRepo(); expect(model.remoteUrl).toBe("https://gitlab.com/org/repo"); }); }); }); // #endregion GitManagerModelTests