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
@@ -0,0 +1,102 @@
/* eslint-disable @typescript-eslint/consistent-type-definitions, @typescript-eslint/no-redundant-type-constituents */
import * as Types from './types.js'

import {TypedDocumentNode as DocumentNode} from '@graphql-typed-document-node/core'

export type BulkOperationCancelMutationVariables = Types.Exact<{
id: Types.Scalars['ID']['input']
}>

export type BulkOperationCancelMutation = {
bulkOperationCancel?: {
bulkOperation?: {
completedAt?: unknown | null
createdAt: unknown
errorCode?: Types.BulkOperationErrorCode | null
fileSize?: unknown | null
id: string
objectCount: unknown
partialDataUrl?: string | null
query: string
rootObjectCount: unknown
status: Types.BulkOperationStatus
type: Types.BulkOperationType
url?: string | null
} | null
userErrors: {field?: string[] | null; message: string}[]
} | null
}

export const BulkOperationCancel = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'mutation',
name: {kind: 'Name', value: 'BulkOperationCancel'},
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: {kind: 'Variable', name: {kind: 'Name', value: 'id'}},
type: {kind: 'NonNullType', type: {kind: 'NamedType', name: {kind: 'Name', value: 'ID'}}},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: {kind: 'Name', value: 'bulkOperationCancel'},
arguments: [
{
kind: 'Argument',
name: {kind: 'Name', value: 'id'},
value: {kind: 'Variable', name: {kind: 'Name', value: 'id'}},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: {kind: 'Name', value: 'bulkOperation'},
selectionSet: {
kind: 'SelectionSet',
selections: [
{kind: 'Field', name: {kind: 'Name', value: 'completedAt'}},
{kind: 'Field', name: {kind: 'Name', value: 'createdAt'}},
{kind: 'Field', name: {kind: 'Name', value: 'errorCode'}},
{kind: 'Field', name: {kind: 'Name', value: 'fileSize'}},
{kind: 'Field', name: {kind: 'Name', value: 'id'}},
{kind: 'Field', name: {kind: 'Name', value: 'objectCount'}},
{kind: 'Field', name: {kind: 'Name', value: 'partialDataUrl'}},
{kind: 'Field', name: {kind: 'Name', value: 'query'}},
{kind: 'Field', name: {kind: 'Name', value: 'rootObjectCount'}},
{kind: 'Field', name: {kind: 'Name', value: 'status'}},
{kind: 'Field', name: {kind: 'Name', value: 'type'}},
{kind: 'Field', name: {kind: 'Name', value: 'url'}},
{kind: 'Field', name: {kind: 'Name', value: '__typename'}},
],
},
},
{
kind: 'Field',
name: {kind: 'Name', value: 'userErrors'},
selectionSet: {
kind: 'SelectionSet',
selections: [
{kind: 'Field', name: {kind: 'Name', value: 'field'}},
{kind: 'Field', name: {kind: 'Name', value: 'message'}},
{kind: 'Field', name: {kind: 'Name', value: '__typename'}},
],
},
},
{kind: 'Field', name: {kind: 'Name', value: '__typename'}},
],
},
},
],
},
},
],
} as unknown as DocumentNode<BulkOperationCancelMutation, BulkOperationCancelMutationVariables>
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
mutation BulkOperationCancel($id: ID!) {
bulkOperationCancel(id: $id) {
bulkOperation {
completedAt
createdAt
errorCode
fileSize
id
objectCount
partialDataUrl
query
rootObjectCount
status
type
url
}
userErrors {
field
message
}
}
}

47 changes: 47 additions & 0 deletions packages/app/src/cli/commands/app/bulk/cancel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import {appFlags} from '../../../flags.js'
import AppLinkedCommand, {AppLinkedCommandOutput} from '../../../utilities/app-linked-command.js'
import {prepareAppStoreContext} from '../../../utilities/execute-command-helpers.js'
import {cancelBulkOperation} from '../../../services/bulk-operations/cancel-bulk-operation.js'
import {normalizeBulkOperationId} from '../../../services/bulk-operations/bulk-operation-status.js'
import {Flags} from '@oclif/core'
import {globalFlags} from '@shopify/cli-kit/node/cli'
import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn'

export default class BulkCancel extends AppLinkedCommand {
static summary = 'Cancel a bulk operation.'

static description = 'Cancels a running bulk operation by ID.'

static hidden = true

static flags = {
...globalFlags,
...appFlags,
id: Flags.string({
description: 'The bulk operation ID to cancel (numeric ID or full GID).',
env: 'SHOPIFY_FLAG_ID',
required: true,
}),
store: Flags.string({
char: 's',
description: 'The store domain. Must be an existing dev store.',
env: 'SHOPIFY_FLAG_STORE',
parse: async (input) => normalizeStoreFqdn(input),
}),
}

async run(): Promise<AppLinkedCommandOutput> {
const {flags} = await this.parse(BulkCancel)

const {appContextResult, store} = await prepareAppStoreContext(flags)

await cancelBulkOperation({
organization: appContextResult.organization,
storeFqdn: store.shopDomain,
operationId: normalizeBulkOperationId(flags.id),
remoteApp: appContextResult.remoteApp,
})

return {app: appContextResult.app}
}
}
2 changes: 2 additions & 0 deletions packages/app/src/cli/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Build from './commands/app/build.js'
import BulkCancel from './commands/app/bulk/cancel.js'
import BulkStatus from './commands/app/bulk/status.js'
import ConfigLink from './commands/app/config/link.js'
import ConfigUse from './commands/app/config/use.js'
Expand Down Expand Up @@ -39,6 +40,7 @@ import FunctionInfo from './commands/app/function/info.js'
*/
export const commands: {[key: string]: typeof AppLinkedCommand | typeof AppUnlinkedCommand} = {
'app:build': Build,
'app:bulk:cancel': BulkCancel,
'app:bulk:status': BulkStatus,
'app:deploy': Deploy,
'app:dev': Dev,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import {cancelBulkOperation} from './cancel-bulk-operation.js'
import {createAdminSessionAsApp, formatOperationInfo} from '../graphql/common.js'
import {OrganizationApp, Organization, OrganizationSource} from '../../models/organization.js'
import {describe, test, expect, vi, beforeEach, afterEach} from 'vitest'
import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output'
import {adminRequestDoc} from '@shopify/cli-kit/node/api/admin'
import {renderInfo, renderError, renderSuccess, renderWarning} from '@shopify/cli-kit/node/ui'

vi.mock('../graphql/common.js')
vi.mock('@shopify/cli-kit/node/api/admin')
vi.mock('@shopify/cli-kit/node/ui')

describe('cancelBulkOperation', () => {
const mockOrganization: Organization = {
id: 'test-org-id',
businessName: 'Test Organization',
source: OrganizationSource.BusinessPlatform,
}

const mockRemoteApp = {
apiKey: 'test-app-client-id',
apiSecretKeys: [{secret: 'test-api-secret'}],
title: 'Test App',
} as OrganizationApp

const storeFqdn = 'test-store.myshopify.com'
const operationId = 'gid://shopify/BulkOperation/123'
const mockAdminSession = {token: 'test-token', storeFqdn}

beforeEach(() => {
vi.mocked(createAdminSessionAsApp).mockResolvedValue(mockAdminSession)
vi.mocked(formatOperationInfo).mockReturnValue([
`Organization: ${mockOrganization.businessName}`,
`App: ${mockRemoteApp.title}`,
`Store: ${storeFqdn}`,
])
})

afterEach(() => {
mockAndCaptureOutput().clear()
})

test('renders initial info message with operation details', async () => {
vi.mocked(adminRequestDoc).mockResolvedValue({
bulkOperationCancel: {
bulkOperation: {
id: operationId,
status: 'CANCELING',
createdAt: '2024-01-01T00:00:00Z',
completedAt: null,
},
userErrors: [],
},
})

await cancelBulkOperation({organization: mockOrganization, storeFqdn, operationId, remoteApp: mockRemoteApp})

expect(renderInfo).toHaveBeenCalledWith(
expect.objectContaining({
headline: 'Canceling bulk operation.',
}),
)
})

test('calls adminRequestDoc with correct parameters', async () => {
vi.mocked(adminRequestDoc).mockResolvedValue({
bulkOperationCancel: {
bulkOperation: {
id: operationId,
status: 'CANCELING',
createdAt: '2024-01-01T00:00:00Z',
completedAt: null,
},
userErrors: [],
},
})

await cancelBulkOperation({organization: mockOrganization, storeFqdn, operationId, remoteApp: mockRemoteApp})

expect(adminRequestDoc).toHaveBeenCalledWith({
query: expect.any(Object),
session: mockAdminSession,
variables: {id: operationId},
version: '2026-01',
})
})

test.each([
{
status: 'CANCELING' as const,
renderer: 'renderSuccess',
headline: 'Bulk operation is being cancelled.',
},
{
status: 'CANCELED' as const,
renderer: 'renderWarning',
headline: 'Bulk operation is already canceled.',
},
{
status: 'COMPLETED' as const,
renderer: 'renderWarning',
headline: 'Bulk operation is already completed.',
},
{
status: 'RUNNING' as const,
renderer: 'renderInfo',
headline: 'Bulk operation in progress',
},
])('renders $renderer for $status status', async ({status, renderer, headline}) => {
vi.mocked(adminRequestDoc).mockResolvedValue({
bulkOperationCancel: {
bulkOperation: {
id: operationId,
status,
createdAt: '2024-01-01T00:00:00Z',
completedAt: status === 'CANCELING' || status === 'RUNNING' ? null : '2024-01-01T01:00:00Z',
},
userErrors: [],
},
})

await cancelBulkOperation({organization: mockOrganization, storeFqdn, operationId, remoteApp: mockRemoteApp})

const rendererFn = {renderSuccess, renderWarning, renderInfo}[renderer]
expect(rendererFn).toHaveBeenCalledWith(
expect.objectContaining({
headline: expect.stringContaining(headline),
}),
)
})

test('renders user errors when present', async () => {
vi.mocked(adminRequestDoc).mockResolvedValue({
bulkOperationCancel: {
bulkOperation: null,
userErrors: [{field: ['id'], message: 'Operation not found'}],
},
})

await cancelBulkOperation({organization: mockOrganization, storeFqdn, operationId, remoteApp: mockRemoteApp})

expect(renderError).toHaveBeenCalledWith({
headline: 'Bulk operation cancellation errors.',
body: 'id: Operation not found',
})
})

test('renders error when no operation is returned and no user errors', async () => {
vi.mocked(adminRequestDoc).mockResolvedValue({
bulkOperationCancel: {
bulkOperation: null,
userErrors: [],
},
})

await cancelBulkOperation({organization: mockOrganization, storeFqdn, operationId, remoteApp: mockRemoteApp})

expect(renderError).toHaveBeenCalledWith(
expect.objectContaining({
headline: 'Bulk operation not found or could not be canceled.',
}),
)
})
})
Loading
Loading