@vibescope/mcp-server 0.4.9 → 0.5.1

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.
Files changed (159) hide show
  1. package/CHANGELOG.md +84 -84
  2. package/README.md +194 -194
  3. package/dist/api-client/tasks.d.ts +1 -0
  4. package/dist/cli-init.js +21 -21
  5. package/dist/cli.js +26 -26
  6. package/dist/handlers/tasks.js +7 -1
  7. package/dist/handlers/tool-docs.js +1216 -1216
  8. package/dist/index.js +73 -73
  9. package/dist/templates/agent-guidelines.d.ts +1 -1
  10. package/dist/templates/agent-guidelines.js +205 -205
  11. package/dist/templates/help-content.js +1621 -1621
  12. package/dist/tools/bodies-of-work.js +6 -6
  13. package/dist/tools/cloud-agents.js +22 -22
  14. package/dist/tools/milestones.js +2 -2
  15. package/dist/tools/requests.js +1 -1
  16. package/dist/tools/session.js +11 -11
  17. package/dist/tools/sprints.js +9 -9
  18. package/dist/tools/tasks.js +43 -35
  19. package/dist/tools/worktrees.js +14 -14
  20. package/dist/utils.js +11 -11
  21. package/docs/TOOLS.md +2687 -2685
  22. package/package.json +53 -53
  23. package/scripts/generate-docs.ts +212 -212
  24. package/scripts/version-bump.ts +203 -203
  25. package/src/api-client/blockers.ts +86 -86
  26. package/src/api-client/bodies-of-work.ts +194 -194
  27. package/src/api-client/chat.ts +50 -50
  28. package/src/api-client/connectors.ts +152 -152
  29. package/src/api-client/cost.ts +185 -185
  30. package/src/api-client/decisions.ts +87 -87
  31. package/src/api-client/deployment.ts +313 -313
  32. package/src/api-client/discovery.ts +81 -81
  33. package/src/api-client/fallback.ts +52 -52
  34. package/src/api-client/file-checkouts.ts +115 -115
  35. package/src/api-client/findings.ts +100 -100
  36. package/src/api-client/git-issues.ts +88 -88
  37. package/src/api-client/ideas.ts +112 -112
  38. package/src/api-client/index.ts +592 -592
  39. package/src/api-client/milestones.ts +83 -83
  40. package/src/api-client/organizations.ts +185 -185
  41. package/src/api-client/progress.ts +94 -94
  42. package/src/api-client/project.ts +181 -181
  43. package/src/api-client/requests.ts +54 -54
  44. package/src/api-client/session.ts +220 -220
  45. package/src/api-client/sprints.ts +227 -227
  46. package/src/api-client/subtasks.ts +57 -57
  47. package/src/api-client/tasks.ts +451 -450
  48. package/src/api-client/types.ts +32 -32
  49. package/src/api-client/validation.ts +60 -60
  50. package/src/api-client/worktrees.ts +53 -53
  51. package/src/api-client.test.ts +847 -847
  52. package/src/api-client.ts +2728 -2728
  53. package/src/cli-init.ts +558 -558
  54. package/src/cli.test.ts +284 -284
  55. package/src/cli.ts +204 -204
  56. package/src/handlers/__test-setup__.ts +240 -240
  57. package/src/handlers/__test-utils__.ts +89 -89
  58. package/src/handlers/blockers.test.ts +468 -468
  59. package/src/handlers/blockers.ts +172 -172
  60. package/src/handlers/bodies-of-work.test.ts +704 -704
  61. package/src/handlers/bodies-of-work.ts +526 -526
  62. package/src/handlers/chat.test.ts +185 -185
  63. package/src/handlers/chat.ts +101 -101
  64. package/src/handlers/cloud-agents.test.ts +438 -438
  65. package/src/handlers/cloud-agents.ts +156 -156
  66. package/src/handlers/connectors.test.ts +834 -834
  67. package/src/handlers/connectors.ts +229 -229
  68. package/src/handlers/cost.test.ts +462 -462
  69. package/src/handlers/cost.ts +285 -285
  70. package/src/handlers/decisions.test.ts +382 -382
  71. package/src/handlers/decisions.ts +153 -153
  72. package/src/handlers/deployment.test.ts +551 -551
  73. package/src/handlers/deployment.ts +570 -570
  74. package/src/handlers/discovery.test.ts +206 -206
  75. package/src/handlers/discovery.ts +433 -433
  76. package/src/handlers/fallback.test.ts +537 -537
  77. package/src/handlers/fallback.ts +194 -194
  78. package/src/handlers/file-checkouts.test.ts +750 -750
  79. package/src/handlers/file-checkouts.ts +185 -185
  80. package/src/handlers/findings.test.ts +633 -633
  81. package/src/handlers/findings.ts +239 -239
  82. package/src/handlers/git-issues.test.ts +631 -631
  83. package/src/handlers/git-issues.ts +136 -136
  84. package/src/handlers/ideas.test.ts +644 -644
  85. package/src/handlers/ideas.ts +207 -207
  86. package/src/handlers/index.ts +93 -93
  87. package/src/handlers/milestones.test.ts +475 -475
  88. package/src/handlers/milestones.ts +180 -180
  89. package/src/handlers/organizations.test.ts +826 -826
  90. package/src/handlers/organizations.ts +315 -315
  91. package/src/handlers/progress.test.ts +269 -269
  92. package/src/handlers/progress.ts +77 -77
  93. package/src/handlers/project.test.ts +546 -546
  94. package/src/handlers/project.ts +245 -245
  95. package/src/handlers/requests.test.ts +303 -303
  96. package/src/handlers/requests.ts +99 -99
  97. package/src/handlers/roles.test.ts +305 -305
  98. package/src/handlers/roles.ts +219 -219
  99. package/src/handlers/session.test.ts +998 -998
  100. package/src/handlers/session.ts +1105 -1105
  101. package/src/handlers/sprints.test.ts +732 -732
  102. package/src/handlers/sprints.ts +537 -537
  103. package/src/handlers/tasks.test.ts +931 -931
  104. package/src/handlers/tasks.ts +1144 -1137
  105. package/src/handlers/tool-categories.test.ts +66 -66
  106. package/src/handlers/tool-docs.test.ts +511 -511
  107. package/src/handlers/tool-docs.ts +1595 -1595
  108. package/src/handlers/types.test.ts +259 -259
  109. package/src/handlers/types.ts +176 -176
  110. package/src/handlers/validation.test.ts +582 -582
  111. package/src/handlers/validation.ts +164 -164
  112. package/src/handlers/version.ts +63 -63
  113. package/src/index.test.ts +674 -674
  114. package/src/index.ts +884 -884
  115. package/src/setup.test.ts +243 -243
  116. package/src/setup.ts +410 -410
  117. package/src/templates/agent-guidelines.ts +233 -233
  118. package/src/templates/help-content.ts +1751 -1751
  119. package/src/token-tracking.test.ts +463 -463
  120. package/src/token-tracking.ts +167 -167
  121. package/src/tools/blockers.ts +122 -122
  122. package/src/tools/bodies-of-work.ts +283 -283
  123. package/src/tools/chat.ts +72 -72
  124. package/src/tools/cloud-agents.ts +101 -101
  125. package/src/tools/connectors.ts +191 -191
  126. package/src/tools/cost.ts +111 -111
  127. package/src/tools/decisions.ts +111 -111
  128. package/src/tools/deployment.ts +455 -455
  129. package/src/tools/discovery.ts +76 -76
  130. package/src/tools/fallback.ts +111 -111
  131. package/src/tools/features.ts +154 -154
  132. package/src/tools/file-checkouts.ts +145 -145
  133. package/src/tools/findings.ts +101 -101
  134. package/src/tools/git-issues.ts +130 -130
  135. package/src/tools/ideas.ts +162 -162
  136. package/src/tools/index.ts +145 -145
  137. package/src/tools/milestones.ts +118 -118
  138. package/src/tools/organizations.ts +224 -224
  139. package/src/tools/persona-templates.ts +25 -25
  140. package/src/tools/progress.ts +73 -73
  141. package/src/tools/project.ts +210 -210
  142. package/src/tools/requests.ts +68 -68
  143. package/src/tools/roles.ts +112 -112
  144. package/src/tools/session.ts +181 -181
  145. package/src/tools/sprints.ts +298 -298
  146. package/src/tools/tasks.ts +583 -575
  147. package/src/tools/tools.test.ts +222 -222
  148. package/src/tools/types.ts +9 -9
  149. package/src/tools/validation.ts +75 -75
  150. package/src/tools/version.ts +34 -34
  151. package/src/tools/worktrees.ts +66 -66
  152. package/src/tools.test.ts +416 -416
  153. package/src/utils.test.ts +1014 -1014
  154. package/src/utils.ts +586 -586
  155. package/src/validators.test.ts +223 -223
  156. package/src/validators.ts +249 -249
  157. package/src/version.ts +162 -162
  158. package/tsconfig.json +16 -16
  159. package/vitest.config.ts +14 -14
@@ -1,847 +1,847 @@
1
- /**
2
- * API Client Tests
3
- *
4
- * Tests for the VibescopeApiClient class that handles all HTTP communication
5
- * with the Vibescope API.
6
- */
7
-
8
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
9
-
10
- // Unmock the api-client module so we test the real implementation
11
- vi.unmock('./api-client.js');
12
- vi.unmock('../api-client.js');
13
-
14
- // Import after unmocking
15
- import { VibescopeApiClient } from './api-client.js';
16
-
17
- // ============================================================================
18
- // Test Setup
19
- // ============================================================================
20
-
21
- // Mock fetch globally
22
- const mockFetch = vi.fn();
23
- global.fetch = mockFetch;
24
-
25
- // Helper to create mock response
26
- function createMockResponse(data: unknown, ok = true, status = 200, headers?: Record<string, string>) {
27
- return {
28
- ok,
29
- status,
30
- json: vi.fn().mockResolvedValue(data),
31
- headers: {
32
- get: (name: string) => headers?.[name] ?? null,
33
- },
34
- };
35
- }
36
-
37
- // Helper to create network error
38
- function createNetworkError(message: string) {
39
- return new Error(message);
40
- }
41
-
42
- // Fast retry config for tests (1ms delays instead of 1000ms)
43
- const FAST_RETRY_CONFIG = {
44
- maxRetries: 3,
45
- baseDelayMs: 1,
46
- maxDelayMs: 10,
47
- retryStatusCodes: [429, 503, 504],
48
- };
49
-
50
- describe('VibescopeApiClient', () => {
51
- let client: VibescopeApiClient;
52
-
53
- beforeEach(() => {
54
- vi.clearAllMocks();
55
- // Use fast retry config so tests don't take forever
56
- client = new VibescopeApiClient({ apiKey: 'test-api-key', retry: FAST_RETRY_CONFIG });
57
- });
58
-
59
- afterEach(() => {
60
- vi.resetAllMocks();
61
- });
62
-
63
- // ============================================================================
64
- // Constructor Tests
65
- // ============================================================================
66
-
67
- describe('constructor', () => {
68
- it('should use default API URL when not provided', () => {
69
- const c = new VibescopeApiClient({ apiKey: 'test-key' });
70
- mockFetch.mockResolvedValue(createMockResponse({ valid: true }));
71
-
72
- // Trigger a request to verify the URL
73
- c.validateAuth();
74
-
75
- expect(mockFetch).toHaveBeenCalledWith(
76
- expect.stringContaining('https://vibescope.dev'),
77
- expect.any(Object)
78
- );
79
- });
80
-
81
- it('should use custom baseUrl when provided', () => {
82
- const c = new VibescopeApiClient({
83
- apiKey: 'test-key',
84
- baseUrl: 'https://custom.api.com',
85
- });
86
- mockFetch.mockResolvedValue(createMockResponse({ valid: true }));
87
-
88
- c.validateAuth();
89
-
90
- expect(mockFetch).toHaveBeenCalledWith(
91
- expect.stringContaining('https://custom.api.com'),
92
- expect.any(Object)
93
- );
94
- });
95
-
96
- it('should use VIBESCOPE_API_URL env var when set', () => {
97
- const originalEnv = process.env.VIBESCOPE_API_URL;
98
- process.env.VIBESCOPE_API_URL = 'https://env-api.com';
99
-
100
- const c = new VibescopeApiClient({ apiKey: 'test-key' });
101
- mockFetch.mockResolvedValue(createMockResponse({ valid: true }));
102
-
103
- c.validateAuth();
104
-
105
- expect(mockFetch).toHaveBeenCalledWith(
106
- expect.stringContaining('https://env-api.com'),
107
- expect.any(Object)
108
- );
109
-
110
- // Restore
111
- if (originalEnv !== undefined) {
112
- process.env.VIBESCOPE_API_URL = originalEnv;
113
- } else {
114
- delete process.env.VIBESCOPE_API_URL;
115
- }
116
- });
117
- });
118
-
119
- // ============================================================================
120
- // Request Method Tests (via validateAuth)
121
- // ============================================================================
122
-
123
- describe('request method', () => {
124
- it('should send correct headers', async () => {
125
- mockFetch.mockResolvedValue(createMockResponse({ valid: true }));
126
-
127
- await client.validateAuth();
128
-
129
- expect(mockFetch).toHaveBeenCalledWith(
130
- expect.any(String),
131
- expect.objectContaining({
132
- method: 'POST',
133
- headers: {
134
- 'Content-Type': 'application/json',
135
- 'X-API-Key': 'test-api-key',
136
- },
137
- })
138
- );
139
- });
140
-
141
- it('should return success response on 2xx status', async () => {
142
- const responseData = { valid: true, user_id: 'user-123' };
143
- mockFetch.mockResolvedValue(createMockResponse(responseData, true, 200));
144
-
145
- const result = await client.validateAuth();
146
-
147
- expect(result).toEqual({
148
- ok: true,
149
- status: 200,
150
- data: responseData,
151
- });
152
- });
153
-
154
- it('should return error response on 4xx status', async () => {
155
- const errorData = { error: 'Invalid API key' };
156
- mockFetch.mockResolvedValue(createMockResponse(errorData, false, 401));
157
-
158
- const result = await client.validateAuth();
159
-
160
- expect(result).toEqual({
161
- ok: false,
162
- status: 401,
163
- error: 'Invalid API key',
164
- data: errorData,
165
- });
166
- });
167
-
168
- it('should return error response on 5xx status', async () => {
169
- const errorData = { error: 'Internal server error' };
170
- mockFetch.mockResolvedValue(createMockResponse(errorData, false, 500));
171
-
172
- const result = await client.validateAuth();
173
-
174
- expect(result).toEqual({
175
- ok: false,
176
- status: 500,
177
- error: 'Internal server error',
178
- data: errorData,
179
- });
180
- });
181
-
182
- it('should handle missing error field in error response', async () => {
183
- const errorData = { message: 'Something went wrong' };
184
- mockFetch.mockResolvedValue(createMockResponse(errorData, false, 400));
185
-
186
- const result = await client.validateAuth();
187
-
188
- expect(result).toEqual({
189
- ok: false,
190
- status: 400,
191
- error: 'HTTP 400',
192
- data: errorData,
193
- });
194
- });
195
-
196
- it('should handle network errors after retries', async () => {
197
- mockFetch.mockRejectedValue(createNetworkError('Network request failed'));
198
-
199
- const result = await client.validateAuth();
200
-
201
- // Network errors are retried, so 4 attempts total (1 initial + 3 retries)
202
- expect(mockFetch).toHaveBeenCalledTimes(4);
203
- expect(result).toEqual({
204
- ok: false,
205
- status: 0,
206
- error: 'Network request failed',
207
- });
208
- }, 30000);
209
-
210
- it('should handle non-Error exceptions after retries', async () => {
211
- mockFetch.mockRejectedValue('String error');
212
-
213
- const result = await client.validateAuth();
214
-
215
- // Non-Error exceptions are converted to Error('Network error') and retried
216
- expect(mockFetch).toHaveBeenCalledTimes(4);
217
- expect(result).toEqual({
218
- ok: false,
219
- status: 0,
220
- error: 'Network error',
221
- });
222
- }, 30000);
223
- });
224
-
225
- // ============================================================================
226
- // Auth Endpoints
227
- // ============================================================================
228
-
229
- describe('validateAuth', () => {
230
- it('should call correct endpoint', async () => {
231
- mockFetch.mockResolvedValue(createMockResponse({ valid: true }));
232
-
233
- await client.validateAuth();
234
-
235
- expect(mockFetch).toHaveBeenCalledWith(
236
- expect.stringContaining('/api/mcp/auth/validate'),
237
- expect.objectContaining({
238
- method: 'POST',
239
- body: JSON.stringify({ api_key: 'test-api-key' }),
240
- })
241
- );
242
- });
243
- });
244
-
245
- // ============================================================================
246
- // Session Endpoints
247
- // ============================================================================
248
-
249
- describe('startSession', () => {
250
- it('should call correct endpoint with params', async () => {
251
- mockFetch.mockResolvedValue(
252
- createMockResponse({ session_started: true, session_id: 'session-123' })
253
- );
254
-
255
- await client.startSession({
256
- project_id: 'proj-123',
257
- mode: 'lite',
258
- model: 'opus',
259
- });
260
-
261
- const [url, opts] = mockFetch.mock.calls[0] as [string, RequestInit];
262
- const body = JSON.parse(opts.body as string);
263
- expect(url).toContain('/api/mcp/sessions/start');
264
- expect(opts.method).toBe('POST');
265
- expect(body).toMatchObject({
266
- project_id: 'proj-123',
267
- mode: 'lite',
268
- model: 'opus',
269
- });
270
- expect(body.instance_id).toBeDefined();
271
- });
272
-
273
- it('should support git_url param', async () => {
274
- mockFetch.mockResolvedValue(
275
- createMockResponse({ session_started: true })
276
- );
277
-
278
- await client.startSession({
279
- git_url: 'https://github.com/org/repo',
280
- });
281
-
282
- const [, opts] = mockFetch.mock.calls[0] as [string, RequestInit];
283
- const body = JSON.parse(opts.body as string);
284
- expect(body).toMatchObject({
285
- git_url: 'https://github.com/org/repo',
286
- });
287
- expect(body.instance_id).toBeDefined();
288
- });
289
- });
290
-
291
- describe('heartbeat', () => {
292
- it('should call correct endpoint', async () => {
293
- mockFetch.mockResolvedValue(
294
- createMockResponse({ timestamp: '2026-01-14T10:00:00Z' })
295
- );
296
-
297
- await client.heartbeat('session-123');
298
-
299
- expect(mockFetch).toHaveBeenCalledWith(
300
- expect.stringContaining('/api/mcp/sessions/heartbeat'),
301
- expect.objectContaining({
302
- method: 'POST',
303
- body: JSON.stringify({ session_id: 'session-123' }),
304
- })
305
- );
306
- });
307
-
308
- it('should pass worktree path option', async () => {
309
- mockFetch.mockResolvedValue(
310
- createMockResponse({ timestamp: '2026-01-14T10:00:00Z' })
311
- );
312
-
313
- await client.heartbeat('session-123', {
314
- current_worktree_path: '../project-task-abc',
315
- });
316
-
317
- expect(mockFetch).toHaveBeenCalledWith(
318
- expect.any(String),
319
- expect.objectContaining({
320
- body: JSON.stringify({
321
- session_id: 'session-123',
322
- current_worktree_path: '../project-task-abc',
323
- }),
324
- })
325
- );
326
- });
327
- });
328
-
329
- describe('endSession', () => {
330
- it('should call correct endpoint', async () => {
331
- mockFetch.mockResolvedValue(
332
- createMockResponse({ success: true })
333
- );
334
-
335
- await client.endSession('session-123');
336
-
337
- expect(mockFetch).toHaveBeenCalledWith(
338
- expect.stringContaining('/api/mcp/sessions/end'),
339
- expect.objectContaining({
340
- method: 'POST',
341
- body: JSON.stringify({ session_id: 'session-123' }),
342
- })
343
- );
344
- });
345
- });
346
-
347
- // ============================================================================
348
- // Project Endpoints
349
- // ============================================================================
350
-
351
- describe('listProjects', () => {
352
- it('should call correct endpoint', async () => {
353
- mockFetch.mockResolvedValue(
354
- createMockResponse({ projects: [] })
355
- );
356
-
357
- await client.listProjects();
358
-
359
- expect(mockFetch).toHaveBeenCalledWith(
360
- expect.stringContaining('/api/mcp/projects'),
361
- expect.objectContaining({ method: 'GET' })
362
- );
363
- });
364
- });
365
-
366
- describe('getProject', () => {
367
- it('should call correct endpoint with project ID', async () => {
368
- mockFetch.mockResolvedValue(
369
- createMockResponse({ project: { id: 'proj-123', name: 'Test' } })
370
- );
371
-
372
- await client.getProject('proj-123');
373
-
374
- expect(mockFetch).toHaveBeenCalledWith(
375
- expect.stringContaining('/api/mcp/projects/proj-123'),
376
- expect.objectContaining({ method: 'GET' })
377
- );
378
- });
379
-
380
- it('should include git_url param when provided', async () => {
381
- mockFetch.mockResolvedValue(
382
- createMockResponse({ project: { id: 'proj-123' } })
383
- );
384
-
385
- await client.getProject('proj-123', 'https://github.com/org/repo');
386
-
387
- expect(mockFetch).toHaveBeenCalledWith(
388
- expect.stringContaining('git_url=https'),
389
- expect.any(Object)
390
- );
391
- });
392
- });
393
-
394
- describe('createProject', () => {
395
- it('should call correct endpoint', async () => {
396
- mockFetch.mockResolvedValue(
397
- createMockResponse({ project: { id: 'new-proj' } })
398
- );
399
-
400
- await client.createProject({
401
- name: 'New Project',
402
- description: 'Test description',
403
- });
404
-
405
- expect(mockFetch).toHaveBeenCalledWith(
406
- expect.stringContaining('/api/mcp/projects'),
407
- expect.objectContaining({
408
- method: 'POST',
409
- body: JSON.stringify({
410
- name: 'New Project',
411
- description: 'Test description',
412
- }),
413
- })
414
- );
415
- });
416
- });
417
-
418
- describe('updateProject', () => {
419
- it('should call correct endpoint', async () => {
420
- mockFetch.mockResolvedValue(
421
- createMockResponse({ success: true })
422
- );
423
-
424
- await client.updateProject('proj-123', {
425
- name: 'Updated Name',
426
- status: 'active',
427
- });
428
-
429
- expect(mockFetch).toHaveBeenCalledWith(
430
- expect.stringContaining('/api/mcp/projects/proj-123'),
431
- expect.objectContaining({
432
- method: 'PATCH',
433
- body: JSON.stringify({
434
- name: 'Updated Name',
435
- status: 'active',
436
- }),
437
- })
438
- );
439
- });
440
- });
441
-
442
- // ============================================================================
443
- // Task Endpoints
444
- // ============================================================================
445
-
446
- describe('getTasks', () => {
447
- it('should call correct endpoint', async () => {
448
- mockFetch.mockResolvedValue(
449
- createMockResponse({ tasks: [] })
450
- );
451
-
452
- await client.getTasks('proj-123');
453
-
454
- expect(mockFetch).toHaveBeenCalledWith(
455
- expect.stringContaining('/api/mcp/projects/proj-123/tasks'),
456
- expect.objectContaining({ method: 'GET' })
457
- );
458
- });
459
-
460
- it('should include filter params', async () => {
461
- mockFetch.mockResolvedValue(
462
- createMockResponse({ tasks: [] })
463
- );
464
-
465
- await client.getTasks('proj-123', {
466
- status: 'in_progress',
467
- limit: 10,
468
- });
469
-
470
- const url = mockFetch.mock.calls[0][0] as string;
471
- expect(url).toContain('status=in_progress');
472
- expect(url).toContain('limit=10');
473
- });
474
- });
475
-
476
- describe('createTask', () => {
477
- it('should call correct endpoint', async () => {
478
- mockFetch.mockResolvedValue(
479
- createMockResponse({ task: { id: 'task-123' } })
480
- );
481
-
482
- await client.createTask('proj-123', {
483
- title: 'New Task',
484
- priority: 2,
485
- });
486
-
487
- expect(mockFetch).toHaveBeenCalledWith(
488
- expect.stringContaining('/api/mcp/projects/proj-123/tasks'),
489
- expect.objectContaining({
490
- method: 'POST',
491
- body: JSON.stringify({
492
- title: 'New Task',
493
- priority: 2,
494
- }),
495
- })
496
- );
497
- });
498
- });
499
-
500
- describe('updateTask', () => {
501
- it('should call correct endpoint', async () => {
502
- mockFetch.mockResolvedValue(
503
- createMockResponse({ success: true })
504
- );
505
-
506
- await client.updateTask('task-123', {
507
- status: 'in_progress',
508
- progress_percentage: 50,
509
- });
510
-
511
- expect(mockFetch).toHaveBeenCalledWith(
512
- expect.stringContaining('/api/mcp/tasks/task-123'),
513
- expect.objectContaining({
514
- method: 'PATCH',
515
- body: JSON.stringify({
516
- status: 'in_progress',
517
- progress_percentage: 50,
518
- }),
519
- })
520
- );
521
- });
522
- });
523
-
524
- describe('completeTask', () => {
525
- it('should call proxy endpoint', async () => {
526
- mockFetch.mockResolvedValue(
527
- createMockResponse({ success: true })
528
- );
529
-
530
- await client.completeTask('task-123', {
531
- summary: 'Task completed successfully',
532
- });
533
-
534
- // completeTask uses proxy endpoint for consistency (direct endpoint had Vercel routing issues)
535
- expect(mockFetch).toHaveBeenCalledWith(
536
- expect.stringContaining('/api/mcp/proxy'),
537
- expect.objectContaining({
538
- method: 'POST',
539
- body: JSON.stringify({
540
- operation: 'complete_task',
541
- args: {
542
- task_id: 'task-123',
543
- summary: 'Task completed successfully',
544
- },
545
- }),
546
- })
547
- );
548
- });
549
- });
550
-
551
- describe('deleteTask', () => {
552
- it('should call correct endpoint', async () => {
553
- mockFetch.mockResolvedValue(
554
- createMockResponse({ success: true })
555
- );
556
-
557
- await client.deleteTask('task-123');
558
-
559
- expect(mockFetch).toHaveBeenCalledWith(
560
- expect.stringContaining('/api/mcp/tasks/task-123'),
561
- expect.objectContaining({ method: 'DELETE' })
562
- );
563
- });
564
- });
565
-
566
- describe('getNextTask', () => {
567
- it('should call correct endpoint', async () => {
568
- mockFetch.mockResolvedValue(
569
- createMockResponse({ task: { id: 'task-123', title: 'Next task' } })
570
- );
571
-
572
- await client.getNextTask('proj-123');
573
-
574
- expect(mockFetch).toHaveBeenCalledWith(
575
- expect.stringContaining('/api/mcp/projects/proj-123/next-task'),
576
- expect.objectContaining({ method: 'GET' })
577
- );
578
- });
579
-
580
- it('should include session_id when provided', async () => {
581
- mockFetch.mockResolvedValue(
582
- createMockResponse({ task: null })
583
- );
584
-
585
- await client.getNextTask('proj-123', 'session-456');
586
-
587
- const url = mockFetch.mock.calls[0][0] as string;
588
- expect(url).toContain('session_id=session-456');
589
- });
590
- });
591
-
592
- // ============================================================================
593
- // Blocker Endpoints
594
- // ============================================================================
595
-
596
- describe('getBlockers', () => {
597
- it('should call correct endpoint', async () => {
598
- mockFetch.mockResolvedValue(
599
- createMockResponse({ blockers: [] })
600
- );
601
-
602
- await client.getBlockers('proj-123');
603
-
604
- expect(mockFetch).toHaveBeenCalledWith(
605
- expect.stringContaining('/api/mcp/proxy'),
606
- expect.objectContaining({ method: 'POST' })
607
- );
608
- });
609
- });
610
-
611
- describe('addBlocker', () => {
612
- it('should call correct endpoint', async () => {
613
- mockFetch.mockResolvedValue(
614
- createMockResponse({ blocker_id: 'blocker-123' })
615
- );
616
-
617
- await client.addBlocker('proj-123', 'Blocked by dependency');
618
-
619
- expect(mockFetch).toHaveBeenCalledWith(
620
- expect.stringContaining('/api/mcp/proxy'),
621
- expect.objectContaining({ method: 'POST' })
622
- );
623
- });
624
- });
625
-
626
- // ============================================================================
627
- // Validation Endpoints
628
- // ============================================================================
629
-
630
- describe('getTasksAwaitingValidation', () => {
631
- it('should call correct endpoint', async () => {
632
- mockFetch.mockResolvedValue(
633
- createMockResponse({ tasks: [] })
634
- );
635
-
636
- await client.getTasksAwaitingValidation('proj-123');
637
-
638
- expect(mockFetch).toHaveBeenCalledWith(
639
- expect.stringContaining('/api/mcp/proxy'),
640
- expect.objectContaining({ method: 'POST' })
641
- );
642
- });
643
- });
644
-
645
- describe('validateTask', () => {
646
- it('should call correct endpoint', async () => {
647
- mockFetch.mockResolvedValue(
648
- createMockResponse({ success: true })
649
- );
650
-
651
- await client.validateTask('task-123', {
652
- approved: true,
653
- validation_notes: 'All tests pass',
654
- });
655
-
656
- expect(mockFetch).toHaveBeenCalledWith(
657
- expect.stringContaining('/api/mcp/proxy'),
658
- expect.objectContaining({ method: 'POST' })
659
- );
660
- });
661
- });
662
-
663
- // ============================================================================
664
- // Proxy Endpoint
665
- // ============================================================================
666
-
667
- describe('proxy', () => {
668
- it('should call proxy endpoint with operation', async () => {
669
- mockFetch.mockResolvedValue(
670
- createMockResponse({ result: 'success' })
671
- );
672
-
673
- await client.proxy('custom_operation', { param1: 'value1' });
674
-
675
- expect(mockFetch).toHaveBeenCalledWith(
676
- expect.stringContaining('/api/mcp/proxy'),
677
- expect.objectContaining({
678
- method: 'POST',
679
- body: expect.stringContaining('custom_operation'),
680
- })
681
- );
682
- });
683
-
684
- it('should pass session context when provided', async () => {
685
- mockFetch.mockResolvedValue(
686
- createMockResponse({ result: 'success' })
687
- );
688
-
689
- await client.proxy(
690
- 'operation',
691
- { arg: 'value' },
692
- { sessionId: 'session-123', persona: 'Wave' }
693
- );
694
-
695
- expect(mockFetch).toHaveBeenCalledWith(
696
- expect.any(String),
697
- expect.objectContaining({
698
- body: expect.stringContaining('session-123'),
699
- })
700
- );
701
- });
702
- });
703
-
704
- // ============================================================================
705
- // Error Handling Edge Cases
706
- // ============================================================================
707
-
708
- describe('error handling edge cases', () => {
709
- it('should handle JSON parse error in response after retries', async () => {
710
- mockFetch.mockResolvedValue({
711
- ok: true,
712
- status: 200,
713
- json: vi.fn().mockRejectedValue(new SyntaxError('Invalid JSON')),
714
- headers: { get: () => null },
715
- });
716
-
717
- const result = await client.validateAuth();
718
-
719
- // JSON parse errors are treated as network errors and retried
720
- expect(mockFetch).toHaveBeenCalledTimes(4);
721
- expect(result.ok).toBe(false);
722
- expect(result.error).toContain('Invalid JSON');
723
- }, 30000);
724
-
725
- it('should handle timeout errors after retries', async () => {
726
- const timeoutError = new Error('Request timeout');
727
- timeoutError.name = 'AbortError';
728
- mockFetch.mockRejectedValue(timeoutError);
729
-
730
- const result = await client.validateAuth();
731
-
732
- // Timeout errors are retried
733
- expect(mockFetch).toHaveBeenCalledTimes(4);
734
- expect(result).toEqual({
735
- ok: false,
736
- status: 0,
737
- error: 'Request timeout',
738
- });
739
- }, 30000);
740
- });
741
-
742
- // ============================================================================
743
- // Retry Logic Tests
744
- // ============================================================================
745
-
746
- describe('retry logic', () => {
747
- it('should retry on 429 status code', async () => {
748
- // First two calls return 429, third succeeds
749
- mockFetch
750
- .mockResolvedValueOnce(createMockResponse({ error: 'Rate limited' }, false, 429))
751
- .mockResolvedValueOnce(createMockResponse({ error: 'Rate limited' }, false, 429))
752
- .mockResolvedValueOnce(createMockResponse({ valid: true }, true, 200));
753
-
754
- const result = await client.validateAuth();
755
-
756
- expect(mockFetch).toHaveBeenCalledTimes(3);
757
- expect(result.ok).toBe(true);
758
- });
759
-
760
- it('should retry on 503 status code', async () => {
761
- mockFetch
762
- .mockResolvedValueOnce(createMockResponse({ error: 'Service unavailable' }, false, 503))
763
- .mockResolvedValueOnce(createMockResponse({ valid: true }, true, 200));
764
-
765
- const result = await client.validateAuth();
766
-
767
- expect(mockFetch).toHaveBeenCalledTimes(2);
768
- expect(result.ok).toBe(true);
769
- });
770
-
771
- it('should retry on 504 status code', async () => {
772
- mockFetch
773
- .mockResolvedValueOnce(createMockResponse({ error: 'Gateway timeout' }, false, 504))
774
- .mockResolvedValueOnce(createMockResponse({ valid: true }, true, 200));
775
-
776
- const result = await client.validateAuth();
777
-
778
- expect(mockFetch).toHaveBeenCalledTimes(2);
779
- expect(result.ok).toBe(true);
780
- });
781
-
782
- it('should not retry on non-retryable status codes (400)', async () => {
783
- mockFetch.mockResolvedValueOnce(createMockResponse({ error: 'Bad request' }, false, 400));
784
-
785
- const result = await client.validateAuth();
786
-
787
- expect(mockFetch).toHaveBeenCalledTimes(1);
788
- expect(result.ok).toBe(false);
789
- expect(result.status).toBe(400);
790
- });
791
-
792
- it('should not retry on non-retryable status codes (401)', async () => {
793
- mockFetch.mockResolvedValueOnce(createMockResponse({ error: 'Unauthorized' }, false, 401));
794
-
795
- const result = await client.validateAuth();
796
-
797
- expect(mockFetch).toHaveBeenCalledTimes(1);
798
- expect(result.ok).toBe(false);
799
- expect(result.status).toBe(401);
800
- });
801
-
802
- it('should not retry on non-retryable status codes (500)', async () => {
803
- mockFetch.mockResolvedValueOnce(createMockResponse({ error: 'Server error' }, false, 500));
804
-
805
- const result = await client.validateAuth();
806
-
807
- expect(mockFetch).toHaveBeenCalledTimes(1);
808
- expect(result.ok).toBe(false);
809
- expect(result.status).toBe(500);
810
- });
811
-
812
- it('should stop after max retries (3)', async () => {
813
- // All 4 attempts (1 initial + 3 retries) return 429
814
- mockFetch.mockResolvedValue(createMockResponse({ error: 'Rate limited' }, false, 429));
815
-
816
- const result = await client.validateAuth();
817
-
818
- expect(mockFetch).toHaveBeenCalledTimes(4); // 1 initial + 3 retries
819
- expect(result.ok).toBe(false);
820
- expect(result.status).toBe(429);
821
- // API error message takes precedence over generic retry message
822
- expect(result.error).toBe('Rate limited');
823
- }, 30000);
824
-
825
- it('should retry on network errors', async () => {
826
- mockFetch
827
- .mockRejectedValueOnce(new Error('Network error'))
828
- .mockResolvedValueOnce(createMockResponse({ valid: true }, true, 200));
829
-
830
- const result = await client.validateAuth();
831
-
832
- expect(mockFetch).toHaveBeenCalledTimes(2);
833
- expect(result.ok).toBe(true);
834
- }, 10000);
835
-
836
- it('should return last error after all retries exhausted on network failure', async () => {
837
- mockFetch.mockRejectedValue(new Error('Connection refused'));
838
-
839
- const result = await client.validateAuth();
840
-
841
- expect(mockFetch).toHaveBeenCalledTimes(4);
842
- expect(result.ok).toBe(false);
843
- expect(result.status).toBe(0);
844
- expect(result.error).toBe('Connection refused');
845
- }, 30000);
846
- });
847
- });
1
+ /**
2
+ * API Client Tests
3
+ *
4
+ * Tests for the VibescopeApiClient class that handles all HTTP communication
5
+ * with the Vibescope API.
6
+ */
7
+
8
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
9
+
10
+ // Unmock the api-client module so we test the real implementation
11
+ vi.unmock('./api-client.js');
12
+ vi.unmock('../api-client.js');
13
+
14
+ // Import after unmocking
15
+ import { VibescopeApiClient } from './api-client.js';
16
+
17
+ // ============================================================================
18
+ // Test Setup
19
+ // ============================================================================
20
+
21
+ // Mock fetch globally
22
+ const mockFetch = vi.fn();
23
+ global.fetch = mockFetch;
24
+
25
+ // Helper to create mock response
26
+ function createMockResponse(data: unknown, ok = true, status = 200, headers?: Record<string, string>) {
27
+ return {
28
+ ok,
29
+ status,
30
+ json: vi.fn().mockResolvedValue(data),
31
+ headers: {
32
+ get: (name: string) => headers?.[name] ?? null,
33
+ },
34
+ };
35
+ }
36
+
37
+ // Helper to create network error
38
+ function createNetworkError(message: string) {
39
+ return new Error(message);
40
+ }
41
+
42
+ // Fast retry config for tests (1ms delays instead of 1000ms)
43
+ const FAST_RETRY_CONFIG = {
44
+ maxRetries: 3,
45
+ baseDelayMs: 1,
46
+ maxDelayMs: 10,
47
+ retryStatusCodes: [429, 503, 504],
48
+ };
49
+
50
+ describe('VibescopeApiClient', () => {
51
+ let client: VibescopeApiClient;
52
+
53
+ beforeEach(() => {
54
+ vi.clearAllMocks();
55
+ // Use fast retry config so tests don't take forever
56
+ client = new VibescopeApiClient({ apiKey: 'test-api-key', retry: FAST_RETRY_CONFIG });
57
+ });
58
+
59
+ afterEach(() => {
60
+ vi.resetAllMocks();
61
+ });
62
+
63
+ // ============================================================================
64
+ // Constructor Tests
65
+ // ============================================================================
66
+
67
+ describe('constructor', () => {
68
+ it('should use default API URL when not provided', () => {
69
+ const c = new VibescopeApiClient({ apiKey: 'test-key' });
70
+ mockFetch.mockResolvedValue(createMockResponse({ valid: true }));
71
+
72
+ // Trigger a request to verify the URL
73
+ c.validateAuth();
74
+
75
+ expect(mockFetch).toHaveBeenCalledWith(
76
+ expect.stringContaining('https://vibescope.dev'),
77
+ expect.any(Object)
78
+ );
79
+ });
80
+
81
+ it('should use custom baseUrl when provided', () => {
82
+ const c = new VibescopeApiClient({
83
+ apiKey: 'test-key',
84
+ baseUrl: 'https://custom.api.com',
85
+ });
86
+ mockFetch.mockResolvedValue(createMockResponse({ valid: true }));
87
+
88
+ c.validateAuth();
89
+
90
+ expect(mockFetch).toHaveBeenCalledWith(
91
+ expect.stringContaining('https://custom.api.com'),
92
+ expect.any(Object)
93
+ );
94
+ });
95
+
96
+ it('should use VIBESCOPE_API_URL env var when set', () => {
97
+ const originalEnv = process.env.VIBESCOPE_API_URL;
98
+ process.env.VIBESCOPE_API_URL = 'https://env-api.com';
99
+
100
+ const c = new VibescopeApiClient({ apiKey: 'test-key' });
101
+ mockFetch.mockResolvedValue(createMockResponse({ valid: true }));
102
+
103
+ c.validateAuth();
104
+
105
+ expect(mockFetch).toHaveBeenCalledWith(
106
+ expect.stringContaining('https://env-api.com'),
107
+ expect.any(Object)
108
+ );
109
+
110
+ // Restore
111
+ if (originalEnv !== undefined) {
112
+ process.env.VIBESCOPE_API_URL = originalEnv;
113
+ } else {
114
+ delete process.env.VIBESCOPE_API_URL;
115
+ }
116
+ });
117
+ });
118
+
119
+ // ============================================================================
120
+ // Request Method Tests (via validateAuth)
121
+ // ============================================================================
122
+
123
+ describe('request method', () => {
124
+ it('should send correct headers', async () => {
125
+ mockFetch.mockResolvedValue(createMockResponse({ valid: true }));
126
+
127
+ await client.validateAuth();
128
+
129
+ expect(mockFetch).toHaveBeenCalledWith(
130
+ expect.any(String),
131
+ expect.objectContaining({
132
+ method: 'POST',
133
+ headers: {
134
+ 'Content-Type': 'application/json',
135
+ 'X-API-Key': 'test-api-key',
136
+ },
137
+ })
138
+ );
139
+ });
140
+
141
+ it('should return success response on 2xx status', async () => {
142
+ const responseData = { valid: true, user_id: 'user-123' };
143
+ mockFetch.mockResolvedValue(createMockResponse(responseData, true, 200));
144
+
145
+ const result = await client.validateAuth();
146
+
147
+ expect(result).toEqual({
148
+ ok: true,
149
+ status: 200,
150
+ data: responseData,
151
+ });
152
+ });
153
+
154
+ it('should return error response on 4xx status', async () => {
155
+ const errorData = { error: 'Invalid API key' };
156
+ mockFetch.mockResolvedValue(createMockResponse(errorData, false, 401));
157
+
158
+ const result = await client.validateAuth();
159
+
160
+ expect(result).toEqual({
161
+ ok: false,
162
+ status: 401,
163
+ error: 'Invalid API key',
164
+ data: errorData,
165
+ });
166
+ });
167
+
168
+ it('should return error response on 5xx status', async () => {
169
+ const errorData = { error: 'Internal server error' };
170
+ mockFetch.mockResolvedValue(createMockResponse(errorData, false, 500));
171
+
172
+ const result = await client.validateAuth();
173
+
174
+ expect(result).toEqual({
175
+ ok: false,
176
+ status: 500,
177
+ error: 'Internal server error',
178
+ data: errorData,
179
+ });
180
+ });
181
+
182
+ it('should handle missing error field in error response', async () => {
183
+ const errorData = { message: 'Something went wrong' };
184
+ mockFetch.mockResolvedValue(createMockResponse(errorData, false, 400));
185
+
186
+ const result = await client.validateAuth();
187
+
188
+ expect(result).toEqual({
189
+ ok: false,
190
+ status: 400,
191
+ error: 'HTTP 400',
192
+ data: errorData,
193
+ });
194
+ });
195
+
196
+ it('should handle network errors after retries', async () => {
197
+ mockFetch.mockRejectedValue(createNetworkError('Network request failed'));
198
+
199
+ const result = await client.validateAuth();
200
+
201
+ // Network errors are retried, so 4 attempts total (1 initial + 3 retries)
202
+ expect(mockFetch).toHaveBeenCalledTimes(4);
203
+ expect(result).toEqual({
204
+ ok: false,
205
+ status: 0,
206
+ error: 'Network request failed',
207
+ });
208
+ }, 30000);
209
+
210
+ it('should handle non-Error exceptions after retries', async () => {
211
+ mockFetch.mockRejectedValue('String error');
212
+
213
+ const result = await client.validateAuth();
214
+
215
+ // Non-Error exceptions are converted to Error('Network error') and retried
216
+ expect(mockFetch).toHaveBeenCalledTimes(4);
217
+ expect(result).toEqual({
218
+ ok: false,
219
+ status: 0,
220
+ error: 'Network error',
221
+ });
222
+ }, 30000);
223
+ });
224
+
225
+ // ============================================================================
226
+ // Auth Endpoints
227
+ // ============================================================================
228
+
229
+ describe('validateAuth', () => {
230
+ it('should call correct endpoint', async () => {
231
+ mockFetch.mockResolvedValue(createMockResponse({ valid: true }));
232
+
233
+ await client.validateAuth();
234
+
235
+ expect(mockFetch).toHaveBeenCalledWith(
236
+ expect.stringContaining('/api/mcp/auth/validate'),
237
+ expect.objectContaining({
238
+ method: 'POST',
239
+ body: JSON.stringify({ api_key: 'test-api-key' }),
240
+ })
241
+ );
242
+ });
243
+ });
244
+
245
+ // ============================================================================
246
+ // Session Endpoints
247
+ // ============================================================================
248
+
249
+ describe('startSession', () => {
250
+ it('should call correct endpoint with params', async () => {
251
+ mockFetch.mockResolvedValue(
252
+ createMockResponse({ session_started: true, session_id: 'session-123' })
253
+ );
254
+
255
+ await client.startSession({
256
+ project_id: 'proj-123',
257
+ mode: 'lite',
258
+ model: 'opus',
259
+ });
260
+
261
+ const [url, opts] = mockFetch.mock.calls[0] as [string, RequestInit];
262
+ const body = JSON.parse(opts.body as string);
263
+ expect(url).toContain('/api/mcp/sessions/start');
264
+ expect(opts.method).toBe('POST');
265
+ expect(body).toMatchObject({
266
+ project_id: 'proj-123',
267
+ mode: 'lite',
268
+ model: 'opus',
269
+ });
270
+ expect(body.instance_id).toBeDefined();
271
+ });
272
+
273
+ it('should support git_url param', async () => {
274
+ mockFetch.mockResolvedValue(
275
+ createMockResponse({ session_started: true })
276
+ );
277
+
278
+ await client.startSession({
279
+ git_url: 'https://github.com/org/repo',
280
+ });
281
+
282
+ const [, opts] = mockFetch.mock.calls[0] as [string, RequestInit];
283
+ const body = JSON.parse(opts.body as string);
284
+ expect(body).toMatchObject({
285
+ git_url: 'https://github.com/org/repo',
286
+ });
287
+ expect(body.instance_id).toBeDefined();
288
+ });
289
+ });
290
+
291
+ describe('heartbeat', () => {
292
+ it('should call correct endpoint', async () => {
293
+ mockFetch.mockResolvedValue(
294
+ createMockResponse({ timestamp: '2026-01-14T10:00:00Z' })
295
+ );
296
+
297
+ await client.heartbeat('session-123');
298
+
299
+ expect(mockFetch).toHaveBeenCalledWith(
300
+ expect.stringContaining('/api/mcp/sessions/heartbeat'),
301
+ expect.objectContaining({
302
+ method: 'POST',
303
+ body: JSON.stringify({ session_id: 'session-123' }),
304
+ })
305
+ );
306
+ });
307
+
308
+ it('should pass worktree path option', async () => {
309
+ mockFetch.mockResolvedValue(
310
+ createMockResponse({ timestamp: '2026-01-14T10:00:00Z' })
311
+ );
312
+
313
+ await client.heartbeat('session-123', {
314
+ current_worktree_path: '../project-task-abc',
315
+ });
316
+
317
+ expect(mockFetch).toHaveBeenCalledWith(
318
+ expect.any(String),
319
+ expect.objectContaining({
320
+ body: JSON.stringify({
321
+ session_id: 'session-123',
322
+ current_worktree_path: '../project-task-abc',
323
+ }),
324
+ })
325
+ );
326
+ });
327
+ });
328
+
329
+ describe('endSession', () => {
330
+ it('should call correct endpoint', async () => {
331
+ mockFetch.mockResolvedValue(
332
+ createMockResponse({ success: true })
333
+ );
334
+
335
+ await client.endSession('session-123');
336
+
337
+ expect(mockFetch).toHaveBeenCalledWith(
338
+ expect.stringContaining('/api/mcp/sessions/end'),
339
+ expect.objectContaining({
340
+ method: 'POST',
341
+ body: JSON.stringify({ session_id: 'session-123' }),
342
+ })
343
+ );
344
+ });
345
+ });
346
+
347
+ // ============================================================================
348
+ // Project Endpoints
349
+ // ============================================================================
350
+
351
+ describe('listProjects', () => {
352
+ it('should call correct endpoint', async () => {
353
+ mockFetch.mockResolvedValue(
354
+ createMockResponse({ projects: [] })
355
+ );
356
+
357
+ await client.listProjects();
358
+
359
+ expect(mockFetch).toHaveBeenCalledWith(
360
+ expect.stringContaining('/api/mcp/projects'),
361
+ expect.objectContaining({ method: 'GET' })
362
+ );
363
+ });
364
+ });
365
+
366
+ describe('getProject', () => {
367
+ it('should call correct endpoint with project ID', async () => {
368
+ mockFetch.mockResolvedValue(
369
+ createMockResponse({ project: { id: 'proj-123', name: 'Test' } })
370
+ );
371
+
372
+ await client.getProject('proj-123');
373
+
374
+ expect(mockFetch).toHaveBeenCalledWith(
375
+ expect.stringContaining('/api/mcp/projects/proj-123'),
376
+ expect.objectContaining({ method: 'GET' })
377
+ );
378
+ });
379
+
380
+ it('should include git_url param when provided', async () => {
381
+ mockFetch.mockResolvedValue(
382
+ createMockResponse({ project: { id: 'proj-123' } })
383
+ );
384
+
385
+ await client.getProject('proj-123', 'https://github.com/org/repo');
386
+
387
+ expect(mockFetch).toHaveBeenCalledWith(
388
+ expect.stringContaining('git_url=https'),
389
+ expect.any(Object)
390
+ );
391
+ });
392
+ });
393
+
394
+ describe('createProject', () => {
395
+ it('should call correct endpoint', async () => {
396
+ mockFetch.mockResolvedValue(
397
+ createMockResponse({ project: { id: 'new-proj' } })
398
+ );
399
+
400
+ await client.createProject({
401
+ name: 'New Project',
402
+ description: 'Test description',
403
+ });
404
+
405
+ expect(mockFetch).toHaveBeenCalledWith(
406
+ expect.stringContaining('/api/mcp/projects'),
407
+ expect.objectContaining({
408
+ method: 'POST',
409
+ body: JSON.stringify({
410
+ name: 'New Project',
411
+ description: 'Test description',
412
+ }),
413
+ })
414
+ );
415
+ });
416
+ });
417
+
418
+ describe('updateProject', () => {
419
+ it('should call correct endpoint', async () => {
420
+ mockFetch.mockResolvedValue(
421
+ createMockResponse({ success: true })
422
+ );
423
+
424
+ await client.updateProject('proj-123', {
425
+ name: 'Updated Name',
426
+ status: 'active',
427
+ });
428
+
429
+ expect(mockFetch).toHaveBeenCalledWith(
430
+ expect.stringContaining('/api/mcp/projects/proj-123'),
431
+ expect.objectContaining({
432
+ method: 'PATCH',
433
+ body: JSON.stringify({
434
+ name: 'Updated Name',
435
+ status: 'active',
436
+ }),
437
+ })
438
+ );
439
+ });
440
+ });
441
+
442
+ // ============================================================================
443
+ // Task Endpoints
444
+ // ============================================================================
445
+
446
+ describe('getTasks', () => {
447
+ it('should call correct endpoint', async () => {
448
+ mockFetch.mockResolvedValue(
449
+ createMockResponse({ tasks: [] })
450
+ );
451
+
452
+ await client.getTasks('proj-123');
453
+
454
+ expect(mockFetch).toHaveBeenCalledWith(
455
+ expect.stringContaining('/api/mcp/projects/proj-123/tasks'),
456
+ expect.objectContaining({ method: 'GET' })
457
+ );
458
+ });
459
+
460
+ it('should include filter params', async () => {
461
+ mockFetch.mockResolvedValue(
462
+ createMockResponse({ tasks: [] })
463
+ );
464
+
465
+ await client.getTasks('proj-123', {
466
+ status: 'in_progress',
467
+ limit: 10,
468
+ });
469
+
470
+ const url = mockFetch.mock.calls[0][0] as string;
471
+ expect(url).toContain('status=in_progress');
472
+ expect(url).toContain('limit=10');
473
+ });
474
+ });
475
+
476
+ describe('createTask', () => {
477
+ it('should call correct endpoint', async () => {
478
+ mockFetch.mockResolvedValue(
479
+ createMockResponse({ task: { id: 'task-123' } })
480
+ );
481
+
482
+ await client.createTask('proj-123', {
483
+ title: 'New Task',
484
+ priority: 2,
485
+ });
486
+
487
+ expect(mockFetch).toHaveBeenCalledWith(
488
+ expect.stringContaining('/api/mcp/projects/proj-123/tasks'),
489
+ expect.objectContaining({
490
+ method: 'POST',
491
+ body: JSON.stringify({
492
+ title: 'New Task',
493
+ priority: 2,
494
+ }),
495
+ })
496
+ );
497
+ });
498
+ });
499
+
500
+ describe('updateTask', () => {
501
+ it('should call correct endpoint', async () => {
502
+ mockFetch.mockResolvedValue(
503
+ createMockResponse({ success: true })
504
+ );
505
+
506
+ await client.updateTask('task-123', {
507
+ status: 'in_progress',
508
+ progress_percentage: 50,
509
+ });
510
+
511
+ expect(mockFetch).toHaveBeenCalledWith(
512
+ expect.stringContaining('/api/mcp/tasks/task-123'),
513
+ expect.objectContaining({
514
+ method: 'PATCH',
515
+ body: JSON.stringify({
516
+ status: 'in_progress',
517
+ progress_percentage: 50,
518
+ }),
519
+ })
520
+ );
521
+ });
522
+ });
523
+
524
+ describe('completeTask', () => {
525
+ it('should call proxy endpoint', async () => {
526
+ mockFetch.mockResolvedValue(
527
+ createMockResponse({ success: true })
528
+ );
529
+
530
+ await client.completeTask('task-123', {
531
+ summary: 'Task completed successfully',
532
+ });
533
+
534
+ // completeTask uses proxy endpoint for consistency (direct endpoint had Vercel routing issues)
535
+ expect(mockFetch).toHaveBeenCalledWith(
536
+ expect.stringContaining('/api/mcp/proxy'),
537
+ expect.objectContaining({
538
+ method: 'POST',
539
+ body: JSON.stringify({
540
+ operation: 'complete_task',
541
+ args: {
542
+ task_id: 'task-123',
543
+ summary: 'Task completed successfully',
544
+ },
545
+ }),
546
+ })
547
+ );
548
+ });
549
+ });
550
+
551
+ describe('deleteTask', () => {
552
+ it('should call correct endpoint', async () => {
553
+ mockFetch.mockResolvedValue(
554
+ createMockResponse({ success: true })
555
+ );
556
+
557
+ await client.deleteTask('task-123');
558
+
559
+ expect(mockFetch).toHaveBeenCalledWith(
560
+ expect.stringContaining('/api/mcp/tasks/task-123'),
561
+ expect.objectContaining({ method: 'DELETE' })
562
+ );
563
+ });
564
+ });
565
+
566
+ describe('getNextTask', () => {
567
+ it('should call correct endpoint', async () => {
568
+ mockFetch.mockResolvedValue(
569
+ createMockResponse({ task: { id: 'task-123', title: 'Next task' } })
570
+ );
571
+
572
+ await client.getNextTask('proj-123');
573
+
574
+ expect(mockFetch).toHaveBeenCalledWith(
575
+ expect.stringContaining('/api/mcp/projects/proj-123/next-task'),
576
+ expect.objectContaining({ method: 'GET' })
577
+ );
578
+ });
579
+
580
+ it('should include session_id when provided', async () => {
581
+ mockFetch.mockResolvedValue(
582
+ createMockResponse({ task: null })
583
+ );
584
+
585
+ await client.getNextTask('proj-123', 'session-456');
586
+
587
+ const url = mockFetch.mock.calls[0][0] as string;
588
+ expect(url).toContain('session_id=session-456');
589
+ });
590
+ });
591
+
592
+ // ============================================================================
593
+ // Blocker Endpoints
594
+ // ============================================================================
595
+
596
+ describe('getBlockers', () => {
597
+ it('should call correct endpoint', async () => {
598
+ mockFetch.mockResolvedValue(
599
+ createMockResponse({ blockers: [] })
600
+ );
601
+
602
+ await client.getBlockers('proj-123');
603
+
604
+ expect(mockFetch).toHaveBeenCalledWith(
605
+ expect.stringContaining('/api/mcp/proxy'),
606
+ expect.objectContaining({ method: 'POST' })
607
+ );
608
+ });
609
+ });
610
+
611
+ describe('addBlocker', () => {
612
+ it('should call correct endpoint', async () => {
613
+ mockFetch.mockResolvedValue(
614
+ createMockResponse({ blocker_id: 'blocker-123' })
615
+ );
616
+
617
+ await client.addBlocker('proj-123', 'Blocked by dependency');
618
+
619
+ expect(mockFetch).toHaveBeenCalledWith(
620
+ expect.stringContaining('/api/mcp/proxy'),
621
+ expect.objectContaining({ method: 'POST' })
622
+ );
623
+ });
624
+ });
625
+
626
+ // ============================================================================
627
+ // Validation Endpoints
628
+ // ============================================================================
629
+
630
+ describe('getTasksAwaitingValidation', () => {
631
+ it('should call correct endpoint', async () => {
632
+ mockFetch.mockResolvedValue(
633
+ createMockResponse({ tasks: [] })
634
+ );
635
+
636
+ await client.getTasksAwaitingValidation('proj-123');
637
+
638
+ expect(mockFetch).toHaveBeenCalledWith(
639
+ expect.stringContaining('/api/mcp/proxy'),
640
+ expect.objectContaining({ method: 'POST' })
641
+ );
642
+ });
643
+ });
644
+
645
+ describe('validateTask', () => {
646
+ it('should call correct endpoint', async () => {
647
+ mockFetch.mockResolvedValue(
648
+ createMockResponse({ success: true })
649
+ );
650
+
651
+ await client.validateTask('task-123', {
652
+ approved: true,
653
+ validation_notes: 'All tests pass',
654
+ });
655
+
656
+ expect(mockFetch).toHaveBeenCalledWith(
657
+ expect.stringContaining('/api/mcp/proxy'),
658
+ expect.objectContaining({ method: 'POST' })
659
+ );
660
+ });
661
+ });
662
+
663
+ // ============================================================================
664
+ // Proxy Endpoint
665
+ // ============================================================================
666
+
667
+ describe('proxy', () => {
668
+ it('should call proxy endpoint with operation', async () => {
669
+ mockFetch.mockResolvedValue(
670
+ createMockResponse({ result: 'success' })
671
+ );
672
+
673
+ await client.proxy('custom_operation', { param1: 'value1' });
674
+
675
+ expect(mockFetch).toHaveBeenCalledWith(
676
+ expect.stringContaining('/api/mcp/proxy'),
677
+ expect.objectContaining({
678
+ method: 'POST',
679
+ body: expect.stringContaining('custom_operation'),
680
+ })
681
+ );
682
+ });
683
+
684
+ it('should pass session context when provided', async () => {
685
+ mockFetch.mockResolvedValue(
686
+ createMockResponse({ result: 'success' })
687
+ );
688
+
689
+ await client.proxy(
690
+ 'operation',
691
+ { arg: 'value' },
692
+ { sessionId: 'session-123', persona: 'Wave' }
693
+ );
694
+
695
+ expect(mockFetch).toHaveBeenCalledWith(
696
+ expect.any(String),
697
+ expect.objectContaining({
698
+ body: expect.stringContaining('session-123'),
699
+ })
700
+ );
701
+ });
702
+ });
703
+
704
+ // ============================================================================
705
+ // Error Handling Edge Cases
706
+ // ============================================================================
707
+
708
+ describe('error handling edge cases', () => {
709
+ it('should handle JSON parse error in response after retries', async () => {
710
+ mockFetch.mockResolvedValue({
711
+ ok: true,
712
+ status: 200,
713
+ json: vi.fn().mockRejectedValue(new SyntaxError('Invalid JSON')),
714
+ headers: { get: () => null },
715
+ });
716
+
717
+ const result = await client.validateAuth();
718
+
719
+ // JSON parse errors are treated as network errors and retried
720
+ expect(mockFetch).toHaveBeenCalledTimes(4);
721
+ expect(result.ok).toBe(false);
722
+ expect(result.error).toContain('Invalid JSON');
723
+ }, 30000);
724
+
725
+ it('should handle timeout errors after retries', async () => {
726
+ const timeoutError = new Error('Request timeout');
727
+ timeoutError.name = 'AbortError';
728
+ mockFetch.mockRejectedValue(timeoutError);
729
+
730
+ const result = await client.validateAuth();
731
+
732
+ // Timeout errors are retried
733
+ expect(mockFetch).toHaveBeenCalledTimes(4);
734
+ expect(result).toEqual({
735
+ ok: false,
736
+ status: 0,
737
+ error: 'Request timeout',
738
+ });
739
+ }, 30000);
740
+ });
741
+
742
+ // ============================================================================
743
+ // Retry Logic Tests
744
+ // ============================================================================
745
+
746
+ describe('retry logic', () => {
747
+ it('should retry on 429 status code', async () => {
748
+ // First two calls return 429, third succeeds
749
+ mockFetch
750
+ .mockResolvedValueOnce(createMockResponse({ error: 'Rate limited' }, false, 429))
751
+ .mockResolvedValueOnce(createMockResponse({ error: 'Rate limited' }, false, 429))
752
+ .mockResolvedValueOnce(createMockResponse({ valid: true }, true, 200));
753
+
754
+ const result = await client.validateAuth();
755
+
756
+ expect(mockFetch).toHaveBeenCalledTimes(3);
757
+ expect(result.ok).toBe(true);
758
+ });
759
+
760
+ it('should retry on 503 status code', async () => {
761
+ mockFetch
762
+ .mockResolvedValueOnce(createMockResponse({ error: 'Service unavailable' }, false, 503))
763
+ .mockResolvedValueOnce(createMockResponse({ valid: true }, true, 200));
764
+
765
+ const result = await client.validateAuth();
766
+
767
+ expect(mockFetch).toHaveBeenCalledTimes(2);
768
+ expect(result.ok).toBe(true);
769
+ });
770
+
771
+ it('should retry on 504 status code', async () => {
772
+ mockFetch
773
+ .mockResolvedValueOnce(createMockResponse({ error: 'Gateway timeout' }, false, 504))
774
+ .mockResolvedValueOnce(createMockResponse({ valid: true }, true, 200));
775
+
776
+ const result = await client.validateAuth();
777
+
778
+ expect(mockFetch).toHaveBeenCalledTimes(2);
779
+ expect(result.ok).toBe(true);
780
+ });
781
+
782
+ it('should not retry on non-retryable status codes (400)', async () => {
783
+ mockFetch.mockResolvedValueOnce(createMockResponse({ error: 'Bad request' }, false, 400));
784
+
785
+ const result = await client.validateAuth();
786
+
787
+ expect(mockFetch).toHaveBeenCalledTimes(1);
788
+ expect(result.ok).toBe(false);
789
+ expect(result.status).toBe(400);
790
+ });
791
+
792
+ it('should not retry on non-retryable status codes (401)', async () => {
793
+ mockFetch.mockResolvedValueOnce(createMockResponse({ error: 'Unauthorized' }, false, 401));
794
+
795
+ const result = await client.validateAuth();
796
+
797
+ expect(mockFetch).toHaveBeenCalledTimes(1);
798
+ expect(result.ok).toBe(false);
799
+ expect(result.status).toBe(401);
800
+ });
801
+
802
+ it('should not retry on non-retryable status codes (500)', async () => {
803
+ mockFetch.mockResolvedValueOnce(createMockResponse({ error: 'Server error' }, false, 500));
804
+
805
+ const result = await client.validateAuth();
806
+
807
+ expect(mockFetch).toHaveBeenCalledTimes(1);
808
+ expect(result.ok).toBe(false);
809
+ expect(result.status).toBe(500);
810
+ });
811
+
812
+ it('should stop after max retries (3)', async () => {
813
+ // All 4 attempts (1 initial + 3 retries) return 429
814
+ mockFetch.mockResolvedValue(createMockResponse({ error: 'Rate limited' }, false, 429));
815
+
816
+ const result = await client.validateAuth();
817
+
818
+ expect(mockFetch).toHaveBeenCalledTimes(4); // 1 initial + 3 retries
819
+ expect(result.ok).toBe(false);
820
+ expect(result.status).toBe(429);
821
+ // API error message takes precedence over generic retry message
822
+ expect(result.error).toBe('Rate limited');
823
+ }, 30000);
824
+
825
+ it('should retry on network errors', async () => {
826
+ mockFetch
827
+ .mockRejectedValueOnce(new Error('Network error'))
828
+ .mockResolvedValueOnce(createMockResponse({ valid: true }, true, 200));
829
+
830
+ const result = await client.validateAuth();
831
+
832
+ expect(mockFetch).toHaveBeenCalledTimes(2);
833
+ expect(result.ok).toBe(true);
834
+ }, 10000);
835
+
836
+ it('should return last error after all retries exhausted on network failure', async () => {
837
+ mockFetch.mockRejectedValue(new Error('Connection refused'));
838
+
839
+ const result = await client.validateAuth();
840
+
841
+ expect(mockFetch).toHaveBeenCalledTimes(4);
842
+ expect(result.ok).toBe(false);
843
+ expect(result.status).toBe(0);
844
+ expect(result.error).toBe('Connection refused');
845
+ }, 30000);
846
+ });
847
+ });