@sqaoss/flowy 1.3.0 → 1.4.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/docker-compose.yml +7 -2
- package/package.json +1 -1
- package/server/src/index.errors.test.ts +37 -7
- package/server/src/index.test.ts +29 -0
- package/server/src/index.ts +15 -5
- package/server/src/resolvers.test.ts +15 -4
- package/server/src/resolvers.ts +3 -1
- package/src/commands/feature.test.ts +161 -1
- package/src/commands/feature.ts +81 -2
- package/src/commands/project.test.ts +169 -2
- package/src/commands/project.ts +60 -0
- package/src/commands/serve.test.ts +89 -0
- package/src/commands/serve.ts +76 -0
- package/src/commands/setup.test.ts +12 -14
- package/src/commands/setup.ts +14 -73
- package/src/commands/task.test.ts +178 -3
- package/src/commands/task.ts +68 -4
- package/src/index.test.ts +16 -0
- package/src/index.ts +2 -0
- package/src/util/client.test.ts +194 -81
- package/src/util/client.ts +127 -27
- package/src/util/description.test.ts +59 -7
- package/src/util/description.ts +59 -4
- package/src/util/format.test.ts +65 -0
- package/src/util/format.ts +22 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, test, vi } from 'vitest'
|
|
1
|
+
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
|
2
2
|
|
|
3
3
|
vi.mock('../util/config.ts', () => ({
|
|
4
4
|
requireFeature: vi.fn(() => {
|
|
@@ -18,10 +18,14 @@ vi.mock('../util/format.ts', () => ({
|
|
|
18
18
|
}))
|
|
19
19
|
|
|
20
20
|
describe('task command', () => {
|
|
21
|
-
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
vi.clearAllMocks()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('exports a command group with 7 subcommands', async () => {
|
|
22
26
|
const { taskCommand } = await import('./task.ts')
|
|
23
27
|
expect(taskCommand.name()).toBe('task')
|
|
24
|
-
expect(taskCommand.commands).toHaveLength(
|
|
28
|
+
expect(taskCommand.commands).toHaveLength(7)
|
|
25
29
|
|
|
26
30
|
const names = taskCommand.commands.map((c) => c.name())
|
|
27
31
|
expect(names).toContain('create')
|
|
@@ -29,6 +33,16 @@ describe('task command', () => {
|
|
|
29
33
|
expect(names).toContain('show')
|
|
30
34
|
expect(names).toContain('block')
|
|
31
35
|
expect(names).toContain('unblock')
|
|
36
|
+
expect(names).toContain('update')
|
|
37
|
+
expect(names).toContain('delete')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('create exposes both --description and --description-file options', async () => {
|
|
41
|
+
const { taskCommand } = await import('./task.ts')
|
|
42
|
+
const createCmd = taskCommand.commands.find((c) => c.name() === 'create')!
|
|
43
|
+
const optionFlags = createCmd.options.map((o) => o.long)
|
|
44
|
+
expect(optionFlags).toContain('--description')
|
|
45
|
+
expect(optionFlags).toContain('--description-file')
|
|
32
46
|
})
|
|
33
47
|
|
|
34
48
|
test('create calls outputError when no active feature', async () => {
|
|
@@ -80,4 +94,165 @@ describe('task command', () => {
|
|
|
80
94
|
}),
|
|
81
95
|
)
|
|
82
96
|
})
|
|
97
|
+
|
|
98
|
+
test('update sends updateNode with only the title when title-only', async () => {
|
|
99
|
+
const { graphql } = await import('../util/client.ts')
|
|
100
|
+
const { output } = await import('../util/format.ts')
|
|
101
|
+
const { taskCommand } = await import('./task.ts')
|
|
102
|
+
|
|
103
|
+
vi.mocked(graphql).mockResolvedValueOnce({
|
|
104
|
+
updateNode: { id: 'task_1', title: 'New' },
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
const updateCmd = taskCommand.commands.find((c) => c.name() === 'update')!
|
|
108
|
+
await updateCmd.parseAsync(['task_1', '--title', 'New'], { from: 'user' })
|
|
109
|
+
|
|
110
|
+
expect(graphql).toHaveBeenCalledWith(
|
|
111
|
+
expect.stringContaining('updateNode'),
|
|
112
|
+
{ id: 'task_1', title: 'New' },
|
|
113
|
+
)
|
|
114
|
+
expect(output).toHaveBeenCalledWith({ id: 'task_1', title: 'New' })
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test('update sends updateNode with only the description when description-only', async () => {
|
|
118
|
+
const { graphql } = await import('../util/client.ts')
|
|
119
|
+
const { taskCommand } = await import('./task.ts')
|
|
120
|
+
|
|
121
|
+
vi.mocked(graphql).mockResolvedValueOnce({
|
|
122
|
+
updateNode: { id: 'task_1' },
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
const updateCmd = taskCommand.commands.find((c) => c.name() === 'update')!
|
|
126
|
+
await updateCmd.parseAsync(['task_1', '--description', 'Body'], {
|
|
127
|
+
from: 'user',
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
expect(graphql).toHaveBeenCalledWith(
|
|
131
|
+
expect.stringContaining('updateNode'),
|
|
132
|
+
{
|
|
133
|
+
id: 'task_1',
|
|
134
|
+
description: 'Body',
|
|
135
|
+
},
|
|
136
|
+
)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test('update sends updateNode with only the metadata when metadata-only', async () => {
|
|
140
|
+
const { graphql } = await import('../util/client.ts')
|
|
141
|
+
const { taskCommand } = await import('./task.ts')
|
|
142
|
+
|
|
143
|
+
vi.mocked(graphql).mockResolvedValueOnce({
|
|
144
|
+
updateNode: { id: 'task_1' },
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
const updateCmd = taskCommand.commands.find((c) => c.name() === 'update')!
|
|
148
|
+
await updateCmd.parseAsync(['task_1', '--metadata', '{"k":"v"}'], {
|
|
149
|
+
from: 'user',
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
expect(graphql).toHaveBeenCalledWith(
|
|
153
|
+
expect.stringContaining('updateNode'),
|
|
154
|
+
{
|
|
155
|
+
id: 'task_1',
|
|
156
|
+
metadata: '{"k":"v"}',
|
|
157
|
+
},
|
|
158
|
+
)
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
test('update sends updateNode with combined fields', async () => {
|
|
162
|
+
const { graphql } = await import('../util/client.ts')
|
|
163
|
+
const { taskCommand } = await import('./task.ts')
|
|
164
|
+
|
|
165
|
+
vi.mocked(graphql).mockResolvedValueOnce({
|
|
166
|
+
updateNode: { id: 'task_1' },
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
const updateCmd = taskCommand.commands.find((c) => c.name() === 'update')!
|
|
170
|
+
await updateCmd.parseAsync(
|
|
171
|
+
['task_1', '--title', 'New', '--description', 'Body', '--metadata', '{}'],
|
|
172
|
+
{ from: 'user' },
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
expect(graphql).toHaveBeenCalledWith(
|
|
176
|
+
expect.stringContaining('updateNode'),
|
|
177
|
+
{
|
|
178
|
+
id: 'task_1',
|
|
179
|
+
title: 'New',
|
|
180
|
+
description: 'Body',
|
|
181
|
+
metadata: '{}',
|
|
182
|
+
},
|
|
183
|
+
)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
test('update surfaces NOT_FOUND via outputError with its code', async () => {
|
|
187
|
+
const { graphql } = await import('../util/client.ts')
|
|
188
|
+
const { outputError } = await import('../util/format.ts')
|
|
189
|
+
const { taskCommand } = await import('./task.ts')
|
|
190
|
+
|
|
191
|
+
const notFound = Object.assign(new Error('Node task_x not found'), {
|
|
192
|
+
code: 'NOT_FOUND',
|
|
193
|
+
})
|
|
194
|
+
vi.mocked(graphql).mockRejectedValueOnce(notFound)
|
|
195
|
+
|
|
196
|
+
const updateCmd = taskCommand.commands.find((c) => c.name() === 'update')!
|
|
197
|
+
await updateCmd.parseAsync(['task_x', '--title', 'New'], { from: 'user' })
|
|
198
|
+
|
|
199
|
+
expect(outputError).toHaveBeenCalledWith(
|
|
200
|
+
expect.objectContaining({ code: 'NOT_FOUND' }),
|
|
201
|
+
)
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
test('delete sends deleteNode mutation', async () => {
|
|
205
|
+
const { graphql } = await import('../util/client.ts')
|
|
206
|
+
const { output } = await import('../util/format.ts')
|
|
207
|
+
const { taskCommand } = await import('./task.ts')
|
|
208
|
+
|
|
209
|
+
vi.mocked(graphql).mockResolvedValueOnce({ deleteNode: true })
|
|
210
|
+
|
|
211
|
+
const deleteCmd = taskCommand.commands.find((c) => c.name() === 'delete')!
|
|
212
|
+
await deleteCmd.parseAsync(['task_1'], { from: 'user' })
|
|
213
|
+
|
|
214
|
+
expect(graphql).toHaveBeenCalledWith(
|
|
215
|
+
expect.stringContaining('deleteNode'),
|
|
216
|
+
{
|
|
217
|
+
id: 'task_1',
|
|
218
|
+
},
|
|
219
|
+
)
|
|
220
|
+
expect(output).toHaveBeenCalledWith({ deleted: true })
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
test('delete surfaces CONFLICT via outputError with its code', async () => {
|
|
224
|
+
const { graphql } = await import('../util/client.ts')
|
|
225
|
+
const { outputError } = await import('../util/format.ts')
|
|
226
|
+
const { taskCommand } = await import('./task.ts')
|
|
227
|
+
|
|
228
|
+
const conflict = Object.assign(new Error('has children'), {
|
|
229
|
+
code: 'CONFLICT',
|
|
230
|
+
})
|
|
231
|
+
vi.mocked(graphql).mockRejectedValueOnce(conflict)
|
|
232
|
+
|
|
233
|
+
const deleteCmd = taskCommand.commands.find((c) => c.name() === 'delete')!
|
|
234
|
+
await deleteCmd.parseAsync(['task_1'], { from: 'user' })
|
|
235
|
+
|
|
236
|
+
expect(outputError).toHaveBeenCalledWith(
|
|
237
|
+
expect.objectContaining({ code: 'CONFLICT' }),
|
|
238
|
+
)
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
test('delete surfaces NOT_FOUND via outputError with its code', async () => {
|
|
242
|
+
const { graphql } = await import('../util/client.ts')
|
|
243
|
+
const { outputError } = await import('../util/format.ts')
|
|
244
|
+
const { taskCommand } = await import('./task.ts')
|
|
245
|
+
|
|
246
|
+
const notFound = Object.assign(new Error('Node task_x not found'), {
|
|
247
|
+
code: 'NOT_FOUND',
|
|
248
|
+
})
|
|
249
|
+
vi.mocked(graphql).mockRejectedValueOnce(notFound)
|
|
250
|
+
|
|
251
|
+
const deleteCmd = taskCommand.commands.find((c) => c.name() === 'delete')!
|
|
252
|
+
await deleteCmd.parseAsync(['task_x'], { from: 'user' })
|
|
253
|
+
|
|
254
|
+
expect(outputError).toHaveBeenCalledWith(
|
|
255
|
+
expect.objectContaining({ code: 'NOT_FOUND' }),
|
|
256
|
+
)
|
|
257
|
+
})
|
|
83
258
|
})
|
package/src/commands/task.ts
CHANGED
|
@@ -12,14 +12,21 @@ taskCommand
|
|
|
12
12
|
.command('create')
|
|
13
13
|
.description('Create a task in the active feature')
|
|
14
14
|
.requiredOption('--title <title>', 'Task title')
|
|
15
|
-
.
|
|
16
|
-
'--description <
|
|
17
|
-
'Task description (
|
|
15
|
+
.option(
|
|
16
|
+
'--description <text>',
|
|
17
|
+
'Task description, used verbatim (never read as a file path)',
|
|
18
|
+
)
|
|
19
|
+
.option(
|
|
20
|
+
'--description-file <path>',
|
|
21
|
+
'Read the task description from a file, or "-" for stdin',
|
|
18
22
|
)
|
|
19
23
|
.action(async (opts) => {
|
|
20
24
|
try {
|
|
21
25
|
const featureId = requireFeature()
|
|
22
|
-
const description = await resolveDescription(
|
|
26
|
+
const description = await resolveDescription({
|
|
27
|
+
description: opts.description,
|
|
28
|
+
descriptionFile: opts.descriptionFile,
|
|
29
|
+
})
|
|
23
30
|
const data = await graphql<{ createNode: { id: string } }>(
|
|
24
31
|
`mutation CreateTask($type: String!, $title: String!, $description: String) {
|
|
25
32
|
createNode(type: $type, title: $title, description: $description) {
|
|
@@ -86,6 +93,63 @@ taskCommand
|
|
|
86
93
|
}
|
|
87
94
|
})
|
|
88
95
|
|
|
96
|
+
taskCommand
|
|
97
|
+
.command('update')
|
|
98
|
+
.description('Update a task')
|
|
99
|
+
.argument('<id>', 'Task ID')
|
|
100
|
+
.option('--title <title>', 'New title')
|
|
101
|
+
.option(
|
|
102
|
+
'--description <text>',
|
|
103
|
+
'New description, used verbatim (never read as a file path)',
|
|
104
|
+
)
|
|
105
|
+
.option(
|
|
106
|
+
'--description-file <path>',
|
|
107
|
+
'Read the new description from a file, or "-" for stdin',
|
|
108
|
+
)
|
|
109
|
+
.option('--metadata <json>', 'New metadata as a JSON string')
|
|
110
|
+
.action(async (id: string, opts) => {
|
|
111
|
+
try {
|
|
112
|
+
const variables: Record<string, unknown> = { id }
|
|
113
|
+
if (opts.title != null) variables.title = opts.title
|
|
114
|
+
if (opts.description != null || opts.descriptionFile != null) {
|
|
115
|
+
variables.description = await resolveDescription({
|
|
116
|
+
description: opts.description,
|
|
117
|
+
descriptionFile: opts.descriptionFile,
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
if (opts.metadata != null) variables.metadata = opts.metadata
|
|
121
|
+
const data = await graphql<{ updateNode: unknown }>(
|
|
122
|
+
`mutation UpdateNode($id: String!, $title: String, $description: String, $metadata: String) {
|
|
123
|
+
updateNode(id: $id, title: $title, description: $description, metadata: $metadata) {
|
|
124
|
+
id type title description status metadata createdAt updatedAt
|
|
125
|
+
}
|
|
126
|
+
}`,
|
|
127
|
+
variables,
|
|
128
|
+
)
|
|
129
|
+
output(data.updateNode)
|
|
130
|
+
} catch (error) {
|
|
131
|
+
outputError(error)
|
|
132
|
+
}
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
taskCommand
|
|
136
|
+
.command('delete')
|
|
137
|
+
.description('Delete a task')
|
|
138
|
+
.argument('<id>', 'Task ID')
|
|
139
|
+
.action(async (id: string) => {
|
|
140
|
+
try {
|
|
141
|
+
const data = await graphql<{ deleteNode: boolean }>(
|
|
142
|
+
`mutation DeleteNode($id: String!) {
|
|
143
|
+
deleteNode(id: $id)
|
|
144
|
+
}`,
|
|
145
|
+
{ id },
|
|
146
|
+
)
|
|
147
|
+
output({ deleted: data.deleteNode })
|
|
148
|
+
} catch (error) {
|
|
149
|
+
outputError(error)
|
|
150
|
+
}
|
|
151
|
+
})
|
|
152
|
+
|
|
89
153
|
taskCommand
|
|
90
154
|
.command('block')
|
|
91
155
|
.description('Mark a task as blocking another')
|
package/src/index.test.ts
CHANGED
|
@@ -39,6 +39,9 @@ vi.mock('./commands/billing.ts', () => ({
|
|
|
39
39
|
vi.mock('./commands/key.ts', () => ({
|
|
40
40
|
keyCommand: { name: () => 'key' },
|
|
41
41
|
}))
|
|
42
|
+
vi.mock('./commands/serve.ts', () => ({
|
|
43
|
+
serveCommand: { name: () => 'serve' },
|
|
44
|
+
}))
|
|
42
45
|
|
|
43
46
|
describe('index.ts command registration', () => {
|
|
44
47
|
test('registers billing and key commands', async () => {
|
|
@@ -57,4 +60,17 @@ describe('index.ts command registration', () => {
|
|
|
57
60
|
expect(indexSource).toContain('program.addCommand(billingCommand)')
|
|
58
61
|
expect(indexSource).toContain('program.addCommand(keyCommand)')
|
|
59
62
|
})
|
|
63
|
+
|
|
64
|
+
test('registers the serve command', async () => {
|
|
65
|
+
const { readFileSync } = await import('node:fs')
|
|
66
|
+
const indexSource = readFileSync(
|
|
67
|
+
new URL('./index.ts', import.meta.url).pathname,
|
|
68
|
+
'utf-8',
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
expect(indexSource).toContain(
|
|
72
|
+
"import { serveCommand } from './commands/serve.ts'",
|
|
73
|
+
)
|
|
74
|
+
expect(indexSource).toContain('program.addCommand(serveCommand)')
|
|
75
|
+
})
|
|
60
76
|
})
|
package/src/index.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { initCommand } from './commands/init.ts'
|
|
|
19
19
|
import { keyCommand } from './commands/key.ts'
|
|
20
20
|
import { projectCommand } from './commands/project.ts'
|
|
21
21
|
import { searchCommand } from './commands/search.ts'
|
|
22
|
+
import { serveCommand } from './commands/serve.ts'
|
|
22
23
|
import { setupCommand } from './commands/setup.ts'
|
|
23
24
|
import { statusCommand } from './commands/status.ts'
|
|
24
25
|
import { taskCommand } from './commands/task.ts'
|
|
@@ -32,6 +33,7 @@ const program = new Command()
|
|
|
32
33
|
|
|
33
34
|
program.addCommand(initCommand)
|
|
34
35
|
program.addCommand(setupCommand)
|
|
36
|
+
program.addCommand(serveCommand)
|
|
35
37
|
program.addCommand(clientCommand)
|
|
36
38
|
program.addCommand(projectCommand)
|
|
37
39
|
program.addCommand(featureCommand)
|
package/src/util/client.test.ts
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
|
|
2
2
|
|
|
3
|
+
function ok(json: unknown) {
|
|
4
|
+
return {
|
|
5
|
+
ok: true,
|
|
6
|
+
status: 200,
|
|
7
|
+
headers: { get: () => 'application/json' },
|
|
8
|
+
json: () => Promise.resolve(json),
|
|
9
|
+
text: () => Promise.resolve(JSON.stringify(json)),
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function httpError(status: number, body = '<html>error</html>') {
|
|
14
|
+
return {
|
|
15
|
+
ok: false,
|
|
16
|
+
status,
|
|
17
|
+
headers: { get: () => 'text/html' },
|
|
18
|
+
json: () => Promise.reject(new SyntaxError('Unexpected token <')),
|
|
19
|
+
text: () => Promise.resolve(body),
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
3
23
|
beforeEach(() => {
|
|
4
24
|
vi.doMock('./config.ts', () => ({
|
|
5
25
|
getConfig: () => ({
|
|
@@ -18,12 +38,7 @@ describe('graphql client', () => {
|
|
|
18
38
|
test('returns data on successful response', async () => {
|
|
19
39
|
vi.stubGlobal(
|
|
20
40
|
'fetch',
|
|
21
|
-
vi.fn().mockResolvedValue({
|
|
22
|
-
json: () =>
|
|
23
|
-
Promise.resolve({
|
|
24
|
-
data: { whoami: { id: '1' } },
|
|
25
|
-
}),
|
|
26
|
-
}),
|
|
41
|
+
vi.fn().mockResolvedValue(ok({ data: { whoami: { id: '1' } } })),
|
|
27
42
|
)
|
|
28
43
|
|
|
29
44
|
const { graphql } = await import('./client.ts')
|
|
@@ -34,17 +49,16 @@ describe('graphql client', () => {
|
|
|
34
49
|
test('attaches extensions.code to the thrown error', async () => {
|
|
35
50
|
vi.stubGlobal(
|
|
36
51
|
'fetch',
|
|
37
|
-
vi.fn().mockResolvedValue(
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
}),
|
|
52
|
+
vi.fn().mockResolvedValue(
|
|
53
|
+
ok({
|
|
54
|
+
errors: [
|
|
55
|
+
{
|
|
56
|
+
message: 'Search query must be at least 3 characters',
|
|
57
|
+
extensions: { code: 'VALIDATION_ERROR' },
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
}),
|
|
61
|
+
),
|
|
48
62
|
)
|
|
49
63
|
|
|
50
64
|
const { graphql } = await import('./client.ts')
|
|
@@ -59,17 +73,16 @@ describe('graphql client', () => {
|
|
|
59
73
|
test('throws original server message for unknown error codes', async () => {
|
|
60
74
|
vi.stubGlobal(
|
|
61
75
|
'fetch',
|
|
62
|
-
vi.fn().mockResolvedValue(
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
}),
|
|
76
|
+
vi.fn().mockResolvedValue(
|
|
77
|
+
ok({
|
|
78
|
+
errors: [
|
|
79
|
+
{
|
|
80
|
+
message: 'Something broke',
|
|
81
|
+
extensions: { code: 'UNKNOWN_CODE' },
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
}),
|
|
85
|
+
),
|
|
73
86
|
)
|
|
74
87
|
|
|
75
88
|
const { graphql } = await import('./client.ts')
|
|
@@ -81,12 +94,7 @@ describe('graphql client', () => {
|
|
|
81
94
|
test('throws error message when extensions are absent', async () => {
|
|
82
95
|
vi.stubGlobal(
|
|
83
96
|
'fetch',
|
|
84
|
-
vi.fn().mockResolvedValue({
|
|
85
|
-
json: () =>
|
|
86
|
-
Promise.resolve({
|
|
87
|
-
errors: [{ message: 'Auth required' }],
|
|
88
|
-
}),
|
|
89
|
-
}),
|
|
97
|
+
vi.fn().mockResolvedValue(ok({ errors: [{ message: 'Auth required' }] })),
|
|
90
98
|
)
|
|
91
99
|
|
|
92
100
|
const { graphql } = await import('./client.ts')
|
|
@@ -97,18 +105,12 @@ describe('graphql client', () => {
|
|
|
97
105
|
|
|
98
106
|
test('omits Authorization header when no apiKey configured', async () => {
|
|
99
107
|
vi.doMock('./config.ts', () => ({
|
|
100
|
-
getConfig: () => ({
|
|
101
|
-
apiUrl: 'http://test/graphql',
|
|
102
|
-
apiKey: '',
|
|
103
|
-
}),
|
|
108
|
+
getConfig: () => ({ apiUrl: 'http://test/graphql', apiKey: '' }),
|
|
104
109
|
}))
|
|
105
110
|
|
|
106
|
-
const mockFetch = vi
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
data: { whoami: { id: '1' } },
|
|
110
|
-
}),
|
|
111
|
-
})
|
|
111
|
+
const mockFetch = vi
|
|
112
|
+
.fn()
|
|
113
|
+
.mockResolvedValue(ok({ data: { whoami: { id: '1' } } }))
|
|
112
114
|
vi.stubGlobal('fetch', mockFetch)
|
|
113
115
|
|
|
114
116
|
const { graphql } = await import('./client.ts')
|
|
@@ -118,29 +120,30 @@ describe('graphql client', () => {
|
|
|
118
120
|
expect(callHeaders).not.toHaveProperty('Authorization')
|
|
119
121
|
})
|
|
120
122
|
|
|
121
|
-
test('throws on
|
|
123
|
+
test('throws a NETWORK_ERROR-coded error on fetch rejection', async () => {
|
|
122
124
|
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('fetch failed')))
|
|
123
125
|
|
|
124
126
|
const { graphql } = await import('./client.ts')
|
|
125
|
-
await expect(
|
|
126
|
-
'
|
|
127
|
-
)
|
|
127
|
+
await expect(
|
|
128
|
+
graphql('query { whoami { id } }', undefined, { retryDelayMs: 0 }),
|
|
129
|
+
).rejects.toMatchObject({
|
|
130
|
+
code: 'NETWORK_ERROR',
|
|
131
|
+
})
|
|
128
132
|
})
|
|
129
133
|
|
|
130
134
|
test('throws friendly message for SUBSCRIPTION_REQUIRED error code', async () => {
|
|
131
135
|
vi.stubGlobal(
|
|
132
136
|
'fetch',
|
|
133
|
-
vi.fn().mockResolvedValue(
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
}),
|
|
137
|
+
vi.fn().mockResolvedValue(
|
|
138
|
+
ok({
|
|
139
|
+
errors: [
|
|
140
|
+
{
|
|
141
|
+
message: 'Active subscription required.',
|
|
142
|
+
extensions: { code: 'SUBSCRIPTION_REQUIRED' },
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
}),
|
|
146
|
+
),
|
|
144
147
|
)
|
|
145
148
|
|
|
146
149
|
const { graphql } = await import('./client.ts')
|
|
@@ -152,17 +155,16 @@ describe('graphql client', () => {
|
|
|
152
155
|
test('throws friendly message for SUBSCRIPTION_EXPIRED error code', async () => {
|
|
153
156
|
vi.stubGlobal(
|
|
154
157
|
'fetch',
|
|
155
|
-
vi.fn().mockResolvedValue(
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
}),
|
|
158
|
+
vi.fn().mockResolvedValue(
|
|
159
|
+
ok({
|
|
160
|
+
errors: [
|
|
161
|
+
{
|
|
162
|
+
message: 'Subscription has expired.',
|
|
163
|
+
extensions: { code: 'SUBSCRIPTION_EXPIRED' },
|
|
164
|
+
},
|
|
165
|
+
],
|
|
166
|
+
}),
|
|
167
|
+
),
|
|
166
168
|
)
|
|
167
169
|
|
|
168
170
|
const { graphql } = await import('./client.ts')
|
|
@@ -174,17 +176,16 @@ describe('graphql client', () => {
|
|
|
174
176
|
test('throws friendly message for SUBSCRIPTION_SUSPENDED error code', async () => {
|
|
175
177
|
vi.stubGlobal(
|
|
176
178
|
'fetch',
|
|
177
|
-
vi.fn().mockResolvedValue(
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
}),
|
|
179
|
+
vi.fn().mockResolvedValue(
|
|
180
|
+
ok({
|
|
181
|
+
errors: [
|
|
182
|
+
{
|
|
183
|
+
message: 'Subscription is suspended.',
|
|
184
|
+
extensions: { code: 'SUBSCRIPTION_SUSPENDED' },
|
|
185
|
+
},
|
|
186
|
+
],
|
|
187
|
+
}),
|
|
188
|
+
),
|
|
188
189
|
)
|
|
189
190
|
|
|
190
191
|
const { graphql } = await import('./client.ts')
|
|
@@ -192,4 +193,116 @@ describe('graphql client', () => {
|
|
|
192
193
|
/suspended.*contact support/,
|
|
193
194
|
)
|
|
194
195
|
})
|
|
196
|
+
|
|
197
|
+
describe('transport hardening (F11)', () => {
|
|
198
|
+
test('throws a SERVER_ERROR-coded error on a non-retryable non-2xx (e.g. 500) without crashing on HTML body', async () => {
|
|
199
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(httpError(500)))
|
|
200
|
+
|
|
201
|
+
const { graphql } = await import('./client.ts')
|
|
202
|
+
await expect(
|
|
203
|
+
graphql('query { whoami { id } }', undefined, { retryDelayMs: 0 }),
|
|
204
|
+
).rejects.toMatchObject({ code: 'SERVER_ERROR' })
|
|
205
|
+
// Must surface a real message, not a raw SyntaxError about "<"
|
|
206
|
+
await expect(
|
|
207
|
+
graphql('query { whoami { id } }', undefined, { retryDelayMs: 0 }),
|
|
208
|
+
).rejects.toThrow(/500/)
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
test('throws SERVER_ERROR (no crash) when a 200 body is not JSON', async () => {
|
|
212
|
+
vi.stubGlobal(
|
|
213
|
+
'fetch',
|
|
214
|
+
vi.fn().mockResolvedValue({
|
|
215
|
+
ok: true,
|
|
216
|
+
status: 200,
|
|
217
|
+
headers: { get: () => 'text/html' },
|
|
218
|
+
json: () => Promise.reject(new SyntaxError('Unexpected token <')),
|
|
219
|
+
text: () => Promise.resolve('<html>proxy</html>'),
|
|
220
|
+
}),
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
const { graphql } = await import('./client.ts')
|
|
224
|
+
await expect(graphql('query { whoami { id } }')).rejects.toMatchObject({
|
|
225
|
+
code: 'SERVER_ERROR',
|
|
226
|
+
})
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
test('retries on a transient 429 then succeeds', async () => {
|
|
230
|
+
const mockFetch = vi
|
|
231
|
+
.fn()
|
|
232
|
+
.mockResolvedValueOnce(httpError(429))
|
|
233
|
+
.mockResolvedValueOnce(ok({ data: { whoami: { id: '1' } } }))
|
|
234
|
+
vi.stubGlobal('fetch', mockFetch)
|
|
235
|
+
|
|
236
|
+
const { graphql } = await import('./client.ts')
|
|
237
|
+
const result = await graphql('query { whoami { id } }', undefined, {
|
|
238
|
+
retryDelayMs: 0,
|
|
239
|
+
})
|
|
240
|
+
expect(result).toEqual({ whoami: { id: '1' } })
|
|
241
|
+
expect(mockFetch).toHaveBeenCalledTimes(2)
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
test('retries on 503 then gives up with a SERVER_ERROR-coded error', async () => {
|
|
245
|
+
const mockFetch = vi.fn().mockResolvedValue(httpError(503))
|
|
246
|
+
vi.stubGlobal('fetch', mockFetch)
|
|
247
|
+
|
|
248
|
+
const { graphql } = await import('./client.ts')
|
|
249
|
+
await expect(
|
|
250
|
+
graphql('query { whoami { id } }', undefined, {
|
|
251
|
+
retries: 2,
|
|
252
|
+
retryDelayMs: 0,
|
|
253
|
+
}),
|
|
254
|
+
).rejects.toMatchObject({ code: 'SERVER_ERROR' })
|
|
255
|
+
// initial attempt + 2 retries = 3 calls
|
|
256
|
+
expect(mockFetch).toHaveBeenCalledTimes(3)
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
test('does NOT retry on a non-transient 400', async () => {
|
|
260
|
+
const mockFetch = vi.fn().mockResolvedValue(httpError(400))
|
|
261
|
+
vi.stubGlobal('fetch', mockFetch)
|
|
262
|
+
|
|
263
|
+
const { graphql } = await import('./client.ts')
|
|
264
|
+
await expect(
|
|
265
|
+
graphql('query { whoami { id } }', undefined, { retryDelayMs: 0 }),
|
|
266
|
+
).rejects.toMatchObject({ code: 'SERVER_ERROR' })
|
|
267
|
+
expect(mockFetch).toHaveBeenCalledTimes(1)
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
test('aborts and throws NETWORK_ERROR on timeout', async () => {
|
|
271
|
+
const mockFetch = vi.fn().mockImplementation((_url, init) => {
|
|
272
|
+
return new Promise((_resolve, reject) => {
|
|
273
|
+
const signal = (init as { signal?: AbortSignal }).signal
|
|
274
|
+
signal?.addEventListener('abort', () => {
|
|
275
|
+
reject(
|
|
276
|
+
Object.assign(new Error('The operation was aborted'), {
|
|
277
|
+
name: 'AbortError',
|
|
278
|
+
}),
|
|
279
|
+
)
|
|
280
|
+
})
|
|
281
|
+
})
|
|
282
|
+
})
|
|
283
|
+
vi.stubGlobal('fetch', mockFetch)
|
|
284
|
+
|
|
285
|
+
const { graphql } = await import('./client.ts')
|
|
286
|
+
await expect(
|
|
287
|
+
graphql('query { whoami { id } }', undefined, {
|
|
288
|
+
timeoutMs: 10,
|
|
289
|
+
retries: 0,
|
|
290
|
+
retryDelayMs: 0,
|
|
291
|
+
}),
|
|
292
|
+
).rejects.toMatchObject({ code: 'NETWORK_ERROR' })
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
test('passes a timeout signal to fetch', async () => {
|
|
296
|
+
const mockFetch = vi
|
|
297
|
+
.fn()
|
|
298
|
+
.mockResolvedValue(ok({ data: { whoami: { id: '1' } } }))
|
|
299
|
+
vi.stubGlobal('fetch', mockFetch)
|
|
300
|
+
|
|
301
|
+
const { graphql } = await import('./client.ts')
|
|
302
|
+
await graphql('query { whoami { id } }')
|
|
303
|
+
|
|
304
|
+
const init = mockFetch.mock.calls[0]![1]
|
|
305
|
+
expect(init.signal).toBeInstanceOf(AbortSignal)
|
|
306
|
+
})
|
|
307
|
+
})
|
|
195
308
|
})
|