Skip to content
Draft
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
Expand Up @@ -8,7 +8,10 @@ import {commands} from '../../../index.js'
import {testAppLinked, testOrganizationApp} from '../../../models/app/app.test-data.js'
import {linkedAppContext} from '../../../services/app-context.js'
import {cancelMigrationOperations} from '../../../services/subscription-migrations/cancel-operations.js'
import {migrationCancellationJsonOutputSchema} from '../../../services/subscription-migrations/types.js'
import {
migrationCancellationJsonOutputSchema,
migrationListJsonOutputSchema,
} from '../../../services/subscription-migrations/types.js'
import {outputOperations} from '../../../services/subscription-migrations/command-output.js'
import {getMigrationOperations} from '../../../services/subscription-migrations/get-operations.js'
import {runSubmissionCommand} from '../../../services/subscription-migrations/run-submission-command.js'
Expand Down Expand Up @@ -434,6 +437,12 @@ describe('subscription migration command metadata', () => {
expect(Command.flags.json).toBe(jsonFlag.json)
})

test('list exposes and documents its JSON output schema', () => {
expect(List.jsonOutputSchema).toBe(migrationListJsonOutputSchema)
expect(List.description).toContain('`MigrationListResult` schema')
expect(List.description).toContain('```json')
})

test('cancel exposes its JSON output schema', () => {
expect(Cancel.jsonOutputSchema).toBe(migrationCancellationJsonOutputSchema)
})
Expand Down Expand Up @@ -557,12 +566,9 @@ describe('subscription migration command metadata', () => {
},
)

test.each([Schedule, Unschedule, Status, List])(
'$name has no fenced-code markers in its plain description',
(Command) => {
expect(Command.description).not.toContain('```')
},
)
test.each([Schedule, Unschedule, Status])('$name has no fenced-code markers in its plain description', (Command) => {
expect(Command.description).not.toContain('```')
})

test('cancel documents its JSON output schema', () => {
expect(Cancel.description).toContain('`MigrationCancellationResult` schema')
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import {listFlags} from './flags.js'
import {migrationListJsonOutputSchema} from '../../../services/subscription-migrations/types.js'
import {linkedAppContext} from '../../../services/app-context.js'
import {
iterateMigratableSubscriptionPages,
MigratableSubscriptionsNotFoundError,
} from '../../../services/subscription-migrations/list-migratable-subscriptions.js'
import {outputMigrationList} from '../../../services/subscription-migrations/list-output.js'
import AppLinkedCommand, {AppLinkedCommandOutput} from '../../../utilities/app-linked-command.js'
import {jsonFlag} from '@shopify/cli-kit/node/cli'
import {AbortError} from '@shopify/cli-kit/node/error'

export default class List extends AppLinkedCommand {
Expand All @@ -20,7 +22,7 @@ Use \`--status\` to filter subscriptions by migration status. Supported values a

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 %>',
Expand All @@ -30,7 +32,11 @@ Run the command from an app project. By default, it uses the Client ID from the
'<%= config.bin %> <%= command.id %> --client-id <client-id> > subscriptions.csv',
]

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

static get jsonOutputSchema() {
return migrationListJsonOutputSchema
}

async run(): Promise<AppLinkedCommandOutput> {
const {flags} = await this.parse(List)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {migrationListJsonOutputSchema} from './types.js'
import {outputMigrationList, serializeMigrationListCsv, serializeMigrationListJson} from './list-output.js'
import {outputResult} from '@shopify/cli-kit/node/output'
import {describe, expect, test, vi} from 'vitest'
Expand All @@ -8,6 +9,15 @@ vi.mock('@shopify/cli-kit/node/output', async (importOriginal) => {
return {...actual, outputResult: vi.fn()}
})

// Defaults to true, matching the real isUnitTest behavior under vitest. The stream boundary test overrides it to
// false so outputResult writes to process.stdout instead of collecting logs.
const isUnitTest = vi.hoisted(() => vi.fn(() => true))

vi.mock('@shopify/cli-kit/node/context/local', async (importOriginal) => ({
...(await importOriginal<typeof import('@shopify/cli-kit/node/context/local')>()),
isUnitTest,
}))

const CSV_HEADER =
'shop_id,status,manual_subscription_name,manual_subscription_price_amount,manual_subscription_price_currency_code,manual_subscription_interval,target_plan_handle,notification_kind,notification_opt_out_deadline,notification_sent_at,price_behavior,effective_date,last_failure_reason'

Expand Down Expand Up @@ -257,3 +267,75 @@ describe('outputMigrationList JSON', () => {
expect(outputResult).not.toHaveBeenCalled()
})
})

describe('migration list JSON contract', () => {
test('preserves nullable fields and nested nulls', () => {
const value = subscription({
manualSubscriptionName: null,
manualSubscriptionPrice: null,
targetPlanHandle: null,
notification: {kind: 'NONE', optOutDeadline: null, sentAt: null},
priceBehavior: null,
effectiveDate: null,
lastFailureReason: null,
})

expect(serializeMigrationListJson([value])).toBe(
JSON.stringify({schemaVersion: 1, subscriptions: [value]}, null, 2),
)
})

test.each([
{manualSubscriptionPrice: {amount: 19.99, currencyCode: 'USD'}},
{notification: {kind: 'NONE', optOutDeadline: null}},
])('rejects invalid subscription fields: %j', (fields) => {
expect(() =>
migrationListJsonOutputSchema.validate({
schemaVersion: 1,
subscriptions: [{...subscription(), ...fields}],
}),
).toThrow()
})

test.each([
{status: 'UNKNOWN'},
{priceBehavior: 'UNKNOWN'},
{manualSubscriptionInterval: 'MONTHLY'},
{lastFailureReason: 'UNKNOWN'},
])('accepts unknown server-provided values for pass-through fields: %j', (fields) => {
const value = {...subscription(), ...fields}

const encoded = migrationListJsonOutputSchema.encode({schemaVersion: 1, subscriptions: [value]})

expect(JSON.parse(encoded)).toEqual({schemaVersion: 1, subscriptions: [value]})
})
})

describe('outputMigrationList stream boundary', () => {
test('writes all pages as one JSON document to stdout', async () => {
// Restore the real outputResult and report a non-test context so the document reaches process.stdout.
const actualOutput =
await vi.importActual<typeof import('@shopify/cli-kit/node/output')>('@shopify/cli-kit/node/output')
vi.mocked(outputResult).mockImplementation(actualOutput.outputResult)
isUnitTest.mockReturnValue(false)
const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
const value = subscription()
async function* pages() {
yield [value]
expect(stdout).not.toHaveBeenCalled()
yield []
}

try {
await outputMigrationList({pages: pages(), json: true})

expect(stdout).toHaveBeenCalledOnce()
expect(stdout.mock.calls[0]?.[0]).toBe(`${JSON.stringify({schemaVersion: 1, subscriptions: [value]}, null, 2)}\n`)
expect(stderr).not.toHaveBeenCalled()
} finally {
stdout.mockRestore()
stderr.mockRestore()
}
})
})
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {migrationListJsonOutputSchema} from './types.js'
import {outputResult} from '@shopify/cli-kit/node/output'
import type {MigratableSubscription} from '../../models/subscription-migrations.js'

Expand All @@ -10,7 +11,7 @@ interface MigrationListOutputOptions {
}

export function serializeMigrationListJson(subscriptions: MigratableSubscription[]): string {
return JSON.stringify({schemaVersion: 1, subscriptions}, null, 2)
return migrationListJsonOutputSchema.encode({schemaVersion: 1, subscriptions})
}

export function serializeMigrationListCsv(subscriptions: MigratableSubscription[]): string {
Expand Down
40 changes: 40 additions & 0 deletions packages/app/src/cli/services/subscription-migrations/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,43 @@ export const migrationCancellationJsonOutputSchema = defineJsonOutputSchema({
export type MigrationCancellationJsonOutput = InferJsonOutputSchema<typeof migrationCancellationJsonOutputSchema>
export type MigrationCancellationResult = Omit<MigrationCancellationJsonOutput, 'schemaVersion'>
export type MigrationCancellationOutcome = MigrationCancellationResult['outcomes'][number]

const MigratableSubscriptionPriceSchema = zod.object({
amount: zod.string(),
currencyCode: zod.string(),
})

const MigratableSubscriptionNotificationSchema = zod.object({
kind: zod.string().describe('Known values: NONE, OPT_OUT, WHEN_REQUIRED.'),
optOutDeadline: zod.string().nullable(),
sentAt: zod.string().nullable(),
})

// Server-provided values are typed as strings (with the known values documented) instead of enums, so a new
// server-side value never makes `--json` output fail validation. Compatibility with the `MigratableSubscription`
// model is enforced where `serializeMigrationListJson` passes the model into this schema's `encode`.
const MigratableSubscriptionSchema = zod.object({
shopId: zod.string(),
status: zod.string().describe('Known values: UNSCHEDULED, SCHEDULED, MIGRATED.'),
manualSubscriptionName: zod.string().nullable(),
manualSubscriptionPrice: MigratableSubscriptionPriceSchema.nullable(),
manualSubscriptionInterval: zod.string().describe('Known values: EVERY_30_DAYS, ANNUAL.'),
targetPlanHandle: zod.string().nullable(),
notification: MigratableSubscriptionNotificationSchema.nullable(),
priceBehavior: zod.string().nullable().describe('Known values: HONOR_BILLING_PRICE, PLAN_PRICE.'),
effectiveDate: zod.string().nullable(),
lastFailureReason: zod.string().nullable().describe('Known values: SUPERSEDED, SCHEDULING_FAILED.'),
})

export const migrationListJsonOutputSchema = defineJsonOutputSchema({
name: 'MigrationListResult',
schema: zod.object({
schemaVersion: zod.literal(1),
subscriptions: zod.array(MigratableSubscriptionSchema),
}),
definitions: {
MigratableSubscription: MigratableSubscriptionSchema,
MigratableSubscriptionPrice: MigratableSubscriptionPriceSchema,
MigratableSubscriptionNotification: MigratableSubscriptionNotificationSchema,
},
})
2 changes: 1 addition & 1 deletion packages/cli/oclif.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -4059,7 +4059,7 @@
"args": {
},
"customPluginName": "@shopify/app",
"description": "Lists every app subscription eligible for migration.\n\nBy default, the command writes CSV to stdout, streaming each page of results as it arrives. If a later page fails, the rows already written remain valid CSV. Use `--json` to fetch all pages first and then write a single versioned JSON envelope to stdout. Use shell redirection to save either format, for example `shopify app subscription-migrations list > subscriptions.csv` or `shopify app subscription-migrations list --json > subscriptions.json`.\n\nUse `--status` to filter subscriptions by migration status. Supported values are `UNSCHEDULED`, `SCHEDULED`, and `MIGRATED`.\n\nRun 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.",
"description": "Lists every app subscription eligible for migration.\n\nBy default, the command writes CSV to stdout, streaming each page of results as it arrives. If a later page fails, the rows already written remain valid CSV. Use `--json` to fetch all pages first and then write a single versioned JSON envelope to stdout. Use shell redirection to save either format, for example `shopify app subscription-migrations list > subscriptions.csv` or `shopify app subscription-migrations list --json > subscriptions.json`.\n\nUse `--status` to filter subscriptions by migration status. Supported values are `UNSCHEDULED`, `SCHEDULED`, and `MIGRATED`.\n\nRun 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.\n\nOutput from `--json` conforms to the `MigrationListResult` schema.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"schemaVersion\": {\n \"type\": \"number\",\n \"const\": 1\n },\n \"subscriptions\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MigratableSubscription\"\n }\n }\n },\n \"required\": [\n \"schemaVersion\",\n \"subscriptions\"\n ],\n \"additionalProperties\": false,\n \"title\": \"MigrationListResult\",\n \"definitions\": {\n \"MigratableSubscription\": {\n \"type\": \"object\",\n \"properties\": {\n \"shopId\": {\n \"type\": \"string\"\n },\n \"status\": {\n \"type\": \"string\",\n \"description\": \"Known values: UNSCHEDULED, SCHEDULED, MIGRATED.\"\n },\n \"manualSubscriptionName\": {\n \"type\": [\n \"string\",\n \"null\"\n ]\n },\n \"manualSubscriptionPrice\": {\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/MigratableSubscriptionPrice\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"manualSubscriptionInterval\": {\n \"type\": \"string\",\n \"description\": \"Known values: EVERY_30_DAYS, ANNUAL.\"\n },\n \"targetPlanHandle\": {\n \"type\": [\n \"string\",\n \"null\"\n ]\n },\n \"notification\": {\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/MigratableSubscriptionNotification\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"priceBehavior\": {\n \"type\": [\n \"string\",\n \"null\"\n ],\n \"description\": \"Known values: HONOR_BILLING_PRICE, PLAN_PRICE.\"\n },\n \"effectiveDate\": {\n \"type\": [\n \"string\",\n \"null\"\n ]\n },\n \"lastFailureReason\": {\n \"type\": [\n \"string\",\n \"null\"\n ],\n \"description\": \"Known values: SUPERSEDED, SCHEDULING_FAILED.\"\n }\n },\n \"required\": [\n \"shopId\",\n \"status\",\n \"manualSubscriptionName\",\n \"manualSubscriptionPrice\",\n \"manualSubscriptionInterval\",\n \"targetPlanHandle\",\n \"notification\",\n \"priceBehavior\",\n \"effectiveDate\",\n \"lastFailureReason\"\n ],\n \"additionalProperties\": false\n },\n \"MigratableSubscriptionPrice\": {\n \"type\": \"object\",\n \"properties\": {\n \"amount\": {\n \"type\": \"string\"\n },\n \"currencyCode\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"amount\",\n \"currencyCode\"\n ],\n \"additionalProperties\": false\n },\n \"MigratableSubscriptionNotification\": {\n \"type\": \"object\",\n \"properties\": {\n \"kind\": {\n \"type\": \"string\",\n \"description\": \"Known values: NONE, OPT_OUT, WHEN_REQUIRED.\"\n },\n \"optOutDeadline\": {\n \"type\": [\n \"string\",\n \"null\"\n ]\n },\n \"sentAt\": {\n \"type\": [\n \"string\",\n \"null\"\n ]\n }\n },\n \"required\": [\n \"kind\",\n \"optOutDeadline\",\n \"sentAt\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```",
"descriptionWithMarkdown": "Lists every app subscription eligible for migration.\n\nBy default, the command writes CSV to stdout, streaming each page of results as it arrives. If a later page fails, the rows already written remain valid CSV. Use `--json` to fetch all pages first and then write a single versioned JSON envelope to stdout. Use shell redirection to save either format, for example `shopify app subscription-migrations list > subscriptions.csv` or `shopify app subscription-migrations list --json > subscriptions.json`.\n\nUse `--status` to filter subscriptions by migration status. Supported values are `UNSCHEDULED`, `SCHEDULED`, and `MIGRATED`.\n\nRun 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.",
"examples": [
"<%= config.bin %> <%= command.id %>",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ const commandExceptions = [
'packages/app/src/cli/commands/app/info.ts',
'packages/app/src/cli/commands/app/init.ts',
'packages/app/src/cli/commands/app/release.ts',
'packages/app/src/cli/commands/app/subscription-migrations/list.ts',
'packages/app/src/cli/commands/app/subscription-migrations/schedule.ts',
'packages/app/src/cli/commands/app/subscription-migrations/status.ts',
'packages/app/src/cli/commands/app/subscription-migrations/unschedule.ts',
Expand Down
Loading