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/domains-transfer-command.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@bigcommerce/catalyst": minor
---

Add the `catalyst domains transfer` command, which moves a custom domain from one project to another project in the same store (the same-store counterpart to `domains claim`). Pass `--to-project-uuid <uuid>` to target a specific project, or omit it to pick the destination interactively from your store's projects. When `catalyst domains add` fails because the domain is already bound to another project in the store, it now points you at the exact `domains transfer` command to move it instead of surfacing the raw API error.
301 changes: 295 additions & 6 deletions packages/catalyst/src/cli/commands/domains.spec.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
import { confirm } from '@inquirer/prompts';
import { confirm, select } from '@inquirer/prompts';
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 { claimDomain, createDomain, deleteDomain, getDomain, listDomains } from '../lib/domains';
import {
claimDomain,
createDomain,
deleteDomain,
findDomain,
getDomain,
listDomains,
transferDomain,
} from '../lib/domains';
import { UserActionableError } from '../lib/errors';
import { consola } from '../lib/logger';
import { mkTempDir } from '../lib/mk-temp-dir';
Expand All @@ -15,9 +23,11 @@ import { domains, formatDomain, formatDomainStatus, waitForDomainVerification }

vi.mock('@inquirer/prompts', () => ({
confirm: vi.fn(),
select: vi.fn(),
}));

const confirmMock = vi.mocked(confirm);
const selectMock = vi.mocked(select);

let exitMock: MockInstance;
let tmpDir: string;
Expand Down Expand Up @@ -67,7 +77,7 @@ afterAll(async () => {
});

describe('command configuration', () => {
test('domains has add, list, status, claim, and remove subcommands', () => {
test('domains has add, list, status, claim, transfer, and remove subcommands', () => {
expect(domains).toBeInstanceOf(Command);
expect(domains.name()).toBe('domains');
expect(domains.description()).toBe(
Expand All @@ -78,6 +88,7 @@ describe('command configuration', () => {
const list = domains.commands.find((command) => command.name() === 'list');
const status = domains.commands.find((command) => command.name() === 'status');
const claim = domains.commands.find((command) => command.name() === 'claim');
const transfer = domains.commands.find((command) => command.name() === 'transfer');
const remove = domains.commands.find((command) => command.name() === 'remove');

expect(add).toBeDefined();
Expand Down Expand Up @@ -133,6 +144,21 @@ describe('command configuration', () => {
]),
);

expect(transfer).toBeDefined();
expect(transfer?.description()).toBe(
'Transfer a custom domain to another project in the same store.',
);
expect(transfer?.options).toEqual(
expect.arrayContaining([
expect.objectContaining({ flags: '--store-hash <hash>' }),
expect.objectContaining({ flags: '--access-token <token>' }),
expect.objectContaining({ flags: '--api-host <host>' }),
expect.objectContaining({ flags: '--project-uuid <uuid>' }),
expect.objectContaining({ flags: '--to-project-uuid <uuid>' }),
expect.objectContaining({ flags: '--wait' }),
]),
);

expect(remove).toBeDefined();
expect(remove?.description()).toBe(
'Remove a custom domain from the current Native Hosting project.',
Expand Down Expand Up @@ -410,6 +436,66 @@ describe('domain API client', () => {
expect(claimedDomain).toBe(domain);
});

test('transfers a domain with the destination project in the body', async () => {
const newProjectUuid = 'a23f5785-fd99-4a94-9fb3-945551623923';
let capturedBody: unknown;
let capturedSourceProject: string | readonly string[] | undefined;

server.use(
http.post(
'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid/domains/:domain/transfer',
async ({ request, params }) => {
capturedBody = await request.json();
capturedSourceProject = params.projectUuid;

return new HttpResponse(null, { status: 204 });
},
),
);

await expect(
transferDomain(domain, projectUuid, newProjectUuid, storeHash, accessToken, apiHost),
).resolves.toBeUndefined();
expect(capturedSourceProject).toBe(projectUuid);
expect(capturedBody).toEqual({ new_project_uuid: newProjectUuid });
});

test('findDomain returns the domain when it exists on the project', async () => {
await expect(findDomain(domain, projectUuid, storeHash, accessToken, apiHost)).resolves.toEqual(
{
domain,
project_uuid: projectUuid,
verification_status: 'verified',
},
);
});

test('findDomain returns null when the domain is not on the project', async () => {
server.use(
http.get(
'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid/domains/:domain',
() => new HttpResponse(null, { status: 404 }),
),
);

await expect(
findDomain(domain, projectUuid, storeHash, accessToken, apiHost),
).resolves.toBeNull();
});

test('findDomain still throws on non-404 errors', async () => {
server.use(
http.get(
'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid/domains/:domain',
() => new HttpResponse(null, { status: 403 }),
),
);

await expect(findDomain(domain, projectUuid, storeHash, accessToken, apiHost)).rejects.toThrow(
'Infrastructure Domains API not enabled',
);
});

test('surfaces the ownership-verification TXT record when a claim is not yet verified', async () => {
server.use(
http.post(
Expand Down Expand Up @@ -591,15 +677,53 @@ describe('add command', () => {
expect(consola.log).toHaveBeenCalledWith(
expect.stringContaining('bc-verify=019500e2933d70578e81090dd7240795'),
);
expect(consola.info).toHaveBeenCalledWith(
`Once the record is live, run: catalyst domains claim ${domain}`,
);
expect(consola.info).toHaveBeenCalledWith('Once the record is live, claim it with:');
expect(consola.log).toHaveBeenCalledWith(` catalyst domains claim ${domain}`);
// The raw V3 title/field text isn't echoed — the concise message replaces it.
expect(consola.warn).not.toHaveBeenCalledWith(
expect.stringContaining('Verify ownership using the claim endpoint'),
);
expect(exitMock).toHaveBeenCalledWith(1);
});

test('suggests the transfer command when the domain is bound to another project in the store', async () => {
const boundProjectUuid = 'b23f5785-fd99-4a94-9fb3-945551623924';

server.use(
http.post(
'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid/domains',
() =>
HttpResponse.json(
{
title:
'The domain is already bound to another project in this store. Use the transfer endpoint to move it.',
errors: {
domain: `'${domain}' is already bound to another project in this store.`,
},
meta: { project_uuid: boundProjectUuid },
},
{ status: 409 },
),
),
);

writeCredentials();

await domains.parseAsync(['add', domain], { from: 'user' });

expect(consola.warn).toHaveBeenCalledWith(
expect.stringContaining('already bound to another project in this store'),
);
expect(consola.info).toHaveBeenCalledWith('To move it to this project, run:');
expect(consola.log).toHaveBeenCalledWith(
` catalyst domains transfer ${domain} --project-uuid ${boundProjectUuid} --to-project-uuid ${projectUuid}`,
);
// The raw V3 title/field text isn't echoed — the concise message + suggestion replace it.
expect(consola.warn).not.toHaveBeenCalledWith(
expect.stringContaining('Use the transfer endpoint to move it'),
);
expect(exitMock).toHaveBeenCalledWith(1);
});
});

describe('list command', () => {
Expand Down Expand Up @@ -791,6 +915,171 @@ describe('claim command', () => {
});
});

describe('transfer command', () => {
const destinationProjectUuid = 'a23f5785-fd99-4a94-9fb3-945551623923';

test('transfers to the project passed via --to-project-uuid without prompting', async () => {
let capturedBody: unknown;
let capturedSourceProject: string | readonly string[] | undefined;

server.use(
http.post(
'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid/domains/:domain/transfer',
async ({ request, params }) => {
capturedBody = await request.json();
capturedSourceProject = params.projectUuid;

return new HttpResponse(null, { status: 204 });
},
),
);

writeCredentials();

await domains.parseAsync(['transfer', domain, '--to-project-uuid', destinationProjectUuid], {
from: 'user',
});

expect(selectMock).not.toHaveBeenCalled();
expect(capturedSourceProject).toBe(projectUuid);
expect(capturedBody).toEqual({ new_project_uuid: destinationProjectUuid });
expect(consola.success).toHaveBeenCalledWith(`Domain ${domain} transferred.`);
expect(consola.log).toHaveBeenCalledWith(expect.stringContaining('active'));
expect(exitMock).toHaveBeenCalledWith(0);
});

test('prompts to pick a destination project, excluding the source', async () => {
// Default fetchProjects handler returns Project One + Project Two; the
// linked (source) project UUID matches neither, so both are offered.
selectMock.mockResolvedValue(destinationProjectUuid);

let capturedBody: unknown;

server.use(
http.post(
'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid/domains/:domain/transfer',
async ({ request }) => {
capturedBody = await request.json();

return new HttpResponse(null, { status: 204 });
},
),
);

writeCredentials();

await domains.parseAsync(['transfer', domain], { from: 'user' });

expect(selectMock).toHaveBeenCalledTimes(1);

const choiceValues = selectMock.mock.calls[0][0].choices.map((choice) =>
typeof choice === 'object' && 'value' in choice ? choice.value : undefined,
);

expect(choiceValues).not.toContain(projectUuid);
expect(capturedBody).toEqual({ new_project_uuid: destinationProjectUuid });
expect(consola.success).toHaveBeenCalledWith(`Domain ${domain} transferred.`);
});

test('points at the owning project when the domain lives on another one', async () => {
// Default fetchProjects returns Project One (a23f…) + Project Two (b23f…).
// The domain is on Project One; the linked project and Project Two 404.
const ownerUuid = destinationProjectUuid;
let transferRequests = 0;

server.use(
http.get(
'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid/domains/:domain',
({ params }) =>
params.projectUuid === ownerUuid
? HttpResponse.json({
data: { domain, project_uuid: ownerUuid, verification_status: 'verified' },
})
: new HttpResponse(null, { status: 404 }),
),
http.post(
'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid/domains/:domain/transfer',
() => {
transferRequests += 1;

return new HttpResponse(null, { status: 204 });
},
),
);

writeCredentials();

await domains.parseAsync(['transfer', domain], { from: 'user' });

expect(consola.warn).toHaveBeenCalledWith(
expect.stringContaining(`${domain} is on project "Project One" (${ownerUuid})`),
);
expect(consola.log).toHaveBeenCalledWith(
` catalyst domains transfer ${domain} --project-uuid ${ownerUuid}`,
);
// The transfer is never attempted, and the user is never prompted.
expect(transferRequests).toBe(0);
expect(selectMock).not.toHaveBeenCalled();
expect(exitMock).toHaveBeenCalledWith(1);
});

test('errors when the domain is on no project in the store', async () => {
let transferRequests = 0;

server.use(
http.get(
'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid/domains/:domain',
() => new HttpResponse(null, { status: 404 }),
),
http.post(
'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid/domains/:domain/transfer',
() => {
transferRequests += 1;

return new HttpResponse(null, { status: 204 });
},
),
);

writeCredentials();

await expect(
domains.parseAsync(['transfer', domain, '--to-project-uuid', destinationProjectUuid], {
from: 'user',
}),
).rejects.toThrow(`${domain} isn't on any project in this store`);

// The transfer is never attempted, and the user is never prompted.
expect(transferRequests).toBe(0);
expect(selectMock).not.toHaveBeenCalled();
});

test('rejects a destination that matches the source project', async () => {
writeCredentials();

await expect(
domains.parseAsync(['transfer', domain, '--to-project-uuid', projectUuid], { from: 'user' }),
).rejects.toThrow('destination project must differ from the source project');
});

test('errors when there are no other projects to transfer to', async () => {
server.use(
http.get('https://:apiHost/stores/:storeHash/v3/infrastructure/projects', () =>
HttpResponse.json({
data: [{ uuid: projectUuid, name: 'Only Project', deployment_hostnames: [] }],
}),
),
);

writeCredentials();

await expect(domains.parseAsync(['transfer', domain], { from: 'user' })).rejects.toThrow(
'No other projects to transfer to',
);
expect(selectMock).not.toHaveBeenCalled();
});
});

describe('remove command', () => {
test('confirms before removing an active domain', async () => {
confirmMock.mockResolvedValue(true);
Expand Down
Loading
Loading