Skip to content

Commit e622b68

Browse files
committed
add and refactor tests
1 parent 25dfd57 commit e622b68

File tree

6 files changed

+486
-53
lines changed

6 files changed

+486
-53
lines changed

packages/app/src/cli/services/bulk-operations/execute-bulk-operation.test.ts

Lines changed: 1 addition & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -190,57 +190,6 @@ describe('executeBulkOperation', () => {
190190
expect(renderSuccess).not.toHaveBeenCalled()
191191
})
192192

193-
test('throws GraphQL syntax error when given malformed GraphQL document', async () => {
194-
const malformedQuery = '{ products { edges { node { id } }'
195-
196-
await expect(
197-
executeBulkOperation({
198-
remoteApp: mockRemoteApp,
199-
storeFqdn,
200-
query: malformedQuery,
201-
}),
202-
).rejects.toThrow('Syntax Error')
203-
204-
expect(runBulkOperationQuery).not.toHaveBeenCalled()
205-
expect(runBulkOperationMutation).not.toHaveBeenCalled()
206-
})
207-
208-
test('throws error when GraphQL document contains multiple operation definitions', async () => {
209-
const multipleOperations =
210-
'mutation { productUpdate(input: {}) { product { id } } } mutation { productDelete(input: {}) { deletedProductId } }'
211-
212-
await expect(
213-
executeBulkOperation({
214-
remoteApp: mockRemoteApp,
215-
storeFqdn,
216-
query: multipleOperations,
217-
}),
218-
).rejects.toThrow('Multiple operations are not supported')
219-
220-
expect(runBulkOperationQuery).not.toHaveBeenCalled()
221-
expect(runBulkOperationMutation).not.toHaveBeenCalled()
222-
})
223-
224-
test('throws error when GraphQL document contains no operation definitions', async () => {
225-
const noOperations = `
226-
fragment ProductFields on Product {
227-
id
228-
title
229-
}
230-
`
231-
232-
await expect(
233-
executeBulkOperation({
234-
remoteApp: mockRemoteApp,
235-
storeFqdn,
236-
query: noOperations,
237-
}),
238-
).rejects.toThrow('must contain exactly one operation definition')
239-
240-
expect(runBulkOperationQuery).not.toHaveBeenCalled()
241-
expect(runBulkOperationMutation).not.toHaveBeenCalled()
242-
})
243-
244193
test('reads variables from file when variableFile is provided', async () => {
245194
await inTemporaryDirectory(async (tmpDir) => {
246195
const variableFilePath = joinPath(tmpDir, 'variables.jsonl')
@@ -476,7 +425,7 @@ describe('executeBulkOperation', () => {
476425
).rejects.toThrow('Bulk operation response returned null with no error message.')
477426

478427
expect(renderWarning).toHaveBeenCalledWith({
479-
headline: 'Bulk operation not created succesfully.',
428+
headline: 'Bulk operation not created successfully.',
480429
body: 'This is an unexpected error. Please try again later.',
481430
})
482431

packages/app/src/cli/services/bulk-operations/execute-bulk-operation.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ export async function executeBulkOperation(input: ExecuteBulkOperationInput): Pr
8080
}
8181
} else {
8282
renderWarning({
83-
headline: 'Bulk operation not created succesfully.',
83+
headline: 'Bulk operation not created successfully.',
8484
body: 'This is an unexpected error. Please try again later.',
8585
})
8686
throw new BugError('Bulk operation response returned null with no error message.')
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
import {executeOperation} from './execute-operation.js'
2+
import {createAdminSessionAsApp} from './graphql/common.js'
3+
import {OrganizationApp} from '../models/organization.js'
4+
import {renderSuccess, renderError} from '@shopify/cli-kit/node/ui'
5+
import {adminRequestDoc} from '@shopify/cli-kit/node/api/admin'
6+
import {inTemporaryDirectory, writeFile} from '@shopify/cli-kit/node/fs'
7+
import {joinPath} from '@shopify/cli-kit/node/path'
8+
import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output'
9+
import {describe, test, expect, vi, beforeEach, afterEach} from 'vitest'
10+
11+
vi.mock('./graphql/common.js')
12+
vi.mock('@shopify/cli-kit/node/ui')
13+
vi.mock('@shopify/cli-kit/node/api/admin')
14+
vi.mock('@shopify/cli-kit/node/fs')
15+
16+
describe('executeOperation', () => {
17+
const mockRemoteApp = {
18+
apiKey: 'test-app-client-id',
19+
apiSecretKeys: [{secret: 'test-api-secret'}],
20+
title: 'Test App',
21+
} as OrganizationApp
22+
23+
const storeFqdn = 'test-store.myshopify.com'
24+
const mockAdminSession = {token: 'test-token', storeFqdn}
25+
26+
beforeEach(() => {
27+
vi.mocked(createAdminSessionAsApp).mockResolvedValue(mockAdminSession)
28+
})
29+
30+
afterEach(() => {
31+
mockAndCaptureOutput().clear()
32+
})
33+
34+
test('executes GraphQL operation successfully', async () => {
35+
const query = 'query { shop { name } }'
36+
const mockResult = {data: {shop: {name: 'Test Shop'}}}
37+
vi.mocked(adminRequestDoc).mockResolvedValue(mockResult)
38+
39+
await executeOperation({
40+
remoteApp: mockRemoteApp,
41+
storeFqdn,
42+
query,
43+
})
44+
45+
expect(createAdminSessionAsApp).toHaveBeenCalledWith(mockRemoteApp, storeFqdn)
46+
expect(adminRequestDoc).toHaveBeenCalledWith({
47+
// parsed GraphQL document
48+
query: expect.any(Object),
49+
session: mockAdminSession,
50+
variables: undefined,
51+
version: undefined,
52+
responseOptions: {handleErrors: false},
53+
})
54+
})
55+
56+
test('passes variables correctly when provided', async () => {
57+
const query = 'mutation UpdateProduct($input: ProductInput!) { productUpdate(input: $input) { product { id } } }'
58+
const variables = '{"input":{"id":"gid://shopify/Product/123","title":"Updated"}}'
59+
const mockResult = {data: {productUpdate: {product: {id: 'gid://shopify/Product/123'}}}}
60+
vi.mocked(adminRequestDoc).mockResolvedValue(mockResult)
61+
62+
await executeOperation({
63+
remoteApp: mockRemoteApp,
64+
storeFqdn,
65+
query,
66+
variables,
67+
})
68+
69+
expect(adminRequestDoc).toHaveBeenCalledWith(
70+
expect.objectContaining({
71+
variables: JSON.parse(variables),
72+
}),
73+
)
74+
})
75+
76+
test('throws AbortError when variables contain invalid JSON', async () => {
77+
const query = 'query { shop { name } }'
78+
const invalidVariables = '{invalid json}'
79+
80+
await expect(
81+
executeOperation({
82+
remoteApp: mockRemoteApp,
83+
storeFqdn,
84+
query,
85+
variables: invalidVariables,
86+
}),
87+
).rejects.toThrow('Invalid JSON')
88+
89+
expect(adminRequestDoc).not.toHaveBeenCalled()
90+
})
91+
92+
test('uses specified API version when provided', async () => {
93+
const query = 'query { shop { name } }'
94+
const apiVersion = '2024-01'
95+
const mockResult = {data: {shop: {name: 'Test Shop'}}}
96+
vi.mocked(adminRequestDoc).mockResolvedValue(mockResult)
97+
98+
await executeOperation({
99+
remoteApp: mockRemoteApp,
100+
storeFqdn,
101+
query,
102+
apiVersion,
103+
})
104+
105+
expect(adminRequestDoc).toHaveBeenCalledWith(
106+
expect.objectContaining({
107+
version: apiVersion,
108+
}),
109+
)
110+
})
111+
112+
test('writes formatted JSON results to stdout by default', async () => {
113+
const query = 'query { shop { name } }'
114+
const mockResult = {data: {shop: {name: 'Test Shop'}}}
115+
vi.mocked(adminRequestDoc).mockResolvedValue(mockResult)
116+
117+
const mockOutput = mockAndCaptureOutput()
118+
119+
await executeOperation({
120+
remoteApp: mockRemoteApp,
121+
storeFqdn,
122+
query,
123+
})
124+
125+
const expectedOutput = JSON.stringify(mockResult, null, 2)
126+
expect(mockOutput.info()).toContain(expectedOutput)
127+
expect(writeFile).not.toHaveBeenCalled()
128+
})
129+
130+
test('writes results to file when outputFile is provided', async () => {
131+
await inTemporaryDirectory(async (tmpDir) => {
132+
const outputFile = joinPath(tmpDir, 'results.json')
133+
const query = 'query { shop { name } }'
134+
const mockResult = {data: {shop: {name: 'Test Shop'}}}
135+
vi.mocked(adminRequestDoc).mockResolvedValue(mockResult)
136+
137+
await executeOperation({
138+
remoteApp: mockRemoteApp,
139+
storeFqdn,
140+
query,
141+
outputFile,
142+
})
143+
144+
const expectedContent = JSON.stringify(mockResult, null, 2)
145+
expect(writeFile).toHaveBeenCalledWith(outputFile, expectedContent)
146+
expect(renderSuccess).toHaveBeenCalledWith(
147+
expect.objectContaining({
148+
body: expect.stringContaining(outputFile),
149+
}),
150+
)
151+
})
152+
})
153+
154+
test('renders success message after successful execution', async () => {
155+
const query = 'query { shop { name } }'
156+
const mockResult = {data: {shop: {name: 'Test Shop'}}}
157+
vi.mocked(adminRequestDoc).mockResolvedValue(mockResult)
158+
159+
await executeOperation({
160+
remoteApp: mockRemoteApp,
161+
storeFqdn,
162+
query,
163+
})
164+
165+
expect(renderSuccess).toHaveBeenCalledWith(
166+
expect.objectContaining({
167+
headline: 'Operation completed successfully.',
168+
}),
169+
)
170+
})
171+
172+
test('renders error and rethrows when API request fails', async () => {
173+
const query = 'query { shop { name } }'
174+
const apiError = new Error('API request failed')
175+
vi.mocked(adminRequestDoc).mockRejectedValue(apiError)
176+
177+
await expect(
178+
executeOperation({
179+
remoteApp: mockRemoteApp,
180+
storeFqdn,
181+
query,
182+
}),
183+
).rejects.toThrow('API request failed')
184+
185+
expect(renderError).toHaveBeenCalledWith(
186+
expect.objectContaining({
187+
headline: 'Operation failed.',
188+
body: 'API request failed',
189+
}),
190+
)
191+
})
192+
193+
test('handles GraphQL errors in response', async () => {
194+
const query = 'query { shop { name } }'
195+
const mockResult = {
196+
data: null,
197+
errors: [{message: 'Field "name" not found'}],
198+
}
199+
vi.mocked(adminRequestDoc).mockResolvedValue(mockResult)
200+
201+
await executeOperation({
202+
remoteApp: mockRemoteApp,
203+
storeFqdn,
204+
query,
205+
})
206+
207+
// Should still format and output the result with errors
208+
const mockOutput = mockAndCaptureOutput()
209+
const expectedOutput = JSON.stringify(mockResult, null, 2)
210+
expect(mockOutput.info()).toContain(expectedOutput)
211+
})
212+
})
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import {createAdminSessionAsApp, validateSingleOperation} from './common.js'
2+
import {OrganizationApp} from '../../models/organization.js'
3+
import {ensureAuthenticatedAdminAsApp} from '@shopify/cli-kit/node/session'
4+
import {describe, test, expect, vi, beforeEach} from 'vitest'
5+
6+
vi.mock('@shopify/cli-kit/node/session', async () => {
7+
const actual = await vi.importActual('@shopify/cli-kit/node/session')
8+
return {
9+
...actual,
10+
ensureAuthenticatedAdminAsApp: vi.fn(),
11+
}
12+
})
13+
14+
describe('createAdminSessionAsApp', () => {
15+
const mockRemoteApp = {
16+
apiKey: 'test-api-key',
17+
apiSecretKeys: [{secret: 'test-api-secret'}],
18+
title: 'Test App',
19+
} as OrganizationApp
20+
21+
const storeFqdn = 'test-store.myshopify.com'
22+
const mockAdminSession = {token: 'test-token', storeFqdn}
23+
24+
beforeEach(() => {
25+
vi.mocked(ensureAuthenticatedAdminAsApp).mockResolvedValue(mockAdminSession)
26+
})
27+
28+
test('creates admin session with app credentials', async () => {
29+
const session = await createAdminSessionAsApp(mockRemoteApp, storeFqdn)
30+
31+
expect(ensureAuthenticatedAdminAsApp).toHaveBeenCalledWith(
32+
storeFqdn,
33+
mockRemoteApp.apiKey,
34+
mockRemoteApp.apiSecretKeys[0]!.secret,
35+
)
36+
expect(session).toEqual(mockAdminSession)
37+
})
38+
39+
test('throws BugError when app has no API secret keys', async () => {
40+
const appWithoutSecret = {
41+
...mockRemoteApp,
42+
apiSecretKeys: [],
43+
} as OrganizationApp
44+
45+
await expect(createAdminSessionAsApp(appWithoutSecret, storeFqdn)).rejects.toThrow(
46+
'No API secret keys found for app',
47+
)
48+
49+
expect(ensureAuthenticatedAdminAsApp).not.toHaveBeenCalled()
50+
})
51+
})
52+
53+
describe('validateSingleOperation', () => {
54+
test('accepts valid query operation', () => {
55+
const query = 'query { shop { name } }'
56+
57+
expect(() => validateSingleOperation(query)).not.toThrow()
58+
})
59+
60+
test('accepts valid mutation operation', () => {
61+
const mutation = 'mutation { productUpdate(input: {}) { product { id } } }'
62+
63+
expect(() => validateSingleOperation(mutation)).not.toThrow()
64+
})
65+
66+
test('accepts query with shorthand syntax', () => {
67+
const query = '{ shop { name } }'
68+
69+
expect(() => validateSingleOperation(query)).not.toThrow()
70+
})
71+
72+
test('throws on malformed GraphQL syntax', () => {
73+
const malformedQuery = '{ shop { name }'
74+
75+
expect(() => validateSingleOperation(malformedQuery)).toThrow('Syntax Error')
76+
})
77+
78+
test('throws when GraphQL document contains multiple operations', () => {
79+
// eslint-disable-next-line @shopify/cli/no-inline-graphql
80+
const multipleOperations = `
81+
query GetShop { shop { name } }
82+
mutation UpdateProduct { productUpdate(input: {}) { product { id } } }
83+
`
84+
85+
expect(() => validateSingleOperation(multipleOperations)).toThrow('must contain exactly one operation definition')
86+
})
87+
88+
test('throws when GraphQL document contains no operations', () => {
89+
const fragmentOnly = `
90+
fragment ProductFields on Product {
91+
id
92+
title
93+
}
94+
`
95+
96+
expect(() => validateSingleOperation(fragmentOnly)).toThrow('must contain exactly one operation definition')
97+
})
98+
})

0 commit comments

Comments
 (0)