From 69c5d35041e4057d4383240587984409c52fa1b2 Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Fri, 3 Apr 2026 11:32:57 -0400 Subject: [PATCH] store execute restructure --- .../src/cli/commands/store/execute.test.ts | 4 +- .../cli/src/cli/commands/store/execute.ts | 2 +- .../store/admin-graphql-context.test.ts | 182 --------------- .../src/cli/services/store/execute.test.ts | 219 ------------------ .../store/execute/admin-context.test.ts | 116 ++++++++++ .../admin-context.ts} | 15 +- .../admin-transport.test.ts} | 46 ++-- .../admin-transport.ts} | 25 +- .../cli/services/store/execute/index.test.ts | 77 ++++++ .../store/{execute.ts => execute/index.ts} | 12 +- .../request.test.ts} | 2 +- .../request.ts} | 0 .../result.test.ts} | 2 +- .../{execute-result.ts => execute/result.ts} | 0 .../targets.test.ts} | 35 ++- .../targets.ts} | 19 +- 16 files changed, 265 insertions(+), 491 deletions(-) delete mode 100644 packages/cli/src/cli/services/store/admin-graphql-context.test.ts delete mode 100644 packages/cli/src/cli/services/store/execute.test.ts create mode 100644 packages/cli/src/cli/services/store/execute/admin-context.test.ts rename packages/cli/src/cli/services/store/{admin-graphql-context.ts => execute/admin-context.ts} (80%) rename packages/cli/src/cli/services/store/{admin-graphql-transport.test.ts => execute/admin-transport.test.ts} (68%) rename packages/cli/src/cli/services/store/{admin-graphql-transport.ts => execute/admin-transport.ts} (66%) create mode 100644 packages/cli/src/cli/services/store/execute/index.test.ts rename packages/cli/src/cli/services/store/{execute.ts => execute/index.ts} (78%) rename packages/cli/src/cli/services/store/{execute-request.test.ts => execute/request.test.ts} (98%) rename packages/cli/src/cli/services/store/{execute-request.ts => execute/request.ts} (100%) rename packages/cli/src/cli/services/store/{execute-result.test.ts => execute/result.test.ts} (94%) rename packages/cli/src/cli/services/store/{execute-result.ts => execute/result.ts} (100%) rename packages/cli/src/cli/services/store/{graphql-targets.test.ts => execute/targets.test.ts} (57%) rename packages/cli/src/cli/services/store/{graphql-targets.ts => execute/targets.ts} (61%) diff --git a/packages/cli/src/cli/commands/store/execute.test.ts b/packages/cli/src/cli/commands/store/execute.test.ts index e890b5332a0..4d7bd1e2ba7 100644 --- a/packages/cli/src/cli/commands/store/execute.test.ts +++ b/packages/cli/src/cli/commands/store/execute.test.ts @@ -1,8 +1,8 @@ import {describe, test, expect, vi, beforeEach} from 'vitest' import StoreExecute from './execute.js' -import {executeStoreOperation} from '../../services/store/execute.js' +import {executeStoreOperation} from '../../services/store/execute/index.js' -vi.mock('../../services/store/execute.js') +vi.mock('../../services/store/execute/index.js') describe('store execute command', () => { beforeEach(() => { diff --git a/packages/cli/src/cli/commands/store/execute.ts b/packages/cli/src/cli/commands/store/execute.ts index 661c8af9a24..bffc3e21ec4 100644 --- a/packages/cli/src/cli/commands/store/execute.ts +++ b/packages/cli/src/cli/commands/store/execute.ts @@ -3,7 +3,7 @@ import {globalFlags} from '@shopify/cli-kit/node/cli' import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' import {resolvePath} from '@shopify/cli-kit/node/path' import {Flags} from '@oclif/core' -import {executeStoreOperation} from '../../services/store/execute.js' +import {executeStoreOperation} from '../../services/store/execute/index.js' export default class StoreExecute extends Command { static summary = 'Execute GraphQL queries and mutations on a store.' diff --git a/packages/cli/src/cli/services/store/admin-graphql-context.test.ts b/packages/cli/src/cli/services/store/admin-graphql-context.test.ts deleted file mode 100644 index 83371130436..00000000000 --- a/packages/cli/src/cli/services/store/admin-graphql-context.test.ts +++ /dev/null @@ -1,182 +0,0 @@ -import {beforeEach, describe, expect, test, vi} from 'vitest' -import {fetchApiVersions} from '@shopify/cli-kit/node/api/admin' -import {AbortError} from '@shopify/cli-kit/node/error' -import {fetch} from '@shopify/cli-kit/node/http' -import { - clearStoredStoreAppSession, - getCurrentStoredStoreAppSession, - setStoredStoreAppSession, -} from './auth/session-store.js' -import {STORE_AUTH_APP_CLIENT_ID} from './auth/config.js' -import {prepareAdminStoreGraphQLContext} from './admin-graphql-context.js' - -vi.mock('./auth/session-store.js') -vi.mock('@shopify/cli-kit/node/http') -vi.mock('@shopify/cli-kit/node/api/admin', async () => { - const actual = await vi.importActual('@shopify/cli-kit/node/api/admin') - return { - ...actual, - fetchApiVersions: vi.fn(), - } -}) - -describe('prepareAdminStoreGraphQLContext', () => { - const store = 'shop.myshopify.com' - const futureExpiry = new Date(Date.now() + 60 * 60 * 1000).toISOString() - const expiredAt = new Date(Date.now() - 60 * 1000).toISOString() - - const storedSession = { - store, - clientId: STORE_AUTH_APP_CLIENT_ID, - userId: '42', - accessToken: 'token', - refreshToken: 'refresh-token', - scopes: ['read_products'], - acquiredAt: '2026-03-27T00:00:00.000Z', - expiresAt: futureExpiry, - } - - beforeEach(() => { - vi.clearAllMocks() - vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue(storedSession) - vi.mocked(fetchApiVersions).mockResolvedValue([ - {handle: '2025-10', supported: true}, - {handle: '2025-07', supported: true}, - {handle: 'unstable', supported: false}, - ] as any) - }) - - test('returns the stored admin session and latest supported version by default', async () => { - const result = await prepareAdminStoreGraphQLContext({store}) - - expect(result).toEqual({ - adminSession: { - token: 'token', - storeFqdn: store, - }, - version: '2025-10', - sessionUserId: '42', - }) - }) - - test('refreshes expired sessions before resolving the API version', async () => { - vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue({...storedSession, expiresAt: expiredAt}) - vi.mocked(fetch).mockResolvedValue({ - ok: true, - text: vi.fn().mockResolvedValue( - JSON.stringify({ - access_token: 'fresh-token', - refresh_token: 'fresh-refresh-token', - expires_in: 3600, - refresh_token_expires_in: 7200, - }), - ), - } as any) - - const result = await prepareAdminStoreGraphQLContext({store}) - - expect(result.adminSession.token).toBe('fresh-token') - expect(setStoredStoreAppSession).toHaveBeenCalledWith( - expect.objectContaining({ - store, - accessToken: 'fresh-token', - refreshToken: 'fresh-refresh-token', - }), - ) - }) - - test('returns the requested API version when provided', async () => { - const result = await prepareAdminStoreGraphQLContext({store, userSpecifiedVersion: '2025-07'}) - - expect(result.version).toBe('2025-07') - }) - - test('allows unstable without validating against fetched versions', async () => { - const result = await prepareAdminStoreGraphQLContext({store, userSpecifiedVersion: 'unstable'}) - - expect(result.version).toBe('unstable') - expect(fetchApiVersions).not.toHaveBeenCalled() - }) - - test('throws when no stored auth exists', async () => { - vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue(undefined) - - await expect(prepareAdminStoreGraphQLContext({store})).rejects.toMatchObject({ - message: `No stored app authentication found for ${store}.`, - tryMessage: 'To create stored auth for this store, run:', - nextSteps: [[{command: `shopify store auth --store ${store} --scopes `}]], - }) - }) - - test('clears stored auth when token refresh fails', async () => { - vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue({...storedSession, expiresAt: expiredAt}) - vi.mocked(fetch).mockResolvedValue({ - ok: false, - status: 401, - text: vi.fn().mockResolvedValue('bad refresh'), - } as any) - - await expect(prepareAdminStoreGraphQLContext({store})).rejects.toMatchObject({ - message: `Token refresh failed for ${store} (HTTP 401).`, - tryMessage: 'To re-authenticate, run:', - nextSteps: [[{command: `shopify store auth --store ${store} --scopes read_products`}]], - }) - expect(clearStoredStoreAppSession).toHaveBeenCalledWith(store, '42') - }) - - test('throws when an expired session cannot be refreshed because no refresh token is stored', async () => { - vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue({...storedSession, refreshToken: undefined, expiresAt: expiredAt}) - - await expect(prepareAdminStoreGraphQLContext({store})).rejects.toMatchObject({ - message: `No refresh token stored for ${store}.`, - tryMessage: 'To re-authenticate, run:', - nextSteps: [[{command: `shopify store auth --store ${store} --scopes read_products`}]], - }) - expect(clearStoredStoreAppSession).not.toHaveBeenCalled() - }) - - test('clears only the current stored auth when token refresh returns an invalid response body', async () => { - vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue({...storedSession, expiresAt: expiredAt}) - vi.mocked(fetch).mockResolvedValue({ - ok: true, - text: vi.fn().mockResolvedValue(JSON.stringify({refresh_token: 'fresh-refresh-token'})), - } as any) - - await expect(prepareAdminStoreGraphQLContext({store})).rejects.toMatchObject({ - message: `Token refresh returned an invalid response for ${store}.`, - tryMessage: 'To re-authenticate, run:', - nextSteps: [[{command: `shopify store auth --store ${store} --scopes read_products`}]], - }) - expect(clearStoredStoreAppSession).toHaveBeenCalledWith(store, '42') - }) - - test('clears only the current stored auth when token refresh returns malformed JSON', async () => { - vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue({...storedSession, expiresAt: expiredAt}) - vi.mocked(fetch).mockResolvedValue({ - ok: true, - text: vi.fn().mockResolvedValue('not-json'), - } as any) - - await expect(prepareAdminStoreGraphQLContext({store})).rejects.toThrow('Received an invalid refresh response') - expect(clearStoredStoreAppSession).toHaveBeenCalledWith(store, '42') - }) - - test('clears stored auth and prompts re-auth when API version lookup fails with invalid auth', async () => { - vi.mocked(fetchApiVersions).mockRejectedValue( - new AbortError(`Error connecting to your store ${store}: unauthorized 401 {}`), - ) - - await expect(prepareAdminStoreGraphQLContext({store})).rejects.toMatchObject({ - message: `Stored app authentication for ${store} is no longer valid.`, - tryMessage: 'To re-authenticate, run:', - nextSteps: [[{command: `shopify store auth --store ${store} --scopes read_products`}]], - }) - expect(clearStoredStoreAppSession).toHaveBeenCalledWith(store, '42') - }) - - test('throws when the requested API version is invalid', async () => { - await expect(prepareAdminStoreGraphQLContext({store, userSpecifiedVersion: '1999-01'})).rejects.toThrow( - 'Invalid API version', - ) - }) -}) diff --git a/packages/cli/src/cli/services/store/execute.test.ts b/packages/cli/src/cli/services/store/execute.test.ts deleted file mode 100644 index 0f40ed1d9af..00000000000 --- a/packages/cli/src/cli/services/store/execute.test.ts +++ /dev/null @@ -1,219 +0,0 @@ -import {describe, test, expect, vi, beforeEach, afterEach} from 'vitest' -import {executeStoreOperation} from './execute.js' -import {getCurrentStoredStoreAppSession} from './auth/session-store.js' -import {STORE_AUTH_APP_CLIENT_ID} from './auth/config.js' -import {fetchApiVersions, adminUrl} from '@shopify/cli-kit/node/api/admin' -import {graphqlRequest} from '@shopify/cli-kit/node/api/graphql' -import {renderSingleTask, renderSuccess} from '@shopify/cli-kit/node/ui' -import {fileExists, readFile, writeFile} from '@shopify/cli-kit/node/fs' -import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' - -vi.mock('./auth/session-store.js') -vi.mock('@shopify/cli-kit/node/api/graphql') -vi.mock('@shopify/cli-kit/node/ui') -vi.mock('@shopify/cli-kit/node/fs') -vi.mock('@shopify/cli-kit/node/api/admin', async () => { - const actual = await vi.importActual('@shopify/cli-kit/node/api/admin') - return { - ...actual, - fetchApiVersions: vi.fn(), - adminUrl: vi.fn(), - } -}) - -describe('executeStoreOperation', () => { - const store = 'shop.myshopify.com' - const session = {token: 'token', storeFqdn: store} - const storedSession = { - store, - clientId: STORE_AUTH_APP_CLIENT_ID, - userId: '42', - accessToken: 'token', - scopes: ['read_products'], - acquiredAt: '2026-03-27T00:00:00.000Z', - } - - beforeEach(() => { - vi.clearAllMocks() - vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue(storedSession) - vi.mocked(fetchApiVersions).mockResolvedValue([ - {handle: '2025-10', supported: true}, - {handle: '2025-07', supported: true}, - {handle: 'unstable', supported: false}, - ] as any) - vi.mocked(adminUrl).mockReturnValue('https://shop.myshopify.com/admin/api/2025-10/graphql.json') - vi.mocked(renderSingleTask).mockImplementation(async ({task}) => task(() => {})) - }) - - afterEach(() => { - mockAndCaptureOutput().clear() - }) - - test('executes a query successfully', async () => { - vi.mocked(graphqlRequest).mockResolvedValue({data: {shop: {name: 'Test shop'}}}) - const output = mockAndCaptureOutput() - - await executeStoreOperation({ - store, - query: 'query { shop { name } }', - }) - - expect(renderSingleTask).toHaveBeenCalledWith( - expect.objectContaining({ - title: expect.anything(), - }), - ) - expect(getCurrentStoredStoreAppSession).toHaveBeenCalledWith(store) - expect(fetchApiVersions).toHaveBeenCalledWith(session) - expect(graphqlRequest).toHaveBeenCalledWith({ - query: 'query { shop { name } }', - api: 'Admin', - url: 'https://shop.myshopify.com/admin/api/2025-10/graphql.json', - token: 'token', - variables: undefined, - responseOptions: {handleErrors: false}, - }) - expect(output.info()).toContain('"name": "Test shop"') - expect(renderSuccess).toHaveBeenCalledWith({headline: 'Operation succeeded.'}) - }) - - test('passes parsed variables when provided inline', async () => { - vi.mocked(graphqlRequest).mockResolvedValue({data: {shop: {id: 'gid://shopify/Shop/1'}}}) - - await executeStoreOperation({ - store, - query: 'query Shop($id: ID!) { shop { id } }', - variables: '{"id":"gid://shopify/Shop/1"}', - }) - - expect(graphqlRequest).toHaveBeenCalledWith( - expect.objectContaining({ - variables: {id: 'gid://shopify/Shop/1'}, - }), - ) - }) - - test('reads variables from a file', async () => { - vi.mocked(fileExists).mockResolvedValue(true) - vi.mocked(readFile).mockResolvedValue('{"id":"gid://shopify/Shop/1"}' as any) - vi.mocked(graphqlRequest).mockResolvedValue({data: {shop: {id: 'gid://shopify/Shop/1'}}}) - - await executeStoreOperation({ - store, - query: 'query Shop($id: ID!) { shop { id } }', - variableFile: '/tmp/variables.json', - }) - - expect(graphqlRequest).toHaveBeenCalledWith( - expect.objectContaining({ - variables: {id: 'gid://shopify/Shop/1'}, - }), - ) - }) - - test('throws when variables contain invalid JSON', async () => { - await expect( - executeStoreOperation({ - store, - query: 'query { shop { name } }', - variables: '{invalid json}', - }), - ).rejects.toThrow('Invalid JSON') - - expect(graphqlRequest).not.toHaveBeenCalled() - }) - - test('throws when mutations are not explicitly allowed', async () => { - await expect( - executeStoreOperation({ - store, - query: 'mutation { productCreate(product: {title: "Hat"}) { product { id } } }', - }), - ).rejects.toThrow('Mutations are disabled by default') - - expect(getCurrentStoredStoreAppSession).not.toHaveBeenCalled() - }) - - test('throws when no stored app session exists', async () => { - vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue(undefined) - - await expect( - executeStoreOperation({ - store, - query: 'query { shop { name } }', - }), - ).rejects.toThrow('No stored app authentication found') - }) - - test('allows mutations when explicitly enabled', async () => { - vi.mocked(graphqlRequest).mockResolvedValue({data: {productCreate: {product: {id: 'gid://shopify/Product/1'}}}}) - - await executeStoreOperation({ - store, - query: 'mutation { productCreate(product: {title: "Hat"}) { product { id } } }', - allowMutations: true, - }) - - expect(graphqlRequest).toHaveBeenCalled() - }) - - test('uses the specified API version when provided', async () => { - vi.mocked(graphqlRequest).mockResolvedValue({data: {shop: {name: 'Test shop'}}}) - vi.mocked(adminUrl).mockReturnValue('https://shop.myshopify.com/admin/api/2025-07/graphql.json') - - await executeStoreOperation({ - store, - query: 'query { shop { name } }', - version: '2025-07', - }) - - expect(adminUrl).toHaveBeenCalledWith(store, '2025-07', session) - }) - - test('writes results to a file when outputFile is provided', async () => { - vi.mocked(graphqlRequest).mockResolvedValue({data: {shop: {name: 'Test shop'}}}) - - await executeStoreOperation({ - store, - query: 'query { shop { name } }', - outputFile: '/tmp/results.json', - }) - - expect(writeFile).toHaveBeenCalledWith('/tmp/results.json', expect.stringContaining('Test shop')) - expect(renderSuccess).toHaveBeenCalledWith({ - headline: 'Operation succeeded.', - body: 'Results written to /tmp/results.json', - }) - }) - - test('throws when stored auth is no longer valid', async () => { - vi.mocked(graphqlRequest).mockRejectedValue({ - response: { - status: 401, - }, - }) - - await expect( - executeStoreOperation({ - store, - query: 'query { shop { name } }', - }), - ).rejects.toThrow('Stored app authentication for') - }) - - test('throws on GraphQL errors', async () => { - vi.mocked(graphqlRequest).mockRejectedValue({ - response: { - errors: [{message: 'Field does not exist'}], - }, - }) - - await expect( - executeStoreOperation({ - store, - query: 'query { nope }', - }), - ).rejects.toThrow('GraphQL operation failed.') - }) - -}) diff --git a/packages/cli/src/cli/services/store/execute/admin-context.test.ts b/packages/cli/src/cli/services/store/execute/admin-context.test.ts new file mode 100644 index 00000000000..d82ed61de96 --- /dev/null +++ b/packages/cli/src/cli/services/store/execute/admin-context.test.ts @@ -0,0 +1,116 @@ +import {beforeEach, describe, expect, test, vi} from 'vitest' +import {fetchApiVersions} from '@shopify/cli-kit/node/api/admin' +import {AbortError} from '@shopify/cli-kit/node/error' +import {prepareAdminStoreGraphQLContext} from './admin-context.js' +import {clearStoredStoreAppSession} from '../auth/session-store.js' +import {loadStoredStoreSession} from '../auth/session-lifecycle.js' +import {STORE_AUTH_APP_CLIENT_ID} from '../auth/config.js' + +vi.mock('../auth/session-store.js') +vi.mock('../auth/session-lifecycle.js', () => ({loadStoredStoreSession: vi.fn()})) +vi.mock('@shopify/cli-kit/node/api/admin', async () => { + const actual = await vi.importActual('@shopify/cli-kit/node/api/admin') + return { + ...actual, + fetchApiVersions: vi.fn(), + } +}) + +describe('prepareAdminStoreGraphQLContext', () => { + const store = 'shop.myshopify.com' + const storedSession = { + store, + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: '42', + accessToken: 'token', + refreshToken: 'refresh-token', + scopes: ['read_products', 'write_orders'], + acquiredAt: '2026-03-27T00:00:00.000Z', + } + + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(loadStoredStoreSession).mockResolvedValue(storedSession) + vi.mocked(fetchApiVersions).mockResolvedValue([ + {handle: '2025-10', supported: true}, + {handle: '2025-07', supported: true}, + {handle: 'unstable', supported: false}, + ] as any) + }) + + test('returns the stored admin session, version, and full auth session', async () => { + const result = await prepareAdminStoreGraphQLContext({store}) + + expect(loadStoredStoreSession).toHaveBeenCalledWith(store) + expect(fetchApiVersions).toHaveBeenCalledWith({ + token: 'token', + storeFqdn: store, + }) + expect(result).toEqual({ + adminSession: { + token: 'token', + storeFqdn: store, + }, + version: '2025-10', + session: storedSession, + }) + }) + + test('uses the loaded refreshed session for both admin auth and returned context', async () => { + const refreshedSession = { + ...storedSession, + accessToken: 'fresh-token', + refreshToken: 'fresh-refresh-token', + expiresAt: '2026-04-03T00:00:00.000Z', + } + vi.mocked(loadStoredStoreSession).mockResolvedValue(refreshedSession) + + const result = await prepareAdminStoreGraphQLContext({store}) + + expect(fetchApiVersions).toHaveBeenCalledWith({ + token: 'fresh-token', + storeFqdn: store, + }) + expect(result.adminSession.token).toBe('fresh-token') + expect(result.session).toEqual(refreshedSession) + }) + + test('returns the requested API version when provided', async () => { + const result = await prepareAdminStoreGraphQLContext({store, userSpecifiedVersion: '2025-07'}) + + expect(result.version).toBe('2025-07') + }) + + test('allows unstable without validating against fetched versions', async () => { + const result = await prepareAdminStoreGraphQLContext({store, userSpecifiedVersion: 'unstable'}) + + expect(result.version).toBe('unstable') + expect(fetchApiVersions).not.toHaveBeenCalled() + }) + + test('clears the current stored auth and prompts re-auth with real scopes when API version lookup gets invalid auth', async () => { + vi.mocked(fetchApiVersions).mockRejectedValue( + new AbortError(`Error connecting to your store ${store}: unauthorized 401 {}`), + ) + + await expect(prepareAdminStoreGraphQLContext({store})).rejects.toMatchObject({ + message: `Stored app authentication for ${store} is no longer valid.`, + tryMessage: 'To re-authenticate, run:', + nextSteps: [[{command: `shopify store auth --store ${store} --scopes read_products,write_orders`}]], + }) + expect(clearStoredStoreAppSession).toHaveBeenCalledWith(store, '42') + }) + + test('rethrows unrelated API version lookup failures', async () => { + vi.mocked(fetchApiVersions).mockRejectedValue(new AbortError('upstream exploded')) + + await expect(prepareAdminStoreGraphQLContext({store})).rejects.toThrow('upstream exploded') + expect(clearStoredStoreAppSession).not.toHaveBeenCalled() + }) + + test('throws when the requested API version is invalid', async () => { + await expect(prepareAdminStoreGraphQLContext({store, userSpecifiedVersion: '1999-01'})).rejects.toThrow( + 'Invalid API version', + ) + }) +}) diff --git a/packages/cli/src/cli/services/store/admin-graphql-context.ts b/packages/cli/src/cli/services/store/execute/admin-context.ts similarity index 80% rename from packages/cli/src/cli/services/store/admin-graphql-context.ts rename to packages/cli/src/cli/services/store/execute/admin-context.ts index e0a493fbc09..dfabaa2eb6f 100644 --- a/packages/cli/src/cli/services/store/admin-graphql-context.ts +++ b/packages/cli/src/cli/services/store/execute/admin-context.ts @@ -1,16 +1,15 @@ import {fetchApiVersions} from '@shopify/cli-kit/node/api/admin' import {AbortError} from '@shopify/cli-kit/node/error' -import {outputContent, outputDebug, outputToken} from '@shopify/cli-kit/node/output' -import {AdminSession} from '@shopify/cli-kit/node/session' -import {reauthenticateStoreAuthError} from './auth/recovery.js' -import {clearStoredStoreAppSession} from './auth/session-store.js' -import type {StoredStoreAppSession} from './auth/session-store.js' -import {loadStoredStoreSession} from './auth/session-lifecycle.js' +import type {AdminSession} from '@shopify/cli-kit/node/session' +import {reauthenticateStoreAuthError} from '../auth/recovery.js' +import {clearStoredStoreAppSession} from '../auth/session-store.js' +import type {StoredStoreAppSession} from '../auth/session-store.js' +import {loadStoredStoreSession} from '../auth/session-lifecycle.js' export interface AdminStoreGraphQLContext { adminSession: AdminSession version: string - sessionUserId: string + session: StoredStoreAppSession } async function resolveApiVersion(options: { @@ -64,5 +63,5 @@ export async function prepareAdminStoreGraphQLContext(input: { } const version = await resolveApiVersion({session, adminSession, userSpecifiedVersion: input.userSpecifiedVersion}) - return {adminSession, version, sessionUserId: session.userId} + return {adminSession, version, session} } diff --git a/packages/cli/src/cli/services/store/admin-graphql-transport.test.ts b/packages/cli/src/cli/services/store/execute/admin-transport.test.ts similarity index 68% rename from packages/cli/src/cli/services/store/admin-graphql-transport.test.ts rename to packages/cli/src/cli/services/store/execute/admin-transport.test.ts index 3a994321fce..0d39950d7aa 100644 --- a/packages/cli/src/cli/services/store/admin-graphql-transport.test.ts +++ b/packages/cli/src/cli/services/store/execute/admin-transport.test.ts @@ -2,11 +2,12 @@ import {beforeEach, describe, expect, test, vi} from 'vitest' import {adminUrl} from '@shopify/cli-kit/node/api/admin' import {graphqlRequest} from '@shopify/cli-kit/node/api/graphql' import {renderSingleTask} from '@shopify/cli-kit/node/ui' -import {clearStoredStoreAppSession} from './auth/session-store.js' -import {prepareStoreExecuteRequest} from './execute-request.js' -import {runAdminStoreGraphQLOperation} from './admin-graphql-transport.js' +import {clearStoredStoreAppSession} from '../auth/session-store.js' +import {prepareStoreExecuteRequest} from './request.js' +import {runAdminStoreGraphQLOperation} from './admin-transport.js' +import {STORE_AUTH_APP_CLIENT_ID} from '../auth/config.js' -vi.mock('./auth/session-store.js') +vi.mock('../auth/session-store.js') vi.mock('@shopify/cli-kit/node/api/graphql') vi.mock('@shopify/cli-kit/node/ui') vi.mock('@shopify/cli-kit/node/api/admin', async () => { @@ -19,7 +20,18 @@ vi.mock('@shopify/cli-kit/node/api/admin', async () => { describe('runAdminStoreGraphQLOperation', () => { const store = 'shop.myshopify.com' - const adminSession = {token: 'token', storeFqdn: store} + const context = { + adminSession: {token: 'token', storeFqdn: store}, + version: '2025-10', + session: { + store, + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: '42', + accessToken: 'token', + scopes: ['read_products', 'write_orders'], + acquiredAt: '2026-03-27T00:00:00.000Z', + }, + } beforeEach(() => { vi.clearAllMocks() @@ -31,13 +43,7 @@ describe('runAdminStoreGraphQLOperation', () => { vi.mocked(graphqlRequest).mockResolvedValue({data: {shop: {name: 'Test shop'}}}) const request = await prepareStoreExecuteRequest({query: 'query { shop { name } }'}) - const result = await runAdminStoreGraphQLOperation({ - store, - adminSession, - sessionUserId: '42', - version: '2025-10', - request, - }) + const result = await runAdminStoreGraphQLOperation({context, request}) expect(result).toEqual({data: {shop: {name: 'Test shop'}}}) expect(graphqlRequest).toHaveBeenCalledWith({ @@ -50,16 +56,14 @@ describe('runAdminStoreGraphQLOperation', () => { }) }) - test('clears stored auth and throws a re-auth error on 401', async () => { + test('clears stored auth and throws a re-auth error on 401 using the real session scopes', async () => { vi.mocked(graphqlRequest).mockRejectedValue({response: {status: 401}}) const request = await prepareStoreExecuteRequest({query: 'query { shop { name } }'}) - await expect( - runAdminStoreGraphQLOperation({store, adminSession, sessionUserId: '42', version: '2025-10', request}), - ).rejects.toMatchObject({ + await expect(runAdminStoreGraphQLOperation({context, request})).rejects.toMatchObject({ message: `Stored app authentication for ${store} is no longer valid.`, tryMessage: 'To re-authenticate, run:', - nextSteps: [[{command: `shopify store auth --store ${store} --scopes `}]], + nextSteps: [[{command: `shopify store auth --store ${store} --scopes read_products,write_orders`}]], }) expect(clearStoredStoreAppSession).toHaveBeenCalledWith(store, '42') }) @@ -68,17 +72,13 @@ describe('runAdminStoreGraphQLOperation', () => { vi.mocked(graphqlRequest).mockRejectedValue({response: {errors: [{message: 'Field does not exist'}]}}) const request = await prepareStoreExecuteRequest({query: 'query { nope }'}) - await expect( - runAdminStoreGraphQLOperation({store, adminSession, sessionUserId: '42', version: '2025-10', request}), - ).rejects.toThrow('GraphQL operation failed.') + await expect(runAdminStoreGraphQLOperation({context, request})).rejects.toThrow('GraphQL operation failed.') }) test('rethrows non-GraphQL errors', async () => { vi.mocked(graphqlRequest).mockRejectedValue(new Error('boom')) const request = await prepareStoreExecuteRequest({query: 'query { shop { name } }'}) - await expect( - runAdminStoreGraphQLOperation({store, adminSession, sessionUserId: '42', version: '2025-10', request}), - ).rejects.toThrow('boom') + await expect(runAdminStoreGraphQLOperation({context, request})).rejects.toThrow('boom') }) }) diff --git a/packages/cli/src/cli/services/store/admin-graphql-transport.ts b/packages/cli/src/cli/services/store/execute/admin-transport.ts similarity index 66% rename from packages/cli/src/cli/services/store/admin-graphql-transport.ts rename to packages/cli/src/cli/services/store/execute/admin-transport.ts index 6c1a3f8a5db..9ba1dced9c1 100644 --- a/packages/cli/src/cli/services/store/admin-graphql-transport.ts +++ b/packages/cli/src/cli/services/store/execute/admin-transport.ts @@ -2,11 +2,11 @@ import {adminUrl} from '@shopify/cli-kit/node/api/admin' import {graphqlRequest} from '@shopify/cli-kit/node/api/graphql' import {AbortError} from '@shopify/cli-kit/node/error' import {outputContent} from '@shopify/cli-kit/node/output' -import {AdminSession} from '@shopify/cli-kit/node/session' import {renderSingleTask} from '@shopify/cli-kit/node/ui' -import {reauthenticateStoreAuthError} from './auth/recovery.js' -import {PreparedStoreExecuteRequest} from './execute-request.js' -import {clearStoredStoreAppSession} from './auth/session-store.js' +import {reauthenticateStoreAuthError} from '../auth/recovery.js' +import {clearStoredStoreAppSession} from '../auth/session-store.js' +import type {PreparedStoreExecuteRequest} from './request.js' +import type {AdminStoreGraphQLContext} from './admin-context.js' function isGraphQLClientError(error: unknown): error is {response: {errors?: unknown; status?: number}} { if (!error || typeof error !== 'object' || !('response' in error)) return false @@ -15,10 +15,7 @@ function isGraphQLClientError(error: unknown): error is {response: {errors?: unk } export async function runAdminStoreGraphQLOperation(input: { - store: string - adminSession: AdminSession - sessionUserId: string - version: string + context: AdminStoreGraphQLContext request: PreparedStoreExecuteRequest }): Promise { try { @@ -28,8 +25,8 @@ export async function runAdminStoreGraphQLOperation(input: { return graphqlRequest({ query: input.request.query, api: 'Admin', - url: adminUrl(input.adminSession.storeFqdn, input.version, input.adminSession), - token: input.adminSession.token, + url: adminUrl(input.context.adminSession.storeFqdn, input.context.version, input.context.adminSession), + token: input.context.adminSession.token, variables: input.request.parsedVariables, responseOptions: {handleErrors: false}, }) @@ -38,11 +35,11 @@ export async function runAdminStoreGraphQLOperation(input: { }) } catch (error) { if (isGraphQLClientError(error) && error.response.status === 401) { - clearStoredStoreAppSession(input.store, input.sessionUserId) + clearStoredStoreAppSession(input.context.session.store, input.context.session.userId) throw reauthenticateStoreAuthError( - `Stored app authentication for ${input.store} is no longer valid.`, - input.store, - '', + `Stored app authentication for ${input.context.session.store} is no longer valid.`, + input.context.session.store, + input.context.session.scopes.join(','), ) } diff --git a/packages/cli/src/cli/services/store/execute/index.test.ts b/packages/cli/src/cli/services/store/execute/index.test.ts new file mode 100644 index 00000000000..5c69b6fa0c3 --- /dev/null +++ b/packages/cli/src/cli/services/store/execute/index.test.ts @@ -0,0 +1,77 @@ +import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' +import {renderSingleTask} from '@shopify/cli-kit/node/ui' +import {executeStoreOperation} from './index.js' +import {prepareStoreExecuteRequest} from './request.js' +import {writeOrOutputStoreExecuteResult} from './result.js' +import {getStoreGraphQLTarget} from './targets.js' +import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' + +vi.mock('./request.js') +vi.mock('./result.js') +vi.mock('./targets.js') +vi.mock('@shopify/cli-kit/node/ui') + +describe('executeStoreOperation', () => { + const request = { + query: 'query { shop { name } }', + parsedOperation: {operationDefinition: {operation: 'query'}}, + parsedVariables: {id: 'gid://shopify/Shop/1'}, + requestedVersion: '2025-10', + outputFile: '/tmp/result.json', + } as any + const context = {kind: 'admin-context'} as any + const result = {data: {shop: {name: 'Test shop'}}} + const target = { + prepareContext: vi.fn(), + execute: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(prepareStoreExecuteRequest).mockResolvedValue(request) + vi.mocked(getStoreGraphQLTarget).mockReturnValue(target as any) + target.prepareContext.mockResolvedValue(context) + target.execute.mockResolvedValue(result) + vi.mocked(renderSingleTask).mockImplementation(async ({task}) => task(() => {})) + }) + + afterEach(() => { + mockAndCaptureOutput().clear() + }) + + test('prepares the request, loads context, executes the target, and writes the result', async () => { + await executeStoreOperation({ + store: 'shop.myshopify.com', + query: 'query { shop { name } }', + variables: '{"id":"gid://shopify/Shop/1"}', + outputFile: '/tmp/result.json', + version: '2025-10', + }) + + expect(getStoreGraphQLTarget).toHaveBeenCalledWith('admin') + expect(prepareStoreExecuteRequest).toHaveBeenCalledWith({ + query: 'query { shop { name } }', + queryFile: undefined, + variables: '{"id":"gid://shopify/Shop/1"}', + variableFile: undefined, + outputFile: '/tmp/result.json', + version: '2025-10', + allowMutations: undefined, + }) + expect(target.prepareContext).toHaveBeenCalledWith({ + store: 'shop.myshopify.com', + requestedVersion: '2025-10', + }) + expect(target.execute).toHaveBeenCalledWith({context, request}) + expect(writeOrOutputStoreExecuteResult).toHaveBeenCalledWith(result, '/tmp/result.json') + }) + + test('defaults to the admin target', async () => { + await executeStoreOperation({ + store: 'shop.myshopify.com', + query: 'query { shop { name } }', + }) + + expect(getStoreGraphQLTarget).toHaveBeenCalledWith('admin') + }) +}) diff --git a/packages/cli/src/cli/services/store/execute.ts b/packages/cli/src/cli/services/store/execute/index.ts similarity index 78% rename from packages/cli/src/cli/services/store/execute.ts rename to packages/cli/src/cli/services/store/execute/index.ts index 899bf5a662d..a25556e2de9 100644 --- a/packages/cli/src/cli/services/store/execute.ts +++ b/packages/cli/src/cli/services/store/execute/index.ts @@ -1,8 +1,8 @@ import {renderSingleTask} from '@shopify/cli-kit/node/ui' import {outputContent} from '@shopify/cli-kit/node/output' -import {prepareStoreExecuteRequest} from './execute-request.js' -import {writeOrOutputStoreExecuteResult} from './execute-result.js' -import {getStoreGraphQLTarget, StoreGraphQLApi} from './graphql-targets.js' +import {prepareStoreExecuteRequest} from './request.js' +import {writeOrOutputStoreExecuteResult} from './result.js' +import {getStoreGraphQLTarget, type StoreGraphQLApi} from './targets.js' interface ExecuteStoreOperationInput { store: string @@ -35,11 +35,7 @@ export async function executeStoreOperation(input: ExecuteStoreOperationInput): renderOptions: {stdout: process.stderr}, }) - const result = await target.execute({ - store: input.store, - context, - request, - }) + const result = await target.execute({context, request}) await writeOrOutputStoreExecuteResult(result, request.outputFile) } diff --git a/packages/cli/src/cli/services/store/execute-request.test.ts b/packages/cli/src/cli/services/store/execute/request.test.ts similarity index 98% rename from packages/cli/src/cli/services/store/execute-request.test.ts rename to packages/cli/src/cli/services/store/execute/request.test.ts index 7146746bced..df625bb994f 100644 --- a/packages/cli/src/cli/services/store/execute-request.test.ts +++ b/packages/cli/src/cli/services/store/execute/request.test.ts @@ -1,6 +1,6 @@ import {beforeEach, describe, expect, test, vi} from 'vitest' import {fileExists, readFile} from '@shopify/cli-kit/node/fs' -import {prepareStoreExecuteRequest} from './execute-request.js' +import {prepareStoreExecuteRequest} from './request.js' vi.mock('@shopify/cli-kit/node/fs') diff --git a/packages/cli/src/cli/services/store/execute-request.ts b/packages/cli/src/cli/services/store/execute/request.ts similarity index 100% rename from packages/cli/src/cli/services/store/execute-request.ts rename to packages/cli/src/cli/services/store/execute/request.ts diff --git a/packages/cli/src/cli/services/store/execute-result.test.ts b/packages/cli/src/cli/services/store/execute/result.test.ts similarity index 94% rename from packages/cli/src/cli/services/store/execute-result.test.ts rename to packages/cli/src/cli/services/store/execute/result.test.ts index 2a370560acf..bbde05a73cf 100644 --- a/packages/cli/src/cli/services/store/execute-result.test.ts +++ b/packages/cli/src/cli/services/store/execute/result.test.ts @@ -2,7 +2,7 @@ import {beforeEach, describe, expect, test, vi} from 'vitest' import {writeFile} from '@shopify/cli-kit/node/fs' import {renderSuccess} from '@shopify/cli-kit/node/ui' import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' -import {writeOrOutputStoreExecuteResult} from './execute-result.js' +import {writeOrOutputStoreExecuteResult} from './result.js' vi.mock('@shopify/cli-kit/node/fs') vi.mock('@shopify/cli-kit/node/ui') diff --git a/packages/cli/src/cli/services/store/execute-result.ts b/packages/cli/src/cli/services/store/execute/result.ts similarity index 100% rename from packages/cli/src/cli/services/store/execute-result.ts rename to packages/cli/src/cli/services/store/execute/result.ts diff --git a/packages/cli/src/cli/services/store/graphql-targets.test.ts b/packages/cli/src/cli/services/store/execute/targets.test.ts similarity index 57% rename from packages/cli/src/cli/services/store/graphql-targets.test.ts rename to packages/cli/src/cli/services/store/execute/targets.test.ts index a5fba30398d..28fc67d8b92 100644 --- a/packages/cli/src/cli/services/store/graphql-targets.test.ts +++ b/packages/cli/src/cli/services/store/execute/targets.test.ts @@ -1,11 +1,12 @@ import {beforeEach, describe, expect, test, vi} from 'vitest' -import {prepareStoreExecuteRequest} from './execute-request.js' -import {prepareAdminStoreGraphQLContext} from './admin-graphql-context.js' -import {runAdminStoreGraphQLOperation} from './admin-graphql-transport.js' -import {getStoreGraphQLTarget} from './graphql-targets.js' +import {prepareStoreExecuteRequest} from './request.js' +import {prepareAdminStoreGraphQLContext} from './admin-context.js' +import {runAdminStoreGraphQLOperation} from './admin-transport.js' +import {getStoreGraphQLTarget} from './targets.js' +import {STORE_AUTH_APP_CLIENT_ID} from '../auth/config.js' -vi.mock('./admin-graphql-context.js') -vi.mock('./admin-graphql-transport.js') +vi.mock('./admin-context.js') +vi.mock('./admin-transport.js') describe('getStoreGraphQLTarget', () => { beforeEach(() => { @@ -18,28 +19,26 @@ describe('getStoreGraphQLTarget', () => { const context = { adminSession: {token: 'token', storeFqdn: 'shop.myshopify.com'}, version: '2025-10', - sessionUserId: '42', + session: { + store: 'shop.myshopify.com', + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: '42', + accessToken: 'token', + scopes: ['read_products'], + acquiredAt: '2026-03-27T00:00:00.000Z', + }, } vi.mocked(prepareAdminStoreGraphQLContext).mockResolvedValue(context) vi.mocked(runAdminStoreGraphQLOperation).mockResolvedValue({data: {shop: {name: 'Test shop'}}}) await expect(target.prepareContext({store: 'shop.myshopify.com', requestedVersion: '2025-10'})).resolves.toEqual(context) - - await expect(target.execute({store: 'shop.myshopify.com', context, request})).resolves.toEqual({ - data: {shop: {name: 'Test shop'}}, - }) + await expect(target.execute({context, request})).resolves.toEqual({data: {shop: {name: 'Test shop'}}}) expect(prepareAdminStoreGraphQLContext).toHaveBeenCalledWith({ store: 'shop.myshopify.com', userSpecifiedVersion: '2025-10', }) - expect(runAdminStoreGraphQLOperation).toHaveBeenCalledWith({ - store: 'shop.myshopify.com', - adminSession: context.adminSession, - sessionUserId: context.sessionUserId, - version: context.version, - request, - }) + expect(runAdminStoreGraphQLOperation).toHaveBeenCalledWith({context, request}) }) }) diff --git a/packages/cli/src/cli/services/store/graphql-targets.ts b/packages/cli/src/cli/services/store/execute/targets.ts similarity index 61% rename from packages/cli/src/cli/services/store/graphql-targets.ts rename to packages/cli/src/cli/services/store/execute/targets.ts index 809568d7e2e..125a86410b1 100644 --- a/packages/cli/src/cli/services/store/graphql-targets.ts +++ b/packages/cli/src/cli/services/store/execute/targets.ts @@ -1,7 +1,7 @@ import {BugError} from '@shopify/cli-kit/node/error' -import {PreparedStoreExecuteRequest} from './execute-request.js' -import {prepareAdminStoreGraphQLContext, AdminStoreGraphQLContext} from './admin-graphql-context.js' -import {runAdminStoreGraphQLOperation} from './admin-graphql-transport.js' +import type {PreparedStoreExecuteRequest} from './request.js' +import {prepareAdminStoreGraphQLContext, type AdminStoreGraphQLContext} from './admin-context.js' +import {runAdminStoreGraphQLOperation} from './admin-transport.js' export type StoreGraphQLApi = 'admin' @@ -11,13 +11,10 @@ interface PrepareStoreGraphQLTargetContextInput { } interface ExecuteStoreGraphQLTargetInput { - store: string context: TContext request: PreparedStoreExecuteRequest } -// Internal seam for store-scoped GraphQL APIs. Different targets may need different -// auth/context preparation and execution behavior, so each target owns both phases. interface StoreGraphQLTarget { id: StoreGraphQLApi prepareContext(input: PrepareStoreGraphQLTargetContextInput): Promise @@ -29,14 +26,8 @@ const adminStoreGraphQLTarget: StoreGraphQLTarget = { prepareContext: async ({store, requestedVersion}) => { return prepareAdminStoreGraphQLContext({store, userSpecifiedVersion: requestedVersion}) }, - execute: async ({store, context, request}) => { - return runAdminStoreGraphQLOperation({ - store, - adminSession: context.adminSession, - sessionUserId: context.sessionUserId, - version: context.version, - request, - }) + execute: async ({context, request}) => { + return runAdminStoreGraphQLOperation({context, request}) }, }