|
| 1 | +import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test' |
| 2 | +import { Hono } from 'hono' |
| 3 | +import { Database } from 'bun:sqlite' |
| 4 | +import { migrate } from '../db/migration-runner' |
| 5 | +import { allMigrations } from '../db/migrations' |
| 6 | +import { createRepoRoutes } from './repos' |
| 7 | +import { createRepo } from '../db/queries' |
| 8 | +import { createStubOpenCodeClient } from '../../test/helpers/stub-opencode-client' |
| 9 | +import type { GitAuthService } from '../services/git-auth' |
| 10 | +import type { OpenCodeClient } from '../services/opencode/client' |
| 11 | +import { getReposPath } from '@opencode-manager/shared/config/env' |
| 12 | +import path from 'path' |
| 13 | + |
| 14 | +beforeEach(() => { |
| 15 | + mock.module('../services/project-id-resolver', () => ({ |
| 16 | + resolveProjectId: (() => null) as any, |
| 17 | + isGitMainCheckout: (() => Promise.resolve(false)) as any, |
| 18 | + })) |
| 19 | +}) |
| 20 | + |
| 21 | +afterEach(() => { |
| 22 | + mock.restore() |
| 23 | +}) |
| 24 | + |
| 25 | +const stubGitAuthService = { |
| 26 | + getGitEnvironment: () => ({}), |
| 27 | + getGitCredentials: async () => [], |
| 28 | +} as unknown as GitAuthService |
| 29 | + |
| 30 | +function createTestApp(db: Database, openCodeClient: OpenCodeClient = createStubOpenCodeClient({ |
| 31 | + getJson: mock(async () => []) as any, |
| 32 | +})): Hono { |
| 33 | + const app = new Hono() |
| 34 | + const scheduleService = { |
| 35 | + createSchedule: () => {}, |
| 36 | + getScheduleById: () => null, |
| 37 | + listSchedules: () => [], |
| 38 | + updateSchedule: () => {}, |
| 39 | + deleteSchedule: () => {}, |
| 40 | + } as any |
| 41 | + app.route('/repos', createRepoRoutes(db, stubGitAuthService, scheduleService, openCodeClient)) |
| 42 | + return app |
| 43 | +} |
| 44 | + |
| 45 | +function createTestDb(): Database { |
| 46 | + const db = new Database(':memory:') |
| 47 | + migrate(db, allMigrations) |
| 48 | + return db |
| 49 | +} |
| 50 | + |
| 51 | +describe('GET /api/repos/:id/siblings', () => { |
| 52 | + let db: Database |
| 53 | + let app: Hono |
| 54 | + |
| 55 | + beforeEach(() => { |
| 56 | + db = createTestDb() |
| 57 | + app = createTestApp(db) |
| 58 | + }) |
| 59 | + |
| 60 | + it('returns siblings including self with currentBranch', async () => { |
| 61 | + mock.module('../services/project-id-resolver', () => ({ |
| 62 | + resolveProjectId: ((path: string) => Promise.resolve( |
| 63 | + path.includes('repo-unrelated') ? 'commit-B' : 'commit-A' |
| 64 | + )) as any, |
| 65 | + isGitMainCheckout: (() => Promise.resolve(false)) as any, |
| 66 | + })) |
| 67 | + |
| 68 | + createRepo(db, { localPath: 'repo-a', defaultBranch: 'main', cloneStatus: 'ready', clonedAt: Date.now(), isLocal: true }) |
| 69 | + createRepo(db, { localPath: 'repo-b', defaultBranch: 'main', cloneStatus: 'ready', clonedAt: Date.now(), isLocal: true }) |
| 70 | + createRepo(db, { localPath: 'repo-c', defaultBranch: 'main', cloneStatus: 'ready', clonedAt: Date.now(), isLocal: true }) |
| 71 | + createRepo(db, { localPath: 'repo-unrelated', defaultBranch: 'main', cloneStatus: 'ready', clonedAt: Date.now(), isLocal: true }) |
| 72 | + |
| 73 | + const res = await app.request('/repos/1/siblings') |
| 74 | + expect(res.status).toBe(200) |
| 75 | + const data = await res.json() as Array<{ id: number; currentBranch: string | null | undefined }> |
| 76 | + expect(data).toHaveLength(3) |
| 77 | + expect(data.map((d) => d.id).sort((a, b) => a - b)).toEqual([1, 2, 3]) |
| 78 | + }) |
| 79 | + |
| 80 | + it('includes OpenCode workspaces that are not manager repo rows', async () => { |
| 81 | + mock.module('../services/project-id-resolver', () => ({ |
| 82 | + resolveProjectId: (() => Promise.resolve('commit-A')) as any, |
| 83 | + isGitMainCheckout: (() => Promise.resolve(false)) as any, |
| 84 | + })) |
| 85 | + |
| 86 | + createRepo(db, { localPath: 'repo-a', defaultBranch: 'main', cloneStatus: 'ready', clonedAt: Date.now(), isLocal: true }) |
| 87 | + app = createTestApp(db, createStubOpenCodeClient({ |
| 88 | + getJson: mock(async () => ([{ |
| 89 | + id: 'wrk_test', |
| 90 | + type: 'worktree', |
| 91 | + name: 'plugin-workspace', |
| 92 | + branch: 'plugin-branch', |
| 93 | + directory: '/tmp/plugin-workspace', |
| 94 | + projectID: 'commit-A', |
| 95 | + }])) as any, |
| 96 | + })) |
| 97 | + |
| 98 | + const res = await app.request('/repos/1/siblings') |
| 99 | + expect(res.status).toBe(200) |
| 100 | + const data = await res.json() as Array<{ id: number; workspaceId?: string; currentBranch?: string }> |
| 101 | + expect(data).toHaveLength(2) |
| 102 | + expect(data[1]).toMatchObject({ |
| 103 | + id: -1, |
| 104 | + workspaceId: 'wrk_test', |
| 105 | + currentBranch: 'plugin-branch', |
| 106 | + }) |
| 107 | + }) |
| 108 | + |
| 109 | + it('deduplicates OpenCode workspaces with the same directory', async () => { |
| 110 | + mock.module('../services/project-id-resolver', () => ({ |
| 111 | + resolveProjectId: (() => Promise.resolve('commit-A')) as any, |
| 112 | + isGitMainCheckout: (() => Promise.resolve(false)) as any, |
| 113 | + })) |
| 114 | + |
| 115 | + createRepo(db, { localPath: 'repo-a', defaultBranch: 'main', cloneStatus: 'ready', clonedAt: Date.now(), isLocal: true }) |
| 116 | + app = createTestApp(db, createStubOpenCodeClient({ |
| 117 | + getJson: mock(async () => ([ |
| 118 | + { |
| 119 | + id: 'wrk_first', |
| 120 | + type: 'worktree', |
| 121 | + name: 'duplicate-workspace', |
| 122 | + branch: 'duplicate-branch', |
| 123 | + directory: '/tmp/duplicate-workspace', |
| 124 | + projectID: 'commit-A', |
| 125 | + }, |
| 126 | + { |
| 127 | + id: 'wrk_second', |
| 128 | + type: 'worktree', |
| 129 | + name: 'duplicate-workspace', |
| 130 | + branch: 'duplicate-branch', |
| 131 | + directory: '/tmp/duplicate-workspace/', |
| 132 | + projectID: 'commit-A', |
| 133 | + }, |
| 134 | + ])) as any, |
| 135 | + })) |
| 136 | + |
| 137 | + const res = await app.request('/repos/1/siblings') |
| 138 | + expect(res.status).toBe(200) |
| 139 | + const data = await res.json() as Array<{ workspaceId?: string }> |
| 140 | + expect(data.filter((entry) => entry.workspaceId)).toHaveLength(1) |
| 141 | + expect(data.some((entry) => entry.workspaceId === 'wrk_first')).toBe(true) |
| 142 | + }) |
| 143 | + |
| 144 | + it('excludes a workspace pointing at the repo directory so it cannot be deleted', async () => { |
| 145 | + mock.module('../services/project-id-resolver', () => ({ |
| 146 | + resolveProjectId: (() => Promise.resolve('commit-A')) as any, |
| 147 | + isGitMainCheckout: (() => Promise.resolve(false)) as any, |
| 148 | + })) |
| 149 | + |
| 150 | + createRepo(db, { localPath: 'repo-a', defaultBranch: 'main', cloneStatus: 'ready', clonedAt: Date.now(), isLocal: true }) |
| 151 | + const repoDirectory = path.join(getReposPath(), 'repo-a') |
| 152 | + app = createTestApp(db, createStubOpenCodeClient({ |
| 153 | + getJson: mock(async () => ([{ |
| 154 | + id: 'wrk_self', |
| 155 | + type: 'worktree', |
| 156 | + name: 'self-workspace', |
| 157 | + branch: 'main', |
| 158 | + directory: `${repoDirectory}/`, |
| 159 | + projectID: 'commit-A', |
| 160 | + }])) as any, |
| 161 | + })) |
| 162 | + |
| 163 | + const res = await app.request('/repos/1/siblings') |
| 164 | + expect(res.status).toBe(200) |
| 165 | + const data = await res.json() as Array<{ id: number; workspaceId?: string }> |
| 166 | + expect(data).toHaveLength(1) |
| 167 | + expect(data.some((d) => d.workspaceId === 'wrk_self')).toBe(false) |
| 168 | + }) |
| 169 | + |
| 170 | + it('excludes a workspace that is a git main checkout so the main repo cannot be deleted', async () => { |
| 171 | + mock.module('../services/project-id-resolver', () => ({ |
| 172 | + resolveProjectId: (() => Promise.resolve('commit-A')) as any, |
| 173 | + isGitMainCheckout: ((dir: string) => |
| 174 | + Promise.resolve(dir === '/Users/dev/main-repo')) as any, |
| 175 | + })) |
| 176 | + |
| 177 | + createRepo(db, { localPath: 'repo-wt', defaultBranch: 'main', cloneStatus: 'ready', clonedAt: Date.now(), isLocal: true }) |
| 178 | + app = createTestApp(db, createStubOpenCodeClient({ |
| 179 | + getJson: mock(async () => ([ |
| 180 | + { |
| 181 | + id: 'wrk_main', |
| 182 | + type: 'worktree', |
| 183 | + name: 'main-checkout', |
| 184 | + branch: 'dev', |
| 185 | + directory: '/Users/dev/main-repo', |
| 186 | + projectID: 'commit-A', |
| 187 | + }, |
| 188 | + { |
| 189 | + id: 'wrk_linked', |
| 190 | + type: 'worktree', |
| 191 | + name: 'feature', |
| 192 | + branch: 'feature/x', |
| 193 | + directory: '/Users/dev/worktrees/feature-x', |
| 194 | + projectID: 'commit-A', |
| 195 | + }, |
| 196 | + ])) as any, |
| 197 | + })) |
| 198 | + |
| 199 | + const res = await app.request('/repos/1/siblings') |
| 200 | + expect(res.status).toBe(200) |
| 201 | + const data = await res.json() as Array<{ workspaceId?: string }> |
| 202 | + expect(data.some((d) => d.workspaceId === 'wrk_main')).toBe(false) |
| 203 | + expect(data.some((d) => d.workspaceId === 'wrk_linked')).toBe(true) |
| 204 | + }) |
| 205 | + |
| 206 | + it('excludes repos with non-matching projectID', async () => { |
| 207 | + mock.module('../services/project-id-resolver', () => ({ |
| 208 | + resolveProjectId: ((path: string) => Promise.resolve( |
| 209 | + path.includes('repo-only') ? 'commit-X' : 'commit-Y' |
| 210 | + )) as any, |
| 211 | + isGitMainCheckout: (() => Promise.resolve(false)) as any, |
| 212 | + })) |
| 213 | + |
| 214 | + createRepo(db, { localPath: 'repo-only', defaultBranch: 'main', cloneStatus: 'ready', clonedAt: Date.now(), isLocal: true }) |
| 215 | + createRepo(db, { localPath: 'repo-other', defaultBranch: 'main', cloneStatus: 'ready', clonedAt: Date.now(), isLocal: true }) |
| 216 | + |
| 217 | + const res = await app.request('/repos/1/siblings') |
| 218 | + expect(res.status).toBe(200) |
| 219 | + const data = await res.json() as Array<{ id: number }> |
| 220 | + expect(data).toHaveLength(1) |
| 221 | + expect(data[0]!.id).toBe(1) |
| 222 | + }) |
| 223 | + |
| 224 | + it('returns empty when target has no projectID', async () => { |
| 225 | + mock.module('../services/project-id-resolver', () => ({ |
| 226 | + resolveProjectId: (() => null) as any, |
| 227 | + })) |
| 228 | + |
| 229 | + createRepo(db, { localPath: 'repo-no-project', defaultBranch: 'main', cloneStatus: 'ready', clonedAt: Date.now(), isLocal: true }) |
| 230 | + |
| 231 | + const res = await app.request('/repos/1/siblings') |
| 232 | + expect(res.status).toBe(200) |
| 233 | + const data = await res.json() as unknown[] |
| 234 | + expect(data).toEqual([]) |
| 235 | + }) |
| 236 | + |
| 237 | + it('returns empty when target cloneStatus !== ready', async () => { |
| 238 | + mock.module('../services/project-id-resolver', () => ({ |
| 239 | + resolveProjectId: (() => 'commit-A') as any, |
| 240 | + isGitMainCheckout: (() => Promise.resolve(false)) as any, |
| 241 | + })) |
| 242 | + |
| 243 | + createRepo(db, { localPath: 'repo-cloning', defaultBranch: 'main', cloneStatus: 'cloning', clonedAt: Date.now(), isLocal: true }) |
| 244 | + |
| 245 | + const res = await app.request('/repos/1/siblings') |
| 246 | + expect(res.status).toBe(200) |
| 247 | + const data = await res.json() as unknown[] |
| 248 | + expect(data).toEqual([]) |
| 249 | + }) |
| 250 | + |
| 251 | + it('returns empty when target missing', async () => { |
| 252 | + mock.module('../services/project-id-resolver', () => ({ |
| 253 | + resolveProjectId: (() => 'commit-A') as any, |
| 254 | + isGitMainCheckout: (() => Promise.resolve(false)) as any, |
| 255 | + })) |
| 256 | + |
| 257 | + const res = await app.request('/repos/9999/siblings') |
| 258 | + expect(res.status).toBe(200) |
| 259 | + const data = await res.json() as unknown[] |
| 260 | + expect(data).toEqual([]) |
| 261 | + }) |
| 262 | + |
| 263 | + it('invalid id returns 400', async () => { |
| 264 | + const res = await app.request('/repos/abc/siblings') |
| 265 | + expect(res.status).toBe(400) |
| 266 | + const data = await res.json() as { error: string } |
| 267 | + expect(data.error).toBe('Invalid repo id') |
| 268 | + }) |
| 269 | +}) |
| 270 | + |
| 271 | +describe('DELETE /api/repos/:id/workspaces/:workspaceId', () => { |
| 272 | + let db: Database |
| 273 | + let captured: { path: string; directory?: string } | null |
| 274 | + |
| 275 | + beforeEach(() => { |
| 276 | + db = createTestDb() |
| 277 | + captured = null |
| 278 | + }) |
| 279 | + |
| 280 | + afterEach(() => { |
| 281 | + db.close() |
| 282 | + }) |
| 283 | + |
| 284 | + it('forwards workspace delete to OpenCode with repo directory', async () => { |
| 285 | + createRepo(db, { localPath: 'repo-a', defaultBranch: 'main', cloneStatus: 'ready', clonedAt: Date.now(), isLocal: true }) |
| 286 | + const forward = mock(async (req: Parameters<OpenCodeClient['forward']>[0]) => { |
| 287 | + captured = { path: req.path, directory: req.directory } |
| 288 | + return new Response(JSON.stringify({ id: 'wrk_test' }), { status: 200 }) |
| 289 | + }) |
| 290 | + const app = createTestApp(db, createStubOpenCodeClient({ |
| 291 | + forward, |
| 292 | + })) |
| 293 | + |
| 294 | + const res = await app.request('/repos/1/workspaces/wrk_test', { method: 'DELETE' }) |
| 295 | + |
| 296 | + expect(res.status).toBe(200) |
| 297 | + expect(captured?.path).toBe('/experimental/workspace/wrk_test') |
| 298 | + expect(captured?.directory?.endsWith('/repos/repo-a')).toBe(true) |
| 299 | + }) |
| 300 | +}) |
| 301 | + |
| 302 | +describe('POST /api/repos/:id/workspaces', () => { |
| 303 | + let db: Database |
| 304 | + let captured: { path: string; directory?: string; body?: string } | null |
| 305 | + |
| 306 | + beforeEach(() => { |
| 307 | + db = createTestDb() |
| 308 | + captured = null |
| 309 | + }) |
| 310 | + |
| 311 | + afterEach(() => { |
| 312 | + db.close() |
| 313 | + }) |
| 314 | + |
| 315 | + it('forwards workspace creation to OpenCode with repo directory', async () => { |
| 316 | + createRepo(db, { localPath: 'repo-a', defaultBranch: 'main', cloneStatus: 'ready', clonedAt: Date.now(), isLocal: true }) |
| 317 | + const forward = mock(async (req: Parameters<OpenCodeClient['forward']>[0]) => { |
| 318 | + captured = { path: req.path, directory: req.directory, body: req.body } |
| 319 | + return new Response(JSON.stringify({ id: 'wrk_test', type: 'worktree', directory: '/tmp/wrk-test' }), { status: 200 }) |
| 320 | + }) |
| 321 | + const app = createTestApp(db, createStubOpenCodeClient({ |
| 322 | + forward, |
| 323 | + })) |
| 324 | + |
| 325 | + const res = await app.request('/repos/1/workspaces', { method: 'POST' }) |
| 326 | + |
| 327 | + expect(res.status).toBe(200) |
| 328 | + expect(captured?.path).toBe('/experimental/workspace') |
| 329 | + expect(captured?.directory?.endsWith('/repos/repo-a')).toBe(true) |
| 330 | + expect(JSON.parse(captured?.body ?? '{}')).toEqual({ type: 'worktree', branch: null }) |
| 331 | + expect(await res.json()).toMatchObject({ id: 'wrk_test', type: 'worktree' }) |
| 332 | + }) |
| 333 | +}) |
0 commit comments