specrails-hub 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,538 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest'
2
+
3
+ // Mock config module for endpoint tests
4
+ vi.mock('./config', () => ({
5
+ getConfig: vi.fn().mockReturnValue({
6
+ project: { name: 'test-project', repo: 'owner/test-project' },
7
+ issueTracker: {
8
+ github: { available: true, authenticated: true },
9
+ jira: { available: false, authenticated: false },
10
+ active: 'github',
11
+ labelFilter: '',
12
+ },
13
+ commands: [
14
+ { id: 'implement', name: 'Implement', description: 'Implement a feature', slug: 'implement' },
15
+ ],
16
+ }),
17
+ fetchIssues: vi.fn().mockReturnValue([
18
+ { number: 1, title: 'Test issue', labels: ['bug'], body: 'Description', url: 'https://github.com/...' },
19
+ ]),
20
+ }))
21
+
22
+ // Mock QueueManager so routes are tested without spawning real processes
23
+ vi.mock('./queue-manager', async () => {
24
+ const ClaudeNotFoundError = class extends Error {
25
+ constructor() {
26
+ super('claude binary not found')
27
+ this.name = 'ClaudeNotFoundError'
28
+ }
29
+ }
30
+ const JobNotFoundError = class extends Error {
31
+ constructor() {
32
+ super('Job not found')
33
+ this.name = 'JobNotFoundError'
34
+ }
35
+ }
36
+ const JobAlreadyTerminalError = class extends Error {
37
+ constructor() {
38
+ super('Job is already in terminal state')
39
+ this.name = 'JobAlreadyTerminalError'
40
+ }
41
+ }
42
+
43
+ // QueueManager is a class mock — each call to `new QueueManager()` returns a fresh
44
+ // object with vi.fn() methods so tests can control behavior per-test.
45
+ const QueueManager = vi.fn().mockImplementation(() => ({
46
+ enqueue: vi.fn(),
47
+ cancel: vi.fn(),
48
+ pause: vi.fn(),
49
+ resume: vi.fn(),
50
+ reorder: vi.fn(),
51
+ getJobs: vi.fn().mockReturnValue([]),
52
+ getActiveJobId: vi.fn().mockReturnValue(null),
53
+ isPaused: vi.fn().mockReturnValue(false),
54
+ getLogBuffer: vi.fn().mockReturnValue([]),
55
+ }))
56
+
57
+ return { QueueManager, ClaudeNotFoundError, JobNotFoundError, JobAlreadyTerminalError }
58
+ })
59
+
60
+ import express from 'express'
61
+ import { createHooksRouter, getPhaseStates, resetPhases } from './hooks'
62
+ import { QueueManager, ClaudeNotFoundError, JobNotFoundError, JobAlreadyTerminalError } from './queue-manager'
63
+ import { initDb, listJobs, getJob, getJobEvents, getStats } from './db'
64
+ import type { DbInstance } from './db'
65
+ import { getConfig, fetchIssues } from './config'
66
+
67
+ const mockGetConfig = getConfig as ReturnType<typeof vi.fn>
68
+ const mockFetchIssues = fetchIssues as ReturnType<typeof vi.fn>
69
+
70
+ // Typed helper so tests can call methods without TS complaints
71
+ type MockQueueManager = {
72
+ enqueue: ReturnType<typeof vi.fn>
73
+ cancel: ReturnType<typeof vi.fn>
74
+ pause: ReturnType<typeof vi.fn>
75
+ resume: ReturnType<typeof vi.fn>
76
+ reorder: ReturnType<typeof vi.fn>
77
+ getJobs: ReturnType<typeof vi.fn>
78
+ getActiveJobId: ReturnType<typeof vi.fn>
79
+ isPaused: ReturnType<typeof vi.fn>
80
+ getLogBuffer: ReturnType<typeof vi.fn>
81
+ }
82
+
83
+ function createTestApp() {
84
+ const broadcast = vi.fn()
85
+ const db = initDb(':memory:')
86
+
87
+ // Cast is safe because QueueManager is mocked above
88
+ const queueManager = new QueueManager(broadcast, db) as unknown as MockQueueManager
89
+
90
+ const app = express()
91
+ app.use(express.json())
92
+
93
+ app.use('/hooks', createHooksRouter(broadcast, db, {
94
+ current: null,
95
+ }))
96
+
97
+ app.post('/api/spawn', (req, res) => {
98
+ const { command } = req.body ?? {}
99
+ if (!command || typeof command !== 'string' || !command.trim()) {
100
+ res.status(400).json({ error: 'command is required' })
101
+ return
102
+ }
103
+ try {
104
+ const job = queueManager.enqueue(command) as { id: string; queuePosition: number | null }
105
+ const position = job.queuePosition ?? 0
106
+ res.status(202).json({ jobId: job.id, position })
107
+ } catch (err) {
108
+ if (err instanceof ClaudeNotFoundError) {
109
+ res.status(400).json({ error: err.message })
110
+ } else {
111
+ res.status(500).json({ error: 'Internal server error' })
112
+ }
113
+ }
114
+ })
115
+
116
+ app.get('/api/state', (_req, res) => {
117
+ res.json({
118
+ projectName: 'test-project',
119
+ phases: getPhaseStates(),
120
+ busy: (queueManager.getActiveJobId() as string | null) !== null,
121
+ })
122
+ })
123
+
124
+ app.delete('/api/jobs/:id', (req, res) => {
125
+ try {
126
+ const result = queueManager.cancel(req.params.id) as string
127
+ res.json({ ok: true, status: result })
128
+ } catch (err) {
129
+ if (err instanceof JobNotFoundError) {
130
+ res.status(404).json({ error: 'Job not found' })
131
+ } else if (err instanceof JobAlreadyTerminalError) {
132
+ res.status(409).json({ error: 'Job is already in terminal state' })
133
+ } else {
134
+ res.status(500).json({ error: 'Internal server error' })
135
+ }
136
+ }
137
+ })
138
+
139
+ app.post('/api/queue/pause', (_req, res) => {
140
+ queueManager.pause()
141
+ res.json({ ok: true, paused: true })
142
+ })
143
+
144
+ app.post('/api/queue/resume', (_req, res) => {
145
+ queueManager.resume()
146
+ res.json({ ok: true, paused: false })
147
+ })
148
+
149
+ app.put('/api/queue/reorder', (req, res) => {
150
+ const { jobIds } = req.body ?? {}
151
+ if (!Array.isArray(jobIds)) {
152
+ res.status(400).json({ error: 'jobIds must be an array' })
153
+ return
154
+ }
155
+ try {
156
+ queueManager.reorder(jobIds)
157
+ res.json({ ok: true, queue: jobIds })
158
+ } catch (err) {
159
+ res.status(400).json({ error: (err as Error).message })
160
+ }
161
+ })
162
+
163
+ app.get('/api/queue', (_req, res) => {
164
+ res.json({
165
+ jobs: queueManager.getJobs() as unknown[],
166
+ paused: queueManager.isPaused() as boolean,
167
+ activeJobId: queueManager.getActiveJobId() as string | null,
168
+ })
169
+ })
170
+
171
+ app.get('/api/jobs', (req, res) => {
172
+ const limit = Math.min(parseInt(String(req.query.limit ?? '50'), 10) || 50, 200)
173
+ const offset = parseInt(String(req.query.offset ?? '0'), 10) || 0
174
+ const status = req.query.status as string | undefined
175
+ const from = req.query.from as string | undefined
176
+ const to = req.query.to as string | undefined
177
+ const result = listJobs(db, { limit, offset, status, from, to })
178
+ res.json(result)
179
+ })
180
+
181
+ app.get('/api/jobs/:id', (req, res) => {
182
+ const job = getJob(db, req.params.id)
183
+ if (!job) { res.status(404).json({ error: 'Job not found' }); return }
184
+ const events = getJobEvents(db, req.params.id)
185
+ res.json({ job, events })
186
+ })
187
+
188
+ app.get('/api/stats', (_req, res) => {
189
+ res.json(getStats(db))
190
+ })
191
+
192
+ app.get('/api/config', (_req, res) => {
193
+ try {
194
+ const config = getConfig(process.cwd(), db, 'test-project')
195
+ res.json(config)
196
+ } catch {
197
+ res.status(500).json({ error: 'Failed to read config' })
198
+ }
199
+ })
200
+
201
+ app.post('/api/config', (req, res) => {
202
+ const { active, labelFilter } = req.body ?? {}
203
+ try {
204
+ if (active !== undefined) {
205
+ db.prepare(`INSERT OR REPLACE INTO queue_state (key, value) VALUES ('config.active_tracker', ?)`).run(active ?? '')
206
+ }
207
+ if (labelFilter !== undefined) {
208
+ db.prepare(`INSERT OR REPLACE INTO queue_state (key, value) VALUES ('config.label_filter', ?)`).run(labelFilter ?? '')
209
+ }
210
+ res.json({ ok: true })
211
+ } catch {
212
+ res.status(500).json({ error: 'Failed to persist config' })
213
+ }
214
+ })
215
+
216
+ app.get('/api/issues', (req, res) => {
217
+ try {
218
+ const config = getConfig(process.cwd(), db, 'test-project')
219
+ const tracker = config.issueTracker.active
220
+ if (!tracker) {
221
+ res.status(503).json({ error: 'No issue tracker configured', trackers: config.issueTracker })
222
+ return
223
+ }
224
+ const search = req.query.search as string | undefined
225
+ const label = req.query.label as string | undefined
226
+ const issues = fetchIssues(tracker, { search, label, repo: config.project.repo })
227
+ res.json(issues)
228
+ } catch {
229
+ res.status(500).json({ error: 'Failed to fetch issues' })
230
+ }
231
+ })
232
+
233
+ return { app, broadcast, db, queueManager }
234
+ }
235
+
236
+ describe('API endpoints', () => {
237
+ let app: express.Express
238
+ let queueManager: MockQueueManager
239
+ let db: DbInstance
240
+ let request: any
241
+
242
+ beforeEach(async () => {
243
+ // Reset phases to clean state
244
+ const dummyBroadcast = vi.fn()
245
+ resetPhases(dummyBroadcast)
246
+
247
+ const created = createTestApp()
248
+ app = created.app
249
+ queueManager = created.queueManager
250
+ db = created.db
251
+
252
+ const mod = await import('supertest')
253
+ request = mod.default
254
+ })
255
+
256
+ describe('POST /api/spawn', () => {
257
+ it('returns 202 with jobId and position on success', async () => {
258
+ queueManager.enqueue.mockReturnValue({ id: 'job-abc', queuePosition: 0 })
259
+
260
+ const res = await request(app).post('/api/spawn').send({ command: '/implement #42' })
261
+
262
+ expect(res.status).toBe(202)
263
+ expect(res.body.jobId).toBe('job-abc')
264
+ expect(res.body.position).toBe(0)
265
+ })
266
+
267
+ it('returns 400 when command is missing', async () => {
268
+ const res = await request(app).post('/api/spawn').send({})
269
+
270
+ expect(res.status).toBe(400)
271
+ expect(res.body.error).toBe('command is required')
272
+ })
273
+
274
+ it('returns 400 when ClaudeNotFoundError is thrown', async () => {
275
+ queueManager.enqueue.mockImplementation(() => {
276
+ throw new ClaudeNotFoundError()
277
+ })
278
+
279
+ const res = await request(app).post('/api/spawn').send({ command: '/implement #42' })
280
+
281
+ expect(res.status).toBe(400)
282
+ expect(res.body.error).toBe('claude binary not found')
283
+ })
284
+
285
+ it('does NOT return 409 — second enqueue is queued', async () => {
286
+ queueManager.enqueue
287
+ .mockReturnValueOnce({ id: 'job-1', queuePosition: null })
288
+ .mockReturnValueOnce({ id: 'job-2', queuePosition: 1 })
289
+
290
+ await request(app).post('/api/spawn').send({ command: '/implement #1' })
291
+ const res = await request(app).post('/api/spawn').send({ command: '/implement #2' })
292
+
293
+ expect(res.status).toBe(202)
294
+ expect(res.body.position).toBe(1)
295
+ })
296
+ })
297
+
298
+ describe('DELETE /api/jobs/:id', () => {
299
+ it('returns 200 with status canceled for a queued job', async () => {
300
+ queueManager.cancel.mockReturnValue('canceled')
301
+
302
+ const res = await request(app).delete('/api/jobs/job-abc')
303
+
304
+ expect(res.status).toBe(200)
305
+ expect(res.body).toEqual({ ok: true, status: 'canceled' })
306
+ })
307
+
308
+ it('returns 200 with status canceling for a running job', async () => {
309
+ queueManager.cancel.mockReturnValue('canceling')
310
+
311
+ const res = await request(app).delete('/api/jobs/job-running')
312
+
313
+ expect(res.status).toBe(200)
314
+ expect(res.body).toEqual({ ok: true, status: 'canceling' })
315
+ })
316
+
317
+ it('returns 404 for unknown id', async () => {
318
+ queueManager.cancel.mockImplementation(() => {
319
+ throw new JobNotFoundError()
320
+ })
321
+
322
+ const res = await request(app).delete('/api/jobs/no-such-id')
323
+
324
+ expect(res.status).toBe(404)
325
+ expect(res.body.error).toBe('Job not found')
326
+ })
327
+
328
+ it('returns 409 for terminal job', async () => {
329
+ queueManager.cancel.mockImplementation(() => {
330
+ throw new JobAlreadyTerminalError()
331
+ })
332
+
333
+ const res = await request(app).delete('/api/jobs/completed-job')
334
+
335
+ expect(res.status).toBe(409)
336
+ expect(res.body.error).toBe('Job is already in terminal state')
337
+ })
338
+ })
339
+
340
+ describe('POST /api/queue/pause', () => {
341
+ it('returns ok: true and paused: true', async () => {
342
+ const res = await request(app).post('/api/queue/pause')
343
+
344
+ expect(res.status).toBe(200)
345
+ expect(res.body).toEqual({ ok: true, paused: true })
346
+ expect(queueManager.pause).toHaveBeenCalledOnce()
347
+ })
348
+ })
349
+
350
+ describe('POST /api/queue/resume', () => {
351
+ it('returns ok: true and paused: false', async () => {
352
+ const res = await request(app).post('/api/queue/resume')
353
+
354
+ expect(res.status).toBe(200)
355
+ expect(res.body).toEqual({ ok: true, paused: false })
356
+ expect(queueManager.resume).toHaveBeenCalledOnce()
357
+ })
358
+ })
359
+
360
+ describe('PUT /api/queue/reorder', () => {
361
+ it('returns 200 with reordered queue', async () => {
362
+ const jobIds = ['job-b', 'job-a']
363
+
364
+ const res = await request(app).put('/api/queue/reorder').send({ jobIds })
365
+
366
+ expect(res.status).toBe(200)
367
+ expect(res.body).toEqual({ ok: true, queue: jobIds })
368
+ expect(queueManager.reorder).toHaveBeenCalledWith(jobIds)
369
+ })
370
+
371
+ it('returns 400 when jobIds is mismatched', async () => {
372
+ queueManager.reorder.mockImplementation(() => {
373
+ throw new Error('jobIds must contain exactly the IDs of all currently-queued jobs')
374
+ })
375
+
376
+ const res = await request(app).put('/api/queue/reorder').send({ jobIds: ['wrong-id'] })
377
+
378
+ expect(res.status).toBe(400)
379
+ expect(res.body.error).toBeDefined()
380
+ })
381
+
382
+ it('returns 400 when jobIds is not an array', async () => {
383
+ const res = await request(app).put('/api/queue/reorder').send({ jobIds: 'not-array' })
384
+
385
+ expect(res.status).toBe(400)
386
+ expect(res.body.error).toBe('jobIds must be an array')
387
+ })
388
+ })
389
+
390
+ describe('GET /api/queue', () => {
391
+ it('returns current queue state', async () => {
392
+ queueManager.getJobs.mockReturnValue([])
393
+ queueManager.isPaused.mockReturnValue(false)
394
+ queueManager.getActiveJobId.mockReturnValue(null)
395
+
396
+ const res = await request(app).get('/api/queue')
397
+
398
+ expect(res.status).toBe(200)
399
+ expect(res.body).toEqual({ jobs: [], paused: false, activeJobId: null })
400
+ })
401
+ })
402
+
403
+ describe('GET /api/state', () => {
404
+ it('returns busy: false when no active job', async () => {
405
+ queueManager.getActiveJobId.mockReturnValue(null)
406
+
407
+ const res = await request(app).get('/api/state')
408
+
409
+ expect(res.status).toBe(200)
410
+ expect(res.body.busy).toBe(false)
411
+ })
412
+
413
+ it('returns busy: true when activeJobId is non-null', async () => {
414
+ queueManager.getActiveJobId.mockReturnValue('some-job-id')
415
+
416
+ const res = await request(app).get('/api/state')
417
+
418
+ expect(res.status).toBe(200)
419
+ expect(res.body.busy).toBe(true)
420
+ })
421
+ })
422
+
423
+ describe('POST /hooks/events', () => {
424
+ it('still works unchanged — transitions phase state and returns ok', async () => {
425
+ const res = await request(app)
426
+ .post('/hooks/events')
427
+ .send({ event: 'agent_start', agent: 'architect' })
428
+
429
+ expect(res.status).toBe(200)
430
+ expect(res.body).toEqual({ ok: true })
431
+
432
+ const stateRes = await request(app).get('/api/state')
433
+ expect(stateRes.body.phases.architect).toBe('running')
434
+ })
435
+ })
436
+
437
+ describe('GET /api/jobs', () => {
438
+ it('returns empty list on fresh DB', async () => {
439
+ const res = await request(app).get('/api/jobs')
440
+
441
+ expect(res.status).toBe(200)
442
+ expect(res.body.jobs).toEqual([])
443
+ expect(res.body.total).toBe(0)
444
+ })
445
+ })
446
+
447
+ describe('GET /api/jobs/:id', () => {
448
+ it('returns 404 for unknown id', async () => {
449
+ const res = await request(app).get('/api/jobs/nonexistent-id')
450
+
451
+ expect(res.status).toBe(404)
452
+ expect(res.body.error).toBe('Job not found')
453
+ })
454
+ })
455
+
456
+ describe('GET /api/stats', () => {
457
+ it('returns zeroed stats on fresh DB', async () => {
458
+ const res = await request(app).get('/api/stats')
459
+
460
+ expect(res.status).toBe(200)
461
+ expect(res.body.totalJobs).toBe(0)
462
+ expect(res.body.jobsToday).toBe(0)
463
+ })
464
+ })
465
+
466
+ describe('GET /api/config', () => {
467
+ it('returns config with project, issueTracker, and commands', async () => {
468
+ const res = await request(app).get('/api/config')
469
+
470
+ expect(res.status).toBe(200)
471
+ expect(res.body).toHaveProperty('project')
472
+ expect(res.body).toHaveProperty('issueTracker')
473
+ expect(res.body).toHaveProperty('commands')
474
+ expect(res.body.project.name).toBe('test-project')
475
+ expect(res.body.issueTracker.active).toBe('github')
476
+ expect(Array.isArray(res.body.commands)).toBe(true)
477
+ })
478
+
479
+ it('returns 500 when config detection throws', async () => {
480
+ mockGetConfig.mockImplementationOnce(() => { throw new Error('detection failed') })
481
+
482
+ const res = await request(app).get('/api/config')
483
+
484
+ expect(res.status).toBe(500)
485
+ expect(res.body.error).toBe('Failed to read config')
486
+ })
487
+ })
488
+
489
+ describe('POST /api/config', () => {
490
+ it('persists active tracker setting and returns ok', async () => {
491
+ const res = await request(app).post('/api/config').send({ active: 'github' })
492
+
493
+ expect(res.status).toBe(200)
494
+ expect(res.body).toEqual({ ok: true })
495
+ })
496
+
497
+ it('persists label filter setting and returns ok', async () => {
498
+ const res = await request(app).post('/api/config').send({ labelFilter: 'feature' })
499
+
500
+ expect(res.status).toBe(200)
501
+ expect(res.body).toEqual({ ok: true })
502
+ })
503
+ })
504
+
505
+ describe('GET /api/issues', () => {
506
+ it('returns issues list when tracker is configured', async () => {
507
+ const res = await request(app).get('/api/issues')
508
+
509
+ expect(res.status).toBe(200)
510
+ expect(Array.isArray(res.body)).toBe(true)
511
+ expect(res.body[0]).toHaveProperty('number')
512
+ expect(res.body[0]).toHaveProperty('title')
513
+ expect(res.body[0]).toHaveProperty('labels')
514
+ })
515
+
516
+ it('returns 503 when no tracker is configured', async () => {
517
+ mockGetConfig.mockReturnValueOnce({
518
+ project: { name: 'test', repo: null },
519
+ issueTracker: { github: { available: false, authenticated: false }, jira: { available: false, authenticated: false }, active: null, labelFilter: '' },
520
+ commands: [],
521
+ })
522
+
523
+ const res = await request(app).get('/api/issues')
524
+
525
+ expect(res.status).toBe(503)
526
+ expect(res.body.error).toBe('No issue tracker configured')
527
+ })
528
+
529
+ it('passes search query param to fetchIssues', async () => {
530
+ await request(app).get('/api/issues?search=bug')
531
+
532
+ expect(mockFetchIssues).toHaveBeenCalledWith(
533
+ 'github',
534
+ expect.objectContaining({ search: 'bug' })
535
+ )
536
+ })
537
+ })
538
+ })