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
5 changes: 5 additions & 0 deletions .changeset/normalize-migration-unscheduling-json.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@shopify/cli": major
---

Normalize migration unscheduling JSON with explicit outcomes, successful cancellation, GID fields, and flattened results.
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {getMigrationOperations} from '../../../services/subscription-migrations/
import {runSubmissionCommand} from '../../../services/subscription-migrations/run-submission-command.js'
import {watchMigrationOperations} from '../../../services/subscription-migrations/watch-operations.js'
import AppLinkedCommand from '../../../utilities/app-linked-command.js'
import * as system from '@shopify/cli-kit/node/system'
import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli'
import {outputResult} from '@shopify/cli-kit/node/output'
import {renderSuccess, renderWarning} from '@shopify/cli-kit/node/ui'
Expand Down Expand Up @@ -141,6 +142,49 @@ describe('subscription migration submission commands', () => {
)
})

test.each([
{json: false, noInput: false},
{json: true, noInput: false},
{json: false, noInput: true},
{json: true, noInput: true},
])('unschedule keeps format independent of no-input: %j', async ({json, noInput}) => {
const submission = {...successfulSubmissionResult.submission, action: 'unschedule' as const}
vi.mocked(runSubmissionCommand).mockResolvedValue({status: 'success', submission})
await Unschedule.run(['--input', '-', '--force', ...(json ? ['--json'] : []), ...(noInput ? ['--no-input'] : [])])
expect(runSubmissionCommand).toHaveBeenCalledWith(
expect.objectContaining({action: 'unschedule', skipConfirmation: true}),
)
if (json) {
expect(JSON.parse(vi.mocked(outputResult).mock.calls[0]![0] as string)).toMatchObject({
status: 'success',
changed: true,
action: 'unschedule',
})
} else {
expect(outputResult).not.toHaveBeenCalled()
expect(renderSuccess).toHaveBeenCalledOnce()
}
})

test('unschedule emits cancellation and exits zero after declined confirmation', async () => {
const result = {
status: 'cancelled' as const,
changed: false as const,
action: 'unschedule' as const,
reason: 'Confirmation declined.',
}
vi.mocked(runSubmissionCommand).mockResolvedValue(result)
const supportsPrompting = vi.spyOn(system, 'terminalSupportsPrompting').mockReturnValue(true)
try {
await Unschedule.run(['--input', '-', '--json'])
expect(runSubmissionCommand).toHaveBeenCalledWith(expect.objectContaining({skipConfirmation: false}))
expect(JSON.parse(vi.mocked(outputResult).mock.calls[0]![0] as string)).toEqual(result)
expect(process.exitCode).toBeUndefined()
} finally {
supportsPrompting.mockRestore()
}
})

test('unschedule delegates without idempotency controls', async () => {
const result = await Unschedule.run(['--input', '-', '--client-id', 'unschedule-client-id', '--force'])

Expand Down Expand Up @@ -228,6 +272,30 @@ describe('subscription migration submission commands', () => {
expect(outputResult).not.toHaveBeenCalled()
})

test.each([false, true])('unschedule preserves JSON results and failure exits with watch=%s', async (watch) => {
const submission = {...successfulSubmissionResult.submission, action: 'unschedule' as const}
const failure = {type: 'submission' as const, batchIndex: 1, userErrors: [{message: 'Rejected', field: null}]}
vi.mocked(runSubmissionCommand).mockResolvedValue({status: 'failed', submission, failure})

await expect(
Unschedule.run(['--input', 'migrations.csv', '--force', '--json', ...(watch ? ['--watch'] : [])]),
).resolves.toEqual({app})

expect(runSubmissionCommand).toHaveBeenCalledWith({
action: 'unschedule',
input: 'migrations.csv',
clientId: 'remote-client-id',
skipConfirmation: true,
watch,
})
expect(outputResult).toHaveBeenCalledOnce()
expect(JSON.parse(vi.mocked(outputResult).mock.calls[0]![0] as string)).toEqual(
projectMigrationSubmissionResult({status: 'failed', submission, failure}),
)
expect(process.exitCode).toBe(1)
expect(renderWarning).not.toHaveBeenCalled()
})

test.each([Schedule, Unschedule])(
'$name rejects positional CSV input before calling its service',
async (Command) => {
Expand Down Expand Up @@ -611,8 +679,10 @@ describe('subscription migration command metadata', () => {
},
)

test('unschedule has no fenced-code markers in its plain description', () => {
expect(Unschedule.description).not.toContain('```')
test('unschedule exposes and documents the shared submission schema', () => {
expect(Unschedule.jsonOutputSchema).toBe(Schedule.jsonOutputSchema)
expect(Unschedule.description).toContain('`MigrationSubmissionResult` schema')
expect(Unschedule.description).toContain('```json')
})

test('cancel documents its JSON output schema', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -271,3 +271,34 @@ describe('migration submission JSON contract', () => {
}
})
})

describe('unschedule JSON compatibility', () => {
test('uses the shared submission contract and preserves the unschedule action', () => {
const value = {...submission(), action: 'unschedule' as const}
const encoded = encodeMigrationSubmissionResult({status: 'success', submission: value})

const expected = {
status: 'success',
changed: true,
clientId: 'client-id',
action: 'unschedule',
inputDigest: 'input-digest',
total: 1,
operations: [
{
batchIndex: 0,
batchPayloadDigest: 'batch-digest',
operation: {
gid: 'gid://shopify/AppSubscriptionMigrationOperation/operation-one',
status: 'RUNNING',
total: 1,
results: [],
},
},
],
}
expect(encoded).toBe(JSON.stringify(expected, null, 2))
expect(migrationSubmissionJsonOutputSchema.validate(JSON.parse(encoded))).toEqual(expected)
expect(JSON.parse(encoded)).not.toHaveProperty('failure')
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -152,12 +152,17 @@ describe('migration cancellation JSON output', () => {
})

describe('migration submission JSON output', () => {
test.each([false, true])('writes one final failure document with watch=%s', (watch) => {
test.each([
{action: 'schedule' as const, watch: false},
{action: 'schedule' as const, watch: true},
{action: 'unschedule' as const, watch: false},
{action: 'unschedule' as const, watch: true},
])('writes one final $action failure document with watch=$watch', ({action, watch}) => {
const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
const submission = {
clientId: 'client-id',
action: 'schedule' as const,
action,
inputDigest: 'input-digest',
total: 2,
operations: [
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import {submissionFlags} from './flags.js'
import {presentAcceptedMigrationSubmission, presentMigrationSubmissionResult} from './result-presenter.js'
import {migrationSubmissionJsonOutputSchema} from '../../../services/subscription-migrations/types.js'
import {linkedAppContext} from '../../../services/app-context.js'
import {runSubmissionCommand} from '../../../services/subscription-migrations/run-submission-command.js'
import AppLinkedCommand, {AppLinkedCommandOutput} from '../../../utilities/app-linked-command.js'
import {jsonFlag} from '@shopify/cli-kit/node/cli'

export default class Unschedule extends AppLinkedCommand {
static summary = 'Reverses app subscription migrations that are still scheduled.'
Expand All @@ -22,7 +24,7 @@ Operations are submitted in batches of 250 shops. Preserve every operation GID p

Run the command from an app project. By default, it uses the Client ID from the active app configuration. Use \`--path\` to select an app directory or \`--config\` to select a configuration. Pass \`--client-id\` to select a different app within the project. Use \`--reset\` to relink the app.`

static description = this.descriptionWithoutMarkdown()
static description = this.descriptionForHelp()

static examples = [
'<%= config.bin %> <%= command.id %> --input migrations.csv --force',
Expand All @@ -32,7 +34,11 @@ Run the command from an app project. By default, it uses the Client ID from the
'<%= config.bin %> <%= command.id %> --input - --force --watch',
]

static flags = {...submissionFlags}
static flags = {...submissionFlags, ...jsonFlag}

static get jsonOutputSchema() {
return migrationSubmissionJsonOutputSchema
}

async run(): Promise<AppLinkedCommandOutput> {
const {flags} = await this.parse(Unschedule)
Expand Down
Loading
Loading