diff --git a/apps/server/git/remote-sync.test.ts b/apps/server/git/remote-sync.test.ts new file mode 100644 index 0000000..8f08044 --- /dev/null +++ b/apps/server/git/remote-sync.test.ts @@ -0,0 +1,172 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { ProjectSyncManager, type SyncResult } from './remote-sync.js' +import type { ProjectRepository } from '../db/repositories/project-repository.js' + +vi.mock('simple-git', () => ({ + simpleGit: vi.fn(), +})) + +vi.mock('./project-clones.js', () => ({ + ensureProjectClone: vi.fn().mockResolvedValue('/tmp/fake-clone'), +})) + +vi.mock('../logger/index.js', () => ({ + createModuleLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), +})) + +function makeRepo(overrides: Partial = {}): ProjectRepository { + return { + findAll: vi.fn().mockResolvedValue([]), + findById: vi.fn().mockResolvedValue(null), + create: vi.fn(), + update: vi.fn().mockResolvedValue({}), + delete: vi.fn(), + findProjectsByInitiativeId: vi.fn().mockResolvedValue([]), + setInitiativeProjects: vi.fn().mockResolvedValue(undefined), + ...overrides, + } as unknown as ProjectRepository +} + +const project1 = { + id: 'proj-1', + name: 'alpha', + url: 'https://github.com/org/alpha', + defaultBranch: 'main', + lastFetchedAt: null, + createdAt: new Date(), + updatedAt: new Date(), +} + +const project2 = { + id: 'proj-2', + name: 'beta', + url: 'https://github.com/org/beta', + defaultBranch: 'main', + lastFetchedAt: null, + createdAt: new Date(), + updatedAt: new Date(), +} + +describe('ProjectSyncManager', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let simpleGitMock: any + + beforeEach(async () => { + const mod = await import('simple-git') + simpleGitMock = vi.mocked(mod.simpleGit) + simpleGitMock.mockReset() + }) + + describe('syncAllProjects', () => { + it('returns empty array when no projects exist', async () => { + const repo = makeRepo({ findAll: vi.fn().mockResolvedValue([]) }) + const manager = new ProjectSyncManager(repo, '/workspace') + const results = await manager.syncAllProjects() + expect(results).toEqual([]) + }) + + it('returns success result for each project when all succeed', async () => { + const mockGit = { + fetch: vi.fn().mockResolvedValue({}), + raw: vi.fn().mockResolvedValue(''), + } + simpleGitMock.mockReturnValue(mockGit) + + const repo = makeRepo({ + findAll: vi.fn().mockResolvedValue([project1, project2]), + findById: vi.fn() + .mockResolvedValueOnce(project1) + .mockResolvedValueOnce(project2), + update: vi.fn().mockResolvedValue({}), + }) + const manager = new ProjectSyncManager(repo, '/workspace') + const results = await manager.syncAllProjects() + + expect(results).toHaveLength(2) + expect(results[0]).toMatchObject({ + projectId: 'proj-1', + projectName: 'alpha', + success: true, + fetched: true, + }) + expect(results[1]).toMatchObject({ + projectId: 'proj-2', + projectName: 'beta', + success: true, + fetched: true, + }) + }) + + it('returns partial failure when the second project fetch throws', async () => { + const mockGitSuccess = { + fetch: vi.fn().mockResolvedValue({}), + raw: vi.fn().mockResolvedValue(''), + } + const mockGitFail = { + fetch: vi.fn().mockRejectedValue(new Error('network error')), + raw: vi.fn().mockResolvedValue(''), + } + simpleGitMock + .mockReturnValueOnce(mockGitSuccess) + .mockReturnValueOnce(mockGitFail) + + const repo = makeRepo({ + findAll: vi.fn().mockResolvedValue([project1, project2]), + findById: vi.fn() + .mockResolvedValueOnce(project1) + .mockResolvedValueOnce(project2), + update: vi.fn().mockResolvedValue({}), + }) + const manager = new ProjectSyncManager(repo, '/workspace') + const results = await manager.syncAllProjects() + + expect(results).toHaveLength(2) + expect(results[0]).toMatchObject({ projectId: 'proj-1', success: true }) + expect(results[1]).toMatchObject({ + projectId: 'proj-2', + success: false, + error: expect.any(String), + }) + }) + }) + + describe('SyncResult shape', () => { + it('result always contains projectId and success fields', async () => { + const mockGit = { + fetch: vi.fn().mockResolvedValue({}), + raw: vi.fn().mockResolvedValue(''), + } + simpleGitMock.mockReturnValue(mockGit) + + const repo = makeRepo({ + findAll: vi.fn().mockResolvedValue([project1]), + findById: vi.fn().mockResolvedValue(project1), + update: vi.fn().mockResolvedValue({}), + }) + const manager = new ProjectSyncManager(repo, '/workspace') + const results = await manager.syncAllProjects() + + expect(results[0]).toMatchObject({ + projectId: expect.any(String), + success: expect.any(Boolean), + }) + }) + }) + + describe('failure counting logic', () => { + it('counts failures from SyncResult array', () => { + const results: Pick[] = [ + { success: true }, + { success: false }, + { success: true }, + { success: false }, + ] + const failed = results.filter(r => !r.success) + expect(failed.length).toBe(2) + }) + }) +}) diff --git a/apps/web/src/routes/settings/projects.tsx b/apps/web/src/routes/settings/projects.tsx index 230ceb2..95b561d 100644 --- a/apps/web/src/routes/settings/projects.tsx +++ b/apps/web/src/routes/settings/projects.tsx @@ -19,6 +19,24 @@ function ProjectsSettingsPage() { const projectsQuery = trpc.listProjects.useQuery() const utils = trpc.useUtils() + const syncAllMutation = trpc.syncAllProjects.useMutation({ + onSuccess: (results) => { + const failed = results.filter(r => !r.success) + if (failed.length === 0) { + toast.success('All projects synced.') + } else { + toast.error(`${failed.length} project(s) failed to sync — check project names in the list.`) + } + void utils.listProjects.invalidate() + results.forEach(r => { + void utils.getProjectSyncStatus.invalidate({ id: r.projectId }) + }) + }, + onError: (err) => { + toast.error(`Sync failed: ${err.message}`) + }, + }) + const deleteMutation = trpc.deleteProject.useMutation({ onSuccess: () => { void utils.listProjects.invalidate() @@ -43,7 +61,18 @@ function ProjectsSettingsPage() { return (
-
+
+ {(projects?.length ?? 0) > 0 && ( + + )}