feat: Add Sync All button to projects settings page with tests
- Adds syncAllMutation calling trpc.syncAllProjects once for all cards - Shows Sync All button in header when ≥1 project exists (hidden otherwise) - Propagates isPending to ProjectCard.syncAllPending to disable per-card sync - On success: toasts all-success or failure count; invalidates listProjects + getProjectSyncStatus per project - On network error: toasts Sync failed with err.message - Adds unit tests for ProjectSyncManager.syncAllProjects: empty list, all succeed, partial failure, result shape, and failure counting logic Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
172
apps/server/git/remote-sync.test.ts
Normal file
172
apps/server/git/remote-sync.test.ts
Normal file
@@ -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> = {}): 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<SyncResult, 'success'>[] = [
|
||||
{ success: true },
|
||||
{ success: false },
|
||||
{ success: true },
|
||||
{ success: false },
|
||||
]
|
||||
const failed = results.filter(r => !r.success)
|
||||
expect(failed.length).toBe(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-end">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{(projects?.length ?? 0) > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => syncAllMutation.mutate()}
|
||||
disabled={syncAllMutation.isPending}
|
||||
>
|
||||
<RefreshCw className={syncAllMutation.isPending ? 'animate-spin mr-2 h-4 w-4' : 'mr-2 h-4 w-4'} />
|
||||
{syncAllMutation.isPending ? 'Syncing…' : 'Sync All'}
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" onClick={() => setRegisterOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Register Project
|
||||
@@ -72,6 +101,7 @@ function ProjectsSettingsPage() {
|
||||
<ProjectCard
|
||||
key={project.id}
|
||||
project={project}
|
||||
syncAllPending={syncAllMutation.isPending}
|
||||
onDelete={() => {
|
||||
if (window.confirm(`Delete project "${project.name}"? This will also remove the cloned repository.`)) {
|
||||
deleteMutation.mutate({ id: project.id })
|
||||
@@ -108,9 +138,11 @@ function formatRelativeTime(date: Date | string | null): string {
|
||||
function ProjectCard({
|
||||
project,
|
||||
onDelete,
|
||||
syncAllPending,
|
||||
}: {
|
||||
project: { id: string; name: string; url: string; defaultBranch: string; lastFetchedAt: Date | null }
|
||||
onDelete: () => void
|
||||
syncAllPending?: boolean
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [editValue, setEditValue] = useState(project.defaultBranch)
|
||||
@@ -215,7 +247,7 @@ function ProjectCard({
|
||||
size="icon"
|
||||
className="shrink-0 text-muted-foreground"
|
||||
onClick={() => syncMutation.mutate({ id: project.id })}
|
||||
disabled={syncMutation.isPending}
|
||||
disabled={syncMutation.isPending || syncAllPending}
|
||||
title="Sync from remote"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${syncMutation.isPending ? 'animate-spin' : ''}`} />
|
||||
|
||||
Reference in New Issue
Block a user