@vibescope/mcp-server 0.0.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 (170) hide show
  1. package/README.md +98 -0
  2. package/dist/cli.d.ts +34 -0
  3. package/dist/cli.js +356 -0
  4. package/dist/cli.test.d.ts +1 -0
  5. package/dist/cli.test.js +367 -0
  6. package/dist/handlers/__test-utils__.d.ts +72 -0
  7. package/dist/handlers/__test-utils__.js +176 -0
  8. package/dist/handlers/blockers.d.ts +18 -0
  9. package/dist/handlers/blockers.js +81 -0
  10. package/dist/handlers/bodies-of-work.d.ts +34 -0
  11. package/dist/handlers/bodies-of-work.js +614 -0
  12. package/dist/handlers/checkouts.d.ts +37 -0
  13. package/dist/handlers/checkouts.js +377 -0
  14. package/dist/handlers/cost.d.ts +39 -0
  15. package/dist/handlers/cost.js +247 -0
  16. package/dist/handlers/decisions.d.ts +16 -0
  17. package/dist/handlers/decisions.js +64 -0
  18. package/dist/handlers/deployment.d.ts +36 -0
  19. package/dist/handlers/deployment.js +1062 -0
  20. package/dist/handlers/discovery.d.ts +14 -0
  21. package/dist/handlers/discovery.js +870 -0
  22. package/dist/handlers/fallback.d.ts +18 -0
  23. package/dist/handlers/fallback.js +216 -0
  24. package/dist/handlers/findings.d.ts +18 -0
  25. package/dist/handlers/findings.js +110 -0
  26. package/dist/handlers/git-issues.d.ts +22 -0
  27. package/dist/handlers/git-issues.js +247 -0
  28. package/dist/handlers/ideas.d.ts +19 -0
  29. package/dist/handlers/ideas.js +188 -0
  30. package/dist/handlers/index.d.ts +29 -0
  31. package/dist/handlers/index.js +65 -0
  32. package/dist/handlers/knowledge-query.d.ts +22 -0
  33. package/dist/handlers/knowledge-query.js +253 -0
  34. package/dist/handlers/knowledge.d.ts +12 -0
  35. package/dist/handlers/knowledge.js +108 -0
  36. package/dist/handlers/milestones.d.ts +20 -0
  37. package/dist/handlers/milestones.js +179 -0
  38. package/dist/handlers/organizations.d.ts +36 -0
  39. package/dist/handlers/organizations.js +428 -0
  40. package/dist/handlers/progress.d.ts +14 -0
  41. package/dist/handlers/progress.js +149 -0
  42. package/dist/handlers/project.d.ts +20 -0
  43. package/dist/handlers/project.js +278 -0
  44. package/dist/handlers/requests.d.ts +16 -0
  45. package/dist/handlers/requests.js +131 -0
  46. package/dist/handlers/roles.d.ts +30 -0
  47. package/dist/handlers/roles.js +281 -0
  48. package/dist/handlers/session.d.ts +20 -0
  49. package/dist/handlers/session.js +791 -0
  50. package/dist/handlers/tasks.d.ts +52 -0
  51. package/dist/handlers/tasks.js +1111 -0
  52. package/dist/handlers/tasks.test.d.ts +1 -0
  53. package/dist/handlers/tasks.test.js +431 -0
  54. package/dist/handlers/types.d.ts +94 -0
  55. package/dist/handlers/types.js +1 -0
  56. package/dist/handlers/validation.d.ts +16 -0
  57. package/dist/handlers/validation.js +188 -0
  58. package/dist/index.d.ts +2 -0
  59. package/dist/index.js +2707 -0
  60. package/dist/knowledge.d.ts +6 -0
  61. package/dist/knowledge.js +121 -0
  62. package/dist/tools.d.ts +2 -0
  63. package/dist/tools.js +2498 -0
  64. package/dist/utils.d.ts +149 -0
  65. package/dist/utils.js +317 -0
  66. package/dist/utils.test.d.ts +1 -0
  67. package/dist/utils.test.js +532 -0
  68. package/dist/validators.d.ts +35 -0
  69. package/dist/validators.js +111 -0
  70. package/dist/validators.test.d.ts +1 -0
  71. package/dist/validators.test.js +176 -0
  72. package/package.json +44 -0
  73. package/src/cli.test.ts +442 -0
  74. package/src/cli.ts +439 -0
  75. package/src/handlers/__test-utils__.ts +217 -0
  76. package/src/handlers/blockers.test.ts +390 -0
  77. package/src/handlers/blockers.ts +110 -0
  78. package/src/handlers/bodies-of-work.test.ts +1276 -0
  79. package/src/handlers/bodies-of-work.ts +783 -0
  80. package/src/handlers/cost.test.ts +436 -0
  81. package/src/handlers/cost.ts +322 -0
  82. package/src/handlers/decisions.test.ts +401 -0
  83. package/src/handlers/decisions.ts +86 -0
  84. package/src/handlers/deployment.test.ts +516 -0
  85. package/src/handlers/deployment.ts +1289 -0
  86. package/src/handlers/discovery.test.ts +254 -0
  87. package/src/handlers/discovery.ts +969 -0
  88. package/src/handlers/fallback.test.ts +687 -0
  89. package/src/handlers/fallback.ts +260 -0
  90. package/src/handlers/findings.test.ts +565 -0
  91. package/src/handlers/findings.ts +153 -0
  92. package/src/handlers/ideas.test.ts +753 -0
  93. package/src/handlers/ideas.ts +247 -0
  94. package/src/handlers/index.ts +69 -0
  95. package/src/handlers/milestones.test.ts +584 -0
  96. package/src/handlers/milestones.ts +217 -0
  97. package/src/handlers/organizations.test.ts +997 -0
  98. package/src/handlers/organizations.ts +550 -0
  99. package/src/handlers/progress.test.ts +369 -0
  100. package/src/handlers/progress.ts +188 -0
  101. package/src/handlers/project.test.ts +562 -0
  102. package/src/handlers/project.ts +352 -0
  103. package/src/handlers/requests.test.ts +531 -0
  104. package/src/handlers/requests.ts +150 -0
  105. package/src/handlers/session.test.ts +459 -0
  106. package/src/handlers/session.ts +912 -0
  107. package/src/handlers/tasks.test.ts +602 -0
  108. package/src/handlers/tasks.ts +1393 -0
  109. package/src/handlers/types.ts +88 -0
  110. package/src/handlers/validation.test.ts +880 -0
  111. package/src/handlers/validation.ts +223 -0
  112. package/src/index.ts +3205 -0
  113. package/src/knowledge.ts +132 -0
  114. package/src/tmpclaude-0078-cwd +1 -0
  115. package/src/tmpclaude-0ee1-cwd +1 -0
  116. package/src/tmpclaude-2dd5-cwd +1 -0
  117. package/src/tmpclaude-344c-cwd +1 -0
  118. package/src/tmpclaude-3860-cwd +1 -0
  119. package/src/tmpclaude-4b63-cwd +1 -0
  120. package/src/tmpclaude-5c73-cwd +1 -0
  121. package/src/tmpclaude-5ee3-cwd +1 -0
  122. package/src/tmpclaude-6795-cwd +1 -0
  123. package/src/tmpclaude-709e-cwd +1 -0
  124. package/src/tmpclaude-9839-cwd +1 -0
  125. package/src/tmpclaude-d829-cwd +1 -0
  126. package/src/tmpclaude-e072-cwd +1 -0
  127. package/src/tmpclaude-f6ee-cwd +1 -0
  128. package/src/utils.test.ts +681 -0
  129. package/src/utils.ts +375 -0
  130. package/src/validators.test.ts +223 -0
  131. package/src/validators.ts +122 -0
  132. package/tmpclaude-0439-cwd +1 -0
  133. package/tmpclaude-132f-cwd +1 -0
  134. package/tmpclaude-15bb-cwd +1 -0
  135. package/tmpclaude-165a-cwd +1 -0
  136. package/tmpclaude-1ba9-cwd +1 -0
  137. package/tmpclaude-21a3-cwd +1 -0
  138. package/tmpclaude-2a38-cwd +1 -0
  139. package/tmpclaude-2adf-cwd +1 -0
  140. package/tmpclaude-2f56-cwd +1 -0
  141. package/tmpclaude-3626-cwd +1 -0
  142. package/tmpclaude-3727-cwd +1 -0
  143. package/tmpclaude-40bc-cwd +1 -0
  144. package/tmpclaude-436f-cwd +1 -0
  145. package/tmpclaude-4783-cwd +1 -0
  146. package/tmpclaude-4b6d-cwd +1 -0
  147. package/tmpclaude-4ba4-cwd +1 -0
  148. package/tmpclaude-51e6-cwd +1 -0
  149. package/tmpclaude-5ecf-cwd +1 -0
  150. package/tmpclaude-6f97-cwd +1 -0
  151. package/tmpclaude-7fb2-cwd +1 -0
  152. package/tmpclaude-825c-cwd +1 -0
  153. package/tmpclaude-8baf-cwd +1 -0
  154. package/tmpclaude-8d9f-cwd +1 -0
  155. package/tmpclaude-975c-cwd +1 -0
  156. package/tmpclaude-9983-cwd +1 -0
  157. package/tmpclaude-a045-cwd +1 -0
  158. package/tmpclaude-ac4a-cwd +1 -0
  159. package/tmpclaude-b593-cwd +1 -0
  160. package/tmpclaude-b891-cwd +1 -0
  161. package/tmpclaude-c032-cwd +1 -0
  162. package/tmpclaude-cf43-cwd +1 -0
  163. package/tmpclaude-d040-cwd +1 -0
  164. package/tmpclaude-dcdd-cwd +1 -0
  165. package/tmpclaude-dcee-cwd +1 -0
  166. package/tmpclaude-e16b-cwd +1 -0
  167. package/tmpclaude-ecd2-cwd +1 -0
  168. package/tmpclaude-f48d-cwd +1 -0
  169. package/tsconfig.json +16 -0
  170. package/vitest.config.ts +13 -0
@@ -0,0 +1,1276 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import type { SupabaseClient } from '@supabase/supabase-js';
3
+ import type { HandlerContext, TokenUsage } from './types.js';
4
+ import {
5
+ createBodyOfWork,
6
+ updateBodyOfWork,
7
+ getBodyOfWork,
8
+ getBodiesOfWork,
9
+ deleteBodyOfWork,
10
+ addTaskToBodyOfWork,
11
+ removeTaskFromBodyOfWork,
12
+ activateBodyOfWork,
13
+ addTaskDependency,
14
+ removeTaskDependency,
15
+ getTaskDependencies,
16
+ getNextBodyOfWorkTask,
17
+ } from './bodies-of-work.js';
18
+ import { ValidationError } from '../validators.js';
19
+
20
+ // ============================================================================
21
+ // Test Utilities
22
+ // ============================================================================
23
+
24
+ function createMockSupabase(overrides: {
25
+ selectResult?: { data: unknown; error: unknown };
26
+ insertResult?: { data: unknown; error: unknown };
27
+ updateResult?: { data: unknown; error: unknown };
28
+ deleteResult?: { data: unknown; error: unknown };
29
+ countResult?: { count: number | null; error: unknown };
30
+ } = {}) {
31
+ const defaultResult = { data: null, error: null };
32
+ let currentOperation = 'select';
33
+ let insertThenSelect = false;
34
+
35
+ const mock = {
36
+ from: vi.fn().mockReturnThis(),
37
+ select: vi.fn(() => {
38
+ if (currentOperation === 'insert') {
39
+ insertThenSelect = true;
40
+ } else {
41
+ currentOperation = 'select';
42
+ insertThenSelect = false;
43
+ }
44
+ return mock;
45
+ }),
46
+ insert: vi.fn(() => {
47
+ currentOperation = 'insert';
48
+ insertThenSelect = false;
49
+ return mock;
50
+ }),
51
+ update: vi.fn(() => {
52
+ currentOperation = 'update';
53
+ insertThenSelect = false;
54
+ return mock;
55
+ }),
56
+ delete: vi.fn(() => {
57
+ currentOperation = 'delete';
58
+ insertThenSelect = false;
59
+ return mock;
60
+ }),
61
+ eq: vi.fn().mockReturnThis(),
62
+ neq: vi.fn().mockReturnThis(),
63
+ in: vi.fn().mockReturnThis(),
64
+ is: vi.fn().mockReturnThis(),
65
+ not: vi.fn().mockReturnThis(),
66
+ or: vi.fn().mockReturnThis(),
67
+ gte: vi.fn().mockReturnThis(),
68
+ lte: vi.fn().mockReturnThis(),
69
+ lt: vi.fn().mockReturnThis(),
70
+ order: vi.fn().mockReturnThis(),
71
+ limit: vi.fn().mockReturnThis(),
72
+ head: vi.fn().mockReturnThis(),
73
+ single: vi.fn(() => {
74
+ if (currentOperation === 'insert' || insertThenSelect) {
75
+ return Promise.resolve(overrides.insertResult ?? defaultResult);
76
+ }
77
+ if (currentOperation === 'select') {
78
+ return Promise.resolve(overrides.selectResult ?? defaultResult);
79
+ }
80
+ if (currentOperation === 'update') {
81
+ return Promise.resolve(overrides.updateResult ?? defaultResult);
82
+ }
83
+ if (currentOperation === 'delete') {
84
+ return Promise.resolve(overrides.deleteResult ?? defaultResult);
85
+ }
86
+ return Promise.resolve(defaultResult);
87
+ }),
88
+ maybeSingle: vi.fn(() => {
89
+ return Promise.resolve(overrides.selectResult ?? defaultResult);
90
+ }),
91
+ then: vi.fn((resolve: (value: unknown) => void) => {
92
+ if (currentOperation === 'insert' || insertThenSelect) {
93
+ return Promise.resolve(overrides.insertResult ?? defaultResult).then(resolve);
94
+ }
95
+ if (currentOperation === 'select') {
96
+ const result = overrides.countResult ?? overrides.selectResult ?? defaultResult;
97
+ return Promise.resolve(result).then(resolve);
98
+ }
99
+ if (currentOperation === 'update') {
100
+ return Promise.resolve(overrides.updateResult ?? defaultResult).then(resolve);
101
+ }
102
+ if (currentOperation === 'delete') {
103
+ return Promise.resolve(overrides.deleteResult ?? defaultResult).then(resolve);
104
+ }
105
+ return Promise.resolve(defaultResult).then(resolve);
106
+ }),
107
+ };
108
+
109
+ return mock as unknown as SupabaseClient;
110
+ }
111
+
112
+ function createMockContext(
113
+ supabase: SupabaseClient,
114
+ options: { sessionId?: string | null } = {}
115
+ ): HandlerContext {
116
+ const defaultTokenUsage: TokenUsage = {
117
+ callCount: 5,
118
+ totalTokens: 2500,
119
+ byTool: {},
120
+ };
121
+
122
+ const sessionId = 'sessionId' in options ? options.sessionId : 'session-123';
123
+
124
+ return {
125
+ supabase,
126
+ auth: { userId: 'user-123', apiKeyId: 'api-key-123' },
127
+ session: {
128
+ instanceId: 'instance-abc',
129
+ currentSessionId: sessionId,
130
+ currentPersona: 'Wave',
131
+ tokenUsage: defaultTokenUsage,
132
+ },
133
+ updateSession: vi.fn(),
134
+ };
135
+ }
136
+
137
+ const VALID_UUID = '123e4567-e89b-12d3-a456-426614174000';
138
+ const VALID_UUID_2 = '223e4567-e89b-12d3-a456-426614174001';
139
+ const VALID_UUID_3 = '323e4567-e89b-12d3-a456-426614174002';
140
+
141
+ // ============================================================================
142
+ // createBodyOfWork Tests
143
+ // ============================================================================
144
+
145
+ describe('createBodyOfWork', () => {
146
+ beforeEach(() => vi.clearAllMocks());
147
+
148
+ it('should throw error for missing project_id', async () => {
149
+ const supabase = createMockSupabase();
150
+ const ctx = createMockContext(supabase);
151
+
152
+ await expect(createBodyOfWork({ title: 'Test' }, ctx)).rejects.toThrow(ValidationError);
153
+ });
154
+
155
+ it('should throw error for invalid project_id UUID', async () => {
156
+ const supabase = createMockSupabase();
157
+ const ctx = createMockContext(supabase);
158
+
159
+ await expect(
160
+ createBodyOfWork({ project_id: 'invalid', title: 'Test' }, ctx)
161
+ ).rejects.toThrow(ValidationError);
162
+ });
163
+
164
+ it('should throw error for missing title', async () => {
165
+ const supabase = createMockSupabase();
166
+ const ctx = createMockContext(supabase);
167
+
168
+ await expect(
169
+ createBodyOfWork({ project_id: VALID_UUID }, ctx)
170
+ ).rejects.toThrow(ValidationError);
171
+ });
172
+
173
+ it('should create body of work with required fields', async () => {
174
+ const supabase = createMockSupabase({
175
+ insertResult: { data: { id: 'bow-1' }, error: null },
176
+ });
177
+ const ctx = createMockContext(supabase);
178
+
179
+ const result = await createBodyOfWork(
180
+ { project_id: VALID_UUID, title: 'Sprint 1' },
181
+ ctx
182
+ );
183
+
184
+ expect(result.result).toMatchObject({
185
+ success: true,
186
+ body_of_work_id: 'bow-1',
187
+ title: 'Sprint 1',
188
+ status: 'draft',
189
+ });
190
+ expect(supabase.from).toHaveBeenCalledWith('bodies_of_work');
191
+ expect(supabase.insert).toHaveBeenCalledWith(
192
+ expect.objectContaining({
193
+ project_id: VALID_UUID,
194
+ title: 'Sprint 1',
195
+ auto_deploy_on_completion: false,
196
+ deploy_environment: 'production',
197
+ deploy_version_bump: 'minor',
198
+ deploy_trigger: 'all_completed_validated',
199
+ })
200
+ );
201
+ });
202
+
203
+ it('should create body of work with all optional fields', async () => {
204
+ const supabase = createMockSupabase({
205
+ insertResult: { data: { id: 'bow-2' }, error: null },
206
+ });
207
+ const ctx = createMockContext(supabase);
208
+
209
+ await createBodyOfWork(
210
+ {
211
+ project_id: VALID_UUID,
212
+ title: 'Release 2.0',
213
+ description: 'Major release',
214
+ auto_deploy_on_completion: true,
215
+ deploy_environment: 'staging',
216
+ deploy_version_bump: 'major',
217
+ deploy_trigger: 'all_completed',
218
+ },
219
+ ctx
220
+ );
221
+
222
+ expect(supabase.insert).toHaveBeenCalledWith(
223
+ expect.objectContaining({
224
+ description: 'Major release',
225
+ auto_deploy_on_completion: true,
226
+ deploy_environment: 'staging',
227
+ deploy_version_bump: 'major',
228
+ deploy_trigger: 'all_completed',
229
+ })
230
+ );
231
+ });
232
+
233
+ it('should throw error when database insert fails', async () => {
234
+ const supabase = createMockSupabase({
235
+ insertResult: { data: null, error: { message: 'Insert failed' } },
236
+ });
237
+ const ctx = createMockContext(supabase);
238
+
239
+ await expect(
240
+ createBodyOfWork({ project_id: VALID_UUID, title: 'Test' }, ctx)
241
+ ).rejects.toThrow('Failed to create body of work');
242
+ });
243
+ });
244
+
245
+ // ============================================================================
246
+ // updateBodyOfWork Tests
247
+ // ============================================================================
248
+
249
+ describe('updateBodyOfWork', () => {
250
+ beforeEach(() => vi.clearAllMocks());
251
+
252
+ it('should throw error for missing body_of_work_id', async () => {
253
+ const supabase = createMockSupabase();
254
+ const ctx = createMockContext(supabase);
255
+
256
+ await expect(updateBodyOfWork({}, ctx)).rejects.toThrow(ValidationError);
257
+ });
258
+
259
+ it('should return success when no updates provided', async () => {
260
+ const supabase = createMockSupabase();
261
+ const ctx = createMockContext(supabase);
262
+
263
+ const result = await updateBodyOfWork({ body_of_work_id: VALID_UUID }, ctx);
264
+
265
+ expect(result.result).toMatchObject({
266
+ success: true,
267
+ message: 'No updates provided',
268
+ });
269
+ expect(supabase.update).not.toHaveBeenCalled();
270
+ });
271
+
272
+ it('should update title', async () => {
273
+ const supabase = createMockSupabase({
274
+ updateResult: { data: null, error: null },
275
+ });
276
+ const ctx = createMockContext(supabase);
277
+
278
+ const result = await updateBodyOfWork(
279
+ { body_of_work_id: VALID_UUID, title: 'New Title' },
280
+ ctx
281
+ );
282
+
283
+ expect(result.result).toMatchObject({ success: true, body_of_work_id: VALID_UUID });
284
+ expect(supabase.update).toHaveBeenCalledWith({ title: 'New Title' });
285
+ });
286
+
287
+ it('should update multiple fields', async () => {
288
+ const supabase = createMockSupabase({
289
+ updateResult: { data: null, error: null },
290
+ });
291
+ const ctx = createMockContext(supabase);
292
+
293
+ await updateBodyOfWork(
294
+ {
295
+ body_of_work_id: VALID_UUID,
296
+ title: 'Updated',
297
+ description: 'New desc',
298
+ auto_deploy_on_completion: true,
299
+ },
300
+ ctx
301
+ );
302
+
303
+ expect(supabase.update).toHaveBeenCalledWith({
304
+ title: 'Updated',
305
+ description: 'New desc',
306
+ auto_deploy_on_completion: true,
307
+ });
308
+ });
309
+ });
310
+
311
+ // ============================================================================
312
+ // getBodyOfWork Tests
313
+ // ============================================================================
314
+
315
+ describe('getBodyOfWork', () => {
316
+ beforeEach(() => vi.clearAllMocks());
317
+
318
+ it('should throw error for missing body_of_work_id', async () => {
319
+ const supabase = createMockSupabase();
320
+ const ctx = createMockContext(supabase);
321
+
322
+ await expect(getBodyOfWork({}, ctx)).rejects.toThrow(ValidationError);
323
+ });
324
+
325
+ it('should throw error when body of work not found', async () => {
326
+ const supabase = createMockSupabase({
327
+ selectResult: { data: null, error: { message: 'Not found' } },
328
+ });
329
+ const ctx = createMockContext(supabase);
330
+
331
+ await expect(
332
+ getBodyOfWork({ body_of_work_id: VALID_UUID }, ctx)
333
+ ).rejects.toThrow('Body of work not found');
334
+ });
335
+
336
+ it('should return body of work with tasks organized by phase', async () => {
337
+ const mockBow = {
338
+ id: 'bow-1',
339
+ title: 'Sprint 1',
340
+ status: 'active',
341
+ };
342
+ const mockTaskLinks = [
343
+ { phase: 'pre', order_index: 0, tasks: { id: 't1', title: 'Setup', status: 'completed', priority: 1, progress_percentage: 100 } },
344
+ { phase: 'core', order_index: 0, tasks: { id: 't2', title: 'Feature A', status: 'in_progress', priority: 2, progress_percentage: 50 } },
345
+ { phase: 'core', order_index: 1, tasks: { id: 't3', title: 'Feature B', status: 'pending', priority: 2, progress_percentage: 0 } },
346
+ { phase: 'post', order_index: 0, tasks: { id: 't4', title: 'Cleanup', status: 'pending', priority: 3, progress_percentage: 0 } },
347
+ ];
348
+
349
+ const supabase = createMockSupabase({
350
+ selectResult: { data: mockBow, error: null },
351
+ });
352
+
353
+ // Override for task links query
354
+ let queryCount = 0;
355
+ vi.mocked(supabase.from).mockImplementation((table: string) => {
356
+ queryCount++;
357
+ if (table === 'bodies_of_work') {
358
+ return {
359
+ ...supabase,
360
+ select: vi.fn().mockReturnValue({
361
+ ...supabase,
362
+ eq: vi.fn().mockReturnValue({
363
+ ...supabase,
364
+ single: vi.fn().mockResolvedValue({ data: mockBow, error: null }),
365
+ }),
366
+ }),
367
+ } as unknown as ReturnType<SupabaseClient['from']>;
368
+ }
369
+ if (table === 'body_of_work_tasks') {
370
+ return {
371
+ ...supabase,
372
+ select: vi.fn().mockReturnValue({
373
+ ...supabase,
374
+ eq: vi.fn().mockReturnValue({
375
+ ...supabase,
376
+ order: vi.fn().mockReturnValue({
377
+ then: (resolve: (val: unknown) => void) =>
378
+ Promise.resolve({ data: mockTaskLinks, error: null }).then(resolve),
379
+ }),
380
+ }),
381
+ }),
382
+ } as unknown as ReturnType<SupabaseClient['from']>;
383
+ }
384
+ return supabase as unknown as ReturnType<SupabaseClient['from']>;
385
+ });
386
+
387
+ const ctx = createMockContext(supabase);
388
+
389
+ const result = await getBodyOfWork({ body_of_work_id: VALID_UUID }, ctx);
390
+
391
+ expect(result.result).toHaveProperty('pre_tasks');
392
+ expect(result.result).toHaveProperty('core_tasks');
393
+ expect(result.result).toHaveProperty('post_tasks');
394
+ expect(result.result).toHaveProperty('total_tasks');
395
+ });
396
+ });
397
+
398
+ // ============================================================================
399
+ // getBodiesOfWork Tests
400
+ // ============================================================================
401
+
402
+ describe('getBodiesOfWork', () => {
403
+ beforeEach(() => vi.clearAllMocks());
404
+
405
+ it('should throw error for missing project_id', async () => {
406
+ const supabase = createMockSupabase();
407
+ const ctx = createMockContext(supabase);
408
+
409
+ await expect(getBodiesOfWork({}, ctx)).rejects.toThrow(ValidationError);
410
+ });
411
+
412
+ it('should return empty array when no bodies of work exist', async () => {
413
+ const supabase = createMockSupabase();
414
+
415
+ vi.mocked(supabase.from).mockReturnValue({
416
+ ...supabase,
417
+ select: vi.fn().mockReturnValue({
418
+ ...supabase,
419
+ eq: vi.fn().mockReturnValue({
420
+ ...supabase,
421
+ order: vi.fn().mockReturnValue({
422
+ then: (resolve: (val: unknown) => void) =>
423
+ Promise.resolve({ data: [], error: null }).then(resolve),
424
+ }),
425
+ }),
426
+ }),
427
+ } as unknown as ReturnType<SupabaseClient['from']>);
428
+
429
+ const ctx = createMockContext(supabase);
430
+
431
+ const result = await getBodiesOfWork({ project_id: VALID_UUID }, ctx);
432
+
433
+ expect(result.result).toMatchObject({ bodies_of_work: [] });
434
+ });
435
+
436
+ it('should filter by status when provided', async () => {
437
+ const supabase = createMockSupabase();
438
+ const ctx = createMockContext(supabase);
439
+
440
+ await getBodiesOfWork({ project_id: VALID_UUID, status: 'active' }, ctx);
441
+
442
+ expect(supabase.eq).toHaveBeenCalledWith('project_id', VALID_UUID);
443
+ expect(supabase.eq).toHaveBeenCalledWith('status', 'active');
444
+ });
445
+ });
446
+
447
+ // ============================================================================
448
+ // deleteBodyOfWork Tests
449
+ // ============================================================================
450
+
451
+ describe('deleteBodyOfWork', () => {
452
+ beforeEach(() => vi.clearAllMocks());
453
+
454
+ it('should throw error for missing body_of_work_id', async () => {
455
+ const supabase = createMockSupabase();
456
+ const ctx = createMockContext(supabase);
457
+
458
+ await expect(deleteBodyOfWork({}, ctx)).rejects.toThrow(ValidationError);
459
+ });
460
+
461
+ it('should delete body of work successfully', async () => {
462
+ const supabase = createMockSupabase({
463
+ deleteResult: { data: null, error: null },
464
+ });
465
+ const ctx = createMockContext(supabase);
466
+
467
+ const result = await deleteBodyOfWork({ body_of_work_id: VALID_UUID }, ctx);
468
+
469
+ expect(result.result).toMatchObject({
470
+ success: true,
471
+ message: 'Body of work deleted. Tasks are preserved.',
472
+ });
473
+ expect(supabase.from).toHaveBeenCalledWith('bodies_of_work');
474
+ expect(supabase.delete).toHaveBeenCalled();
475
+ });
476
+ });
477
+
478
+ // ============================================================================
479
+ // addTaskToBodyOfWork Tests
480
+ // ============================================================================
481
+
482
+ describe('addTaskToBodyOfWork', () => {
483
+ beforeEach(() => vi.clearAllMocks());
484
+
485
+ it('should throw error for missing body_of_work_id', async () => {
486
+ const supabase = createMockSupabase();
487
+ const ctx = createMockContext(supabase);
488
+
489
+ await expect(
490
+ addTaskToBodyOfWork({ task_id: VALID_UUID }, ctx)
491
+ ).rejects.toThrow(ValidationError);
492
+ });
493
+
494
+ it('should throw error for missing task_id', async () => {
495
+ const supabase = createMockSupabase();
496
+ const ctx = createMockContext(supabase);
497
+
498
+ await expect(
499
+ addTaskToBodyOfWork({ body_of_work_id: VALID_UUID }, ctx)
500
+ ).rejects.toThrow(ValidationError);
501
+ });
502
+
503
+ it('should throw error when body of work not found', async () => {
504
+ const supabase = createMockSupabase({
505
+ selectResult: { data: null, error: { message: 'Not found' } },
506
+ });
507
+ const ctx = createMockContext(supabase);
508
+
509
+ await expect(
510
+ addTaskToBodyOfWork({ body_of_work_id: VALID_UUID, task_id: VALID_UUID_2 }, ctx)
511
+ ).rejects.toThrow('Body of work not found');
512
+ });
513
+
514
+ it('should throw error when body of work is completed', async () => {
515
+ const supabase = createMockSupabase({
516
+ selectResult: { data: { status: 'completed' }, error: null },
517
+ });
518
+ const ctx = createMockContext(supabase);
519
+
520
+ await expect(
521
+ addTaskToBodyOfWork({ body_of_work_id: VALID_UUID, task_id: VALID_UUID_2 }, ctx)
522
+ ).rejects.toThrow('Cannot add tasks to completed body of work');
523
+ });
524
+
525
+ it('should throw error when task is already in a body of work', async () => {
526
+ const supabase = createMockSupabase();
527
+ let callCount = 0;
528
+
529
+ vi.mocked(supabase.from).mockImplementation((table: string) => {
530
+ if (table === 'bodies_of_work') {
531
+ return {
532
+ ...supabase,
533
+ select: vi.fn().mockReturnValue({
534
+ ...supabase,
535
+ eq: vi.fn().mockReturnValue({
536
+ ...supabase,
537
+ single: vi.fn().mockResolvedValue({ data: { status: 'draft' }, error: null }),
538
+ }),
539
+ }),
540
+ } as unknown as ReturnType<SupabaseClient['from']>;
541
+ }
542
+ if (table === 'body_of_work_tasks') {
543
+ callCount++;
544
+ if (callCount === 1) {
545
+ // First call - check existing link
546
+ return {
547
+ ...supabase,
548
+ select: vi.fn().mockReturnValue({
549
+ ...supabase,
550
+ eq: vi.fn().mockReturnValue({
551
+ ...supabase,
552
+ single: vi.fn().mockResolvedValue({ data: { body_of_work_id: 'other-bow' }, error: null }),
553
+ }),
554
+ }),
555
+ } as unknown as ReturnType<SupabaseClient['from']>;
556
+ }
557
+ }
558
+ return supabase as unknown as ReturnType<SupabaseClient['from']>;
559
+ });
560
+
561
+ const ctx = createMockContext(supabase);
562
+
563
+ await expect(
564
+ addTaskToBodyOfWork({ body_of_work_id: VALID_UUID, task_id: VALID_UUID_2 }, ctx)
565
+ ).rejects.toThrow('Task is already assigned to a body of work');
566
+ });
567
+
568
+ it('should add task with default phase "core"', async () => {
569
+ const supabase = createMockSupabase();
570
+ let bowCallCount = 0;
571
+ let taskLinksCallCount = 0;
572
+
573
+ vi.mocked(supabase.from).mockImplementation((table: string) => {
574
+ if (table === 'bodies_of_work') {
575
+ bowCallCount++;
576
+ return {
577
+ ...supabase,
578
+ select: vi.fn().mockReturnValue({
579
+ ...supabase,
580
+ eq: vi.fn().mockReturnValue({
581
+ ...supabase,
582
+ single: vi.fn().mockResolvedValue({ data: { status: 'draft' }, error: null }),
583
+ }),
584
+ }),
585
+ } as unknown as ReturnType<SupabaseClient['from']>;
586
+ }
587
+ if (table === 'body_of_work_tasks') {
588
+ taskLinksCallCount++;
589
+ if (taskLinksCallCount === 1) {
590
+ // Check existing link - none
591
+ return {
592
+ ...supabase,
593
+ select: vi.fn().mockReturnValue({
594
+ ...supabase,
595
+ eq: vi.fn().mockReturnValue({
596
+ ...supabase,
597
+ single: vi.fn().mockResolvedValue({ data: null, error: null }),
598
+ }),
599
+ }),
600
+ } as unknown as ReturnType<SupabaseClient['from']>;
601
+ }
602
+ if (taskLinksCallCount === 2) {
603
+ // Get max order - none
604
+ return {
605
+ ...supabase,
606
+ select: vi.fn().mockReturnValue({
607
+ ...supabase,
608
+ eq: vi.fn().mockReturnValue({
609
+ ...supabase,
610
+ eq: vi.fn().mockReturnValue({
611
+ ...supabase,
612
+ order: vi.fn().mockReturnValue({
613
+ ...supabase,
614
+ limit: vi.fn().mockReturnValue({
615
+ ...supabase,
616
+ single: vi.fn().mockResolvedValue({ data: null, error: null }),
617
+ }),
618
+ }),
619
+ }),
620
+ }),
621
+ }),
622
+ } as unknown as ReturnType<SupabaseClient['from']>;
623
+ }
624
+ // Insert
625
+ return {
626
+ ...supabase,
627
+ insert: vi.fn().mockReturnValue({
628
+ then: (resolve: (val: unknown) => void) =>
629
+ Promise.resolve({ data: null, error: null }).then(resolve),
630
+ }),
631
+ } as unknown as ReturnType<SupabaseClient['from']>;
632
+ }
633
+ return supabase as unknown as ReturnType<SupabaseClient['from']>;
634
+ });
635
+
636
+ const ctx = createMockContext(supabase);
637
+
638
+ const result = await addTaskToBodyOfWork(
639
+ { body_of_work_id: VALID_UUID, task_id: VALID_UUID_2 },
640
+ ctx
641
+ );
642
+
643
+ expect(result.result).toMatchObject({
644
+ success: true,
645
+ body_of_work_id: VALID_UUID,
646
+ task_id: VALID_UUID_2,
647
+ phase: 'core',
648
+ order_index: 0,
649
+ });
650
+ });
651
+ });
652
+
653
+ // ============================================================================
654
+ // removeTaskFromBodyOfWork Tests
655
+ // ============================================================================
656
+
657
+ describe('removeTaskFromBodyOfWork', () => {
658
+ beforeEach(() => vi.clearAllMocks());
659
+
660
+ it('should throw error for missing task_id', async () => {
661
+ const supabase = createMockSupabase();
662
+ const ctx = createMockContext(supabase);
663
+
664
+ await expect(removeTaskFromBodyOfWork({}, ctx)).rejects.toThrow(ValidationError);
665
+ });
666
+
667
+ it('should return success when task is not in any body of work', async () => {
668
+ const supabase = createMockSupabase({
669
+ selectResult: { data: null, error: null },
670
+ });
671
+ const ctx = createMockContext(supabase);
672
+
673
+ const result = await removeTaskFromBodyOfWork({ task_id: VALID_UUID }, ctx);
674
+
675
+ expect(result.result).toMatchObject({
676
+ success: true,
677
+ message: 'Task is not in any body of work',
678
+ });
679
+ });
680
+
681
+ it('should throw error when body of work is completed', async () => {
682
+ const supabase = createMockSupabase();
683
+ let callCount = 0;
684
+
685
+ vi.mocked(supabase.from).mockImplementation((table: string) => {
686
+ callCount++;
687
+ if (table === 'body_of_work_tasks' && callCount === 1) {
688
+ return {
689
+ ...supabase,
690
+ select: vi.fn().mockReturnValue({
691
+ ...supabase,
692
+ eq: vi.fn().mockReturnValue({
693
+ ...supabase,
694
+ single: vi.fn().mockResolvedValue({ data: { body_of_work_id: 'bow-1' }, error: null }),
695
+ }),
696
+ }),
697
+ } as unknown as ReturnType<SupabaseClient['from']>;
698
+ }
699
+ if (table === 'bodies_of_work') {
700
+ return {
701
+ ...supabase,
702
+ select: vi.fn().mockReturnValue({
703
+ ...supabase,
704
+ eq: vi.fn().mockReturnValue({
705
+ ...supabase,
706
+ single: vi.fn().mockResolvedValue({ data: { status: 'completed' }, error: null }),
707
+ }),
708
+ }),
709
+ } as unknown as ReturnType<SupabaseClient['from']>;
710
+ }
711
+ return supabase as unknown as ReturnType<SupabaseClient['from']>;
712
+ });
713
+
714
+ const ctx = createMockContext(supabase);
715
+
716
+ await expect(
717
+ removeTaskFromBodyOfWork({ task_id: VALID_UUID }, ctx)
718
+ ).rejects.toThrow('Cannot remove tasks from a completed body of work');
719
+ });
720
+ });
721
+
722
+ // ============================================================================
723
+ // activateBodyOfWork Tests
724
+ // ============================================================================
725
+
726
+ describe('activateBodyOfWork', () => {
727
+ beforeEach(() => vi.clearAllMocks());
728
+
729
+ it('should throw error for missing body_of_work_id', async () => {
730
+ const supabase = createMockSupabase();
731
+ const ctx = createMockContext(supabase);
732
+
733
+ await expect(activateBodyOfWork({}, ctx)).rejects.toThrow(ValidationError);
734
+ });
735
+
736
+ it('should throw error when body of work not found', async () => {
737
+ const supabase = createMockSupabase({
738
+ selectResult: { data: null, error: { message: 'Not found' } },
739
+ });
740
+ const ctx = createMockContext(supabase);
741
+
742
+ await expect(
743
+ activateBodyOfWork({ body_of_work_id: VALID_UUID }, ctx)
744
+ ).rejects.toThrow('Body of work not found');
745
+ });
746
+
747
+ it('should throw error when body of work is not draft', async () => {
748
+ const supabase = createMockSupabase({
749
+ selectResult: { data: { status: 'active', title: 'Test' }, error: null },
750
+ });
751
+ const ctx = createMockContext(supabase);
752
+
753
+ await expect(
754
+ activateBodyOfWork({ body_of_work_id: VALID_UUID }, ctx)
755
+ ).rejects.toThrow('Can only activate draft bodies of work');
756
+ });
757
+
758
+ it('should throw error when body of work has no tasks', async () => {
759
+ const supabase = createMockSupabase();
760
+ let callCount = 0;
761
+
762
+ vi.mocked(supabase.from).mockImplementation((table: string) => {
763
+ callCount++;
764
+ if (table === 'bodies_of_work' && callCount === 1) {
765
+ return {
766
+ ...supabase,
767
+ select: vi.fn().mockReturnValue({
768
+ ...supabase,
769
+ eq: vi.fn().mockReturnValue({
770
+ ...supabase,
771
+ single: vi.fn().mockResolvedValue({ data: { status: 'draft', title: 'Test' }, error: null }),
772
+ }),
773
+ }),
774
+ } as unknown as ReturnType<SupabaseClient['from']>;
775
+ }
776
+ if (table === 'body_of_work_tasks') {
777
+ return {
778
+ ...supabase,
779
+ select: vi.fn().mockReturnValue({
780
+ ...supabase,
781
+ eq: vi.fn().mockReturnValue({
782
+ then: (resolve: (val: unknown) => void) =>
783
+ Promise.resolve({ count: 0, error: null }).then(resolve),
784
+ }),
785
+ }),
786
+ } as unknown as ReturnType<SupabaseClient['from']>;
787
+ }
788
+ return supabase as unknown as ReturnType<SupabaseClient['from']>;
789
+ });
790
+
791
+ const ctx = createMockContext(supabase);
792
+
793
+ await expect(
794
+ activateBodyOfWork({ body_of_work_id: VALID_UUID }, ctx)
795
+ ).rejects.toThrow('Cannot activate body of work with no tasks');
796
+ });
797
+
798
+ it('should activate body of work with tasks', async () => {
799
+ const supabase = createMockSupabase();
800
+ let callCount = 0;
801
+
802
+ vi.mocked(supabase.from).mockImplementation((table: string) => {
803
+ callCount++;
804
+ if (table === 'bodies_of_work' && callCount === 1) {
805
+ return {
806
+ ...supabase,
807
+ select: vi.fn().mockReturnValue({
808
+ ...supabase,
809
+ eq: vi.fn().mockReturnValue({
810
+ ...supabase,
811
+ single: vi.fn().mockResolvedValue({ data: { status: 'draft', title: 'Sprint 1' }, error: null }),
812
+ }),
813
+ }),
814
+ } as unknown as ReturnType<SupabaseClient['from']>;
815
+ }
816
+ if (table === 'body_of_work_tasks') {
817
+ return {
818
+ ...supabase,
819
+ select: vi.fn().mockReturnValue({
820
+ ...supabase,
821
+ eq: vi.fn().mockReturnValue({
822
+ then: (resolve: (val: unknown) => void) =>
823
+ Promise.resolve({ count: 3, error: null }).then(resolve),
824
+ }),
825
+ }),
826
+ } as unknown as ReturnType<SupabaseClient['from']>;
827
+ }
828
+ if (table === 'bodies_of_work' && callCount > 2) {
829
+ return {
830
+ ...supabase,
831
+ update: vi.fn().mockReturnValue({
832
+ ...supabase,
833
+ eq: vi.fn().mockReturnValue({
834
+ then: (resolve: (val: unknown) => void) =>
835
+ Promise.resolve({ data: null, error: null }).then(resolve),
836
+ }),
837
+ }),
838
+ } as unknown as ReturnType<SupabaseClient['from']>;
839
+ }
840
+ return supabase as unknown as ReturnType<SupabaseClient['from']>;
841
+ });
842
+
843
+ const ctx = createMockContext(supabase);
844
+
845
+ const result = await activateBodyOfWork({ body_of_work_id: VALID_UUID }, ctx);
846
+
847
+ expect(result.result).toMatchObject({
848
+ success: true,
849
+ body_of_work_id: VALID_UUID,
850
+ title: 'Sprint 1',
851
+ status: 'active',
852
+ });
853
+ });
854
+ });
855
+
856
+ // ============================================================================
857
+ // addTaskDependency Tests
858
+ // ============================================================================
859
+
860
+ describe('addTaskDependency', () => {
861
+ beforeEach(() => vi.clearAllMocks());
862
+
863
+ it('should throw error for missing body_of_work_id', async () => {
864
+ const supabase = createMockSupabase();
865
+ const ctx = createMockContext(supabase);
866
+
867
+ await expect(
868
+ addTaskDependency({ task_id: VALID_UUID, depends_on_task_id: VALID_UUID_2 }, ctx)
869
+ ).rejects.toThrow(ValidationError);
870
+ });
871
+
872
+ it('should throw error when task depends on itself', async () => {
873
+ const supabase = createMockSupabase();
874
+ const ctx = createMockContext(supabase);
875
+
876
+ await expect(
877
+ addTaskDependency({
878
+ body_of_work_id: VALID_UUID,
879
+ task_id: VALID_UUID_2,
880
+ depends_on_task_id: VALID_UUID_2,
881
+ }, ctx)
882
+ ).rejects.toThrow('A task cannot depend on itself');
883
+ });
884
+
885
+ it('should throw error when tasks do not belong to body of work', async () => {
886
+ const supabase = createMockSupabase();
887
+
888
+ vi.mocked(supabase.from).mockReturnValue({
889
+ ...supabase,
890
+ select: vi.fn().mockReturnValue({
891
+ ...supabase,
892
+ eq: vi.fn().mockReturnValue({
893
+ ...supabase,
894
+ in: vi.fn().mockReturnValue({
895
+ then: (resolve: (val: unknown) => void) =>
896
+ Promise.resolve({ data: [{ task_id: VALID_UUID_2 }], error: null }).then(resolve),
897
+ }),
898
+ }),
899
+ }),
900
+ } as unknown as ReturnType<SupabaseClient['from']>);
901
+
902
+ const ctx = createMockContext(supabase);
903
+
904
+ await expect(
905
+ addTaskDependency({
906
+ body_of_work_id: VALID_UUID,
907
+ task_id: VALID_UUID_2,
908
+ depends_on_task_id: VALID_UUID_3,
909
+ }, ctx)
910
+ ).rejects.toThrow('Both tasks must belong to the specified body of work');
911
+ });
912
+ });
913
+
914
+ // ============================================================================
915
+ // removeTaskDependency Tests
916
+ // ============================================================================
917
+
918
+ describe('removeTaskDependency', () => {
919
+ beforeEach(() => vi.clearAllMocks());
920
+
921
+ it('should throw error for missing task_id', async () => {
922
+ const supabase = createMockSupabase();
923
+ const ctx = createMockContext(supabase);
924
+
925
+ await expect(
926
+ removeTaskDependency({ depends_on_task_id: VALID_UUID }, ctx)
927
+ ).rejects.toThrow(ValidationError);
928
+ });
929
+
930
+ it('should throw error for missing depends_on_task_id', async () => {
931
+ const supabase = createMockSupabase();
932
+ const ctx = createMockContext(supabase);
933
+
934
+ await expect(
935
+ removeTaskDependency({ task_id: VALID_UUID }, ctx)
936
+ ).rejects.toThrow(ValidationError);
937
+ });
938
+
939
+ it('should remove dependency successfully', async () => {
940
+ const supabase = createMockSupabase({
941
+ deleteResult: { data: null, error: null },
942
+ });
943
+ const ctx = createMockContext(supabase);
944
+
945
+ const result = await removeTaskDependency(
946
+ { task_id: VALID_UUID, depends_on_task_id: VALID_UUID_2 },
947
+ ctx
948
+ );
949
+
950
+ expect(result.result).toMatchObject({
951
+ success: true,
952
+ task_id: VALID_UUID,
953
+ depends_on_task_id: VALID_UUID_2,
954
+ });
955
+ });
956
+ });
957
+
958
+ // ============================================================================
959
+ // getTaskDependencies Tests
960
+ // ============================================================================
961
+
962
+ describe('getTaskDependencies', () => {
963
+ beforeEach(() => vi.clearAllMocks());
964
+
965
+ it('should throw error when neither body_of_work_id nor task_id provided', async () => {
966
+ const supabase = createMockSupabase();
967
+ const ctx = createMockContext(supabase);
968
+
969
+ await expect(getTaskDependencies({}, ctx)).rejects.toThrow(
970
+ 'Either body_of_work_id or task_id is required'
971
+ );
972
+ });
973
+
974
+ it('should return dependencies filtered by body_of_work_id', async () => {
975
+ const mockDeps = [
976
+ { id: 'd1', task_id: 't1', depends_on_task_id: 't2', created_at: '2026-01-14' },
977
+ ];
978
+
979
+ const supabase = createMockSupabase();
980
+
981
+ vi.mocked(supabase.from).mockReturnValue({
982
+ ...supabase,
983
+ select: vi.fn().mockReturnValue({
984
+ ...supabase,
985
+ eq: vi.fn().mockReturnValue({
986
+ then: (resolve: (val: unknown) => void) =>
987
+ Promise.resolve({ data: mockDeps, error: null }).then(resolve),
988
+ }),
989
+ }),
990
+ } as unknown as ReturnType<SupabaseClient['from']>);
991
+
992
+ const ctx = createMockContext(supabase);
993
+
994
+ const result = await getTaskDependencies({ body_of_work_id: VALID_UUID }, ctx);
995
+
996
+ expect(result.result).toMatchObject({ dependencies: mockDeps });
997
+ });
998
+
999
+ it('should return dependencies filtered by task_id', async () => {
1000
+ const supabase = createMockSupabase();
1001
+
1002
+ vi.mocked(supabase.from).mockReturnValue({
1003
+ ...supabase,
1004
+ select: vi.fn().mockReturnValue({
1005
+ ...supabase,
1006
+ eq: vi.fn().mockReturnValue({
1007
+ then: (resolve: (val: unknown) => void) =>
1008
+ Promise.resolve({ data: [], error: null }).then(resolve),
1009
+ }),
1010
+ }),
1011
+ } as unknown as ReturnType<SupabaseClient['from']>);
1012
+
1013
+ const ctx = createMockContext(supabase);
1014
+
1015
+ const result = await getTaskDependencies({ task_id: VALID_UUID }, ctx);
1016
+
1017
+ expect(result.result).toMatchObject({ dependencies: [] });
1018
+ expect(supabase.from).toHaveBeenCalledWith('body_of_work_task_dependencies');
1019
+ });
1020
+ });
1021
+
1022
+ // ============================================================================
1023
+ // getNextBodyOfWorkTask Tests
1024
+ // ============================================================================
1025
+
1026
+ describe('getNextBodyOfWorkTask', () => {
1027
+ beforeEach(() => vi.clearAllMocks());
1028
+
1029
+ it('should throw error for missing body_of_work_id', async () => {
1030
+ const supabase = createMockSupabase();
1031
+ const ctx = createMockContext(supabase);
1032
+
1033
+ await expect(getNextBodyOfWorkTask({}, ctx)).rejects.toThrow(ValidationError);
1034
+ });
1035
+
1036
+ it('should throw error when body of work not found', async () => {
1037
+ const supabase = createMockSupabase({
1038
+ selectResult: { data: null, error: { message: 'Not found' } },
1039
+ });
1040
+ const ctx = createMockContext(supabase);
1041
+
1042
+ await expect(
1043
+ getNextBodyOfWorkTask({ body_of_work_id: VALID_UUID }, ctx)
1044
+ ).rejects.toThrow('Body of work not found');
1045
+ });
1046
+
1047
+ it('should return null when body of work is not active', async () => {
1048
+ const supabase = createMockSupabase({
1049
+ selectResult: { data: { status: 'draft', title: 'Test' }, error: null },
1050
+ });
1051
+ const ctx = createMockContext(supabase);
1052
+
1053
+ const result = await getNextBodyOfWorkTask({ body_of_work_id: VALID_UUID }, ctx);
1054
+
1055
+ expect(result.result).toMatchObject({
1056
+ next_task: null,
1057
+ message: 'Body of work is draft, not active',
1058
+ });
1059
+ });
1060
+
1061
+ it('should return next pending task from pre phase first', async () => {
1062
+ const mockBow = { status: 'active', title: 'Sprint' };
1063
+ const mockTaskLinks = [
1064
+ {
1065
+ task_id: 't1',
1066
+ phase: 'pre',
1067
+ order_index: 0,
1068
+ tasks: { id: 't1', title: 'Setup', status: 'pending', priority: 1, claimed_by_session_id: null },
1069
+ },
1070
+ {
1071
+ task_id: 't2',
1072
+ phase: 'core',
1073
+ order_index: 0,
1074
+ tasks: { id: 't2', title: 'Feature', status: 'pending', priority: 2, claimed_by_session_id: null },
1075
+ },
1076
+ ];
1077
+
1078
+ const supabase = createMockSupabase();
1079
+ let callCount = 0;
1080
+
1081
+ vi.mocked(supabase.from).mockImplementation((table: string) => {
1082
+ callCount++;
1083
+ if (table === 'bodies_of_work') {
1084
+ return {
1085
+ ...supabase,
1086
+ select: vi.fn().mockReturnValue({
1087
+ ...supabase,
1088
+ eq: vi.fn().mockReturnValue({
1089
+ ...supabase,
1090
+ single: vi.fn().mockResolvedValue({ data: mockBow, error: null }),
1091
+ }),
1092
+ }),
1093
+ } as unknown as ReturnType<SupabaseClient['from']>;
1094
+ }
1095
+ if (table === 'body_of_work_tasks') {
1096
+ return {
1097
+ ...supabase,
1098
+ select: vi.fn().mockReturnValue({
1099
+ ...supabase,
1100
+ eq: vi.fn().mockReturnValue({
1101
+ ...supabase,
1102
+ order: vi.fn().mockReturnValue({
1103
+ then: (resolve: (val: unknown) => void) =>
1104
+ Promise.resolve({ data: mockTaskLinks, error: null }).then(resolve),
1105
+ }),
1106
+ }),
1107
+ }),
1108
+ } as unknown as ReturnType<SupabaseClient['from']>;
1109
+ }
1110
+ if (table === 'body_of_work_task_dependencies') {
1111
+ return {
1112
+ ...supabase,
1113
+ select: vi.fn().mockReturnValue({
1114
+ ...supabase,
1115
+ eq: vi.fn().mockReturnValue({
1116
+ then: (resolve: (val: unknown) => void) =>
1117
+ Promise.resolve({ data: [], error: null }).then(resolve),
1118
+ }),
1119
+ }),
1120
+ } as unknown as ReturnType<SupabaseClient['from']>;
1121
+ }
1122
+ return supabase as unknown as ReturnType<SupabaseClient['from']>;
1123
+ });
1124
+
1125
+ const ctx = createMockContext(supabase);
1126
+
1127
+ const result = await getNextBodyOfWorkTask({ body_of_work_id: VALID_UUID }, ctx);
1128
+
1129
+ expect(result.result).toHaveProperty('next_task');
1130
+ const nextTask = (result.result as { next_task: { id: string; phase: string } }).next_task;
1131
+ expect(nextTask.id).toBe('t1');
1132
+ expect(nextTask.phase).toBe('pre');
1133
+ });
1134
+
1135
+ it('should skip tasks claimed by other sessions', async () => {
1136
+ const mockBow = { status: 'active', title: 'Sprint' };
1137
+ const mockTaskLinks = [
1138
+ {
1139
+ task_id: 't1',
1140
+ phase: 'pre',
1141
+ order_index: 0,
1142
+ tasks: { id: 't1', title: 'Claimed', status: 'pending', priority: 1, claimed_by_session_id: 'other-session' },
1143
+ },
1144
+ {
1145
+ task_id: 't2',
1146
+ phase: 'pre',
1147
+ order_index: 1,
1148
+ tasks: { id: 't2', title: 'Available', status: 'pending', priority: 2, claimed_by_session_id: null },
1149
+ },
1150
+ ];
1151
+
1152
+ const supabase = createMockSupabase();
1153
+
1154
+ vi.mocked(supabase.from).mockImplementation((table: string) => {
1155
+ if (table === 'bodies_of_work') {
1156
+ return {
1157
+ ...supabase,
1158
+ select: vi.fn().mockReturnValue({
1159
+ ...supabase,
1160
+ eq: vi.fn().mockReturnValue({
1161
+ ...supabase,
1162
+ single: vi.fn().mockResolvedValue({ data: mockBow, error: null }),
1163
+ }),
1164
+ }),
1165
+ } as unknown as ReturnType<SupabaseClient['from']>;
1166
+ }
1167
+ if (table === 'body_of_work_tasks') {
1168
+ return {
1169
+ ...supabase,
1170
+ select: vi.fn().mockReturnValue({
1171
+ ...supabase,
1172
+ eq: vi.fn().mockReturnValue({
1173
+ ...supabase,
1174
+ order: vi.fn().mockReturnValue({
1175
+ then: (resolve: (val: unknown) => void) =>
1176
+ Promise.resolve({ data: mockTaskLinks, error: null }).then(resolve),
1177
+ }),
1178
+ }),
1179
+ }),
1180
+ } as unknown as ReturnType<SupabaseClient['from']>;
1181
+ }
1182
+ if (table === 'body_of_work_task_dependencies') {
1183
+ return {
1184
+ ...supabase,
1185
+ select: vi.fn().mockReturnValue({
1186
+ ...supabase,
1187
+ eq: vi.fn().mockReturnValue({
1188
+ then: (resolve: (val: unknown) => void) =>
1189
+ Promise.resolve({ data: [], error: null }).then(resolve),
1190
+ }),
1191
+ }),
1192
+ } as unknown as ReturnType<SupabaseClient['from']>;
1193
+ }
1194
+ return supabase as unknown as ReturnType<SupabaseClient['from']>;
1195
+ });
1196
+
1197
+ const ctx = createMockContext(supabase);
1198
+
1199
+ const result = await getNextBodyOfWorkTask({ body_of_work_id: VALID_UUID }, ctx);
1200
+
1201
+ const nextTask = (result.result as { next_task: { id: string } }).next_task;
1202
+ expect(nextTask.id).toBe('t2');
1203
+ });
1204
+
1205
+ it('should return null when all tasks are completed or in progress', async () => {
1206
+ const mockBow = { status: 'active', title: 'Sprint' };
1207
+ const mockTaskLinks = [
1208
+ {
1209
+ task_id: 't1',
1210
+ phase: 'core',
1211
+ order_index: 0,
1212
+ tasks: { id: 't1', title: 'Done', status: 'completed', priority: 1, claimed_by_session_id: null },
1213
+ },
1214
+ {
1215
+ task_id: 't2',
1216
+ phase: 'core',
1217
+ order_index: 1,
1218
+ tasks: { id: 't2', title: 'Working', status: 'in_progress', priority: 2, claimed_by_session_id: null },
1219
+ },
1220
+ ];
1221
+
1222
+ const supabase = createMockSupabase();
1223
+
1224
+ vi.mocked(supabase.from).mockImplementation((table: string) => {
1225
+ if (table === 'bodies_of_work') {
1226
+ return {
1227
+ ...supabase,
1228
+ select: vi.fn().mockReturnValue({
1229
+ ...supabase,
1230
+ eq: vi.fn().mockReturnValue({
1231
+ ...supabase,
1232
+ single: vi.fn().mockResolvedValue({ data: mockBow, error: null }),
1233
+ }),
1234
+ }),
1235
+ } as unknown as ReturnType<SupabaseClient['from']>;
1236
+ }
1237
+ if (table === 'body_of_work_tasks') {
1238
+ return {
1239
+ ...supabase,
1240
+ select: vi.fn().mockReturnValue({
1241
+ ...supabase,
1242
+ eq: vi.fn().mockReturnValue({
1243
+ ...supabase,
1244
+ order: vi.fn().mockReturnValue({
1245
+ then: (resolve: (val: unknown) => void) =>
1246
+ Promise.resolve({ data: mockTaskLinks, error: null }).then(resolve),
1247
+ }),
1248
+ }),
1249
+ }),
1250
+ } as unknown as ReturnType<SupabaseClient['from']>;
1251
+ }
1252
+ if (table === 'body_of_work_task_dependencies') {
1253
+ return {
1254
+ ...supabase,
1255
+ select: vi.fn().mockReturnValue({
1256
+ ...supabase,
1257
+ eq: vi.fn().mockReturnValue({
1258
+ then: (resolve: (val: unknown) => void) =>
1259
+ Promise.resolve({ data: [], error: null }).then(resolve),
1260
+ }),
1261
+ }),
1262
+ } as unknown as ReturnType<SupabaseClient['from']>;
1263
+ }
1264
+ return supabase as unknown as ReturnType<SupabaseClient['from']>;
1265
+ });
1266
+
1267
+ const ctx = createMockContext(supabase);
1268
+
1269
+ const result = await getNextBodyOfWorkTask({ body_of_work_id: VALID_UUID }, ctx);
1270
+
1271
+ expect(result.result).toMatchObject({
1272
+ next_task: null,
1273
+ message: expect.stringContaining('No available tasks'),
1274
+ });
1275
+ });
1276
+ });