diff --git a/packages/app/src/cli/services/bulk-operations/bulk-operation-status.test.ts b/packages/app/src/cli/services/bulk-operations/bulk-operation-status.test.ts index fb5bb6f364..3b5900b809 100644 --- a/packages/app/src/cli/services/bulk-operations/bulk-operation-status.test.ts +++ b/packages/app/src/cli/services/bulk-operations/bulk-operation-status.test.ts @@ -4,9 +4,11 @@ import { normalizeBulkOperationId, extractBulkOperationId, } from './bulk-operation-status.js' +import {BULK_OPERATIONS_MIN_API_VERSION} from './constants.js' import {GetBulkOperationByIdQuery} from '../../api/graphql/bulk-operations/generated/get-bulk-operation-by-id.js' import {OrganizationApp, Organization, OrganizationSource} from '../../models/organization.js' import {ListBulkOperationsQuery} from '../../api/graphql/bulk-operations/generated/list-bulk-operations.js' +import {resolveApiVersion} from '../graphql/common.js' import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' import {ensureAuthenticatedAdminAsApp} from '@shopify/cli-kit/node/session' import {adminRequestDoc} from '@shopify/cli-kit/node/api/admin' @@ -14,6 +16,13 @@ import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' vi.mock('@shopify/cli-kit/node/session') vi.mock('@shopify/cli-kit/node/api/admin') +vi.mock('../graphql/common.js', async () => { + const actual = await vi.importActual('../graphql/common.js') + return { + ...actual, + resolveApiVersion: vi.fn(), + } +}) const storeFqdn = 'test-store.myshopify.com' const operationId = 'gid://shopify/BulkOperation/123' @@ -35,6 +44,7 @@ const remoteApp = { beforeEach(() => { vi.mocked(ensureAuthenticatedAdminAsApp).mockResolvedValue({token: 'test-token', storeFqdn}) + vi.mocked(resolveApiVersion).mockResolvedValue(BULK_OPERATIONS_MIN_API_VERSION) }) afterEach(() => { @@ -169,6 +179,30 @@ describe('getBulkOperationStatus', () => { expect(output.info()).toContain('Bulk operation canceled.') }) + test('calls resolveApiVersion with minimum API version constant', async () => { + vi.mocked(adminRequestDoc).mockResolvedValue(mockBulkOperation({status: 'RUNNING'})) + + await getBulkOperationStatus({organization: mockOrganization, storeFqdn, operationId, remoteApp}) + + expect(resolveApiVersion).toHaveBeenCalledWith({ + adminSession: {token: 'test-token', storeFqdn}, + minimumDefaultVersion: BULK_OPERATIONS_MIN_API_VERSION, + }) + }) + + test('uses resolved API version in admin request', async () => { + vi.mocked(resolveApiVersion).mockResolvedValue('test-api-version') + vi.mocked(adminRequestDoc).mockResolvedValue(mockBulkOperation({status: 'RUNNING'})) + + await getBulkOperationStatus({organization: mockOrganization, storeFqdn, operationId, remoteApp}) + + expect(adminRequestDoc).toHaveBeenCalledWith( + expect.objectContaining({ + version: 'test-api-version', + }), + ) + }) + describe('time formatting', () => { test('uses "Started" for running operations', async () => { vi.mocked(adminRequestDoc).mockResolvedValue(mockBulkOperation({status: 'RUNNING'})) @@ -328,4 +362,28 @@ describe('listBulkOperations', () => { expect(output.info()).toContain('Listing bulk operations.') expect(output.info()).toContain('No bulk operations found in the last 7 days.') }) + + test('calls resolveApiVersion with minimum API version constant', async () => { + vi.mocked(adminRequestDoc).mockResolvedValue(mockBulkOperationsList([])) + + await listBulkOperations({organization: mockOrganization, storeFqdn, remoteApp}) + + expect(resolveApiVersion).toHaveBeenCalledWith({ + adminSession: {token: 'test-token', storeFqdn}, + minimumDefaultVersion: BULK_OPERATIONS_MIN_API_VERSION, + }) + }) + + test('uses resolved API version in admin request', async () => { + vi.mocked(resolveApiVersion).mockResolvedValue('test-api-version') + vi.mocked(adminRequestDoc).mockResolvedValue(mockBulkOperationsList([])) + + await listBulkOperations({organization: mockOrganization, storeFqdn, remoteApp}) + + expect(adminRequestDoc).toHaveBeenCalledWith( + expect.objectContaining({ + version: 'test-api-version', + }), + ) + }) }) diff --git a/packages/app/src/cli/services/bulk-operations/bulk-operation-status.ts b/packages/app/src/cli/services/bulk-operations/bulk-operation-status.ts index 1dca3cabc1..54b8c8aa3f 100644 --- a/packages/app/src/cli/services/bulk-operations/bulk-operation-status.ts +++ b/packages/app/src/cli/services/bulk-operations/bulk-operation-status.ts @@ -1,10 +1,11 @@ import {BulkOperation} from './watch-bulk-operation.js' import {formatBulkOperationStatus} from './format-bulk-operation-status.js' +import {BULK_OPERATIONS_MIN_API_VERSION} from './constants.js' import { GetBulkOperationById, GetBulkOperationByIdQuery, } from '../../api/graphql/bulk-operations/generated/get-bulk-operation-by-id.js' -import {formatOperationInfo} from '../graphql/common.js' +import {formatOperationInfo, resolveApiVersion} from '../graphql/common.js' import {OrganizationApp, Organization} from '../../models/organization.js' import { ListBulkOperations, @@ -19,8 +20,6 @@ import {timeAgo, formatDate} from '@shopify/cli-kit/common/string' import {BugError} from '@shopify/cli-kit/node/error' import colors from '@shopify/cli-kit/node/colors' -const API_VERSION = '2026-01' - export function normalizeBulkOperationId(id: string): string { // If already a GID, return as-is if (id.startsWith('gid://')) { @@ -63,7 +62,7 @@ export async function getBulkOperationStatus(options: GetBulkOperationStatusOpti body: [ { list: { - items: formatOperationInfo({organization, remoteApp, storeFqdn, showVersion: false}), + items: formatOperationInfo({organization, remoteApp, storeFqdn}), }, }, ], @@ -78,7 +77,10 @@ export async function getBulkOperationStatus(options: GetBulkOperationStatusOpti query: GetBulkOperationById, session: adminSession, variables: {id: operationId}, - version: API_VERSION, + version: await resolveApiVersion({ + adminSession, + minimumDefaultVersion: BULK_OPERATIONS_MIN_API_VERSION, + }), }) if (response.bulkOperation) { @@ -99,7 +101,7 @@ export async function listBulkOperations(options: ListBulkOperationsOptions): Pr body: [ { list: { - items: formatOperationInfo({organization, remoteApp, storeFqdn, showVersion: false}), + items: formatOperationInfo({organization, remoteApp, storeFqdn}), }, }, ], @@ -121,7 +123,10 @@ export async function listBulkOperations(options: ListBulkOperationsOptions): Pr sortKey: 'CREATED_AT', reverse: true, }, - version: API_VERSION, + version: await resolveApiVersion({ + adminSession, + minimumDefaultVersion: BULK_OPERATIONS_MIN_API_VERSION, + }), }) const operations = response.bulkOperations.nodes.map((operation) => ({ diff --git a/packages/app/src/cli/services/bulk-operations/constants.ts b/packages/app/src/cli/services/bulk-operations/constants.ts new file mode 100644 index 0000000000..3a1bcc32a7 --- /dev/null +++ b/packages/app/src/cli/services/bulk-operations/constants.ts @@ -0,0 +1,5 @@ +/** + * Minimum API version for bulk operations. + * This ensures bulk operation features work correctly across all operations. + */ +export const BULK_OPERATIONS_MIN_API_VERSION = '2026-01' diff --git a/packages/app/src/cli/services/bulk-operations/execute-bulk-operation.test.ts b/packages/app/src/cli/services/bulk-operations/execute-bulk-operation.test.ts index f4ac7eb5a6..8d450a17d2 100644 --- a/packages/app/src/cli/services/bulk-operations/execute-bulk-operation.test.ts +++ b/packages/app/src/cli/services/bulk-operations/execute-bulk-operation.test.ts @@ -3,7 +3,8 @@ import {runBulkOperationQuery} from './run-query.js' import {runBulkOperationMutation} from './run-mutation.js' import {watchBulkOperation, shortBulkOperationPoll} from './watch-bulk-operation.js' import {downloadBulkOperationResults} from './download-bulk-operation-results.js' -import {validateApiVersion} from '../graphql/common.js' +import {BULK_OPERATIONS_MIN_API_VERSION} from './constants.js' +import {resolveApiVersion} from '../graphql/common.js' import {BulkOperationRunQueryMutation} from '../../api/graphql/bulk-operations/generated/bulk-operation-run-query.js' import {BulkOperationRunMutationMutation} from '../../api/graphql/bulk-operations/generated/bulk-operation-run-mutation.js' import {OrganizationApp, OrganizationSource} from '../../models/organization.js' @@ -22,7 +23,7 @@ vi.mock('../graphql/common.js', async () => { const actual = await vi.importActual('../graphql/common.js') return { ...actual, - validateApiVersion: vi.fn(), + resolveApiVersion: vi.fn(), } }) vi.mock('@shopify/cli-kit/node/ui') @@ -68,6 +69,7 @@ describe('executeBulkOperation', () => { beforeEach(() => { vi.mocked(ensureAuthenticatedAdminAsApp).mockResolvedValue(mockAdminSession) vi.mocked(shortBulkOperationPoll).mockResolvedValue(createdBulkOperation) + vi.mocked(resolveApiVersion).mockResolvedValue(BULK_OPERATIONS_MIN_API_VERSION) }) afterEach(() => { @@ -92,6 +94,7 @@ describe('executeBulkOperation', () => { expect(runBulkOperationQuery).toHaveBeenCalledWith({ adminSession: mockAdminSession, query, + version: BULK_OPERATIONS_MIN_API_VERSION, }) expect(runBulkOperationMutation).not.toHaveBeenCalled() }) @@ -114,6 +117,7 @@ describe('executeBulkOperation', () => { expect(runBulkOperationQuery).toHaveBeenCalledWith({ adminSession: mockAdminSession, query, + version: BULK_OPERATIONS_MIN_API_VERSION, }) expect(runBulkOperationMutation).not.toHaveBeenCalled() }) @@ -137,6 +141,7 @@ describe('executeBulkOperation', () => { adminSession: mockAdminSession, query: mutation, variablesJsonl: undefined, + version: BULK_OPERATIONS_MIN_API_VERSION, }) expect(runBulkOperationQuery).not.toHaveBeenCalled() }) @@ -162,6 +167,7 @@ describe('executeBulkOperation', () => { adminSession: mockAdminSession, query: mutation, variablesJsonl: '{"input":{"id":"gid://shopify/Product/123","tags":["test"]}}', + version: BULK_OPERATIONS_MIN_API_VERSION, }) }) @@ -204,9 +210,13 @@ describe('executeBulkOperation', () => { query, }) - expect(renderWarning).toHaveBeenCalledWith({ - headline: 'Bulk operation errors.', - body: 'query: Invalid query syntax\nunknown: Another error', + expect(renderError).toHaveBeenCalledWith({ + headline: 'Error creating bulk operation.', + body: { + list: { + items: ['query: Invalid query syntax', 'Another error'], + }, + }, }) expect(renderSuccess).not.toHaveBeenCalled() @@ -558,51 +568,6 @@ describe('executeBulkOperation', () => { expect(renderSuccess).not.toHaveBeenCalled() }) - test('validates API version when provided', async () => { - const query = '{ products { edges { node { id } } } }' - const version = '2025-01' - const mockResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = { - bulkOperation: createdBulkOperation, - userErrors: [], - } - vi.mocked(runBulkOperationQuery).mockResolvedValue(mockResponse) - vi.mocked(validateApiVersion).mockResolvedValue() - - await executeBulkOperation({ - organization: mockOrganization, - remoteApp: mockRemoteApp, - storeFqdn, - query, - version, - }) - - expect(validateApiVersion).toHaveBeenCalledWith(mockAdminSession, version) - expect(runBulkOperationQuery).toHaveBeenCalledWith({ - adminSession: mockAdminSession, - query, - version, - }) - }) - - test('does not validate version when not provided', async () => { - const query = '{ products { edges { node { id } } } }' - const mockResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = { - bulkOperation: createdBulkOperation, - userErrors: [], - } - vi.mocked(runBulkOperationQuery).mockResolvedValue(mockResponse) - vi.mocked(validateApiVersion).mockClear() - - await executeBulkOperation({ - organization: mockOrganization, - remoteApp: mockRemoteApp, - storeFqdn, - query, - }) - - 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}' @@ -711,4 +676,49 @@ describe('executeBulkOperation', () => { }), ) }) + + test('calls resolveApiVersion with minimum API version constant', async () => { + const query = '{ products { edges { node { id } } } }' + const mockResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = { + bulkOperation: createdBulkOperation, + userErrors: [], + } + vi.mocked(runBulkOperationQuery).mockResolvedValue(mockResponse) + + await executeBulkOperation({ + organization: mockOrganization, + remoteApp: mockRemoteApp, + storeFqdn, + query, + }) + + expect(resolveApiVersion).toHaveBeenCalledWith({ + adminSession: mockAdminSession, + userSpecifiedVersion: undefined, + minimumDefaultVersion: BULK_OPERATIONS_MIN_API_VERSION, + }) + }) + + test('uses resolved API version when running bulk operation', async () => { + vi.mocked(resolveApiVersion).mockResolvedValue('test-api-version') + const query = '{ products { edges { node { id } } } }' + const mockResponse: BulkOperationRunQueryMutation['bulkOperationRunQuery'] = { + bulkOperation: createdBulkOperation, + userErrors: [], + } + vi.mocked(runBulkOperationQuery).mockResolvedValue(mockResponse) + + await executeBulkOperation({ + organization: mockOrganization, + remoteApp: mockRemoteApp, + storeFqdn, + query, + }) + + expect(runBulkOperationQuery).toHaveBeenCalledWith({ + adminSession: mockAdminSession, + query, + version: 'test-api-version', + }) + }) }) diff --git a/packages/app/src/cli/services/bulk-operations/execute-bulk-operation.ts b/packages/app/src/cli/services/bulk-operations/execute-bulk-operation.ts index 7c4f3df067..06c5d7ff4c 100644 --- a/packages/app/src/cli/services/bulk-operations/execute-bulk-operation.ts +++ b/packages/app/src/cli/services/bulk-operations/execute-bulk-operation.ts @@ -4,11 +4,12 @@ import {watchBulkOperation, shortBulkOperationPoll, type BulkOperation} from './ import {formatBulkOperationStatus} from './format-bulk-operation-status.js' import {downloadBulkOperationResults} from './download-bulk-operation-results.js' import {extractBulkOperationId} from './bulk-operation-status.js' +import {BULK_OPERATIONS_MIN_API_VERSION} from './constants.js' import { createAdminSessionAsApp, validateSingleOperation, - validateApiVersion, formatOperationInfo, + resolveApiVersion, } from '../graphql/common.js' import {OrganizationApp, Organization} from '../../models/organization.js' import {renderSuccess, renderInfo, renderError, renderWarning, TokenItem} from '@shopify/cli-kit/node/ui' @@ -48,11 +49,25 @@ async function parseVariablesToJsonl(variables?: string[], variableFile?: string } export async function executeBulkOperation(input: ExecuteBulkOperationInput): Promise { - const {organization, remoteApp, storeFqdn, query, variables, variableFile, outputFile, watch = false, version} = input + const { + organization, + remoteApp, + storeFqdn, + query, + variables, + variableFile, + outputFile, + watch = false, + version: userSpecifiedVersion, + } = input const adminSession = await createAdminSessionAsApp(remoteApp, storeFqdn) - if (version) await validateApiVersion(adminSession, version) + const version = await resolveApiVersion({ + adminSession, + userSpecifiedVersion, + minimumDefaultVersion: BULK_OPERATIONS_MIN_API_VERSION, + }) const variablesJsonl = await parseVariablesToJsonl(variables, variableFile) @@ -74,15 +89,17 @@ export async function executeBulkOperation(input: ExecuteBulkOperationInput): Pr : await runBulkOperationQuery({adminSession, query, version}) if (bulkOperationResponse?.userErrors?.length) { - const errorMessages = bulkOperationResponse.userErrors - .map( - (error: {field?: string[] | null; message: string}) => - `${error.field?.join('.') ?? 'unknown'}: ${error.message}`, - ) - .join('\n') - renderWarning({ - headline: 'Bulk operation errors.', - body: errorMessages, + const errorMessages = bulkOperationResponse.userErrors.map( + (error: {field?: string[] | null; message: string}) => + `${error.field ? `${error.field.join('.')}: ` : ''}${error.message}`, + ) + renderError({ + headline: 'Error creating bulk operation.', + body: { + list: { + items: errorMessages, + }, + }, }) return } diff --git a/packages/app/src/cli/services/bulk-operations/watch-bulk-operation.ts b/packages/app/src/cli/services/bulk-operations/watch-bulk-operation.ts index 0c44c3497f..e13b68ff8c 100644 --- a/packages/app/src/cli/services/bulk-operations/watch-bulk-operation.ts +++ b/packages/app/src/cli/services/bulk-operations/watch-bulk-operation.ts @@ -1,4 +1,5 @@ import {formatBulkOperationStatus} from './format-bulk-operation-status.js' +import {BULK_OPERATIONS_MIN_API_VERSION} from './constants.js' import { GetBulkOperationById, GetBulkOperationByIdQuery, @@ -14,7 +15,6 @@ const TERMINAL_STATUSES = ['COMPLETED', 'FAILED', 'CANCELED', 'EXPIRED'] const INITIAL_POLL_INTERVAL_SECONDS = 1 const REGULAR_POLL_INTERVAL_SECONDS = 5 const INITIAL_POLL_COUNT = 10 -const API_VERSION = '2026-01' export const QUICK_WATCH_TIMEOUT_MS = 3000 export const QUICK_WATCH_POLL_INTERVAL_MS = 300 @@ -146,6 +146,6 @@ async function fetchBulkOperation(adminSession: AdminSession, operationId: strin query: GetBulkOperationById, session: adminSession, variables: {id: operationId}, - version: API_VERSION, + version: BULK_OPERATIONS_MIN_API_VERSION, }) } diff --git a/packages/app/src/cli/services/execute-operation.test.ts b/packages/app/src/cli/services/execute-operation.test.ts index de5571b26d..8f420b7331 100644 --- a/packages/app/src/cli/services/execute-operation.test.ts +++ b/packages/app/src/cli/services/execute-operation.test.ts @@ -1,5 +1,5 @@ import {executeOperation} from './execute-operation.js' -import {createAdminSessionAsApp, validateApiVersion} from './graphql/common.js' +import {createAdminSessionAsApp, resolveApiVersion} from './graphql/common.js' import {OrganizationApp, OrganizationSource} from '../models/organization.js' import {renderSuccess, renderError, renderSingleTask} from '@shopify/cli-kit/node/ui' import {adminRequestDoc} from '@shopify/cli-kit/node/api/admin' @@ -32,6 +32,7 @@ describe('executeOperation', () => { beforeEach(() => { vi.mocked(createAdminSessionAsApp).mockResolvedValue(mockAdminSession) + vi.mocked(resolveApiVersion).mockResolvedValue('2024-07') vi.mocked(renderSingleTask).mockImplementation(async ({task}) => { return task(() => {}) }) @@ -54,12 +55,13 @@ describe('executeOperation', () => { }) expect(createAdminSessionAsApp).toHaveBeenCalledWith(mockRemoteApp, storeFqdn) + expect(resolveApiVersion).toHaveBeenCalledWith({adminSession: mockAdminSession}) expect(adminRequestDoc).toHaveBeenCalledWith({ // parsed GraphQL document query: expect.any(Object), session: mockAdminSession, variables: undefined, - version: undefined, + version: '2024-07', responseOptions: {handleErrors: false}, }) }) @@ -107,7 +109,7 @@ describe('executeOperation', () => { const version = '2024-01' const mockResult = {data: {shop: {name: 'Test Shop'}}} vi.mocked(adminRequestDoc).mockResolvedValue(mockResult) - vi.mocked(validateApiVersion).mockResolvedValue() + vi.mocked(resolveApiVersion).mockResolvedValue(version) await executeOperation({ organization: mockOrganization, @@ -117,7 +119,7 @@ describe('executeOperation', () => { version, }) - expect(validateApiVersion).toHaveBeenCalledWith(mockAdminSession, version) + expect(resolveApiVersion).toHaveBeenCalledWith({adminSession: mockAdminSession, userSpecifiedVersion: version}) expect(adminRequestDoc).toHaveBeenCalledWith( expect.objectContaining({ version, @@ -125,22 +127,6 @@ describe('executeOperation', () => { ) }) - test('does not validate version when not provided', async () => { - const query = 'query { shop { name } }' - const mockResult = {data: {shop: {name: 'Test Shop'}}} - vi.mocked(adminRequestDoc).mockResolvedValue(mockResult) - vi.mocked(validateApiVersion).mockClear() - - await executeOperation({ - organization: mockOrganization, - remoteApp: mockRemoteApp, - storeFqdn, - query, - }) - - expect(validateApiVersion).not.toHaveBeenCalled() - }) - test('writes formatted JSON results to stdout by default', async () => { const query = 'query { shop { name } }' const mockResult = {data: {shop: {name: 'Test Shop'}}} diff --git a/packages/app/src/cli/services/execute-operation.ts b/packages/app/src/cli/services/execute-operation.ts index a84112bb54..aa3d46149b 100644 --- a/packages/app/src/cli/services/execute-operation.ts +++ b/packages/app/src/cli/services/execute-operation.ts @@ -1,7 +1,7 @@ import { createAdminSessionAsApp, validateSingleOperation, - validateApiVersion, + resolveApiVersion, formatOperationInfo, } from './graphql/common.js' import {OrganizationApp, Organization} from '../models/organization.js' @@ -38,7 +38,11 @@ async function parseVariables(variables?: string): Promise<{[key: string]: unkno } export async function executeOperation(input: ExecuteOperationInput): Promise { - const {organization, remoteApp, storeFqdn, query, variables, version, outputFile} = input + const {organization, remoteApp, storeFqdn, query, variables, version: userSpecifiedVersion, outputFile} = input + + const adminSession = await createAdminSessionAsApp(remoteApp, storeFqdn) + + const version = await resolveApiVersion({adminSession, userSpecifiedVersion}) renderInfo({ headline: 'Executing GraphQL operation.', @@ -51,10 +55,6 @@ export async function executeOperation(input: ExecuteOperationInput): Promise { @@ -16,7 +17,7 @@ vi.mock('@shopify/cli-kit/node/api/admin', async () => { const actual = await vi.importActual('@shopify/cli-kit/node/api/admin') return { ...actual, - supportedApiVersions: vi.fn(), + fetchApiVersions: vi.fn(), } }) @@ -106,28 +107,133 @@ describe('validateSingleOperation', () => { }) }) -describe('validateApiVersion', () => { +describe('resolveApiVersion', () => { const mockAdminSession = {token: 'test-token', storeFqdn: 'test-store.myshopify.com'} - test('allows unstable version without validation', async () => { - await expect(validateApiVersion(mockAdminSession, 'unstable')).resolves.not.toThrow() + test('returns unstable version without validation', async () => { + const result = await resolveApiVersion({adminSession: mockAdminSession, userSpecifiedVersion: 'unstable'}) - expect(supportedApiVersions).not.toHaveBeenCalled() + expect(result).toBe('unstable') + expect(fetchApiVersions).not.toHaveBeenCalled() }) - test('allows supported API version', async () => { - vi.mocked(supportedApiVersions).mockResolvedValue(['2024-01', '2024-04', '2024-07']) + test('returns user-provided version when allowed', async () => { + vi.mocked(fetchApiVersions).mockResolvedValue([ + {handle: '2024-01', supported: true}, + {handle: '2024-04', supported: true}, + {handle: '2024-07', supported: true}, + ]) + + const result = await resolveApiVersion({ + adminSession: mockAdminSession, + userSpecifiedVersion: '2024-04', + minimumDefaultVersion: BULK_OPERATIONS_MIN_API_VERSION, + }) + + expect(result).toBe('2024-04') + expect(fetchApiVersions).toHaveBeenCalledWith(mockAdminSession) + }) + + test('returns user-provided version even when marked as unsupported', async () => { + vi.mocked(fetchApiVersions).mockResolvedValue([ + {handle: '2024-01', supported: true}, + {handle: '2024-04', supported: false}, + {handle: '2024-07', supported: true}, + ]) + + const result = await resolveApiVersion({adminSession: mockAdminSession, userSpecifiedVersion: '2024-04'}) + + expect(result).toBe('2024-04') + expect(fetchApiVersions).toHaveBeenCalledWith(mockAdminSession) + }) + + test('throws error when user-provided version is not in API version list', async () => { + vi.mocked(fetchApiVersions).mockResolvedValue([ + {handle: '2024-01', supported: true}, + {handle: '2024-04', supported: true}, + {handle: '2024-07', supported: true}, + ]) + + await expect(resolveApiVersion({adminSession: mockAdminSession, userSpecifiedVersion: '2023-01'})).rejects.toThrow( + 'Invalid API version: 2023-01', + ) + expect(fetchApiVersions).toHaveBeenCalledWith(mockAdminSession) + }) - await expect(validateApiVersion(mockAdminSession, '2024-04')).resolves.not.toThrow() + test('returns most recent supported version when no version or minimum version provided', async () => { + vi.mocked(fetchApiVersions).mockResolvedValue([ + {handle: '2024-01', supported: true}, + {handle: '2024-04', supported: true}, + {handle: '2024-07', supported: true}, + {handle: '2025-01', supported: false}, + ]) - expect(supportedApiVersions).toHaveBeenCalledWith(mockAdminSession) + const result = await resolveApiVersion({adminSession: mockAdminSession}) + + expect(result).toBe('2024-07') + expect(fetchApiVersions).toHaveBeenCalledWith(mockAdminSession) + }) + + test('returns minimum version when no version provided and most recent supported is older', async () => { + vi.mocked(fetchApiVersions).mockResolvedValue([ + {handle: '2024-01', supported: true}, + {handle: '2024-04', supported: true}, + {handle: '2025-01', supported: false}, + {handle: '2025-10', supported: true}, + ]) + + const result = await resolveApiVersion({ + adminSession: mockAdminSession, + minimumDefaultVersion: BULK_OPERATIONS_MIN_API_VERSION, + }) + + expect(result).toBe(BULK_OPERATIONS_MIN_API_VERSION) + expect(fetchApiVersions).toHaveBeenCalledWith(mockAdminSession) }) - test('throws error when API version is not supported', async () => { - vi.mocked(supportedApiVersions).mockResolvedValue(['2024-01', '2024-04', '2024-07']) + test('returns most recent supported version when newer than minimum version', async () => { + vi.mocked(fetchApiVersions).mockResolvedValue([ + {handle: BULK_OPERATIONS_MIN_API_VERSION, supported: true}, + {handle: '2026-04', supported: true}, + {handle: '2026-07', supported: true}, + {handle: '2027-01', supported: false}, + ]) + + const result = await resolveApiVersion({ + adminSession: mockAdminSession, + minimumDefaultVersion: BULK_OPERATIONS_MIN_API_VERSION, + }) + + expect(result).toBe('2026-07') + expect(fetchApiVersions).toHaveBeenCalledWith(mockAdminSession) + }) +}) + +describe('formatOperationInfo', () => { + const mockOptions = { + organization: {businessName: 'Test Organization'}, + remoteApp: {title: 'Test App'}, + storeFqdn: 'test-store.myshopify.com', + } + + test('includes API version when provided', () => { + const result = formatOperationInfo({ + ...mockOptions, + version: '2024-07', + }) + + expect(result).toEqual([ + 'Organization: Test Organization', + 'App: Test App', + 'Store: test-store.myshopify.com', + 'API version: 2024-07', + ]) + }) - await expect(validateApiVersion(mockAdminSession, '2023-01')).rejects.toThrow('Invalid API version: 2023-01') + test('omits API version when not provided', () => { + const result = formatOperationInfo(mockOptions) - expect(supportedApiVersions).toHaveBeenCalledWith(mockAdminSession) + expect(result).toEqual(['Organization: Test Organization', 'App: Test App', 'Store: test-store.myshopify.com']) + expect(result).not.toContain(expect.stringContaining('API version')) }) }) diff --git a/packages/app/src/cli/services/graphql/common.ts b/packages/app/src/cli/services/graphql/common.ts index 503339b0a0..c13a0ab42f 100644 --- a/packages/app/src/cli/services/graphql/common.ts +++ b/packages/app/src/cli/services/graphql/common.ts @@ -2,7 +2,7 @@ import {OrganizationApp} from '../../models/organization.js' import {ensureAuthenticatedAdminAsApp, AdminSession} from '@shopify/cli-kit/node/session' import {AbortError, BugError} from '@shopify/cli-kit/node/error' import {outputContent} from '@shopify/cli-kit/node/output' -import {supportedApiVersions} from '@shopify/cli-kit/node/api/admin' +import {fetchApiVersions} from '@shopify/cli-kit/node/api/admin' import {parse} from 'graphql' /** @@ -46,31 +46,55 @@ export function validateSingleOperation(graphqlOperation: string): void { } /** - * Validates that the specified API version is supported by the store. + * Options for resolving an API version. + */ +interface ResolveApiVersionOptions { + /** Admin session containing store credentials. */ + adminSession: {token: string; storeFqdn: string} + /** The API version specified by the user. */ + userSpecifiedVersion?: string + /** Optional minimum version to use as a fallback when no version is specified. */ + minimumDefaultVersion?: string +} + +/** + * Determines the API version to use based on the user provided version and the available versions. * The 'unstable' version is always allowed without validation. * - * @param adminSession - Admin session containing store credentials. - * @param version - The API version to validate. - * @throws AbortError if the version is not supported by the store. + * @param options - Options for resolving the API version. + * @throws AbortError if the provided version is not allowed. */ -export async function validateApiVersion( - adminSession: {token: string; storeFqdn: string}, - version: string, -): Promise { - if (version === 'unstable') return +export async function resolveApiVersion(options: ResolveApiVersionOptions): Promise { + const {adminSession, userSpecifiedVersion, minimumDefaultVersion} = options + + if (userSpecifiedVersion === 'unstable') return userSpecifiedVersion - const supportedVersions = await supportedApiVersions(adminSession) - if (supportedVersions.includes(version)) return + const availableVersions = await fetchApiVersions(adminSession) + + if (!userSpecifiedVersion) { + // Return the most recent supported version, or minimumDefaultVersion if specified, whichever is newer. + const supportedVersions = availableVersions.filter((version) => version.supported).map((version) => version.handle) + if (minimumDefaultVersion) { + supportedVersions.push(minimumDefaultVersion) + } + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return supportedVersions.sort().reverse()[0]! + } - const firstLine = outputContent`Invalid API version: ${version}`.value - const secondLine = outputContent`Supported versions: ${supportedVersions.join(', ')}`.value + // Check if the user provided version is allowed. Unsupported versions (RC) are allowed here. + const versionList = availableVersions.map((version) => version.handle) + if (versionList.includes(userSpecifiedVersion)) return userSpecifiedVersion - throw new AbortError(`${firstLine}\n${secondLine}`) + // Invalid user provided version. + const firstLine = outputContent`Invalid API version: ${userSpecifiedVersion}`.value + const secondLine = outputContent`Allowed versions: ${versionList.join(', ')}`.value + throw new AbortError(firstLine, secondLine) } /** * Creates formatted info list items for GraphQL operations. - * Includes organization, app, store, and API version information. + * Includes organization, app, store, and optionally API version information. * * @param options - The operation context information * @returns Array of formatted strings for display @@ -80,14 +104,13 @@ export function formatOperationInfo(options: { remoteApp: {title: string} storeFqdn: string version?: string - showVersion?: boolean }): string[] { - const {organization, remoteApp, storeFqdn, version, showVersion = true} = options + const {organization, remoteApp, storeFqdn, version} = options const items = [`Organization: ${organization.businessName}`, `App: ${remoteApp.title}`, `Store: ${storeFqdn}`] - if (showVersion) { - items.push(`API version: ${version ?? 'default (latest stable)'}`) + if (version) { + items.push(`API version: ${version}`) } return items diff --git a/packages/cli-kit/src/public/node/api/admin.ts b/packages/cli-kit/src/public/node/api/admin.ts index 77c12ea647..107d91686f 100644 --- a/packages/cli-kit/src/public/node/api/admin.ts +++ b/packages/cli-kit/src/public/node/api/admin.ts @@ -157,7 +157,10 @@ export async function supportedApiVersions( * @param preferredBehaviour - Custom request behaviour for retries and timeouts. * @returns - An array of supported and unsupported API versions. */ -async function fetchApiVersions(session: AdminSession, preferredBehaviour?: RequestModeInput): Promise { +export async function fetchApiVersions( + session: AdminSession, + preferredBehaviour?: RequestModeInput, +): Promise { try { const response = await adminRequestDoc({ query: PublicApiVersions,