Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {executeBulkOperation} from './execute-bulk-operation.js'
import {runBulkOperationQuery} from './run-query.js'
import {runBulkOperationMutation} from './run-mutation.js'
import {watchBulkOperation} from './watch-bulk-operation.js'
import {watchBulkOperation, shortBulkOperationPoll} from './watch-bulk-operation.js'
import {downloadBulkOperationResults} from './download-bulk-operation-results.js'
import {validateApiVersion} from '../graphql/common.js'
import {BulkOperationRunQueryMutation} from '../../api/graphql/bulk-operations/generated/bulk-operation-run-query.js'
Expand Down Expand Up @@ -67,6 +67,7 @@ describe('executeBulkOperation', () => {

beforeEach(() => {
vi.mocked(ensureAuthenticatedAdminAsApp).mockResolvedValue(mockAdminSession)
vi.mocked(shortBulkOperationPoll).mockResolvedValue(createdBulkOperation)
})

afterEach(() => {
Expand Down Expand Up @@ -305,7 +306,7 @@ describe('executeBulkOperation', () => {
})
})

test('waits for operation to finish and renders success when watch is provided and operation finishes with COMPLETED status', async () => {
test('uses watchBulkOperation (not quickWatchBulkOperation) when watch flag is true', async () => {
const query = '{ products { edges { node { id } } } }'
const initialResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = {
bulkOperation: createdBulkOperation,
Expand All @@ -320,7 +321,9 @@ describe('executeBulkOperation', () => {

vi.mocked(runBulkOperationQuery).mockResolvedValue(initialResponse)
vi.mocked(watchBulkOperation).mockResolvedValue(completedOperation)
vi.mocked(downloadBulkOperationResults).mockResolvedValue('{"id":"gid://shopify/Product/123"}')
vi.mocked(downloadBulkOperationResults).mockResolvedValue(
'{"data":{"products":{"edges":[{"node":{"id":"gid://shopify/Product/123"}}],"userErrors":[]}},"__lineNumber":0}',
)

await executeBulkOperation({
organization: mockOrganization,
Expand All @@ -330,6 +333,13 @@ describe('executeBulkOperation', () => {
watch: true,
})

expect(watchBulkOperation).toHaveBeenCalledWith(
mockAdminSession,
createdBulkOperation.id,
expect.any(Object),
expect.any(Function),
)
expect(shortBulkOperationPoll).not.toHaveBeenCalled()
expect(renderSuccess).toHaveBeenCalledWith(
expect.objectContaining({
headline: expect.stringContaining('Bulk operation succeeded:'),
Expand Down Expand Up @@ -370,10 +380,64 @@ describe('executeBulkOperation', () => {
expect(downloadBulkOperationResults).not.toHaveBeenCalled()
})

test('uses quickWatchBulkOperation (not watchBulkOperation) when watch flag is false', async () => {
const query = '{ products { edges { node { id } } } }'
const mockResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = {
bulkOperation: createdBulkOperation,
userErrors: [],
}

vi.mocked(runBulkOperationQuery).mockResolvedValue(mockResponse)
vi.mocked(shortBulkOperationPoll).mockResolvedValue(createdBulkOperation)

await executeBulkOperation({
organization: mockOrganization,
remoteApp: mockRemoteApp,
storeFqdn,
query,
watch: false,
})

expect(shortBulkOperationPoll).toHaveBeenCalledWith(mockAdminSession, createdBulkOperation.id)
expect(watchBulkOperation).not.toHaveBeenCalled()
})

test('renders info message when quickWatchBulkOperation returns RUNNING status', async () => {
const query = '{ products { edges { node { id } } } }'
const runningOperation = {
...createdBulkOperation,
status: 'RUNNING' as const,
objectCount: '50',
}
const mockResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = {
bulkOperation: createdBulkOperation,
userErrors: [],
}

vi.mocked(runBulkOperationQuery).mockResolvedValue(mockResponse)
vi.mocked(shortBulkOperationPoll).mockResolvedValue(runningOperation)

await executeBulkOperation({
organization: mockOrganization,
remoteApp: mockRemoteApp,
storeFqdn,
query,
watch: false,
})

expect(renderSuccess).toHaveBeenCalledWith(
expect.objectContaining({
headline: 'Bulk operation is running.',
body: ['Monitor its progress with:\n', {command: expect.stringContaining('shopify app bulk status')}],
}),
)
})

test('writes results to file when --output-file flag is provided', async () => {
const query = '{ products { edges { node { id } } } }'
const outputFile = '/tmp/results.jsonl'
const resultsContent = '{"id":"gid://shopify/Product/123"}\n{"id":"gid://shopify/Product/456"}'
const resultsContent =
'{"data":{"productCreate":{"product":{"id":"gid://shopify/Product/123"},"userErrors":[]}},"__lineNumber":0}\n{"data":{"productCreate":{"product":{"id":"gid://shopify/Product/456"},"userErrors":[]}},"__lineNumber":1}'

const initialResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = {
bulkOperation: createdBulkOperation,
Expand Down Expand Up @@ -404,7 +468,8 @@ describe('executeBulkOperation', () => {

test('writes results to stdout when --output-file flag is not provided', async () => {
const query = '{ products { edges { node { id } } } }'
const resultsContent = '{"id":"gid://shopify/Product/123"}\n{"id":"gid://shopify/Product/456"}'
const resultsContent =
'{"data":{"productCreate":{"product":{"id":"gid://shopify/Product/123"},"userErrors":[]}},"__lineNumber":0}\n{"data":{"productCreate":{"product":{"id":"gid://shopify/Product/456"},"userErrors":[]}},"__lineNumber":1}'

const initialResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = {
bulkOperation: createdBulkOperation,
Expand Down Expand Up @@ -537,4 +602,113 @@ describe('executeBulkOperation', () => {

expect(validateApiVersion).not.toHaveBeenCalled()
})

test('renders warning when completed operation results contain userErrors', async () => {
const query = '{ products { edges { node { id } } } }'
const resultsWithErrors = '{"data":{"productUpdate":{"userErrors":[{"message":"invalid input"}]}},"__lineNumber":0}'

const initialResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = {
bulkOperation: createdBulkOperation,
userErrors: [],
}
const completedOperation = {
...createdBulkOperation,
status: 'COMPLETED' as const,
url: 'https://example.com/download',
objectCount: '1',
}

vi.mocked(runBulkOperationQuery).mockResolvedValue(initialResponse)
vi.mocked(watchBulkOperation).mockResolvedValue(completedOperation)
vi.mocked(downloadBulkOperationResults).mockResolvedValue(resultsWithErrors)

await executeBulkOperation({
organization: mockOrganization,
remoteApp: mockRemoteApp,
storeFqdn,
query,
watch: true,
})

expect(renderWarning).toHaveBeenCalledWith(
expect.objectContaining({
headline: 'Bulk operation completed with errors.',
body: 'Check results for error details.',
}),
)
expect(renderSuccess).not.toHaveBeenCalled()
})

test('renders success when completed operation results have no userErrors', async () => {
const query = '{ products { edges { node { id } } } }'
const resultsWithoutErrors = '{"data":{"productUpdate":{"product":{"id":"123"},"userErrors":[]}},"__lineNumber":0}'

const initialResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = {
bulkOperation: createdBulkOperation,
userErrors: [],
}
const completedOperation = {
...createdBulkOperation,
status: 'COMPLETED' as const,
url: 'https://example.com/download',
objectCount: '1',
}

vi.mocked(runBulkOperationQuery).mockResolvedValue(initialResponse)
vi.mocked(watchBulkOperation).mockResolvedValue(completedOperation)
vi.mocked(downloadBulkOperationResults).mockResolvedValue(resultsWithoutErrors)

await executeBulkOperation({
organization: mockOrganization,
remoteApp: mockRemoteApp,
storeFqdn,
query,
watch: true,
})

expect(renderSuccess).toHaveBeenCalledWith(
expect.objectContaining({
headline: expect.stringContaining('Bulk operation succeeded'),
}),
)
expect(renderWarning).not.toHaveBeenCalled()
})

test('renders warning when results written to file contain userErrors', async () => {
const query = '{ products { edges { node { id } } } }'
const outputFile = '/tmp/results.jsonl'
const resultsWithErrors = '{"data":{"productUpdate":{"userErrors":[{"message":"invalid input"}]}},"__lineNumber":0}'

const initialResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = {
bulkOperation: createdBulkOperation,
userErrors: [],
}
const completedOperation = {
...createdBulkOperation,
status: 'COMPLETED' as const,
url: 'https://example.com/download',
objectCount: '1',
}

vi.mocked(runBulkOperationQuery).mockResolvedValue(initialResponse)
vi.mocked(watchBulkOperation).mockResolvedValue(completedOperation)
vi.mocked(downloadBulkOperationResults).mockResolvedValue(resultsWithErrors)

await executeBulkOperation({
organization: mockOrganization,
remoteApp: mockRemoteApp,
storeFqdn,
query,
watch: true,
outputFile,
})

expect(writeFile).toHaveBeenCalledWith(outputFile, resultsWithErrors)
expect(renderWarning).toHaveBeenCalledWith(
expect.objectContaining({
headline: 'Bulk operation completed with errors.',
body: `Results written to ${outputFile}. Check file for error details.`,
}),
)
})
})
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {runBulkOperationQuery} from './run-query.js'
import {runBulkOperationMutation} from './run-mutation.js'
import {watchBulkOperation, type BulkOperation} from './watch-bulk-operation.js'
import {watchBulkOperation, shortBulkOperationPoll, type BulkOperation} from './watch-bulk-operation.js'
import {formatBulkOperationStatus} from './format-bulk-operation-status.js'
import {downloadBulkOperationResults} from './download-bulk-operation-results.js'
import {extractBulkOperationId} from './bulk-operation-status.js'
Expand Down Expand Up @@ -104,7 +104,8 @@ export async function executeBulkOperation(input: ExecuteBulkOperationInput): Pr
await renderBulkOperationResult(operation, outputFile)
}
} else {
await renderBulkOperationResult(createdOperation, outputFile)
const operation = await shortBulkOperationPoll(adminSession, createdOperation.id)
await renderBulkOperationResult(operation, outputFile)
}
} else {
renderWarning({
Expand Down Expand Up @@ -136,17 +137,39 @@ async function renderBulkOperationResult(operation: BulkOperation, outputFile?:
customSections,
})
break
case 'RUNNING':
renderSuccess({
headline: 'Bulk operation is running.',
body: statusCommandHelpMessage(operation.id),
customSections,
})
break
case 'COMPLETED':
if (operation.url) {
const results = await downloadBulkOperationResults(operation.url)
const hasUserErrors = resultsContainUserErrors(results)

if (outputFile) {
await writeFile(outputFile, results)
renderSuccess({headline, body: [`Results written to ${outputFile}`], customSections})
} else {
renderSuccess({headline, customSections})
outputResult(results)
}

if (hasUserErrors) {
renderWarning({
headline: 'Bulk operation completed with errors.',
body: outputFile
? `Results written to ${outputFile}. Check file for error details.`
: 'Check results for error details.',
customSections,
})
} else {
renderSuccess({
headline,
body: outputFile ? [`Results written to ${outputFile}`] : undefined,
customSections,
})
}
} else {
renderSuccess({headline, customSections})
}
Expand All @@ -157,6 +180,17 @@ async function renderBulkOperationResult(operation: BulkOperation, outputFile?:
}
}

function resultsContainUserErrors(results: string): boolean {
const lines = results.trim().split('\n')

return lines.some((line) => {
const parsed = JSON.parse(line)
if (!parsed.data) return false
const result = Object.values(parsed.data)[0] as {userErrors?: unknown[]} | undefined
return result?.userErrors !== undefined && result.userErrors.length > 0
})
}

function validateGraphQLDocument(graphqlOperation: string, variablesJsonl?: string): void {
validateSingleOperation(graphqlOperation)

Expand Down
Loading
Loading