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
7 changes: 6 additions & 1 deletion scripts/smoke-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,17 @@ import {
import { privateAppSuite } from './smoke/private-app';
import { publicAppSuite } from './smoke/public-app';
import { initWizardSuite } from './smoke/init-wizard';
import { functionSuite } from './smoke/function';

// Suite registry. `--suite=<name[,name]>` picks from these; the init wizard is
// opt-in because it drives interactive prompts through scripted stdin.
// opt-in because it drives interactive prompts through scripted stdin, and the
// function suite is opt-in because it exercises read-only endpoints that may
// return empty results on a fresh account.
const SUITES: Record<string, Suite> = {
private: privateAppSuite,
public: publicAppSuite,
init: initWizardSuite,
function: functionSuite,
};

const DEFAULT_SUITES = ['private', 'public'];
Expand Down Expand Up @@ -169,6 +173,7 @@ Flags:
private private-app lifecycle + client guardrails
public public-app submission/review lifecycle
init 'brevo app init' wizard (interactive, opt-in)
function Brevo Function list/get commands (read-only, opt-in)
all every suite
Default: private,public
--with-init Append the init suite (same as adding 'init').
Expand Down
132 changes: 132 additions & 0 deletions scripts/smoke/function.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/*
* Brevo Function commands: list -> list --draft -> get -> get (404).
*
* Read-only — no apps are created or deleted. The suite validates response shape
* and error handling, not data content (the account may have zero functions).
*/

import {
State,
Suite,
brevoCmd,
exec,
execOrThrow,
must,
parseJson,
} from './core';

function stepFunctionList(state: State): string {
const r = execOrThrow(brevoCmd(state), ['function', 'list', '--json'], state);
Comment thread
satyamdev10 marked this conversation as resolved.
const parsed = parseJson<Record<string, unknown>>(r.stdout);

// The response must carry the list shape regardless of how many functions exist.
must(
Array.isArray(parsed.functions),
`function list --json: "functions" is not an array: ${JSON.stringify(parsed).slice(0, 200)}`,
);
must(
typeof parsed.total === 'number',
`function list --json: "total" is not a number: ${JSON.stringify(parsed).slice(0, 200)}`,
);
must(
typeof parsed.max === 'number',
`function list --json: "max" is not a number: ${JSON.stringify(parsed).slice(0, 200)}`,
);

const functions = parsed.functions as Array<Record<string, unknown>>;

// If there are functions, spot-check the first one's shape.
const first = functions[0];
if (first) {
must(typeof first.id === 'string', 'first function missing "id"');
must(typeof first.name === 'string', 'first function missing "name"');
must(typeof first.formula === 'string', 'first function missing "formula"');
}

// Store the first function ID for the get step.
state._functionId = first ? String(first.id) : null;

return `${functions.length} function(s), total ${parsed.total} / max ${parsed.max}`;
}

function stepFunctionListDraft(state: State): string {
const r = execOrThrow(brevoCmd(state), ['function', 'list', '--draft', '--json'], state);
const parsed = parseJson<Record<string, unknown>>(r.stdout);

must(
Array.isArray(parsed.drafts),
`function list --draft --json: "drafts" is not an array: ${JSON.stringify(parsed).slice(0, 200)}`,
);
must(
typeof parsed.total === 'number',
`function list --draft --json: "total" is not a number`,
);

const drafts = parsed.drafts as Array<Record<string, unknown>>;

const firstDraft = drafts[0];
if (firstDraft) {
must(typeof firstDraft.id === 'string', 'first draft missing "id"');
must(typeof firstDraft.formula === 'string', 'first draft missing "formula"');
}

return `${drafts.length} draft(s), total ${parsed.total}`;
}

function stepFunctionGet(state: State): string {
const id = state._functionId;
if (!id) {
return 'skipped — no functions on this account to get';
}

const r = execOrThrow(brevoCmd(state), ['function', 'get', id, '--json'], state);
const parsed = parseJson<Record<string, unknown>>(r.stdout);

must(parsed.id === id, `function get: returned id "${parsed.id}", expected "${id}"`);
must(typeof parsed.name === 'string', 'function get: missing "name"');
must(typeof parsed.formula === 'string', 'function get: missing "formula"');
must(typeof parsed.version === 'number', 'function get: missing "version"');
must(typeof parsed.is_active === 'boolean', 'function get: missing "is_active"');

return `got function "${parsed.name}" (${id})`;
}

function stepFunctionGetNotFound(state: State): string {
// A nonexistent ID should exit 0 with a JSON error body — the command handles
// 404 gracefully rather than throwing.
const fakeId = 'brevo-cli-smoke-nonexistent-fn';
const r = exec(brevoCmd(state), ['function', 'get', fakeId, '--json'], state);

must(
r.exitCode === 0,
`function get (404) exited ${r.exitCode}, expected 0: ${(r.stderr || r.stdout).slice(0, 200)}`,
);

const parsed = parseJson<Record<string, unknown>>(r.stdout);
must(
parsed.error === 'not_found',
`function get (404): expected error "not_found", got ${JSON.stringify(parsed.error)}`,
);

return `404 handled gracefully for "${fakeId}"`;
}

// Extend State with a temporary field for the function ID found during list.
// This avoids changing the shared State interface — the field is set and read
// only within this suite.
declare module './core' {
interface State {
_functionId?: string | null;
}
}

export const functionSuite: Suite = {
name: 'function',
description: 'Brevo Function commands — list, list --draft, get, get (404)',
steps: [
['Function list', stepFunctionList],
['Function list (draft)', stepFunctionListDraft],
['Function get', stepFunctionGet],
['Function get (not found)', stepFunctionGetNotFound],
],
};
3 changes: 2 additions & 1 deletion src/__tests__/commands/app/create.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,7 @@ describe('app/create', () => {
questionNamed(name).choices.map((choice: { value: string }) => choice.value);

expect(valuesOf('distribution')).toEqual(['private', 'public']);
expect(valuesOf('appType')).toEqual(['oauth', 'ui']);
expect(valuesOf('appType')).toEqual(['oauth', 'ui', 'function']);
});

describe('feature scaffolding', () => {
Expand Down Expand Up @@ -2542,6 +2542,7 @@ describe('app/create', () => {
const labels = appTypeQuestion.choices.map((choice: { name: string }) => choice.name.trim());
expect(labels).toEqual([messages.APP_CREATE_APP_TYPE_OAUTH]);
expect(labels).not.toContain(messages.APP_CREATE_APP_TYPE_UI);
expect(labels).not.toContain(messages.APP_CREATE_APP_TYPE_FUNCTION);
expect(appTypeQuestion.choices.map((choice: { value: string }) => choice.value)).toEqual([
'oauth',
]);
Expand Down
69 changes: 68 additions & 1 deletion src/__tests__/commands/definitions.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { appCommandGroup } from '../../commands/definitions';
import { appCommandGroup, functionCommandGroup } from '../../commands/definitions';

describe('appCommandGroup', () => {
it('registers the available-scopes command', () => {
Expand All @@ -13,3 +13,70 @@ describe('appCommandGroup', () => {
expect(flags).toContain('--json');
});
});

// Tests run with __BREVO_PREVIEW__ = true (jest.setup.js), so functionCommandGroup
// is defined. The conditional export is tested by the preview-gate suite.
describe('functionCommandGroup', () => {
it('is defined in a preview build', () => {
expect(functionCommandGroup).toBeDefined();
});

it('registers list, get, activate, deactivate and delete subcommands', () => {
const names = functionCommandGroup!.commands.map((c) => c.name);
expect(names).toContain('list');
expect(names).toContain('get');
expect(names).toContain('activate');
expect(names).toContain('deactivate');
expect(names).toContain('delete');
});

it('list command supports --json and --draft', () => {
const cmd = functionCommandGroup!.commands.find((c) => c.name === 'list');
expect(cmd).toBeDefined();
const flags = (cmd!.options ?? []).map((o) => o.flags);
expect(flags).toContain('--json');
expect(flags).toContain('--draft');
});

it('get command supports --json', () => {
const cmd = functionCommandGroup!.commands.find((c) => c.name === 'get');
expect(cmd).toBeDefined();
const flags = (cmd!.options ?? []).map((o) => o.flags);
expect(flags).toContain('--json');
});

it('get command takes an <id> argument', () => {
const cmd = functionCommandGroup!.commands.find((c) => c.name === 'get');
expect(cmd).toBeDefined();
expect(cmd!.arguments).toBeDefined();
expect(cmd!.arguments!.some((a) => a.name.includes('id'))).toBe(true);
});

it('activate command supports --json and takes <id>', () => {
const cmd = functionCommandGroup!.commands.find((c) => c.name === 'activate');
expect(cmd).toBeDefined();
const flags = (cmd!.options ?? []).map((o) => o.flags);
expect(flags).toContain('--json');
expect(cmd!.arguments).toBeDefined();
expect(cmd!.arguments!.some((a) => a.name.includes('id'))).toBe(true);
});

it('deactivate command supports --json and takes <id>', () => {
const cmd = functionCommandGroup!.commands.find((c) => c.name === 'deactivate');
expect(cmd).toBeDefined();
const flags = (cmd!.options ?? []).map((o) => o.flags);
expect(flags).toContain('--json');
expect(cmd!.arguments).toBeDefined();
expect(cmd!.arguments!.some((a) => a.name.includes('id'))).toBe(true);
});

it('delete command supports --force, --json and takes <id>', () => {
const cmd = functionCommandGroup!.commands.find((c) => c.name === 'delete');
expect(cmd).toBeDefined();
const flags = (cmd!.options ?? []).map((o) => o.flags);
expect(flags).toContain('--force');
expect(flags).toContain('--json');
expect(cmd!.arguments).toBeDefined();
expect(cmd!.arguments!.some((a) => a.name.includes('id'))).toBe(true);
});
});
86 changes: 86 additions & 0 deletions src/__tests__/commands/function/activate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { activateFunctionCommand } from '../../../commands/function/activate';
import { ApiError } from '../../../lib/errors';

jest.mock('../../../container', () => ({
functionService: {
activateFunction: jest.fn(),
},
}));

import { functionService } from '../../../container';

describe('function/activate', () => {
let stdoutSpy: jest.SpyInstance;

beforeEach(() => {
stdoutSpy = jest.spyOn(process.stdout, 'write').mockImplementation(() => true);
jest.clearAllMocks();
});

afterEach(() => {
stdoutSpy.mockRestore();
});

it('should show success message on activation', async () => {
(functionService.activateFunction as jest.Mock).mockResolvedValue(undefined);

await activateFunctionCommand({ id: 'fn-001', json: false });

expect(functionService.activateFunction).toHaveBeenCalledWith('fn-001');
const output = stdoutSpy.mock.calls.map((c: [string]) => c[0]).join('');
expect(output).toContain('Brevo Function "fn-001" activated');
});

it('should output JSON on success with --json', async () => {
(functionService.activateFunction as jest.Mock).mockResolvedValue(undefined);

await activateFunctionCommand({ id: 'fn-001', json: true });

const output = stdoutSpy.mock.calls[0][0];
const parsed = JSON.parse(output);
expect(parsed.activated).toBe(true);
expect(parsed.id).toBe('fn-001');
});

it('should show not-found message on 404', async () => {
(functionService.activateFunction as jest.Mock).mockRejectedValue(
new ApiError('Not found', 404),
);

await activateFunctionCommand({ id: 'fn-999', json: false });

const output = stdoutSpy.mock.calls.map((c: [string]) => c[0]).join('');
expect(output).toContain('Brevo Function "fn-999" not found');
});

it('should output JSON error on 404 with --json', async () => {
(functionService.activateFunction as jest.Mock).mockRejectedValue(
new ApiError('Not found', 404),
);

await activateFunctionCommand({ id: 'fn-999', json: true });

const output = stdoutSpy.mock.calls[0][0];
const parsed = JSON.parse(output);
expect(parsed.error).toBe('not_found');
expect(parsed.message).toContain('fn-999');
});

it('should propagate non-404 API errors', async () => {
(functionService.activateFunction as jest.Mock).mockRejectedValue(
new ApiError('Server error', 500),
);

await expect(activateFunctionCommand({ id: 'fn-001', json: false })).rejects.toThrow(
'Server error',
);
});

it('should propagate generic errors', async () => {
(functionService.activateFunction as jest.Mock).mockRejectedValue(new Error('Network error'));

await expect(activateFunctionCommand({ id: 'fn-001', json: false })).rejects.toThrow(
'Network error',
);
});
});
Loading