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,424 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest'
2
+
3
+ // Mock command-resolver to always return a resolved prompt (command exists)
4
+ vi.mock('./command-resolver', () => ({
5
+ resolveCommand: vi.fn(() => 'Resolved prompt for testing'),
6
+ }))
7
+
8
+ // Mock config module
9
+ vi.mock('./config', () => ({
10
+ getConfig: vi.fn().mockReturnValue({
11
+ project: { name: 'test-project', repo: 'owner/test-project' },
12
+ issueTracker: {
13
+ github: { available: true, authenticated: true },
14
+ jira: { available: false, authenticated: false },
15
+ active: 'github',
16
+ labelFilter: '',
17
+ },
18
+ commands: [],
19
+ }),
20
+ fetchIssues: vi.fn().mockReturnValue([]),
21
+ }))
22
+
23
+ // Mock QueueManager
24
+ vi.mock('./queue-manager', async () => {
25
+ const ClaudeNotFoundError = class extends Error {
26
+ constructor() { super('claude binary not found'); this.name = 'ClaudeNotFoundError' }
27
+ }
28
+ const JobNotFoundError = class extends Error {
29
+ constructor() { super('Job not found'); this.name = 'JobNotFoundError' }
30
+ }
31
+ const JobAlreadyTerminalError = class extends Error {
32
+ constructor() { super('Job is already in terminal state'); this.name = 'JobAlreadyTerminalError' }
33
+ }
34
+ const QueueManager = vi.fn().mockImplementation(() => ({
35
+ enqueue: vi.fn(),
36
+ cancel: vi.fn(),
37
+ pause: vi.fn(),
38
+ resume: vi.fn(),
39
+ reorder: vi.fn(),
40
+ getJobs: vi.fn().mockReturnValue([]),
41
+ getActiveJobId: vi.fn().mockReturnValue(null),
42
+ isPaused: vi.fn().mockReturnValue(false),
43
+ getLogBuffer: vi.fn().mockReturnValue([]),
44
+ phasesForCommand: vi.fn().mockReturnValue([]),
45
+ setCommands: vi.fn(),
46
+ }))
47
+ return { QueueManager, ClaudeNotFoundError, JobNotFoundError, JobAlreadyTerminalError }
48
+ })
49
+
50
+ // Mock ChatManager
51
+ vi.mock('./chat-manager', () => ({
52
+ ChatManager: vi.fn().mockImplementation(() => ({
53
+ isActive: vi.fn().mockReturnValue(false),
54
+ sendMessage: vi.fn(),
55
+ abort: vi.fn(),
56
+ })),
57
+ }))
58
+
59
+ // Mock SetupManager
60
+ vi.mock('./setup-manager', () => ({
61
+ SetupManager: vi.fn().mockImplementation(() => ({
62
+ isInstalling: vi.fn().mockReturnValue(false),
63
+ isSettingUp: vi.fn().mockReturnValue(false),
64
+ startInstall: vi.fn(),
65
+ startSetup: vi.fn(),
66
+ resumeSetup: vi.fn(),
67
+ abort: vi.fn(),
68
+ getCheckpointStatus: vi.fn().mockReturnValue([]),
69
+ })),
70
+ }))
71
+
72
+ // Mock ProposalManager — will be the mock instance
73
+ let mockProposalManagerInstance: {
74
+ isActive: ReturnType<typeof vi.fn>
75
+ startExploration: ReturnType<typeof vi.fn>
76
+ sendRefinement: ReturnType<typeof vi.fn>
77
+ createIssue: ReturnType<typeof vi.fn>
78
+ cancel: ReturnType<typeof vi.fn>
79
+ }
80
+
81
+ vi.mock('./proposal-manager', () => {
82
+ const ProposalManager = vi.fn().mockImplementation(() => {
83
+ mockProposalManagerInstance = {
84
+ isActive: vi.fn().mockReturnValue(false),
85
+ startExploration: vi.fn().mockResolvedValue(undefined),
86
+ sendRefinement: vi.fn().mockResolvedValue(undefined),
87
+ createIssue: vi.fn().mockResolvedValue(undefined),
88
+ cancel: vi.fn(),
89
+ }
90
+ return mockProposalManagerInstance
91
+ })
92
+ return { ProposalManager }
93
+ })
94
+
95
+ import express from 'express'
96
+ import { initDb, createProposal, updateProposal } from './db'
97
+ import { createProjectRouter } from './project-router'
98
+ import { ProjectRegistry } from './project-registry'
99
+ import type { DbInstance } from './db'
100
+
101
+ function createTestApp() {
102
+ const broadcast = vi.fn()
103
+ const db = initDb(':memory:')
104
+
105
+ // Register a fake project in the hub DB
106
+ const registry = new ProjectRegistry(broadcast)
107
+
108
+ // Manually add a context by calling addProject with a fake path
109
+ const projectId = 'test-proj-001'
110
+
111
+ // Inject a fake context directly by accessing the private map via casting
112
+ const fakeCtx = {
113
+ project: {
114
+ id: projectId,
115
+ slug: 'test-project',
116
+ name: 'Test Project',
117
+ path: '/fake/path',
118
+ db_path: ':memory:',
119
+ added_at: new Date().toISOString(),
120
+ last_seen_at: new Date().toISOString(),
121
+ },
122
+ db,
123
+ queueManager: {
124
+ enqueue: vi.fn(),
125
+ cancel: vi.fn(),
126
+ pause: vi.fn(),
127
+ resume: vi.fn(),
128
+ reorder: vi.fn(),
129
+ getJobs: vi.fn().mockReturnValue([]),
130
+ getActiveJobId: vi.fn().mockReturnValue(null),
131
+ isPaused: vi.fn().mockReturnValue(false),
132
+ getLogBuffer: vi.fn().mockReturnValue([]),
133
+ phasesForCommand: vi.fn().mockReturnValue([]),
134
+ setCommands: vi.fn(),
135
+ },
136
+ chatManager: {
137
+ isActive: vi.fn().mockReturnValue(false),
138
+ sendMessage: vi.fn(),
139
+ abort: vi.fn(),
140
+ },
141
+ setupManager: {
142
+ isInstalling: vi.fn().mockReturnValue(false),
143
+ isSettingUp: vi.fn().mockReturnValue(false),
144
+ startInstall: vi.fn(),
145
+ startSetup: vi.fn(),
146
+ resumeSetup: vi.fn(),
147
+ abort: vi.fn(),
148
+ getCheckpointStatus: vi.fn().mockReturnValue([]),
149
+ },
150
+ proposalManager: mockProposalManagerInstance,
151
+ broadcast: vi.fn(),
152
+ }
153
+
154
+ // Patch registry to return our fake context
155
+ ;(registry as any)._contexts = new Map([[projectId, fakeCtx]])
156
+
157
+ const projectRouter = createProjectRouter(registry)
158
+ const app = express()
159
+ app.use(express.json())
160
+ app.use('/api/projects', projectRouter)
161
+
162
+ return { app, db, projectId, proposalManager: mockProposalManagerInstance }
163
+ }
164
+
165
+ // ─── Tests ────────────────────────────────────────────────────────────────────
166
+
167
+ describe('Proposal API routes', () => {
168
+ let app: express.Express
169
+ let db: DbInstance
170
+ let projectId: string
171
+ let proposalManager: typeof mockProposalManagerInstance
172
+ let request: any
173
+
174
+ beforeEach(async () => {
175
+ vi.clearAllMocks()
176
+
177
+ // ProposalManager mock needs to be initialized — trigger it
178
+ const { ProposalManager } = await import('./proposal-manager')
179
+ new ProposalManager(vi.fn(), initDb(':memory:'), '/test')
180
+
181
+ const created = createTestApp()
182
+ app = created.app
183
+ db = created.db
184
+ projectId = created.projectId
185
+ proposalManager = created.proposalManager
186
+
187
+ const mod = await import('supertest')
188
+ request = mod.default
189
+ })
190
+
191
+ // ─── POST /:projectId/propose ─────────────────────────────────────────────
192
+
193
+ describe('POST /:projectId/propose', () => {
194
+ it('returns 202 with proposalId', async () => {
195
+ const res = await request(app)
196
+ .post(`/api/projects/${projectId}/propose`)
197
+ .send({ idea: 'Add dark mode support' })
198
+
199
+ expect(res.status).toBe(202)
200
+ expect(res.body.proposalId).toBeDefined()
201
+ expect(typeof res.body.proposalId).toBe('string')
202
+ })
203
+
204
+ it('returns 400 when idea is missing', async () => {
205
+ const res = await request(app)
206
+ .post(`/api/projects/${projectId}/propose`)
207
+ .send({})
208
+
209
+ expect(res.status).toBe(400)
210
+ expect(res.body.error).toBe('idea is required')
211
+ })
212
+
213
+ it('returns 400 when idea is empty string', async () => {
214
+ const res = await request(app)
215
+ .post(`/api/projects/${projectId}/propose`)
216
+ .send({ idea: ' ' })
217
+
218
+ expect(res.status).toBe(400)
219
+ expect(res.body.error).toBe('idea is required')
220
+ })
221
+
222
+ it('creates a proposal row in DB', async () => {
223
+ const res = await request(app)
224
+ .post(`/api/projects/${projectId}/propose`)
225
+ .send({ idea: 'New feature idea' })
226
+
227
+ expect(res.status).toBe(202)
228
+ const { getProposal: gp } = await import('./db')
229
+ const row = gp(db, res.body.proposalId)
230
+ expect(row).toBeDefined()
231
+ expect(row!.idea).toBe('New feature idea')
232
+ })
233
+
234
+ it('calls proposalManager.startExploration', async () => {
235
+ await request(app)
236
+ .post(`/api/projects/${projectId}/propose`)
237
+ .send({ idea: 'Some idea' })
238
+
239
+ // Wait for async to complete
240
+ await new Promise((r) => setTimeout(r, 10))
241
+ expect(proposalManager.startExploration).toHaveBeenCalledOnce()
242
+ })
243
+ })
244
+
245
+ // ─── GET /:projectId/propose/:id ─────────────────────────────────────────
246
+
247
+ describe('GET /:projectId/propose/:id', () => {
248
+ it('returns 200 with proposal row', async () => {
249
+ createProposal(db, { id: 'p-get-1', idea: 'Get test idea' })
250
+
251
+ const res = await request(app)
252
+ .get(`/api/projects/${projectId}/propose/p-get-1`)
253
+
254
+ expect(res.status).toBe(200)
255
+ expect(res.body.proposal.id).toBe('p-get-1')
256
+ expect(res.body.proposal.idea).toBe('Get test idea')
257
+ })
258
+
259
+ it('returns 404 for unknown id', async () => {
260
+ const res = await request(app)
261
+ .get(`/api/projects/${projectId}/propose/nonexistent`)
262
+
263
+ expect(res.status).toBe(404)
264
+ expect(res.body.error).toBe('Proposal not found')
265
+ })
266
+ })
267
+
268
+ // ─── GET /:projectId/propose ─────────────────────────────────────────────
269
+
270
+ describe('GET /:projectId/propose', () => {
271
+ it('returns list of proposals', async () => {
272
+ createProposal(db, { id: 'p-list-1', idea: 'Idea A' })
273
+ createProposal(db, { id: 'p-list-2', idea: 'Idea B' })
274
+
275
+ const res = await request(app)
276
+ .get(`/api/projects/${projectId}/propose`)
277
+
278
+ expect(res.status).toBe(200)
279
+ expect(res.body.total).toBeGreaterThanOrEqual(2)
280
+ expect(Array.isArray(res.body.proposals)).toBe(true)
281
+ })
282
+
283
+ it('respects limit and offset params', async () => {
284
+ for (let i = 1; i <= 5; i++) {
285
+ createProposal(db, { id: `p-page-${i}`, idea: `Idea ${i}` })
286
+ }
287
+
288
+ const res = await request(app)
289
+ .get(`/api/projects/${projectId}/propose?limit=2&offset=0`)
290
+
291
+ expect(res.status).toBe(200)
292
+ expect(res.body.proposals.length).toBe(2)
293
+ })
294
+ })
295
+
296
+ // ─── POST /:projectId/propose/:id/refine ─────────────────────────────────
297
+
298
+ describe('POST /:projectId/propose/:id/refine', () => {
299
+ it('returns 202 when proposal is in review status', async () => {
300
+ createProposal(db, { id: 'p-refine-1', idea: 'Idea to refine' })
301
+ updateProposal(db, 'p-refine-1', { status: 'review', session_id: 'sess-r1' })
302
+
303
+ const res = await request(app)
304
+ .post(`/api/projects/${projectId}/propose/p-refine-1/refine`)
305
+ .send({ feedback: 'Make it simpler' })
306
+
307
+ expect(res.status).toBe(202)
308
+ expect(res.body.ok).toBe(true)
309
+ })
310
+
311
+ it('returns 404 for unknown proposal', async () => {
312
+ const res = await request(app)
313
+ .post(`/api/projects/${projectId}/propose/nonexistent/refine`)
314
+ .send({ feedback: 'Feedback' })
315
+
316
+ expect(res.status).toBe(404)
317
+ })
318
+
319
+ it('returns 409 when proposal is busy', async () => {
320
+ createProposal(db, { id: 'p-refine-busy', idea: 'Busy idea' })
321
+ updateProposal(db, 'p-refine-busy', { status: 'review' })
322
+ proposalManager.isActive.mockReturnValue(true)
323
+
324
+ const res = await request(app)
325
+ .post(`/api/projects/${projectId}/propose/p-refine-busy/refine`)
326
+ .send({ feedback: 'Some feedback' })
327
+
328
+ expect(res.status).toBe(409)
329
+ expect(res.body.error).toBe('PROPOSAL_BUSY')
330
+ })
331
+
332
+ it('returns 409 when proposal is not in review status', async () => {
333
+ createProposal(db, { id: 'p-refine-input', idea: 'Not in review' })
334
+ // status defaults to 'input'
335
+
336
+ const res = await request(app)
337
+ .post(`/api/projects/${projectId}/propose/p-refine-input/refine`)
338
+ .send({ feedback: 'Some feedback' })
339
+
340
+ expect(res.status).toBe(409)
341
+ expect(res.body.error).toBe('Proposal is not in review state')
342
+ })
343
+
344
+ it('returns 400 when feedback is empty', async () => {
345
+ createProposal(db, { id: 'p-refine-empty', idea: 'Some idea' })
346
+ updateProposal(db, 'p-refine-empty', { status: 'review' })
347
+
348
+ const res = await request(app)
349
+ .post(`/api/projects/${projectId}/propose/p-refine-empty/refine`)
350
+ .send({ feedback: '' })
351
+
352
+ expect(res.status).toBe(400)
353
+ expect(res.body.error).toBe('feedback is required')
354
+ })
355
+ })
356
+
357
+ // ─── POST /:projectId/propose/:id/create-issue ───────────────────────────
358
+
359
+ describe('POST /:projectId/propose/:id/create-issue', () => {
360
+ it('returns 202 when proposal is in review status', async () => {
361
+ createProposal(db, { id: 'p-issue-1', idea: 'Issue idea' })
362
+ updateProposal(db, 'p-issue-1', { status: 'review', session_id: 'sess-i1' })
363
+
364
+ const res = await request(app)
365
+ .post(`/api/projects/${projectId}/propose/p-issue-1/create-issue`)
366
+
367
+ expect(res.status).toBe(202)
368
+ expect(res.body.ok).toBe(true)
369
+ })
370
+
371
+ it('returns 409 when proposal is busy', async () => {
372
+ createProposal(db, { id: 'p-issue-busy', idea: 'Busy issue' })
373
+ updateProposal(db, 'p-issue-busy', { status: 'review' })
374
+ proposalManager.isActive.mockReturnValue(true)
375
+
376
+ const res = await request(app)
377
+ .post(`/api/projects/${projectId}/propose/p-issue-busy/create-issue`)
378
+
379
+ expect(res.status).toBe(409)
380
+ expect(res.body.error).toBe('PROPOSAL_BUSY')
381
+ })
382
+
383
+ it('returns 409 when not in review status', async () => {
384
+ createProposal(db, { id: 'p-issue-input', idea: 'Not ready' })
385
+ // status is 'input'
386
+
387
+ const res = await request(app)
388
+ .post(`/api/projects/${projectId}/propose/p-issue-input/create-issue`)
389
+
390
+ expect(res.status).toBe(409)
391
+ expect(res.body.error).toBe('Proposal is not in review state')
392
+ })
393
+ })
394
+
395
+ // ─── DELETE /:projectId/propose/:id ──────────────────────────────────────
396
+
397
+ describe('DELETE /:projectId/propose/:id', () => {
398
+ it('returns 200 ok', async () => {
399
+ createProposal(db, { id: 'p-del-1', idea: 'Delete this' })
400
+
401
+ const res = await request(app)
402
+ .delete(`/api/projects/${projectId}/propose/p-del-1`)
403
+
404
+ expect(res.status).toBe(200)
405
+ expect(res.body.ok).toBe(true)
406
+ })
407
+
408
+ it('returns 404 for unknown proposal', async () => {
409
+ const res = await request(app)
410
+ .delete(`/api/projects/${projectId}/propose/nonexistent`)
411
+
412
+ expect(res.status).toBe(404)
413
+ })
414
+
415
+ it('calls proposalManager.cancel', async () => {
416
+ createProposal(db, { id: 'p-del-2', idea: 'Cancel this' })
417
+
418
+ await request(app)
419
+ .delete(`/api/projects/${projectId}/propose/p-del-2`)
420
+
421
+ expect(proposalManager.cancel).toHaveBeenCalledWith('p-del-2')
422
+ })
423
+ })
424
+ })