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
255 changes: 255 additions & 0 deletions packages/catalyst/src/cli/commands/channel.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
import { Command } from 'commander';
import Conf from 'conf';
import { http, HttpResponse } from 'msw';
import { afterAll, afterEach, beforeAll, describe, expect, MockInstance, test, vi } from 'vitest';

import { server } from '../../../tests/mocks/node';
import { consola } from '../lib/logger';
import { mkTempDir } from '../lib/mk-temp-dir';
import { getProjectConfig, ProjectConfigSchema } from '../lib/project-config';
import { program } from '../program';

import { channel } from './channel';

let exitMock: MockInstance;

let tmpDir: string;
let cleanup: () => Promise<void>;
let config: Conf<ProjectConfigSchema>;

const { mockIdentify } = vi.hoisted(() => ({
mockIdentify: vi.fn(),
}));

const linkedProjectUuid = 'a23f5785-fd99-4a94-9fb3-945551623923';
const storeHash = 'test-store';
const accessToken = 'test-token';

beforeAll(async () => {
consola.mockTypes(() => vi.fn());

vi.mock('../lib/telemetry', () => {
const instance = {
identify: mockIdentify,
isEnabled: vi.fn(() => true),
track: vi.fn(),
correlationId: 'test-session-uuid',
commandName: 'unknown',
durationMs: vi.fn().mockReturnValue(0),
analytics: {
closeAndFlush: vi.fn().mockResolvedValue(undefined),
},
};

return {
Telemetry: vi.fn().mockImplementation(() => instance),
getTelemetry: vi.fn(() => instance),
resetTelemetry: vi.fn(),
};
});

// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
exitMock = vi.spyOn(process, 'exit').mockImplementation(() => null as never);

[tmpDir, cleanup] = await mkTempDir();

vi.spyOn(process, 'cwd').mockReturnValue(tmpDir);

config = getProjectConfig();
});

afterEach(() => {
vi.clearAllMocks();
config.delete('storeHash');
config.delete('accessToken');
config.delete('projectUuid');
});

afterAll(async () => {
vi.restoreAllMocks();
exitMock.mockRestore();

await cleanup();
});

describe('channel', () => {
test('has the update subcommand', () => {
expect(channel).toBeInstanceOf(Command);
expect(channel.name()).toBe('channel');

const update = channel.commands.find((cmd) => cmd.name() === 'update');

expect(update).toBeDefined();
expect(update?.description()).toContain('Update a BigCommerce channel');
});
});

describe('channel update', () => {
test('happy path: prompts for channel and hostname, then PUTs', async () => {
let putBody: unknown;
let putChannelId: string | undefined;

server.use(
http.put(
'https://:apiHost/stores/:storeHash/v3/channels/:channelId/site',
async ({ request, params }) => {
putBody = await request.json();
putChannelId = String(params.channelId);

return HttpResponse.json({
data: {
id: 1,
url: 'https://project-one.catalyst-sandbox.store',
channel_id: 2,
},
});
},
),
);

const promptMock = vi
.spyOn(consola, 'prompt')
.mockResolvedValueOnce('2')
.mockResolvedValueOnce('project-one.catalyst-sandbox.store');

await program.parseAsync([
'node',
'catalyst',
'channel',
'update',
'--store-hash',
storeHash,
'--access-token',
accessToken,
'--project-uuid',
linkedProjectUuid,
]);

expect(promptMock).toHaveBeenCalledTimes(2);
expect(putChannelId).toBe('2');
expect(putBody).toEqual({ url: 'https://project-one.catalyst-sandbox.store' });
expect(consola.success).toHaveBeenCalledWith(
expect.stringContaining('Updated channel "Catalyst Storefront" (2) site URL'),
);
expect(exitMock).toHaveBeenCalledWith(0);
});

test('reads project UUID from .bigcommerce/project.json when no flag is passed', async () => {
config.set('projectUuid', linkedProjectUuid);

const promptMock = vi
.spyOn(consola, 'prompt')
.mockResolvedValueOnce('2')
.mockResolvedValueOnce('vanity.project-one.example.com');

await program.parseAsync([
'node',
'catalyst',
'channel',
'update',
'--store-hash',
storeHash,
'--access-token',
accessToken,
]);

expect(promptMock).toHaveBeenCalledTimes(2);
expect(consola.success).toHaveBeenCalledWith(
expect.stringContaining('https://vanity.project-one.example.com'),
);
});

test('--channel-id and --hostname skip both prompts', async () => {
let putBody: unknown;
let putChannelId: string | undefined;

server.use(
http.put(
'https://:apiHost/stores/:storeHash/v3/channels/:channelId/site',
async ({ request, params }) => {
putBody = await request.json();
putChannelId = String(params.channelId);

return HttpResponse.json({
data: { id: 1, url: 'https://override.example', channel_id: 5 },
});
},
),
);

const promptMock = vi.spyOn(consola, 'prompt');

await program.parseAsync([
'node',
'catalyst',
'channel',
'update',
'--store-hash',
storeHash,
'--access-token',
accessToken,
'--project-uuid',
linkedProjectUuid,
'--channel-id',
'5',
'--hostname',
'override.example',
]);

expect(promptMock).not.toHaveBeenCalled();
expect(putChannelId).toBe('5');
expect(putBody).toEqual({ url: 'https://override.example' });
});

test('exits gracefully when no projects exist and user declines to create one', async () => {
server.use(
http.get('https://:apiHost/stores/:storeHash/v3/infrastructure/projects', () =>
HttpResponse.json({ data: [] }),
),
);

// First prompt: "Would you like to create one?" — user says no
vi.spyOn(consola, 'prompt').mockResolvedValueOnce(false);

await program.parseAsync([
'node',
'catalyst',
'channel',
'update',
'--store-hash',
storeHash,
'--access-token',
accessToken,
]);

expect(consola.info).toHaveBeenCalledWith(expect.stringContaining('catalyst project create'));
expect(exitMock).toHaveBeenCalledWith(0);
});

test('propagates errors from the channel-site PUT', async () => {
server.use(
http.put('https://:apiHost/stores/:storeHash/v3/channels/:channelId/site', () =>
HttpResponse.json({}, { status: 401 }),
),
);

vi.spyOn(consola, 'prompt')
.mockResolvedValueOnce('2')
.mockResolvedValueOnce('project-one.catalyst-sandbox.store');

await expect(
program.parseAsync([
'node',
'catalyst',
'channel',
'update',
'--store-hash',
storeHash,
'--access-token',
accessToken,
'--project-uuid',
linkedProjectUuid,
]),
).rejects.toThrow('Re-run `catalyst auth login`');
});
});
82 changes: 82 additions & 0 deletions packages/catalyst/src/cli/commands/channel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { Command, Option } from 'commander';

import { runChannelSiteUrlFlow } from '../lib/channel-site-flow';
import { NoLinkedProjectError } from '../lib/commerce-hosting';
import { consola } from '../lib/logger';
import { getProjectConfig } from '../lib/project-config';
import { resolveCredentials } from '../lib/resolve-credentials';
import {
accessTokenOption,
apiHostOption,
projectUuidOption,
storeHashOption,
} from '../lib/shared-options';
import { getTelemetry } from '../lib/telemetry';

const update = new Command('update')
.configureHelp({ showGlobalOptions: true })
.description(
"Update a BigCommerce channel's site URL to point at one of your project's deployment hostnames.",
)
.addHelpText(
'after',
`
Examples:
# Pick a channel and hostname interactively
$ catalyst channel update

# Skip both prompts
$ catalyst channel update --channel-id 123 --hostname my-storefront.example.com`,
)
.addOption(storeHashOption())
.addOption(accessTokenOption())
.addOption(apiHostOption())
.addOption(projectUuidOption())
.addOption(
new Option(
'--channel-id <id>',
'Skip the channel prompt and target this channel directly.',
).argParser((value: string) => Number(value)),
)
.addOption(
new Option(
'--hostname <hostname>',
"Skip the hostname prompt and use this hostname directly. Must be one of the project's deployment_hostnames.",
),
)
.action(async (options) => {
const config = getProjectConfig();
const { storeHash, accessToken } = resolveCredentials(options, config);

await getTelemetry().identify(storeHash);

try {
await runChannelSiteUrlFlow({
storeHash,
accessToken,
apiHost: options.apiHost,
projectUuid: options.projectUuid ?? config.get('projectUuid'),
channelId: options.channelId,
hostname: options.hostname,
});
} catch (error) {
if (error instanceof NoLinkedProjectError) {
consola.info(
"When you're ready to create a project, run `catalyst project create` or re-run `catalyst channel update`.",
);
process.exit(0);

// Unreachable in production; prevents continuation when process.exit is mocked in tests.
return;
}

throw error;
}

process.exit(0);
});

export const channel = new Command('channel')
.configureHelp({ showGlobalOptions: true })
.description('Manage BigCommerce channels.')
.addCommand(update);
Loading
Loading