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
36 changes: 35 additions & 1 deletion src/commands/__tests__/alerts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ vi.mock('../../api/client.js', () => ({
}));

import { apiClient } from '../../api/client.js';
import { alertPhoneRemove, alertPhoneSet, alertPhoneStatus, alertPhoneVerify } from '../alerts.js';
import { alertPhoneConsents, alertPhoneRemove, alertPhoneSet, alertPhoneStatus, alertPhoneVerify } from '../alerts.js';

const VERIFIED = {
phone: '+141•••2671',
Expand Down Expand Up @@ -115,3 +115,37 @@ describe('alerts phone remove', () => {
expect(apiClient).toHaveBeenCalledWith('/auth/phone', { method: 'DELETE' });
});
});

describe('alerts phone consents', () => {
beforeEach(() => {
vi.mocked(apiClient).mockReset();
vi.spyOn(console, 'log').mockImplementation(() => {});
});

afterEach(() => {
vi.restoreAllMocks();
});

test('When flags are given, then only those fields are PATCHed', async () => {
// Arrange
vi.mocked(apiClient).mockResolvedValueOnce(VERIFIED);
// Act
await alertPhoneConsents({ marketing: 'off', prefer: 'sms', json: true });
// Assert
const [path, init] = vi.mocked(apiClient).mock.calls[0];
expect(path).toBe('/auth/phone/consents');
expect(JSON.parse((init as { body: string }).body)).toEqual({ marketing: false, channelPreference: 'sms' });
});

test('When no flags are given, then it fails locally without calling the API', async () => {
// Act + Assert
await expect(alertPhoneConsents({})).rejects.toThrow(/Nothing to update/);
expect(apiClient).not.toHaveBeenCalled();
});

test('When a consent value is not on or off, then it is rejected before any call', async () => {
// Act + Assert
await expect(alertPhoneConsents({ product: 'yes' })).rejects.toThrow(/"on" or "off"/);
expect(apiClient).not.toHaveBeenCalled();
});
});
76 changes: 76 additions & 0 deletions src/commands/__tests__/org-profile.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Command } from 'commander';

vi.mock('../../api/client.js', () => ({
apiClient: vi.fn(),
}));
vi.mock('../_helpers.js', () => ({
getDefaultWorkspaceId: vi.fn().mockResolvedValue('ws_TEST0001'),
resolveOrgPublicIdForWorkspace: vi.fn().mockResolvedValue('org_abc12345'),
}));

const mockConsoleLog = vi.spyOn(console, 'log').mockImplementation(() => {});

import { apiClient } from '../../api/client.js';
import { registerOrgProfileCommand } from '../org-profile.js';

const mockedApi = vi.mocked(apiClient);

function makeProgram(): Command {
const program = new Command();
program.exitOverride();
program.option('--json');
registerOrgProfileCommand(program);
return program;
}

beforeEach(() => {
mockedApi.mockReset();
mockConsoleLog.mockClear();
});

describe('hookmyapp org profile', () => {
const PROFILE = {
publicId: 'org_abc12345',
name: 'Acme',
email: null,
phone: null,
website: 'https://acme.com',
businessCategory: null,
businessNiche: null,
primaryUseCase: null,
};

it('org profile show GETs the org summary', async () => {
mockedApi.mockResolvedValue(PROFILE);

await makeProgram().parseAsync(['node', 'hookmyapp', 'org', 'profile', 'show', '--json']);

expect(mockedApi).toHaveBeenCalledWith('/organizations/org_abc12345');
expect(mockConsoleLog.mock.calls[0][0]).toContain('acme.com');
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it('org profile set PATCHes only the provided fields', async () => {
mockedApi.mockResolvedValue(PROFILE);

await makeProgram().parseAsync([
'node', 'hookmyapp', 'org', 'profile', 'set',
'--website', 'https://acme.com', '--business-category', 'E-commerce',
]);

const [path, init] = mockedApi.mock.calls[0];
expect(path).toBe('/organizations/org_abc12345/profile');
expect((init as { method: string }).method).toBe('PATCH');
expect(JSON.parse((init as { body: string }).body)).toEqual({
website: 'https://acme.com',
businessCategory: 'E-commerce',
});
});

it('org profile set with no flags fails locally without calling the API', async () => {
await expect(
makeProgram().parseAsync(['node', 'hookmyapp', 'org', 'profile', 'set']),
).rejects.toThrow(/Nothing to update/);
expect(mockedApi).not.toHaveBeenCalled();
});
});
141 changes: 0 additions & 141 deletions src/commands/__tests__/phone.test.ts

This file was deleted.

77 changes: 76 additions & 1 deletion src/commands/alerts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,60 @@ export async function alertPhoneRemove(opts: { json?: boolean; yes?: boolean } =
console.log(c.success('Alert phone removed.'));
}

/** Opt-out / delivery-preference updates without re-verifying (PATCH /auth/phone/consents). */
export async function alertPhoneConsents(
opts: {
json?: boolean;
operational?: string;
product?: string;
marketing?: string;
prefer?: string;
} = {},
): Promise<void> {
const toBool = (v: string | undefined, flag: string): boolean | undefined => {
if (v === undefined) return undefined;
if (v !== 'on' && v !== 'off') throw new ValidationError(`${flag} must be "on" or "off"`, 'ALERT_PHONE_CONSENT_FLAG');
return v === 'on';
};
const body: Record<string, unknown> = {};
const operational = toBool(opts.operational, '--operational');
const product = toBool(opts.product, '--product');
const marketing = toBool(opts.marketing, '--marketing');
if (operational !== undefined) body.operational = operational;
if (product !== undefined) body.product = product;
if (marketing !== undefined) body.marketing = marketing;
if (opts.prefer !== undefined) {
if (!['whatsapp', 'sms', 'both'].includes(opts.prefer)) {
throw new ValidationError('--prefer must be whatsapp, sms, or both', 'ALERT_PHONE_PREFER_FLAG');
}
body.channelPreference = opts.prefer;
}
if (Object.keys(body).length === 0) {
throw new ValidationError(
'Nothing to update — pass at least one of --operational/--product/--marketing/--prefer',
'ALERT_PHONE_CONSENTS_EMPTY',
);
}
const status = (await apiClient('/auth/phone/consents', {
method: 'PATCH',
body: JSON.stringify(body),
})) as AlertPhoneStatus;
if (opts.json) {
output(status, { json: true });
return;
}
output(
{
phone: status.phone,
delivery: status.channelPreference,
'problem alerts': status.consents.operational ? 'on' : 'off',
'product news': status.consents.product ? 'on' : 'off',
offers: status.consents.marketing ? 'on' : 'off',
},
{ json: false, kind: 'read' },
);
}

export function registerAlertsCommand(_program: Command): void {
const alerts = _program.command('alerts').description('Where we reach you when something breaks');
const phone = alerts.command('phone').description('Your alert phone number');
Expand All @@ -198,11 +252,23 @@ export function registerAlertsCommand(_program: Command): void {
.description('Add or change your alert phone (international format, e.g. +14155552671)')
.option('--sms', 'Deliver by SMS instead of WhatsApp')
.option('--code <code>', 'Skip the prompt and verify with this code')
.action(async (phoneArg: string, cmdOpts: { sms?: boolean; product?: boolean; marketing?: boolean; code?: string }) => {
.action(async (phoneArg: string, cmdOpts: { sms?: boolean; code?: string }) => {
const { program: rootProgram } = await import('../index.js');
await alertPhoneSet(phoneArg, { ...cmdOpts, json: !!rootProgram.opts().json });
});

const consentsCmd = phone
.command('consents')
.description('Update what the number receives / delivery preference')
.option('--operational <on|off>', 'Problem alerts')
.option('--product <on|off>', 'Product news')
.option('--marketing <on|off>', 'Offers')
.option('--prefer <channel>', 'Delivery channel: whatsapp | sms | both')
.action(async (cmdOpts: { operational?: string; product?: string; marketing?: string; prefer?: string }) => {
const { program: rootProgram } = await import('../index.js');
await alertPhoneConsents({ ...cmdOpts, json: !!rootProgram.opts().json });
});

const removeCmd = phone
.command('remove')
.description('Remove your alert phone')
Expand Down Expand Up @@ -271,6 +337,15 @@ EXAMPLES:
EXAMPLES:
$ hookmyapp alerts phone verify 123456
$ hookmyapp alerts phone verify 123456 --json
`,
);

addExamples(
consentsCmd,
`
EXAMPLES:
$ hookmyapp alerts phone consents --marketing off
$ hookmyapp alerts phone consents --prefer sms
`,
);
}
4 changes: 2 additions & 2 deletions src/commands/org-profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { getDefaultWorkspaceId, resolveOrgPublicIdForWorkspace } from './_helper
/**
* AIT-370 — `hookmyapp org profile`: read/write the organization profile
* (company info). This is COMPANY data — the `--phone` here is the company
* phone, NOT anyone's personal alert phone (that is `hookmyapp phone`).
* phone, NOT anyone's personal alert phone (that is `hookmyapp alerts phone`).
* Ask the human for company details; never infer or invent them. Org admins only.
*/

Expand Down Expand Up @@ -64,7 +64,7 @@ EXAMPLES:
.command('profile')
.description(
'Company profile (email, phone, website, business category/niche, use case). ' +
'Company data — for your personal alert number use: hookmyapp phone',
'Company data — for your personal alert number use: hookmyapp alerts phone',
);
addExamples(
profile,
Expand Down
Loading
Loading