@sqaoss/flowy 0.1.1 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -661
- package/README.md +90 -56
- package/docker-compose.yml +14 -0
- package/package.json +24 -7
- package/server/Dockerfile +14 -0
- package/server/package.json +25 -0
- package/server/src/db.test.ts +93 -0
- package/server/src/db.ts +47 -0
- package/server/src/index.test.ts +25 -0
- package/server/src/index.ts +45 -0
- package/server/src/resolvers.test.ts +855 -0
- package/server/src/resolvers.ts +308 -0
- package/server/src/schema.test.ts +93 -0
- package/server/src/schema.ts +45 -0
- package/skills/using-flowy/SKILL.md +128 -0
- package/src/commands/client.test.ts +40 -0
- package/src/commands/client.ts +34 -0
- package/src/commands/feature.test.ts +71 -0
- package/src/commands/feature.ts +143 -0
- package/src/commands/init.test.ts +174 -0
- package/src/commands/init.ts +50 -0
- package/src/commands/project.test.ts +83 -0
- package/src/commands/project.ts +104 -0
- package/src/commands/setup.test.ts +135 -0
- package/src/commands/setup.ts +109 -0
- package/src/commands/task.test.ts +83 -0
- package/src/commands/task.ts +127 -0
- package/src/commands/tree.test.ts +9 -0
- package/src/commands/tree.ts +2 -59
- package/src/index.ts +14 -8
- package/src/util/config.test.ts +151 -0
- package/src/util/config.ts +107 -2
- package/src/util/description.test.ts +29 -0
- package/src/util/description.ts +8 -0
- package/src/commands/edge.ts +0 -84
- package/src/commands/node.ts +0 -134
- package/src/commands/register.ts +0 -25
|
@@ -0,0 +1,855 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
2
|
+
import { createDb, type FlowyDb } from './db.ts'
|
|
3
|
+
import { createResolvers, type NodeGql } from './resolvers.ts'
|
|
4
|
+
|
|
5
|
+
// Helper to create a node (always succeeds, typed non-null)
|
|
6
|
+
function create(
|
|
7
|
+
r: ReturnType<typeof createResolvers>,
|
|
8
|
+
args: { type: string; title: string; description?: string },
|
|
9
|
+
): NodeGql {
|
|
10
|
+
return r.Mutation.createNode(null, args)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Helper to find a node (throws if not found, for tests only)
|
|
14
|
+
function find(r: ReturnType<typeof createResolvers>, id: string): NodeGql {
|
|
15
|
+
const node = r.Query.node(null, { id })
|
|
16
|
+
if (!node) throw new Error(`Test helper: node ${id} not found`)
|
|
17
|
+
return node
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('createResolvers', () => {
|
|
21
|
+
let db: FlowyDb
|
|
22
|
+
let resolvers: ReturnType<typeof createResolvers>
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
db = createDb(':memory:')
|
|
26
|
+
resolvers = createResolvers(db)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
db.close()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('returns an object with Query and Mutation keys', () => {
|
|
34
|
+
expect(resolvers).toHaveProperty('Query')
|
|
35
|
+
expect(resolvers).toHaveProperty('Mutation')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
describe('Mutation.createNode', () => {
|
|
39
|
+
it('creates a project node with id starting with proj_', () => {
|
|
40
|
+
const node = resolvers.Mutation.createNode(null, {
|
|
41
|
+
type: 'project',
|
|
42
|
+
title: 'Test Project',
|
|
43
|
+
})
|
|
44
|
+
expect(node.id).toMatch(/^proj_/)
|
|
45
|
+
expect(node).toMatchObject({
|
|
46
|
+
type: 'project',
|
|
47
|
+
title: 'Test Project',
|
|
48
|
+
status: 'draft',
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('creates a feature node with id starting with feat_', () => {
|
|
53
|
+
const node = resolvers.Mutation.createNode(null, {
|
|
54
|
+
type: 'feature',
|
|
55
|
+
title: 'Auth Flow',
|
|
56
|
+
})
|
|
57
|
+
expect(node.id).toMatch(/^feat_/)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('creates a task node with id starting with task_', () => {
|
|
61
|
+
const node = resolvers.Mutation.createNode(null, {
|
|
62
|
+
type: 'task',
|
|
63
|
+
title: 'Write tests',
|
|
64
|
+
})
|
|
65
|
+
expect(node.id).toMatch(/^task_/)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('creates a node with default status draft', () => {
|
|
69
|
+
const node = resolvers.Mutation.createNode(null, {
|
|
70
|
+
type: 'project',
|
|
71
|
+
title: 'Test',
|
|
72
|
+
})
|
|
73
|
+
expect(node.status).toBe('draft')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('creates a node with description', () => {
|
|
77
|
+
const node = resolvers.Mutation.createNode(null, {
|
|
78
|
+
type: 'feature',
|
|
79
|
+
title: 'Auth Flow',
|
|
80
|
+
description: 'OAuth2 integration',
|
|
81
|
+
})
|
|
82
|
+
expect(node).toMatchObject({
|
|
83
|
+
type: 'feature',
|
|
84
|
+
title: 'Auth Flow',
|
|
85
|
+
description: 'OAuth2 integration',
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('returns camelCase timestamps', () => {
|
|
90
|
+
const node = resolvers.Mutation.createNode(null, {
|
|
91
|
+
type: 'project',
|
|
92
|
+
title: 'Test',
|
|
93
|
+
})
|
|
94
|
+
expect(node.createdAt).toBeDefined()
|
|
95
|
+
expect(node.updatedAt).toBeDefined()
|
|
96
|
+
expect(typeof node.createdAt).toBe('string')
|
|
97
|
+
expect(typeof node.updatedAt).toBe('string')
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe('Query.node', () => {
|
|
102
|
+
it('returns a node by id', () => {
|
|
103
|
+
const created = resolvers.Mutation.createNode(null, {
|
|
104
|
+
type: 'project',
|
|
105
|
+
title: 'Find Me',
|
|
106
|
+
})
|
|
107
|
+
const found = resolvers.Query.node(null, { id: created.id })
|
|
108
|
+
expect(found).toMatchObject({
|
|
109
|
+
id: created.id,
|
|
110
|
+
type: 'project',
|
|
111
|
+
title: 'Find Me',
|
|
112
|
+
})
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('returns null for non-existent id', () => {
|
|
116
|
+
const found = resolvers.Query.node(null, { id: 'nonexistent' })
|
|
117
|
+
expect(found).toBeNull()
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
describe('Query.nodes', () => {
|
|
122
|
+
it('lists nodes by type', () => {
|
|
123
|
+
resolvers.Mutation.createNode(null, { type: 'project', title: 'P1' })
|
|
124
|
+
resolvers.Mutation.createNode(null, { type: 'project', title: 'P2' })
|
|
125
|
+
resolvers.Mutation.createNode(null, { type: 'feature', title: 'F1' })
|
|
126
|
+
|
|
127
|
+
const projects = resolvers.Query.nodes(null, { type: 'project' })
|
|
128
|
+
expect(projects).toHaveLength(2)
|
|
129
|
+
expect(
|
|
130
|
+
projects.every((n: { type: string }) => n.type === 'project'),
|
|
131
|
+
).toBe(true)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('lists all nodes when no type filter', () => {
|
|
135
|
+
resolvers.Mutation.createNode(null, { type: 'project', title: 'P1' })
|
|
136
|
+
resolvers.Mutation.createNode(null, { type: 'feature', title: 'F1' })
|
|
137
|
+
|
|
138
|
+
const all = resolvers.Query.nodes(null, {})
|
|
139
|
+
expect(all).toHaveLength(2)
|
|
140
|
+
})
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
describe('Mutation.updateNode', () => {
|
|
144
|
+
it('changes node status', () => {
|
|
145
|
+
const node = resolvers.Mutation.createNode(null, {
|
|
146
|
+
type: 'task',
|
|
147
|
+
title: 'Test',
|
|
148
|
+
})
|
|
149
|
+
const updated = resolvers.Mutation.updateNode(null, {
|
|
150
|
+
id: node.id,
|
|
151
|
+
status: 'in_progress',
|
|
152
|
+
})
|
|
153
|
+
expect(updated.status).toBe('in_progress')
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('throws for missing node id', () => {
|
|
157
|
+
expect(() =>
|
|
158
|
+
resolvers.Mutation.updateNode(null, {
|
|
159
|
+
id: 'nonexistent',
|
|
160
|
+
status: 'done',
|
|
161
|
+
}),
|
|
162
|
+
).toThrow('Node nonexistent not found')
|
|
163
|
+
})
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
describe('Mutation.approveNode', () => {
|
|
167
|
+
it('transitions pending_review to approved', () => {
|
|
168
|
+
const node = resolvers.Mutation.createNode(null, {
|
|
169
|
+
type: 'feature',
|
|
170
|
+
title: 'Review Me',
|
|
171
|
+
})
|
|
172
|
+
resolvers.Mutation.updateNode(null, {
|
|
173
|
+
id: node.id,
|
|
174
|
+
status: 'pending_review',
|
|
175
|
+
})
|
|
176
|
+
const approved = resolvers.Mutation.approveNode(null, { id: node.id })
|
|
177
|
+
expect(approved.status).toBe('approved')
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('rejects approval of draft nodes', () => {
|
|
181
|
+
const node = resolvers.Mutation.createNode(null, {
|
|
182
|
+
type: 'feature',
|
|
183
|
+
title: 'Draft',
|
|
184
|
+
})
|
|
185
|
+
expect(() =>
|
|
186
|
+
resolvers.Mutation.approveNode(null, { id: node.id }),
|
|
187
|
+
).toThrow('Cannot approve node with status "draft"')
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('throws for missing node', () => {
|
|
191
|
+
expect(() =>
|
|
192
|
+
resolvers.Mutation.approveNode(null, { id: 'nonexistent' }),
|
|
193
|
+
).toThrow('Node nonexistent not found')
|
|
194
|
+
})
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
describe('Mutation.createEdge', () => {
|
|
198
|
+
it('links two nodes', () => {
|
|
199
|
+
const project = resolvers.Mutation.createNode(null, {
|
|
200
|
+
type: 'project',
|
|
201
|
+
title: 'Parent',
|
|
202
|
+
})
|
|
203
|
+
const feature = resolvers.Mutation.createNode(null, {
|
|
204
|
+
type: 'feature',
|
|
205
|
+
title: 'Child',
|
|
206
|
+
})
|
|
207
|
+
const edge = resolvers.Mutation.createEdge(null, {
|
|
208
|
+
sourceId: feature.id,
|
|
209
|
+
targetId: project.id,
|
|
210
|
+
relation: 'part_of',
|
|
211
|
+
})
|
|
212
|
+
expect(edge).toMatchObject({
|
|
213
|
+
sourceId: feature.id,
|
|
214
|
+
targetId: project.id,
|
|
215
|
+
relation: 'part_of',
|
|
216
|
+
})
|
|
217
|
+
expect(edge.createdAt).toBeDefined()
|
|
218
|
+
})
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
describe('Mutation.removeEdge', () => {
|
|
222
|
+
it('removes an existing edge and returns true', () => {
|
|
223
|
+
const project = resolvers.Mutation.createNode(null, {
|
|
224
|
+
type: 'project',
|
|
225
|
+
title: 'P',
|
|
226
|
+
})
|
|
227
|
+
const feature = resolvers.Mutation.createNode(null, {
|
|
228
|
+
type: 'feature',
|
|
229
|
+
title: 'F',
|
|
230
|
+
})
|
|
231
|
+
resolvers.Mutation.createEdge(null, {
|
|
232
|
+
sourceId: feature.id,
|
|
233
|
+
targetId: project.id,
|
|
234
|
+
relation: 'part_of',
|
|
235
|
+
})
|
|
236
|
+
const result = resolvers.Mutation.removeEdge(null, {
|
|
237
|
+
sourceId: feature.id,
|
|
238
|
+
targetId: project.id,
|
|
239
|
+
relation: 'part_of',
|
|
240
|
+
})
|
|
241
|
+
expect(result).toBe(true)
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it('returns false for nonexistent edge', () => {
|
|
245
|
+
const result = resolvers.Mutation.removeEdge(null, {
|
|
246
|
+
sourceId: 'a',
|
|
247
|
+
targetId: 'b',
|
|
248
|
+
relation: 'part_of',
|
|
249
|
+
})
|
|
250
|
+
expect(result).toBe(false)
|
|
251
|
+
})
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
describe('Query.descendants', () => {
|
|
255
|
+
it('returns direct children with maxDepth 1', () => {
|
|
256
|
+
const project = resolvers.Mutation.createNode(null, {
|
|
257
|
+
type: 'project',
|
|
258
|
+
title: 'Root',
|
|
259
|
+
})
|
|
260
|
+
const feat1 = resolvers.Mutation.createNode(null, {
|
|
261
|
+
type: 'feature',
|
|
262
|
+
title: 'F1',
|
|
263
|
+
})
|
|
264
|
+
const feat2 = resolvers.Mutation.createNode(null, {
|
|
265
|
+
type: 'feature',
|
|
266
|
+
title: 'F2',
|
|
267
|
+
})
|
|
268
|
+
const task = resolvers.Mutation.createNode(null, {
|
|
269
|
+
type: 'task',
|
|
270
|
+
title: 'T1',
|
|
271
|
+
})
|
|
272
|
+
resolvers.Mutation.createEdge(null, {
|
|
273
|
+
sourceId: feat1.id,
|
|
274
|
+
targetId: project.id,
|
|
275
|
+
relation: 'part_of',
|
|
276
|
+
})
|
|
277
|
+
resolvers.Mutation.createEdge(null, {
|
|
278
|
+
sourceId: feat2.id,
|
|
279
|
+
targetId: project.id,
|
|
280
|
+
relation: 'part_of',
|
|
281
|
+
})
|
|
282
|
+
resolvers.Mutation.createEdge(null, {
|
|
283
|
+
sourceId: task.id,
|
|
284
|
+
targetId: feat1.id,
|
|
285
|
+
relation: 'part_of',
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
const children = resolvers.Query.descendants(null, {
|
|
289
|
+
nodeId: project.id,
|
|
290
|
+
relation: 'part_of',
|
|
291
|
+
maxDepth: 1,
|
|
292
|
+
})
|
|
293
|
+
expect(children).toHaveLength(2)
|
|
294
|
+
const ids = children.map((n: { id: string }) => n.id)
|
|
295
|
+
expect(ids).toContain(feat1.id)
|
|
296
|
+
expect(ids).toContain(feat2.id)
|
|
297
|
+
expect(ids).not.toContain(task.id)
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
it('returns multi-level descendants without maxDepth constraint', () => {
|
|
301
|
+
const project = resolvers.Mutation.createNode(null, {
|
|
302
|
+
type: 'project',
|
|
303
|
+
title: 'Root',
|
|
304
|
+
})
|
|
305
|
+
const feature = resolvers.Mutation.createNode(null, {
|
|
306
|
+
type: 'feature',
|
|
307
|
+
title: 'F1',
|
|
308
|
+
})
|
|
309
|
+
const task = resolvers.Mutation.createNode(null, {
|
|
310
|
+
type: 'task',
|
|
311
|
+
title: 'T1',
|
|
312
|
+
})
|
|
313
|
+
resolvers.Mutation.createEdge(null, {
|
|
314
|
+
sourceId: feature.id,
|
|
315
|
+
targetId: project.id,
|
|
316
|
+
relation: 'part_of',
|
|
317
|
+
})
|
|
318
|
+
resolvers.Mutation.createEdge(null, {
|
|
319
|
+
sourceId: task.id,
|
|
320
|
+
targetId: feature.id,
|
|
321
|
+
relation: 'part_of',
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
const all = resolvers.Query.descendants(null, {
|
|
325
|
+
nodeId: project.id,
|
|
326
|
+
relation: 'part_of',
|
|
327
|
+
})
|
|
328
|
+
expect(all).toHaveLength(2)
|
|
329
|
+
const ids = all.map((n: { id: string }) => n.id)
|
|
330
|
+
expect(ids).toContain(feature.id)
|
|
331
|
+
expect(ids).toContain(task.id)
|
|
332
|
+
})
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
describe('Query.subtree', () => {
|
|
336
|
+
it('returns full tree traversal across all edge types', () => {
|
|
337
|
+
const project = resolvers.Mutation.createNode(null, {
|
|
338
|
+
type: 'project',
|
|
339
|
+
title: 'Root',
|
|
340
|
+
})
|
|
341
|
+
const feature = resolvers.Mutation.createNode(null, {
|
|
342
|
+
type: 'feature',
|
|
343
|
+
title: 'F1',
|
|
344
|
+
})
|
|
345
|
+
const task = resolvers.Mutation.createNode(null, {
|
|
346
|
+
type: 'task',
|
|
347
|
+
title: 'T1',
|
|
348
|
+
})
|
|
349
|
+
resolvers.Mutation.createEdge(null, {
|
|
350
|
+
sourceId: feature.id,
|
|
351
|
+
targetId: project.id,
|
|
352
|
+
relation: 'part_of',
|
|
353
|
+
})
|
|
354
|
+
resolvers.Mutation.createEdge(null, {
|
|
355
|
+
sourceId: task.id,
|
|
356
|
+
targetId: feature.id,
|
|
357
|
+
relation: 'part_of',
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
const tree = resolvers.Query.subtree(null, { nodeId: project.id })
|
|
361
|
+
expect(tree).toHaveLength(2)
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
it('respects maxDepth', () => {
|
|
365
|
+
const project = resolvers.Mutation.createNode(null, {
|
|
366
|
+
type: 'project',
|
|
367
|
+
title: 'Root',
|
|
368
|
+
})
|
|
369
|
+
const feature = resolvers.Mutation.createNode(null, {
|
|
370
|
+
type: 'feature',
|
|
371
|
+
title: 'F1',
|
|
372
|
+
})
|
|
373
|
+
const task = resolvers.Mutation.createNode(null, {
|
|
374
|
+
type: 'task',
|
|
375
|
+
title: 'T1',
|
|
376
|
+
})
|
|
377
|
+
resolvers.Mutation.createEdge(null, {
|
|
378
|
+
sourceId: feature.id,
|
|
379
|
+
targetId: project.id,
|
|
380
|
+
relation: 'part_of',
|
|
381
|
+
})
|
|
382
|
+
resolvers.Mutation.createEdge(null, {
|
|
383
|
+
sourceId: task.id,
|
|
384
|
+
targetId: feature.id,
|
|
385
|
+
relation: 'part_of',
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
const shallow = resolvers.Query.subtree(null, {
|
|
389
|
+
nodeId: project.id,
|
|
390
|
+
maxDepth: 1,
|
|
391
|
+
})
|
|
392
|
+
expect(shallow).toHaveLength(1)
|
|
393
|
+
expect(shallow[0].id).toBe(feature.id)
|
|
394
|
+
})
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
describe('Query.search', () => {
|
|
398
|
+
it('finds nodes by title', () => {
|
|
399
|
+
resolvers.Mutation.createNode(null, {
|
|
400
|
+
type: 'project',
|
|
401
|
+
title: 'Authentication',
|
|
402
|
+
})
|
|
403
|
+
resolvers.Mutation.createNode(null, {
|
|
404
|
+
type: 'project',
|
|
405
|
+
title: 'Database',
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
const results = resolvers.Query.search(null, { query: 'Auth' })
|
|
409
|
+
expect(results).toHaveLength(1)
|
|
410
|
+
expect(results[0].title).toBe('Authentication')
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
it('finds nodes by description', () => {
|
|
414
|
+
resolvers.Mutation.createNode(null, {
|
|
415
|
+
type: 'feature',
|
|
416
|
+
title: 'Login',
|
|
417
|
+
description: 'OAuth2 integration',
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
const results = resolvers.Query.search(null, { query: 'OAuth' })
|
|
421
|
+
expect(results).toHaveLength(1)
|
|
422
|
+
expect(results[0].title).toBe('Login')
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
it('filters by type', () => {
|
|
426
|
+
resolvers.Mutation.createNode(null, {
|
|
427
|
+
type: 'project',
|
|
428
|
+
title: 'Auth Project',
|
|
429
|
+
})
|
|
430
|
+
resolvers.Mutation.createNode(null, {
|
|
431
|
+
type: 'feature',
|
|
432
|
+
title: 'Auth Feature',
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
const results = resolvers.Query.search(null, {
|
|
436
|
+
query: 'Auth',
|
|
437
|
+
type: 'project',
|
|
438
|
+
})
|
|
439
|
+
expect(results).toHaveLength(1)
|
|
440
|
+
expect(results[0].type).toBe('project')
|
|
441
|
+
})
|
|
442
|
+
|
|
443
|
+
it('filters by status', () => {
|
|
444
|
+
const node = resolvers.Mutation.createNode(null, {
|
|
445
|
+
type: 'project',
|
|
446
|
+
title: 'Auth',
|
|
447
|
+
})
|
|
448
|
+
resolvers.Mutation.updateNode(null, {
|
|
449
|
+
id: node.id,
|
|
450
|
+
status: 'in_progress',
|
|
451
|
+
})
|
|
452
|
+
resolvers.Mutation.createNode(null, {
|
|
453
|
+
type: 'project',
|
|
454
|
+
title: 'Auth2',
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
const results = resolvers.Query.search(null, {
|
|
458
|
+
query: 'Auth',
|
|
459
|
+
status: 'in_progress',
|
|
460
|
+
})
|
|
461
|
+
expect(results).toHaveLength(1)
|
|
462
|
+
expect(results[0].title).toBe('Auth')
|
|
463
|
+
})
|
|
464
|
+
|
|
465
|
+
it('respects limit', () => {
|
|
466
|
+
for (let i = 0; i < 5; i++) {
|
|
467
|
+
resolvers.Mutation.createNode(null, {
|
|
468
|
+
type: 'task',
|
|
469
|
+
title: `Task ${i}`,
|
|
470
|
+
})
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const results = resolvers.Query.search(null, {
|
|
474
|
+
query: 'Task',
|
|
475
|
+
limit: 2,
|
|
476
|
+
})
|
|
477
|
+
expect(results).toHaveLength(2)
|
|
478
|
+
})
|
|
479
|
+
})
|
|
480
|
+
|
|
481
|
+
describe('Mutation.createNode — title and description validation', () => {
|
|
482
|
+
it('throws on empty title', () => {
|
|
483
|
+
expect(() =>
|
|
484
|
+
resolvers.Mutation.createNode(null, { type: 'task', title: '' }),
|
|
485
|
+
).toThrow('Title is required')
|
|
486
|
+
})
|
|
487
|
+
|
|
488
|
+
it('throws on whitespace-only title', () => {
|
|
489
|
+
expect(() =>
|
|
490
|
+
resolvers.Mutation.createNode(null, { type: 'task', title: ' ' }),
|
|
491
|
+
).toThrow('Title is required')
|
|
492
|
+
})
|
|
493
|
+
|
|
494
|
+
it('throws on empty description', () => {
|
|
495
|
+
expect(() =>
|
|
496
|
+
resolvers.Mutation.createNode(null, {
|
|
497
|
+
type: 'task',
|
|
498
|
+
title: 'Valid',
|
|
499
|
+
description: '',
|
|
500
|
+
}),
|
|
501
|
+
).toThrow('Description cannot be empty')
|
|
502
|
+
})
|
|
503
|
+
})
|
|
504
|
+
|
|
505
|
+
describe('Mutation.createNode — input validation', () => {
|
|
506
|
+
it('throws on invalid type', () => {
|
|
507
|
+
expect(() =>
|
|
508
|
+
resolvers.Mutation.createNode(null, {
|
|
509
|
+
type: 'epic',
|
|
510
|
+
title: 'Bad Type',
|
|
511
|
+
}),
|
|
512
|
+
).toThrow()
|
|
513
|
+
})
|
|
514
|
+
})
|
|
515
|
+
|
|
516
|
+
describe('Mutation.updateNode — input validation', () => {
|
|
517
|
+
it('throws on invalid status with friendly message', () => {
|
|
518
|
+
const node = create(resolvers, { type: 'task', title: 'Test' })
|
|
519
|
+
expect(() =>
|
|
520
|
+
resolvers.Mutation.updateNode(null, {
|
|
521
|
+
id: node.id,
|
|
522
|
+
status: 'invalid_status',
|
|
523
|
+
}),
|
|
524
|
+
).toThrow(
|
|
525
|
+
'Invalid status: invalid_status. Must be one of: draft, pending_review, approved, in_progress, done, blocked, cancelled',
|
|
526
|
+
)
|
|
527
|
+
})
|
|
528
|
+
|
|
529
|
+
it('keeps current status when status is omitted', () => {
|
|
530
|
+
const node = create(resolvers, { type: 'task', title: 'Test' })
|
|
531
|
+
const updated = resolvers.Mutation.updateNode(null, { id: node.id })
|
|
532
|
+
expect(updated.status).toBe('draft')
|
|
533
|
+
})
|
|
534
|
+
})
|
|
535
|
+
|
|
536
|
+
describe('Mutation.approveNode — decision table completeness', () => {
|
|
537
|
+
it('rejects approval of in_progress nodes', () => {
|
|
538
|
+
const node = create(resolvers, { type: 'feature', title: 'Test' })
|
|
539
|
+
resolvers.Mutation.updateNode(null, {
|
|
540
|
+
id: node.id,
|
|
541
|
+
status: 'in_progress',
|
|
542
|
+
})
|
|
543
|
+
expect(() =>
|
|
544
|
+
resolvers.Mutation.approveNode(null, { id: node.id }),
|
|
545
|
+
).toThrow('Cannot approve node with status "in_progress"')
|
|
546
|
+
})
|
|
547
|
+
|
|
548
|
+
it('rejects approval of done nodes', () => {
|
|
549
|
+
const node = create(resolvers, { type: 'feature', title: 'Test' })
|
|
550
|
+
resolvers.Mutation.updateNode(null, { id: node.id, status: 'done' })
|
|
551
|
+
expect(() =>
|
|
552
|
+
resolvers.Mutation.approveNode(null, { id: node.id }),
|
|
553
|
+
).toThrow('Cannot approve node with status "done"')
|
|
554
|
+
})
|
|
555
|
+
|
|
556
|
+
it('rejects approval of already approved nodes', () => {
|
|
557
|
+
const node = create(resolvers, { type: 'feature', title: 'Test' })
|
|
558
|
+
resolvers.Mutation.updateNode(null, {
|
|
559
|
+
id: node.id,
|
|
560
|
+
status: 'pending_review',
|
|
561
|
+
})
|
|
562
|
+
resolvers.Mutation.approveNode(null, { id: node.id })
|
|
563
|
+
expect(() =>
|
|
564
|
+
resolvers.Mutation.approveNode(null, { id: node.id }),
|
|
565
|
+
).toThrow('Cannot approve node with status "approved"')
|
|
566
|
+
})
|
|
567
|
+
|
|
568
|
+
it('rejects approval of blocked nodes', () => {
|
|
569
|
+
const node = create(resolvers, { type: 'feature', title: 'Test' })
|
|
570
|
+
resolvers.Mutation.updateNode(null, { id: node.id, status: 'blocked' })
|
|
571
|
+
expect(() =>
|
|
572
|
+
resolvers.Mutation.approveNode(null, { id: node.id }),
|
|
573
|
+
).toThrow('Cannot approve node with status "blocked"')
|
|
574
|
+
})
|
|
575
|
+
|
|
576
|
+
it('rejects approval of cancelled nodes', () => {
|
|
577
|
+
const node = create(resolvers, { type: 'feature', title: 'Test' })
|
|
578
|
+
resolvers.Mutation.updateNode(null, {
|
|
579
|
+
id: node.id,
|
|
580
|
+
status: 'cancelled',
|
|
581
|
+
})
|
|
582
|
+
expect(() =>
|
|
583
|
+
resolvers.Mutation.approveNode(null, { id: node.id }),
|
|
584
|
+
).toThrow('Cannot approve node with status "cancelled"')
|
|
585
|
+
})
|
|
586
|
+
})
|
|
587
|
+
|
|
588
|
+
describe('Query.search — edge cases', () => {
|
|
589
|
+
it('returns empty array when no matches', () => {
|
|
590
|
+
create(resolvers, { type: 'project', title: 'Authentication' })
|
|
591
|
+
const results = resolvers.Query.search(null, {
|
|
592
|
+
query: 'zzz_no_match',
|
|
593
|
+
})
|
|
594
|
+
expect(results).toEqual([])
|
|
595
|
+
})
|
|
596
|
+
|
|
597
|
+
it('throws when query is shorter than 3 characters', () => {
|
|
598
|
+
expect(() => resolvers.Query.search(null, { query: '' })).toThrow(
|
|
599
|
+
'Search query must be at least 3 characters',
|
|
600
|
+
)
|
|
601
|
+
expect(() => resolvers.Query.search(null, { query: 'a' })).toThrow(
|
|
602
|
+
'Search query must be at least 3 characters',
|
|
603
|
+
)
|
|
604
|
+
expect(() => resolvers.Query.search(null, { query: 'ab' })).toThrow(
|
|
605
|
+
'Search query must be at least 3 characters',
|
|
606
|
+
)
|
|
607
|
+
})
|
|
608
|
+
|
|
609
|
+
it('succeeds with 3-character query', () => {
|
|
610
|
+
create(resolvers, { type: 'project', title: 'abc match' })
|
|
611
|
+
const results = resolvers.Query.search(null, { query: 'abc' })
|
|
612
|
+
expect(results).toHaveLength(1)
|
|
613
|
+
})
|
|
614
|
+
|
|
615
|
+
it('does not treat % as LIKE wildcard', () => {
|
|
616
|
+
create(resolvers, { type: 'project', title: '100% done' })
|
|
617
|
+
create(resolvers, { type: 'project', title: '100 things' })
|
|
618
|
+
const results = resolvers.Query.search(null, { query: '100%' })
|
|
619
|
+
expect(results).toHaveLength(1)
|
|
620
|
+
expect(results[0].title).toBe('100% done')
|
|
621
|
+
})
|
|
622
|
+
|
|
623
|
+
it('does not treat _ as LIKE wildcard', () => {
|
|
624
|
+
create(resolvers, { type: 'project', title: '_est something' })
|
|
625
|
+
create(resolvers, { type: 'project', title: 'Test something' })
|
|
626
|
+
const results = resolvers.Query.search(null, { query: '_est' })
|
|
627
|
+
expect(results).toHaveLength(1)
|
|
628
|
+
expect(results[0].title).toBe('_est something')
|
|
629
|
+
})
|
|
630
|
+
})
|
|
631
|
+
|
|
632
|
+
describe('Mutation.updateNode — state transitions', () => {
|
|
633
|
+
it('transitions approved to in_progress', () => {
|
|
634
|
+
const node = create(resolvers, { type: 'task', title: 'Test' })
|
|
635
|
+
resolvers.Mutation.updateNode(null, {
|
|
636
|
+
id: node.id,
|
|
637
|
+
status: 'pending_review',
|
|
638
|
+
})
|
|
639
|
+
resolvers.Mutation.approveNode(null, { id: node.id })
|
|
640
|
+
const updated = resolvers.Mutation.updateNode(null, {
|
|
641
|
+
id: node.id,
|
|
642
|
+
status: 'in_progress',
|
|
643
|
+
})
|
|
644
|
+
expect(updated.status).toBe('in_progress')
|
|
645
|
+
})
|
|
646
|
+
|
|
647
|
+
it('transitions in_progress to done', () => {
|
|
648
|
+
const node = create(resolvers, { type: 'task', title: 'Test' })
|
|
649
|
+
resolvers.Mutation.updateNode(null, {
|
|
650
|
+
id: node.id,
|
|
651
|
+
status: 'in_progress',
|
|
652
|
+
})
|
|
653
|
+
const updated = resolvers.Mutation.updateNode(null, {
|
|
654
|
+
id: node.id,
|
|
655
|
+
status: 'done',
|
|
656
|
+
})
|
|
657
|
+
expect(updated.status).toBe('done')
|
|
658
|
+
})
|
|
659
|
+
|
|
660
|
+
it('allows skipping states: draft to done', () => {
|
|
661
|
+
const node = create(resolvers, { type: 'task', title: 'Test' })
|
|
662
|
+
const updated = resolvers.Mutation.updateNode(null, {
|
|
663
|
+
id: node.id,
|
|
664
|
+
status: 'done',
|
|
665
|
+
})
|
|
666
|
+
expect(updated.status).toBe('done')
|
|
667
|
+
})
|
|
668
|
+
|
|
669
|
+
it('allows backwards transition: done to draft', () => {
|
|
670
|
+
const node = create(resolvers, { type: 'task', title: 'Test' })
|
|
671
|
+
resolvers.Mutation.updateNode(null, { id: node.id, status: 'done' })
|
|
672
|
+
const updated = resolvers.Mutation.updateNode(null, {
|
|
673
|
+
id: node.id,
|
|
674
|
+
status: 'draft',
|
|
675
|
+
})
|
|
676
|
+
expect(updated.status).toBe('draft')
|
|
677
|
+
})
|
|
678
|
+
|
|
679
|
+
it('transitions to blocked', () => {
|
|
680
|
+
const node = create(resolvers, { type: 'task', title: 'Test' })
|
|
681
|
+
const updated = resolvers.Mutation.updateNode(null, {
|
|
682
|
+
id: node.id,
|
|
683
|
+
status: 'blocked',
|
|
684
|
+
})
|
|
685
|
+
expect(updated.status).toBe('blocked')
|
|
686
|
+
})
|
|
687
|
+
|
|
688
|
+
it('transitions to cancelled', () => {
|
|
689
|
+
const node = create(resolvers, { type: 'task', title: 'Test' })
|
|
690
|
+
const updated = resolvers.Mutation.updateNode(null, {
|
|
691
|
+
id: node.id,
|
|
692
|
+
status: 'cancelled',
|
|
693
|
+
})
|
|
694
|
+
expect(updated.status).toBe('cancelled')
|
|
695
|
+
})
|
|
696
|
+
|
|
697
|
+
it('allows same status transition (no-op)', () => {
|
|
698
|
+
const node = create(resolvers, { type: 'task', title: 'Test' })
|
|
699
|
+
const updated = resolvers.Mutation.updateNode(null, {
|
|
700
|
+
id: node.id,
|
|
701
|
+
status: 'draft',
|
|
702
|
+
})
|
|
703
|
+
expect(updated.status).toBe('draft')
|
|
704
|
+
})
|
|
705
|
+
})
|
|
706
|
+
|
|
707
|
+
describe('Query.descendants — edge cases', () => {
|
|
708
|
+
it('returns empty array for leaf node', () => {
|
|
709
|
+
const leaf = create(resolvers, { type: 'task', title: 'Leaf' })
|
|
710
|
+
const result = resolvers.Query.descendants(null, {
|
|
711
|
+
nodeId: leaf.id,
|
|
712
|
+
relation: 'part_of',
|
|
713
|
+
})
|
|
714
|
+
expect(result).toEqual([])
|
|
715
|
+
})
|
|
716
|
+
|
|
717
|
+
it('returns empty array for non-existent node', () => {
|
|
718
|
+
const result = resolvers.Query.descendants(null, {
|
|
719
|
+
nodeId: 'nonexistent_id',
|
|
720
|
+
relation: 'part_of',
|
|
721
|
+
})
|
|
722
|
+
expect(result).toEqual([])
|
|
723
|
+
})
|
|
724
|
+
|
|
725
|
+
it('returns empty array when maxDepth is 0', () => {
|
|
726
|
+
const project = create(resolvers, { type: 'project', title: 'Root' })
|
|
727
|
+
const feature = create(resolvers, { type: 'feature', title: 'Child' })
|
|
728
|
+
resolvers.Mutation.createEdge(null, {
|
|
729
|
+
sourceId: feature.id,
|
|
730
|
+
targetId: project.id,
|
|
731
|
+
relation: 'part_of',
|
|
732
|
+
})
|
|
733
|
+
const result = resolvers.Query.descendants(null, {
|
|
734
|
+
nodeId: project.id,
|
|
735
|
+
relation: 'part_of',
|
|
736
|
+
maxDepth: 0,
|
|
737
|
+
})
|
|
738
|
+
expect(result).toEqual([])
|
|
739
|
+
})
|
|
740
|
+
|
|
741
|
+
it('traverses blocks relation', () => {
|
|
742
|
+
const task1 = create(resolvers, { type: 'task', title: 'Blocker' })
|
|
743
|
+
const task2 = create(resolvers, { type: 'task', title: 'Blocked' })
|
|
744
|
+
resolvers.Mutation.createEdge(null, {
|
|
745
|
+
sourceId: task1.id,
|
|
746
|
+
targetId: task2.id,
|
|
747
|
+
relation: 'blocks',
|
|
748
|
+
})
|
|
749
|
+
const blockers = resolvers.Query.descendants(null, {
|
|
750
|
+
nodeId: task2.id,
|
|
751
|
+
relation: 'blocks',
|
|
752
|
+
})
|
|
753
|
+
expect(blockers).toHaveLength(1)
|
|
754
|
+
expect(blockers[0].id).toBe(task1.id)
|
|
755
|
+
})
|
|
756
|
+
})
|
|
757
|
+
|
|
758
|
+
describe('Mutation.createEdge — edge cases', () => {
|
|
759
|
+
it('throws on duplicate edge', () => {
|
|
760
|
+
const p = create(resolvers, { type: 'project', title: 'P' })
|
|
761
|
+
const f = create(resolvers, { type: 'feature', title: 'F' })
|
|
762
|
+
resolvers.Mutation.createEdge(null, {
|
|
763
|
+
sourceId: f.id,
|
|
764
|
+
targetId: p.id,
|
|
765
|
+
relation: 'part_of',
|
|
766
|
+
})
|
|
767
|
+
expect(() =>
|
|
768
|
+
resolvers.Mutation.createEdge(null, {
|
|
769
|
+
sourceId: f.id,
|
|
770
|
+
targetId: p.id,
|
|
771
|
+
relation: 'part_of',
|
|
772
|
+
}),
|
|
773
|
+
).toThrow()
|
|
774
|
+
})
|
|
775
|
+
|
|
776
|
+
it('throws when source node does not exist', () => {
|
|
777
|
+
const target = create(resolvers, { type: 'project', title: 'Target' })
|
|
778
|
+
expect(() =>
|
|
779
|
+
resolvers.Mutation.createEdge(null, {
|
|
780
|
+
sourceId: 'nonexistent_id',
|
|
781
|
+
targetId: target.id,
|
|
782
|
+
relation: 'part_of',
|
|
783
|
+
}),
|
|
784
|
+
).toThrow('Source node nonexistent_id not found')
|
|
785
|
+
})
|
|
786
|
+
|
|
787
|
+
it('throws when target node does not exist', () => {
|
|
788
|
+
const source = create(resolvers, { type: 'feature', title: 'Source' })
|
|
789
|
+
expect(() =>
|
|
790
|
+
resolvers.Mutation.createEdge(null, {
|
|
791
|
+
sourceId: source.id,
|
|
792
|
+
targetId: 'nonexistent_id',
|
|
793
|
+
relation: 'part_of',
|
|
794
|
+
}),
|
|
795
|
+
).toThrow('Target node nonexistent_id not found')
|
|
796
|
+
})
|
|
797
|
+
|
|
798
|
+
it('throws on invalid relation', () => {
|
|
799
|
+
const a = create(resolvers, { type: 'project', title: 'A' })
|
|
800
|
+
const b = create(resolvers, { type: 'feature', title: 'B' })
|
|
801
|
+
expect(() =>
|
|
802
|
+
resolvers.Mutation.createEdge(null, {
|
|
803
|
+
sourceId: b.id,
|
|
804
|
+
targetId: a.id,
|
|
805
|
+
relation: 'depends_on',
|
|
806
|
+
}),
|
|
807
|
+
).toThrow("Invalid relation: depends_on. Must be 'part_of' or 'blocks'")
|
|
808
|
+
})
|
|
809
|
+
|
|
810
|
+
it('throws on self-blocking edge', () => {
|
|
811
|
+
const node = create(resolvers, { type: 'task', title: 'Self' })
|
|
812
|
+
expect(() =>
|
|
813
|
+
resolvers.Mutation.createEdge(null, {
|
|
814
|
+
sourceId: node.id,
|
|
815
|
+
targetId: node.id,
|
|
816
|
+
relation: 'blocks',
|
|
817
|
+
}),
|
|
818
|
+
).toThrow('A node cannot block itself')
|
|
819
|
+
})
|
|
820
|
+
})
|
|
821
|
+
|
|
822
|
+
describe('Query.subtree — edge cases', () => {
|
|
823
|
+
it('returns empty array for leaf node', () => {
|
|
824
|
+
const leaf = create(resolvers, { type: 'task', title: 'Leaf' })
|
|
825
|
+
const result = resolvers.Query.subtree(null, { nodeId: leaf.id })
|
|
826
|
+
expect(result).toEqual([])
|
|
827
|
+
})
|
|
828
|
+
|
|
829
|
+
it('returns empty array when maxDepth is 0', () => {
|
|
830
|
+
const project = create(resolvers, { type: 'project', title: 'Root' })
|
|
831
|
+
const feature = create(resolvers, { type: 'feature', title: 'Child' })
|
|
832
|
+
resolvers.Mutation.createEdge(null, {
|
|
833
|
+
sourceId: feature.id,
|
|
834
|
+
targetId: project.id,
|
|
835
|
+
relation: 'part_of',
|
|
836
|
+
})
|
|
837
|
+
const result = resolvers.Query.subtree(null, {
|
|
838
|
+
nodeId: project.id,
|
|
839
|
+
maxDepth: 0,
|
|
840
|
+
})
|
|
841
|
+
expect(result).toEqual([])
|
|
842
|
+
})
|
|
843
|
+
})
|
|
844
|
+
|
|
845
|
+
describe('Query.search — boundary values', () => {
|
|
846
|
+
it('returns empty array when limit is 0', () => {
|
|
847
|
+
create(resolvers, { type: 'project', title: 'Test' })
|
|
848
|
+
const results = resolvers.Query.search(null, {
|
|
849
|
+
query: 'Test',
|
|
850
|
+
limit: 0,
|
|
851
|
+
})
|
|
852
|
+
expect(results).toEqual([])
|
|
853
|
+
})
|
|
854
|
+
})
|
|
855
|
+
})
|