From a9f32cc7d48f02c18ea2815c1a5f3588610a66ff Mon Sep 17 00:00:00 2001 From: Meryl Dakin Date: Mon, 9 Mar 2026 10:35:03 -0400 Subject: [PATCH 01/18] feat(audience): add full audience resource support Add complete audience resource implementation following existing patterns: ## Commands - audience list: List all audiences with pagination - audience get: Display a single audience - audience open: Open audience in dashboard - audience pull: Download from Knock to filesystem - audience push: Upload from filesystem to Knock - audience validate: Validate local audience files - audience new: Scaffold new audience (prompts for static/dynamic type) - audience archive: Archive audience (affects ALL environments, requires confirmation) ## Infrastructure - Add AudienceData types with static/dynamic support and segments - Add API methods: listAudiences, getAudience, upsertAudience, validateAudience, archiveAudience - Add audience to ResourceType, ALL_RESOURCE_TYPES, and RESOURCE_SUBDIRS - Add audience directory detection in run-context loader - Add audience marshal layer (processor, reader, writer, helpers, generator) - Add viewAudienceUrl helper ## Tests - Full test coverage for all 8 commands - Add audience factory for test support Co-Authored-By: Claude Opus 4.5 --- src/commands/audience/archive.ts | 68 +++++ src/commands/audience/get.ts | 123 ++++++++ src/commands/audience/list.ts | 105 +++++++ src/commands/audience/new.ts | 222 ++++++++++++++ src/commands/audience/open.ts | 53 ++++ src/commands/audience/pull.ts | 207 +++++++++++++ src/commands/audience/push.ts | 133 ++++++++ src/commands/audience/validate.ts | 118 +++++++ src/commands/pull.ts | 4 + src/commands/push.ts | 4 + src/lib/api-v1.ts | 95 ++++++ src/lib/helpers/project-config.ts | 1 + src/lib/marshal/audience/generator.ts | 70 +++++ src/lib/marshal/audience/helpers.ts | 146 +++++++++ src/lib/marshal/audience/index.ts | 6 + .../marshal/audience/processor.isomorphic.ts | 32 ++ src/lib/marshal/audience/reader.ts | 128 ++++++++ src/lib/marshal/audience/types.ts | 35 +++ src/lib/marshal/audience/writer.ts | 190 ++++++++++++ src/lib/marshal/index.isomorphic.ts | 1 + src/lib/marshal/shared/helpers.isomorphic.ts | 2 + src/lib/resources.ts | 3 + src/lib/run-context/loader.ts | 6 + src/lib/run-context/types.ts | 6 + src/lib/urls.ts | 8 + test/commands/audience/archive.test.ts | 112 +++++++ test/commands/audience/get.test.ts | 154 ++++++++++ test/commands/audience/list.test.ts | 212 +++++++++++++ test/commands/audience/new.test.ts | 153 ++++++++++ test/commands/audience/open.test.ts | 75 +++++ test/commands/audience/pull.test.ts | 207 +++++++++++++ test/commands/audience/push.test.ts | 288 ++++++++++++++++++ test/commands/audience/validate.test.ts | 232 ++++++++++++++ test/support/factory.ts | 26 ++ 34 files changed, 3225 insertions(+) create mode 100644 src/commands/audience/archive.ts create mode 100644 src/commands/audience/get.ts create mode 100644 src/commands/audience/list.ts create mode 100644 src/commands/audience/new.ts create mode 100644 src/commands/audience/open.ts create mode 100644 src/commands/audience/pull.ts create mode 100644 src/commands/audience/push.ts create mode 100644 src/commands/audience/validate.ts create mode 100644 src/lib/marshal/audience/generator.ts create mode 100644 src/lib/marshal/audience/helpers.ts create mode 100644 src/lib/marshal/audience/index.ts create mode 100644 src/lib/marshal/audience/processor.isomorphic.ts create mode 100644 src/lib/marshal/audience/reader.ts create mode 100644 src/lib/marshal/audience/types.ts create mode 100644 src/lib/marshal/audience/writer.ts create mode 100644 test/commands/audience/archive.test.ts create mode 100644 test/commands/audience/get.test.ts create mode 100644 test/commands/audience/list.test.ts create mode 100644 test/commands/audience/new.test.ts create mode 100644 test/commands/audience/open.test.ts create mode 100644 test/commands/audience/pull.test.ts create mode 100644 test/commands/audience/push.test.ts create mode 100644 test/commands/audience/validate.test.ts diff --git a/src/commands/audience/archive.ts b/src/commands/audience/archive.ts new file mode 100644 index 00000000..bba0e52e --- /dev/null +++ b/src/commands/audience/archive.ts @@ -0,0 +1,68 @@ +import { Args, Flags, ux } from "@oclif/core"; + +import BaseCommand from "@/lib/base-command"; +import { ApiError } from "@/lib/helpers/error"; +import * as CustomFlags from "@/lib/helpers/flag"; +import { formatErrorRespMessage, isSuccessResp } from "@/lib/helpers/request"; +import { promptToConfirm, spinner } from "@/lib/helpers/ux"; + +export default class AudienceArchive extends BaseCommand< + typeof AudienceArchive +> { + static summary = "Archive an audience (affects ALL environments)."; + + static description = ` +WARNING: Archiving an audience affects ALL environments and cannot be undone. +Use this command with caution. +`; + + static flags = { + environment: Flags.string({ + required: true, + summary: "The environment to use.", + }), + branch: CustomFlags.branch, + force: Flags.boolean({ + summary: "Skip confirmation prompt.", + }), + }; + + static args = { + audienceKey: Args.string({ + required: true, + description: "The key of the audience to archive.", + }), + }; + + async run(): Promise { + const { audienceKey } = this.props.args; + const { force } = this.props.flags; + + // Confirm before archiving since this affects all environments + if (!force) { + const confirmed = await promptToConfirm( + `WARNING: Archiving audience \`${audienceKey}\` will affect ALL environments.\n` + + `This action cannot be undone. Continue?`, + ); + if (!confirmed) { + this.log("Archive cancelled."); + return; + } + } + + spinner.start(`‣ Archiving audience \`${audienceKey}\``); + + const resp = await this.apiV1.archiveAudience(this.props); + + spinner.stop(); + + if (!isSuccessResp(resp)) { + const message = formatErrorRespMessage(resp); + ux.error(new ApiError(message)); + } + + this.log( + `‣ Successfully archived audience \`${audienceKey}\` across all environments.`, + ); + } +} diff --git a/src/commands/audience/get.ts b/src/commands/audience/get.ts new file mode 100644 index 00000000..44c83d71 --- /dev/null +++ b/src/commands/audience/get.ts @@ -0,0 +1,123 @@ +import { Args, Flags, ux } from "@oclif/core"; + +import * as ApiV1 from "@/lib/api-v1"; +import BaseCommand from "@/lib/base-command"; +import { formatCommandScope } from "@/lib/helpers/command"; +import { formatDateTime } from "@/lib/helpers/date"; +import { ApiError } from "@/lib/helpers/error"; +import * as CustomFlags from "@/lib/helpers/flag"; +import { formatErrorRespMessage, isSuccessResp } from "@/lib/helpers/request"; +import { spinner } from "@/lib/helpers/ux"; + +export default class AudienceGet extends BaseCommand { + static summary = "Display a single audience from an environment."; + + static flags = { + environment: Flags.string({ + default: "development", + summary: "The environment to use.", + }), + branch: CustomFlags.branch, + "hide-uncommitted-changes": Flags.boolean({ + summary: "Hide any uncommitted changes.", + }), + }; + + static args = { + audienceKey: Args.string({ + required: true, + }), + }; + + static enableJsonFlag = true; + + async run(): Promise { + spinner.start("‣ Loading"); + + const { audience } = await this.loadAudience(); + + spinner.stop(); + + const { flags } = this.props; + if (flags.json) return audience; + + this.render(audience); + } + + private async loadAudience(): Promise<{ + audience: ApiV1.GetAudienceResp; + }> { + const audienceResp = await this.apiV1.getAudience(this.props); + + if (!isSuccessResp(audienceResp)) { + const message = formatErrorRespMessage(audienceResp); + ux.error(new ApiError(message)); + } + + return { + audience: audienceResp.data, + }; + } + + render(audience: ApiV1.GetAudienceResp): void { + const { audienceKey } = this.props.args; + const { environment: env, "hide-uncommitted-changes": committedOnly } = + this.props.flags; + + const qualifier = + env === "development" && !committedOnly ? "(including uncommitted)" : ""; + + const scope = formatCommandScope(this.props.flags); + this.log( + `‣ Showing audience \`${audienceKey}\` in ${scope} ${qualifier}\n`, + ); + + /* + * Audience table + */ + + const rows = [ + { + key: "Name", + value: audience.name, + }, + { + key: "Key", + value: audience.key, + }, + { + key: "Type", + value: audience.type, + }, + { + key: "Description", + value: audience.description || "-", + }, + { + key: "Created at", + value: formatDateTime(audience.created_at), + }, + { + key: "Updated at", + value: formatDateTime(audience.updated_at), + }, + ]; + + ux.table(rows, { + key: { + header: "Audience", + minWidth: 24, + }, + value: { + header: "", + minWidth: 24, + }, + }); + + // Show segments for dynamic audiences + if (audience.type === "dynamic" && audience.segments) { + this.log("\nSegments:"); + this.log(JSON.stringify(audience.segments, null, 2)); + } + } +} diff --git a/src/commands/audience/list.ts b/src/commands/audience/list.ts new file mode 100644 index 00000000..f30d5518 --- /dev/null +++ b/src/commands/audience/list.ts @@ -0,0 +1,105 @@ +import { Flags, ux } from "@oclif/core"; +import { AxiosResponse } from "axios"; + +import * as ApiV1 from "@/lib/api-v1"; +import BaseCommand from "@/lib/base-command"; +import { formatCommandScope } from "@/lib/helpers/command"; +import { formatDate } from "@/lib/helpers/date"; +import * as CustomFlags from "@/lib/helpers/flag"; +import { merge } from "@/lib/helpers/object.isomorphic"; +import { + maybePromptPageAction, + pageFlags, + paramsForPageAction, +} from "@/lib/helpers/page"; +import { withSpinner } from "@/lib/helpers/request"; + +export default class AudienceList extends BaseCommand { + static summary = "Display all audiences for an environment."; + + static flags = { + environment: Flags.string({ + default: "development", + summary: "The environment to use.", + }), + branch: CustomFlags.branch, + "hide-uncommitted-changes": Flags.boolean({ + summary: "Hide any uncommitted changes.", + }), + ...pageFlags, + }; + + static enableJsonFlag = true; + + async run(): Promise { + const resp = await this.request(); + + const { flags } = this.props; + if (flags.json) return resp.data; + + this.render(resp.data); + } + + async request( + pageParams = {}, + ): Promise> { + const props = merge(this.props, { flags: { ...pageParams } }); + + return withSpinner(() => + this.apiV1.listAudiences(props), + ); + } + + async render(data: ApiV1.ListAudienceResp): Promise { + const { entries } = data; + const { environment: env, "hide-uncommitted-changes": committedOnly } = + this.props.flags; + + const qualifier = + env === "development" && !committedOnly ? "(including uncommitted)" : ""; + + const scope = formatCommandScope(this.props.flags); + this.log( + `‣ Showing ${entries.length} audiences in ${scope} ${qualifier}\n`, + ); + + /* + * Audiences list table + */ + + ux.table(entries, { + key: { + header: "Key", + }, + name: { + header: "Name", + }, + description: { + header: "Description", + }, + type: { + header: "Type", + }, + updated_at: { + header: "Updated at", + get: (entry) => formatDate(entry.updated_at), + }, + }); + + return this.prompt(data); + } + + async prompt(data: ApiV1.ListAudienceResp): Promise { + const { page_info } = data; + + const pageAction = await maybePromptPageAction(page_info); + const pageParams = pageAction && paramsForPageAction(pageAction, page_info); + + if (pageParams) { + this.log("\n"); + + const resp = await this.request(pageParams); + return this.render(resp.data); + } + } +} diff --git a/src/commands/audience/new.ts b/src/commands/audience/new.ts new file mode 100644 index 00000000..6524af88 --- /dev/null +++ b/src/commands/audience/new.ts @@ -0,0 +1,222 @@ +import * as path from "node:path"; + +import { Flags } from "@oclif/core"; +import { prompt } from "enquirer"; + +import BaseCommand from "@/lib/base-command"; +import { KnockEnv } from "@/lib/helpers/const"; +import * as CustomFlags from "@/lib/helpers/flag"; +import { resolveResourceDir } from "@/lib/helpers/project-config"; +import { slugify } from "@/lib/helpers/string"; +import { promptToConfirm, spinner } from "@/lib/helpers/ux"; +import * as Audience from "@/lib/marshal/audience"; +import { AudienceType } from "@/lib/marshal/audience"; +import { + AudienceDirContext, + ensureResourceDirForTarget, + ResourceTarget, +} from "@/lib/run-context"; + +import AudiencePush from "./push"; + +const AUDIENCE_TYPE_CHOICES = [ + { name: AudienceType.Static, message: "Static (manual membership)" }, + { name: AudienceType.Dynamic, message: "Dynamic (rule-based membership)" }, +] as const; + +export default class AudienceNew extends BaseCommand { + static summary = "Create a new audience with a minimal configuration."; + + static flags = { + name: Flags.string({ + summary: "The name of the audience", + char: "n", + }), + key: Flags.string({ + summary: "The key of the audience", + char: "k", + }), + type: Flags.string({ + summary: "The type of the audience (static, dynamic).", + char: "t", + options: [AudienceType.Static, AudienceType.Dynamic], + }), + description: Flags.string({ + summary: "The description of the audience", + char: "d", + }), + environment: Flags.string({ + summary: + "The environment to create the audience in. Defaults to development.", + default: KnockEnv.Development, + }), + branch: CustomFlags.branch, + force: Flags.boolean({ + summary: + "Force the creation of the audience directory without confirmation.", + }), + push: Flags.boolean({ + summary: "Whether or not to push the audience to Knock after creation.", + default: false, + char: "p", + }), + }; + + static args = {}; + + async run(): Promise { + const { flags } = this.props; + const { resourceDir } = this.runContext; + + // 1. Ensure we aren't in any existing resource directory already. + if (resourceDir) { + return this.error( + `Cannot create a new audience inside an existing ${resourceDir.type} directory`, + ); + } + + // 2. Prompt for name and key if not provided + let name = flags.name; + let key = flags.key; + + if (!name) { + const nameResponse = await prompt<{ name: string }>({ + type: "input", + name: "name", + message: "Audience name", + validate: (value: string) => { + if (!value || value.trim().length === 0) { + return "Audience name is required"; + } + + return true; + }, + }); + name = nameResponse.name; + } + + if (!key) { + const keyResponse = await prompt<{ key: string }>({ + type: "input", + name: "key", + message: "Audience key (immutable slug)", + initial: slugify(name), + validate: (value: string) => { + if (!value || value.trim().length === 0) { + return "Audience key is required"; + } + + const keyError = Audience.validateAudienceKey(value); + if (keyError) { + return `Invalid audience key: ${keyError}`; + } + + return true; + }, + }); + key = keyResponse.key; + } + + // Validate the audience key + const audienceKeyError = Audience.validateAudienceKey(key); + if (audienceKeyError) { + return this.error( + `Invalid audience key \`${key}\` (${audienceKeyError})`, + ); + } + + const audienceDirCtx = await this.getAudienceDirContext(key); + + const promptMessage = audienceDirCtx.exists + ? `Found \`${audienceDirCtx.key}\` at ${audienceDirCtx.abspath}, overwrite?` + : `Create a new audience directory \`${audienceDirCtx.key}\` at ${audienceDirCtx.abspath}?`; + + // Check if the audience directory already exists, and prompt to confirm if not. + const input = flags.force || (await promptToConfirm(promptMessage)); + if (!input) return; + + // 3. Prompt for type if not provided + let audienceType: AudienceType; + + if (flags.type) { + audienceType = flags.type as AudienceType; + } else { + const typeResponse = await prompt<{ type: string }>({ + type: "select", + name: "type", + message: "Select audience type", + choices: AUDIENCE_TYPE_CHOICES.map((choice) => ({ + name: choice.name, + message: choice.message, + })), + }); + + audienceType = typeResponse.type as AudienceType; + } + + // Generate the audience directory with scaffolded content + await Audience.generateAudienceDir(audienceDirCtx, { + name, + type: audienceType, + description: flags.description, + }); + + if (flags.push) { + spinner.start("‣ Pushing audience to Knock"); + + try { + await AudiencePush.run([key]); + } catch (error) { + this.error(`Failed to push audience to Knock: ${error}`); + } finally { + spinner.stop(); + } + } + + this.log(`‣ Successfully created audience \`${key}\``); + } + + async getAudienceDirContext( + audienceKey?: string, + ): Promise { + const { resourceDir, cwd: runCwd } = this.runContext; + + // Inside an existing resource dir, use it if valid for the target audience. + if (resourceDir) { + const target: ResourceTarget = { + commandId: BaseCommand.id, + type: "audience", + key: audienceKey, + }; + + return ensureResourceDirForTarget( + resourceDir, + target, + ) as AudienceDirContext; + } + + // Default to knock project config first if present, otherwise cwd. + const dirCtx = await resolveResourceDir( + this.projectConfig, + "audience", + runCwd, + ); + + // Not inside any existing audience directory, which means either create a + // new audience directory in the cwd, or update it if there is one already. + if (audienceKey) { + const dirPath = path.resolve(dirCtx.abspath, audienceKey); + const exists = await Audience.isAudienceDir(dirPath); + + return { + type: "audience", + key: audienceKey, + abspath: dirPath, + exists, + }; + } + + // Not in any audience directory, nor an audience key arg was given so error. + return this.error("Missing 1 required arg:\naudienceKey"); + } +} diff --git a/src/commands/audience/open.ts b/src/commands/audience/open.ts new file mode 100644 index 00000000..802d8eb3 --- /dev/null +++ b/src/commands/audience/open.ts @@ -0,0 +1,53 @@ +import { Args, Flags, ux } from "@oclif/core"; + +import BaseCommand from "@/lib/base-command"; +import { browser } from "@/lib/helpers/browser"; +import { ApiError } from "@/lib/helpers/error"; +import * as CustomFlags from "@/lib/helpers/flag"; +import { formatErrorRespMessage, isSuccessResp } from "@/lib/helpers/request"; +import { viewAudienceUrl } from "@/lib/urls"; + +export default class AudienceOpen extends BaseCommand { + static summary = "Open an audience in the Knock dashboard."; + + static flags = { + environment: Flags.string({ + default: "development", + summary: "The environment to use.", + }), + branch: CustomFlags.branch, + }; + + static args = { + audienceKey: Args.string({ + required: true, + }), + }; + + async run(): Promise { + const whoamiResp = await this.apiV1.whoami(); + + if (!isSuccessResp(whoamiResp)) { + const message = formatErrorRespMessage(whoamiResp); + ux.error(new ApiError(message)); + } + + const { account_slug } = whoamiResp.data; + const { audienceKey } = this.props.args; + const { environment, branch } = this.props.flags; + + const envOrBranch = branch ?? environment; + + const url = viewAudienceUrl( + this.sessionContext.dashboardOrigin, + account_slug, + envOrBranch, + audienceKey, + ); + + this.log(`‣ Opening audience \`${audienceKey}\` in the Knock dashboard...`); + this.log(` ${url}`); + + await browser.openUrl(url); + } +} diff --git a/src/commands/audience/pull.ts b/src/commands/audience/pull.ts new file mode 100644 index 00000000..a1aecc0b --- /dev/null +++ b/src/commands/audience/pull.ts @@ -0,0 +1,207 @@ +import * as path from "node:path"; + +import { Args, Flags } from "@oclif/core"; + +import * as ApiV1 from "@/lib/api-v1"; +import BaseCommand from "@/lib/base-command"; +import { formatCommandScope } from "@/lib/helpers/command"; +import { ApiError } from "@/lib/helpers/error"; +import * as CustomFlags from "@/lib/helpers/flag"; +import { merge } from "@/lib/helpers/object.isomorphic"; +import { MAX_PAGINATION_LIMIT, PageInfo } from "@/lib/helpers/page"; +import { resolveResourceDir } from "@/lib/helpers/project-config"; +import { + formatErrorRespMessage, + isSuccessResp, + withSpinner, +} from "@/lib/helpers/request"; +import { promptToConfirm, spinner } from "@/lib/helpers/ux"; +import * as Audience from "@/lib/marshal/audience"; +import { WithAnnotation } from "@/lib/marshal/shared/types"; +import { + AudienceDirContext, + ensureResourceDirForTarget, + ResourceTarget, +} from "@/lib/run-context"; + +export default class AudiencePull extends BaseCommand { + static summary = + "Pull one or more audiences from an environment into a local file system."; + + static flags = { + environment: Flags.string({ + default: "development", + summary: "The environment to use.", + }), + branch: CustomFlags.branch, + all: Flags.boolean({ + summary: "Whether to pull all audiences from the specified environment.", + }), + "audiences-dir": CustomFlags.dirPath({ + summary: "The target directory path to pull all audiences into.", + dependsOn: ["all"], + }), + "hide-uncommitted-changes": Flags.boolean({ + summary: "Hide any uncommitted changes.", + }), + force: Flags.boolean({ + summary: "Remove the confirmation prompt.", + }), + }; + + static args = { + audienceKey: Args.string({ + required: false, + }), + }; + + async run(): Promise { + const { args, flags } = this.props; + + if (flags.all && args.audienceKey) { + return this.error( + `audienceKey arg \`${args.audienceKey}\` cannot also be provided when using --all`, + ); + } + + return flags.all ? this.pullAllAudiences() : this.pullOneAudience(); + } + + // Pull one audience + async pullOneAudience(): Promise { + const { flags } = this.props; + + const dirContext = await this.getAudienceDirContext(); + + if (dirContext.exists) { + this.log(`‣ Found \`${dirContext.key}\` at ${dirContext.abspath}`); + } else { + const prompt = `Create a new audience directory \`${dirContext.key}\` at ${dirContext.abspath}?`; + const input = flags.force || (await promptToConfirm(prompt)); + if (!input) return; + } + + const resp = await withSpinner>( + () => { + const props = merge(this.props, { + args: { audienceKey: dirContext.key }, + flags: { annotate: true }, + }); + return this.apiV1.getAudience(props); + }, + ); + + await Audience.writeAudienceDirFromData(dirContext, resp.data, { + withSchema: true, + }); + + const action = dirContext.exists ? "updated" : "created"; + const scope = formatCommandScope(flags); + this.log( + `‣ Successfully ${action} \`${dirContext.key}\` at ${dirContext.abspath} using ${scope}`, + ); + } + + // Pull all audiences + async pullAllAudiences(): Promise { + const { flags } = this.props; + + const audiencesIndexDirCtx = await resolveResourceDir( + this.projectConfig, + "audience", + this.runContext.cwd, + ); + + const targetDirCtx = flags["audiences-dir"] || audiencesIndexDirCtx; + + const prompt = targetDirCtx.exists + ? `Pull latest audiences into ${targetDirCtx.abspath}?\n This will overwrite the contents of this directory.` + : `Create a new audiences directory at ${targetDirCtx.abspath}?`; + + const input = flags.force || (await promptToConfirm(prompt)); + if (!input) return; + + spinner.start(`‣ Loading`); + + const audiences = await this.listAllAudiences(); + + await Audience.writeAudiencesIndexDir(targetDirCtx, audiences, { + withSchema: true, + }); + spinner.stop(); + + const action = targetDirCtx.exists ? "updated" : "created"; + const scope = formatCommandScope(flags); + this.log( + `‣ Successfully ${action} the audiences directory at ${targetDirCtx.abspath} using ${scope}`, + ); + } + + async listAllAudiences( + pageParams: Partial = {}, + audiencesFetchedSoFar: Audience.AudienceData[] = [], + ): Promise[]> { + const props = merge(this.props, { + flags: { + ...pageParams, + annotate: true, + limit: MAX_PAGINATION_LIMIT, + }, + }); + + const resp = await this.apiV1.listAudiences(props); + if (!isSuccessResp(resp)) { + const message = formatErrorRespMessage(resp); + this.error(new ApiError(message)); + } + + const { entries, page_info: pageInfo } = resp.data; + const audiences = [...audiencesFetchedSoFar, ...entries]; + + return pageInfo.after + ? this.listAllAudiences({ after: pageInfo.after }, audiences) + : audiences; + } + + async getAudienceDirContext(): Promise { + const { audienceKey } = this.props.args; + const { resourceDir, cwd: runCwd } = this.runContext; + + // Inside an existing resource dir, use it if valid for the target audience. + if (resourceDir) { + const target: ResourceTarget = { + commandId: BaseCommand.id, + type: "audience", + key: audienceKey, + }; + + return ensureResourceDirForTarget( + resourceDir, + target, + ) as AudienceDirContext; + } + + const audiencesIndexDirCtx = await resolveResourceDir( + this.projectConfig, + "audience", + runCwd, + ); + + // Not inside any existing audience directory, which means either create a + // new audience directory in the cwd, or update it if there is one already. + if (audienceKey) { + const dirPath = path.resolve(audiencesIndexDirCtx.abspath, audienceKey); + const exists = await Audience.isAudienceDir(dirPath); + + return { + type: "audience", + key: audienceKey, + abspath: dirPath, + exists, + }; + } + + // Not in any audience directory, nor an audience key arg was given so error. + return this.error("Missing 1 required arg:\naudienceKey"); + } +} diff --git a/src/commands/audience/push.ts b/src/commands/audience/push.ts new file mode 100644 index 00000000..149e9e9d --- /dev/null +++ b/src/commands/audience/push.ts @@ -0,0 +1,133 @@ +import { Args, Flags } from "@oclif/core"; + +import BaseCommand from "@/lib/base-command"; +import { formatCommandScope } from "@/lib/helpers/command"; +import { KnockEnv } from "@/lib/helpers/const"; +import { formatError, formatErrors, SourceError } from "@/lib/helpers/error"; +import * as CustomFlags from "@/lib/helpers/flag"; +import { merge } from "@/lib/helpers/object.isomorphic"; +import { formatErrorRespMessage, isSuccessResp } from "@/lib/helpers/request"; +import { indentString } from "@/lib/helpers/string"; +import { spinner } from "@/lib/helpers/ux"; +import * as Audience from "@/lib/marshal/audience"; +import { WithAnnotation } from "@/lib/marshal/shared/types"; + +import AudienceValidate from "./validate"; + +export default class AudiencePush extends BaseCommand { + static summary = + "Push one or more audiences from a local file system to Knock."; + + static flags = { + environment: Flags.string({ + summary: + "Pushing an audience is only allowed in the development environment", + default: KnockEnv.Development, + options: [KnockEnv.Development], + }), + branch: CustomFlags.branch, + all: Flags.boolean({ + summary: "Whether to push all audiences from the target directory.", + }), + "audiences-dir": CustomFlags.dirPath({ + summary: "The target directory path to find all audiences to push.", + dependsOn: ["all"], + }), + commit: Flags.boolean({ + summary: "Push and commit the audience(s) at the same time", + }), + "commit-message": Flags.string({ + summary: "Use the given value as the commit message", + char: "m", + dependsOn: ["commit"], + }), + }; + + static args = { + audienceKey: Args.string({ + required: false, + }), + }; + + async run(): Promise { + const { flags } = this.props; + + // 1. First read all audience directories found for the given command. + const target = await Audience.ensureValidCommandTarget( + this.props, + this.runContext, + this.projectConfig, + ); + + const [audiences, readErrors] = await Audience.readAllForCommandTarget( + target, + ); + + if (readErrors.length > 0) { + this.error(formatErrors(readErrors, { prependBy: "\n\n" })); + } + + if (audiences.length === 0) { + this.error(`No audience directories found in ${target.context.abspath}`); + } + + // 2. Then validate them all ahead of pushing them. + spinner.start(`‣ Validating`); + + const apiErrors = await AudienceValidate.validateAll( + this.apiV1, + this.props, + audiences, + ); + + if (apiErrors.length > 0) { + this.error(formatErrors(apiErrors, { prependBy: "\n\n" })); + } + + spinner.stop(); + + // 3. Finally push up each audience, abort on the first error. + spinner.start(`‣ Pushing`); + + for (const audience of audiences) { + const props = merge(this.props, { flags: { annotate: true } }); + + // eslint-disable-next-line no-await-in-loop + const resp = await this.apiV1.upsertAudience(props, { + ...audience.content, + key: audience.key, + }); + + if (isSuccessResp(resp)) { + // Update the audience directory with the successfully pushed audience + // payload from the server. + // eslint-disable-next-line no-await-in-loop + await Audience.writeAudienceDirFromData(audience, resp.data.audience!, { + withSchema: true, + }); + + continue; + } + + const error = new SourceError( + formatErrorRespMessage(resp), + Audience.audienceJsonPath(audience), + "ApiError", + ); + + this.error(formatError(error)); + } + + spinner.stop(); + + // 4. Display a success message. + const audienceKeys = audiences.map((a) => a.key); + const actioned = flags.commit ? "pushed and committed" : "pushed"; + + const scope = formatCommandScope(flags); + this.log( + `‣ Successfully ${actioned} ${audiences.length} audience(s) to ${scope}:\n` + + indentString(audienceKeys.join("\n"), 4), + ); + } +} diff --git a/src/commands/audience/validate.ts b/src/commands/audience/validate.ts new file mode 100644 index 00000000..b71d3e78 --- /dev/null +++ b/src/commands/audience/validate.ts @@ -0,0 +1,118 @@ +import { Args, Flags } from "@oclif/core"; + +import * as ApiV1 from "@/lib/api-v1"; +import BaseCommand, { Props } from "@/lib/base-command"; +import { formatCommandScope } from "@/lib/helpers/command"; +import { KnockEnv } from "@/lib/helpers/const"; +import { formatErrors, SourceError } from "@/lib/helpers/error"; +import * as CustomFlags from "@/lib/helpers/flag"; +import { formatErrorRespMessage, isSuccessResp } from "@/lib/helpers/request"; +import { indentString } from "@/lib/helpers/string"; +import { spinner } from "@/lib/helpers/ux"; +import * as Audience from "@/lib/marshal/audience"; + +import AudiencePush from "./push"; + +export default class AudienceValidate extends BaseCommand< + typeof AudienceValidate +> { + static summary = "Validate one or more audiences from a local file system."; + + static flags = { + environment: Flags.string({ + summary: + "Validating an audience is only done in the development environment", + default: KnockEnv.Development, + options: [KnockEnv.Development], + }), + branch: CustomFlags.branch, + all: Flags.boolean({ + summary: "Whether to validate all audiences from the target directory.", + }), + "audiences-dir": CustomFlags.dirPath({ + summary: "The target directory path to find all audiences to validate.", + dependsOn: ["all"], + }), + }; + + static args = { + audienceKey: Args.string({ + required: false, + }), + }; + + async run(): Promise { + // 1. Read all audience directories found for the given command. + const target = await Audience.ensureValidCommandTarget( + this.props, + this.runContext, + this.projectConfig, + ); + + const [audiences, readErrors] = await Audience.readAllForCommandTarget( + target, + ); + + if (readErrors.length > 0) { + this.error(formatErrors(readErrors, { prependBy: "\n\n" })); + } + + if (audiences.length === 0) { + this.error(`No audience directories found in ${target.context.abspath}`); + } + + // 2. Validate each audience data. + spinner.start(`‣ Validating`); + + const apiErrors = await AudienceValidate.validateAll( + this.apiV1, + this.props, + audiences, + ); + + if (apiErrors.length > 0) { + this.error(formatErrors(apiErrors, { prependBy: "\n\n" })); + } + + spinner.stop(); + + // 3. Display a success message. + const audienceKeys = audiences.map((a) => a.key); + const scope = formatCommandScope({ ...this.props.flags }); + this.log( + `‣ Successfully validated ${audiences.length} audience(s) using ${scope}:\n` + + indentString(audienceKeys.join("\n"), 4), + ); + } + + static async validateAll( + api: ApiV1.T, + props: Props, + audiences: Audience.AudienceDirData[], + ): Promise { + // TODO: Throw an error if a non validation error (e.g. authentication error) + // instead of printing out same error messages repeatedly. + + const errorPromises = audiences.map(async (audience) => { + const resp = await api.validateAudience(props, { + ...audience.content, + key: audience.key, + }); + + if (isSuccessResp(resp)) return; + + const error = new SourceError( + formatErrorRespMessage(resp), + Audience.audienceJsonPath(audience), + "ApiError", + ); + return error; + }); + + const errors = (await Promise.all(errorPromises)).filter( + (e): e is Exclude => Boolean(e), + ); + + return errors; + } +} diff --git a/src/commands/pull.ts b/src/commands/pull.ts index ac1fb23c..44272160 100644 --- a/src/commands/pull.ts +++ b/src/commands/pull.ts @@ -13,6 +13,7 @@ import { RESOURCE_SUBDIRS, } from "@/lib/resources"; +import AudiencePull from "./audience/pull"; import GuidePull from "./guide/pull"; import EmailLayoutPull from "./layout/pull"; import MessageTypePull from "./message-type/pull"; @@ -98,6 +99,9 @@ const runResourcePullCommand = async ( ); switch (resourceType) { + case "audience": + return AudiencePull.run([...args, "--audiences-dir", subdirPath]); + case "email_layout": return EmailLayoutPull.run([...args, "--layouts-dir", subdirPath]); diff --git a/src/commands/push.ts b/src/commands/push.ts index 3016e7a5..d8017f17 100644 --- a/src/commands/push.ts +++ b/src/commands/push.ts @@ -14,6 +14,7 @@ import { RESOURCE_SUBDIRS, } from "@/lib/resources"; +import AudiencePush from "./audience/push"; import GuidePush from "./guide/push"; import EmailLayoutPush from "./layout/push"; import MessageTypePush from "./message-type/push"; @@ -104,6 +105,9 @@ const runResourcePushCommand = async ( } switch (resourceType) { + case "audience": + return AudiencePush.run([...args, "--audiences-dir", subdirPath]); + case "email_layout": return EmailLayoutPush.run([...args, "--layouts-dir", subdirPath]); diff --git a/src/lib/api-v1.ts b/src/lib/api-v1.ts index ba3c299f..9c6253b4 100644 --- a/src/lib/api-v1.ts +++ b/src/lib/api-v1.ts @@ -11,6 +11,7 @@ import { Props } from "@/lib/base-command"; import { InputError } from "@/lib/helpers/error"; import { prune } from "@/lib/helpers/object.isomorphic"; import { PaginatedResp, toPageParams } from "@/lib/helpers/page"; +import * as Audience from "@/lib/marshal/audience"; import * as EmailLayout from "@/lib/marshal/email-layout"; import * as Guide from "@/lib/marshal/guide"; import * as MessageType from "@/lib/marshal/message-type"; @@ -550,6 +551,79 @@ export default class ApiV1 { return this.put(`/guides/${args.guideKey}/activate`, {}, { params }); } + // By resources: Audiences + + async listAudiences({ + flags, + }: Props): Promise>> { + const params = prune({ + environment: flags.environment, + branch: flags.branch, + hide_uncommitted_changes: flags["hide-uncommitted-changes"], + annotate: flags.annotate, + ...toPageParams(flags), + }); + + return this.get("/audiences", { params }); + } + + async getAudience({ + args, + flags, + }: Props): Promise>> { + const params = prune({ + environment: flags.environment, + branch: flags.branch, + annotate: flags.annotate, + hide_uncommitted_changes: flags["hide-uncommitted-changes"], + }); + + return this.get(`/audiences/${args.audienceKey}`, { params }); + } + + async upsertAudience( + { flags }: Props, + audience: Audience.AudienceInput, + ): Promise>> { + const params = prune({ + environment: flags.environment, + branch: flags.branch, + annotate: flags.annotate, + commit: flags.commit, + commit_message: flags["commit-message"], + }); + const data = { audience }; + + return this.put(`/audiences/${audience.key}`, data, { params }); + } + + async validateAudience( + { flags }: Props, + audience: Audience.AudienceInput, + ): Promise> { + const params = prune({ + environment: flags.environment, + branch: flags.branch, + }); + const data = { audience }; + + return this.put(`/audiences/${audience.key}/validate`, data, { + params, + }); + } + + async archiveAudience({ + args, + flags, + }: Props): Promise> { + const params = prune({ + environment: flags.environment, + branch: flags.branch, + }); + + return this.put(`/audiences/${args.audienceKey}/archive`, {}, { params }); + } + async listAllChannels(): Promise { const channels: Channel[] = []; for await (const channel of this.mgmtClient.channels.list()) { @@ -732,3 +806,24 @@ export type ActivateGuideResp = { }; export type ListBranchResp = PaginatedResp; + +export type ListAudienceResp = + PaginatedResp>; + +export type GetAudienceResp = + Audience.AudienceData; + +export type UpsertAudienceResp = { + audience?: Audience.AudienceData; + errors?: InputError[]; +}; + +export type ValidateAudienceResp = { + audience?: Audience.AudienceData; + errors?: InputError[]; +}; + +export type ArchiveAudienceResp = { + audience?: Audience.AudienceData; + errors?: InputError[]; +}; diff --git a/src/lib/helpers/project-config.ts b/src/lib/helpers/project-config.ts index 3b2a7bc2..41a7b36a 100644 --- a/src/lib/helpers/project-config.ts +++ b/src/lib/helpers/project-config.ts @@ -117,6 +117,7 @@ export const ResourceDirectoriesByType: Record< Exclude, string > = { + audience: "audiences", workflow: "workflows", guide: "guides", partial: "partials", diff --git a/src/lib/marshal/audience/generator.ts b/src/lib/marshal/audience/generator.ts new file mode 100644 index 00000000..1da9c382 --- /dev/null +++ b/src/lib/marshal/audience/generator.ts @@ -0,0 +1,70 @@ +import { AudienceDirContext } from "@/lib/run-context"; + +import { AUDIENCE_JSON, AudienceDirBundle } from "./processor.isomorphic"; +import { AudienceType } from "./types"; +import { writeAudienceDirFromBundle } from "./writer"; + +/* + * Attributes for creating a new audience from scratch. + */ +type NewAudienceAttrs = { + name: string; + type: AudienceType; + description?: string; +}; + +/* + * Returns the default scaffolded segments for a dynamic audience. + */ +const defaultSegmentsForDynamic = () => [ + { + conditions: [ + { + property: "recipient.plan", + operator: "equal_to", + argument: "premium", + }, + ], + }, +]; + +/* + * Scaffolds a new audience directory bundle with default content. + */ +const scaffoldAudienceDirBundle = ( + attrs: NewAudienceAttrs, +): AudienceDirBundle => { + const audienceJson: Record = { + name: attrs.name, + type: attrs.type, + }; + + if (attrs.description) { + audienceJson.description = attrs.description; + } + + // For dynamic audiences, include example segments + if (attrs.type === AudienceType.Dynamic) { + audienceJson.segments = defaultSegmentsForDynamic(); + } + + return { + [AUDIENCE_JSON]: audienceJson, + }; +}; + +/* + * Generates a new audience directory with a scaffolded audience.json file. + * Assumes the given audience directory context is valid and correct. + */ +export const generateAudienceDir = async ( + audienceDirCtx: AudienceDirContext, + attrs: NewAudienceAttrs, +): Promise => { + const bundle = scaffoldAudienceDirBundle(attrs); + + return writeAudienceDirFromBundle(audienceDirCtx, bundle); +}; + +// Exported for tests. +export { scaffoldAudienceDirBundle }; diff --git a/src/lib/marshal/audience/helpers.ts b/src/lib/marshal/audience/helpers.ts new file mode 100644 index 00000000..b0e2adce --- /dev/null +++ b/src/lib/marshal/audience/helpers.ts @@ -0,0 +1,146 @@ +import * as path from "node:path"; + +import { ux } from "@oclif/core"; +import * as fs from "fs-extra"; + +import { DirContext } from "@/lib/helpers/fs"; +import { + ProjectConfig, + resolveResourceDir, +} from "@/lib/helpers/project-config"; +import { checkSlugifiedFormat } from "@/lib/helpers/string"; +import { AudienceDirContext, RunContext } from "@/lib/run-context"; + +import { AUDIENCE_JSON } from "./processor.isomorphic"; + +export const audienceJsonPath = (audienceDirCtx: AudienceDirContext): string => + path.resolve(audienceDirCtx.abspath, AUDIENCE_JSON); + +/* + * Check for audience.json file and return the file path if present. + */ +export const lsAudienceJson = async ( + dirPath: string, +): Promise => { + const audienceJsonPath = path.resolve(dirPath, AUDIENCE_JSON); + + const exists = await fs.pathExists(audienceJsonPath); + return exists ? audienceJsonPath : undefined; +}; + +/* + * Evaluates whether the given directory path is an audience directory, by + * checking for the presence of audience.json file. + */ +export const isAudienceDir = async (dirPath: string): Promise => + Boolean(await lsAudienceJson(dirPath)); + +/* + * Validates a string input for an audience key, and returns an error reason + * if invalid. + */ +export const validateAudienceKey = (input: string): string | undefined => { + if (!checkSlugifiedFormat(input, { onlyLowerCase: true })) { + return "must include only lowercase alphanumeric, dash, or underscore characters"; + } + + return undefined; +}; + +/* + * Validate the provided args and flags with the current run context, to first + * ensure the invoked command makes sense, and return the target context. + */ +type CommandTargetProps = { + flags: { + all: boolean | undefined; + "audiences-dir": DirContext | undefined; + }; + args: { + audienceKey: string | undefined; + }; +}; +type AudienceDirTarget = { + type: "audienceDir"; + context: AudienceDirContext; +}; +type AudiencesIndexDirTarget = { + type: "audiencesIndexDir"; + context: DirContext; +}; +export type AudienceCommandTarget = AudienceDirTarget | AudiencesIndexDirTarget; + +export const ensureValidCommandTarget = async ( + props: CommandTargetProps, + runContext: RunContext, + projectConfig?: ProjectConfig, +): Promise => { + const { args, flags } = props; + const { commandId, resourceDir: resourceDirCtx, cwd: runCwd } = runContext; + + // If the target resource is a different type than the current resource dir + // type, error out. + if (resourceDirCtx && resourceDirCtx.type !== "audience") { + return ux.error( + `Cannot run ${commandId} inside a ${resourceDirCtx.type} directory`, + ); + } + + // Cannot accept both audience key arg and --all flag. + if (flags.all && args.audienceKey) { + return ux.error( + `audienceKey arg \`${args.audienceKey}\` cannot also be provided when using --all`, + ); + } + + // Default to knock project config first if present, otherwise cwd. + const audiencesIndexDirCtx = await resolveResourceDir( + projectConfig, + "audience", + runCwd, + ); + + // --all flag is given, which means no audience key arg. + if (flags.all) { + // If --all flag used inside an audience directory, then require an audiences + // dir path. + if (resourceDirCtx && !flags["audiences-dir"]) { + return ux.error("Missing required flag audiences-dir"); + } + + return { + type: "audiencesIndexDir", + context: flags["audiences-dir"] || audiencesIndexDirCtx, + }; + } + + // Audience key arg is given, which means no --all flag. + if (args.audienceKey) { + if (resourceDirCtx && resourceDirCtx.key !== args.audienceKey) { + return ux.error( + `Cannot run ${commandId} \`${args.audienceKey}\` inside another audience directory:\n${resourceDirCtx.key}`, + ); + } + + const targetDirPath = resourceDirCtx + ? resourceDirCtx.abspath + : path.resolve(audiencesIndexDirCtx.abspath, args.audienceKey); + + const audienceDirCtx: AudienceDirContext = { + type: "audience", + key: args.audienceKey, + abspath: targetDirPath, + exists: await isAudienceDir(targetDirPath), + }; + + return { type: "audienceDir", context: audienceDirCtx }; + } + + // From this point on, we have neither an audience key arg nor --all flag. + // If running inside an audience directory, then use that audience directory. + if (resourceDirCtx) { + return { type: "audienceDir", context: resourceDirCtx }; + } + + return ux.error("Missing 1 required arg:\naudienceKey"); +}; diff --git a/src/lib/marshal/audience/index.ts b/src/lib/marshal/audience/index.ts new file mode 100644 index 00000000..43ea86e0 --- /dev/null +++ b/src/lib/marshal/audience/index.ts @@ -0,0 +1,6 @@ +export * from "./generator"; +export * from "./helpers"; +export * from "./processor.isomorphic"; +export * from "./reader"; +export * from "./types"; +export * from "./writer"; diff --git a/src/lib/marshal/audience/processor.isomorphic.ts b/src/lib/marshal/audience/processor.isomorphic.ts new file mode 100644 index 00000000..0e3bec77 --- /dev/null +++ b/src/lib/marshal/audience/processor.isomorphic.ts @@ -0,0 +1,32 @@ +import { AnyObj } from "@/lib/helpers/object.isomorphic"; +import { WithAnnotation } from "@/lib/marshal/shared/types"; + +import { prepareResourceJson } from "../shared/helpers.isomorphic"; +import { AudienceData } from "./types"; + +export const AUDIENCE_JSON = "audience.json"; + +export type AudienceDirBundle = { + [relpath: string]: string | Record; +}; + +/* + * For a given audience payload, this function builds an "audience + * directory bundle". This is an object which contains all the relative paths and + * its file content. + * + * Note: Unlike other resources, audiences don't have extractable fields, + * so this is a simpler implementation. + */ +export const buildAudienceDirBundle = ( + remoteAudience: AudienceData, + _localAudience?: AnyObj, + $schema?: string, +): AudienceDirBundle => { + const bundle: AudienceDirBundle = {}; + + return { + ...bundle, + [AUDIENCE_JSON]: prepareResourceJson(remoteAudience, $schema), + }; +}; diff --git a/src/lib/marshal/audience/reader.ts b/src/lib/marshal/audience/reader.ts new file mode 100644 index 00000000..ac6bf55f --- /dev/null +++ b/src/lib/marshal/audience/reader.ts @@ -0,0 +1,128 @@ +import path from "node:path"; + +import * as fs from "fs-extra"; + +import { formatErrors, SourceError } from "@/lib/helpers/error"; +import { ParseJsonResult, readJson } from "@/lib/helpers/json"; +import { AnyObj, omitDeep } from "@/lib/helpers/object.isomorphic"; +import { AudienceDirContext } from "@/lib/run-context"; + +import { + AudienceCommandTarget, + isAudienceDir, + lsAudienceJson, +} from "./helpers"; +import { AUDIENCE_JSON } from "./processor.isomorphic"; + +// Hydrated audience directory context with its content. +export type AudienceDirData = AudienceDirContext & { + content: AnyObj; +}; + +/* + * For the given list of audience directory contexts, read each audience dir and + * return audience directory data. + */ +const readAudienceDirs = async ( + audienceDirCtxs: AudienceDirContext[], +): Promise<[AudienceDirData[], SourceError[]]> => { + const audiences: AudienceDirData[] = []; + const errors: SourceError[] = []; + + for (const audienceDirCtx of audienceDirCtxs) { + // eslint-disable-next-line no-await-in-loop + const [audience, readErrors] = await readAudienceDir(audienceDirCtx); + + if (readErrors.length > 0) { + const audienceJsonPath = path.resolve( + audienceDirCtx.abspath, + AUDIENCE_JSON, + ); + + const e = new SourceError(formatErrors(readErrors), audienceJsonPath); + errors.push(e); + continue; + } + + audiences.push({ ...audienceDirCtx, content: audience! }); + } + + return [audiences, errors]; +}; + +/* + * The main read function that takes the audience directory context, then reads + * the audience json from the file system and returns the audience data obj. + */ +export const readAudienceDir = async ( + audienceDirCtx: AudienceDirContext, +): Promise => { + const { abspath } = audienceDirCtx; + + const dirExists = await fs.pathExists(abspath); + if (!dirExists) throw new Error(`${abspath} does not exist`); + + const audienceJsonPath = await lsAudienceJson(abspath); + if (!audienceJsonPath) + throw new Error(`${abspath} is not an audience directory`); + + const result = await readJson(audienceJsonPath); + if (!result[0]) return result; + + let [audienceJson] = result; + + audienceJson = omitDeep(audienceJson, ["__readonly"]); + + return [audienceJson, []]; +}; + +/* + * List and read all audience directories found for the given command target. + * + * Note, it assumes the valid command target. + */ +export const readAllForCommandTarget = async ( + target: AudienceCommandTarget, +): Promise<[AudienceDirData[], SourceError[]]> => { + const { type: targetType, context: targetCtx } = target; + + if (!targetCtx.exists) { + const subject = + targetType === "audienceDir" + ? "an audience directory at" + : "audience directories in"; + + throw new Error(`Cannot locate ${subject} \`${targetCtx.abspath}\``); + } + + switch (targetType) { + case "audienceDir": { + return readAudienceDirs([targetCtx]); + } + + case "audiencesIndexDir": { + const dirents = await fs.readdir(targetCtx.abspath, { + withFileTypes: true, + }); + + const promises = dirents.map(async (dirent) => { + const abspath = path.resolve(targetCtx.abspath, dirent.name); + const audienceDirCtx: AudienceDirContext = { + type: "audience", + key: dirent.name, + abspath, + exists: await isAudienceDir(abspath), + }; + return audienceDirCtx; + }); + + const audienceDirCtxs = (await Promise.all(promises)).filter( + (audienceDirCtx) => audienceDirCtx.exists, + ); + return readAudienceDirs(audienceDirCtxs); + } + + default: + throw new Error(`Invalid audience command target: ${target}`); + } +}; diff --git a/src/lib/marshal/audience/types.ts b/src/lib/marshal/audience/types.ts new file mode 100644 index 00000000..3fe088ba --- /dev/null +++ b/src/lib/marshal/audience/types.ts @@ -0,0 +1,35 @@ +import { AnyObj } from "@/lib/helpers/object.isomorphic"; + +import { MaybeWithAnnotation } from "../shared/types"; + +export enum AudienceType { + Static = "static", + Dynamic = "dynamic", +} + +export type AudienceCondition = { + property: string; + operator: string; + argument?: string; +}; + +export type AudienceSegment = { + conditions: AudienceCondition[]; +}; + +// Audience payload data from the API. +export type AudienceData = A & { + key: string; + type: AudienceType; + name: string; + description?: string; + segments?: AudienceSegment[]; + environment: string; + created_at: string; + updated_at: string; + sha: string; +}; + +export type AudienceInput = AnyObj & { + key: string; +}; diff --git a/src/lib/marshal/audience/writer.ts b/src/lib/marshal/audience/writer.ts new file mode 100644 index 00000000..cceb4509 --- /dev/null +++ b/src/lib/marshal/audience/writer.ts @@ -0,0 +1,190 @@ +import * as path from "node:path"; + +import * as fs from "fs-extra"; +import { uniqueId } from "lodash"; + +import { sandboxDir } from "@/lib/helpers/const"; +import { DirContext } from "@/lib/helpers/fs"; +import { DOUBLE_SPACES } from "@/lib/helpers/json"; +import { WithAnnotation } from "@/lib/marshal/shared/types"; +import { AudienceDirContext } from "@/lib/run-context"; + +import { isAudienceDir } from "./helpers"; +import { + AUDIENCE_JSON, + AudienceDirBundle, + buildAudienceDirBundle, +} from "./processor.isomorphic"; +import { readAudienceDir } from "./reader"; +import { AudienceData } from "./types"; + +type WriteOpts = { + withSchema?: boolean; +}; + +const AUDIENCE_SCHEMA = "https://schemas.knock.app/cli/audience.json"; + +/* + * The main write function that takes the fetched audience data from Knock API + * (remote audience), and reads the same audience from the local file system + * (local audience, if available), then writes the remote audience into an + * audience directory with the local audience as a reference. + */ +export const writeAudienceDirFromData = async ( + audienceDirCtx: AudienceDirContext, + remoteAudience: AudienceData, + options?: WriteOpts, +): Promise => { + const { withSchema = false } = options || {}; + + // If the audience directory exists on the file system (i.e. previously + // pulled before), then read the audience file to use as a reference. + const [localAudience] = audienceDirCtx.exists + ? await readAudienceDir(audienceDirCtx) + : []; + + const bundle = buildAudienceDirBundle( + remoteAudience, + localAudience, + withSchema ? AUDIENCE_SCHEMA : undefined, + ); + + return writeAudienceDirFromBundle(audienceDirCtx, bundle); +}; + +/* + * A lower level write function that takes a constructed audience dir bundle + * and writes it into an audience directory on a local file system. + * + * It does not make any assumptions about how the audience directory bundle was + * built; for example, it can be from parsing the audience data fetched from + * the Knock API, or built manually for scaffolding purposes. + */ +const writeAudienceDirFromBundle = async ( + audienceDirCtx: AudienceDirContext, + audienceDirBundle: AudienceDirBundle, +): Promise => { + const backupDirPath = path.resolve(sandboxDir, uniqueId("backup")); + + try { + if (audienceDirCtx.exists) { + await fs.copy(audienceDirCtx.abspath, backupDirPath); + await fs.emptyDir(audienceDirCtx.abspath); + } + + const promises = Object.entries(audienceDirBundle).map( + ([relpath, fileContent]) => { + const filePath = path.resolve(audienceDirCtx.abspath, relpath); + + return relpath === AUDIENCE_JSON + ? fs.outputJson(filePath, fileContent, { spaces: DOUBLE_SPACES }) + : fs.outputFile(filePath, (fileContent as string) ?? ""); + }, + ); + await Promise.all(promises); + } catch (error) { + // In case of any error, wipe the target directory that is likely in a bad + // state then restore the backup if one existed before. + if (audienceDirCtx.exists) { + await fs.emptyDir(audienceDirCtx.abspath); + await fs.copy(backupDirPath, audienceDirCtx.abspath); + } else { + await fs.remove(audienceDirCtx.abspath); + } + + throw error; + } finally { + // Always clean up the backup directory in the temp sandbox. + await fs.remove(backupDirPath); + } +}; + +/* + * Prunes the index directory by removing any files, or directories that aren't + * audience dirs found in fetched audiences. We want to preserve any audience + * dirs that are going to be updated with remote audiences, so extracted links + * can be respected. + */ +const pruneAudiencesIndexDir = async ( + indexDirCtx: DirContext, + remoteAudiences: AudienceData[], +): Promise => { + const audiencesByKey = Object.fromEntries( + remoteAudiences.map((a) => [a.key.toLowerCase(), a]), + ); + + const dirents = await fs.readdir(indexDirCtx.abspath, { + withFileTypes: true, + }); + + const promises = dirents.map(async (dirent) => { + const direntName = dirent.name.toLowerCase(); + const direntPath = path.resolve(indexDirCtx.abspath, direntName); + + if ((await isAudienceDir(direntPath)) && audiencesByKey[direntName]) { + return; + } + + await fs.remove(direntPath); + }); + + await Promise.all(promises); +}; + +/* + * The bulk write function that takes the fetched audiences data from Knock API + * (remote audiences), and writes them into an audiences "index" directory by + * referencing locally available audiences. + */ +export const writeAudiencesIndexDir = async ( + indexDirCtx: DirContext, + remoteAudiences: AudienceData[], + options?: WriteOpts, +): Promise => { + const backupDirPath = path.resolve(sandboxDir, uniqueId("backup")); + + try { + // If the index directory already exists, back it up in the temp sandbox + // before wiping it clean. + if (indexDirCtx.exists) { + await fs.copy(indexDirCtx.abspath, backupDirPath); + await pruneAudiencesIndexDir(indexDirCtx, remoteAudiences); + } + + // Write given remote audiences into the given audiences directory path. + const writeAudienceDirPromises = remoteAudiences.map(async (audience) => { + const audienceDirPath = path.resolve(indexDirCtx.abspath, audience.key); + + const audienceDirCtx: AudienceDirContext = { + type: "audience", + key: audience.key, + abspath: audienceDirPath, + exists: indexDirCtx.exists + ? await isAudienceDir(audienceDirPath) + : false, + }; + + return writeAudienceDirFromData(audienceDirCtx, audience, options); + }); + + await Promise.all(writeAudienceDirPromises); + } catch (error) { + console.log(error); + // In case of any error, wipe the index directory that is likely in a bad + // state then restore the backup if one existed before. + if (indexDirCtx.exists) { + await fs.emptyDir(indexDirCtx.abspath); + await fs.copy(backupDirPath, indexDirCtx.abspath); + } else { + await fs.remove(indexDirCtx.abspath); + } + + throw error; + } finally { + // Always clean up the backup directory in the temp sandbox. + await fs.remove(backupDirPath); + } +}; + +// Exported for tests. +export { pruneAudiencesIndexDir, writeAudienceDirFromBundle }; diff --git a/src/lib/marshal/index.isomorphic.ts b/src/lib/marshal/index.isomorphic.ts index 76984cf7..ca4dd71a 100644 --- a/src/lib/marshal/index.isomorphic.ts +++ b/src/lib/marshal/index.isomorphic.ts @@ -1,6 +1,7 @@ /* * IMPORTANT: You must only expose exports from isomorphic modules. */ +export { buildAudienceDirBundle } from "./audience/processor.isomorphic"; export { buildEmailLayoutDirBundle } from "./email-layout/processor.isomorphic"; export { buildGuideDirBundle } from "./guide/processor.isomorphic"; export { buildMessageTypeDirBundle } from "./message-type/processor.isomorphic"; diff --git a/src/lib/marshal/shared/helpers.isomorphic.ts b/src/lib/marshal/shared/helpers.isomorphic.ts index 8da8c248..9103db07 100644 --- a/src/lib/marshal/shared/helpers.isomorphic.ts +++ b/src/lib/marshal/shared/helpers.isomorphic.ts @@ -3,6 +3,7 @@ import { omit } from "lodash"; import { AnyObj, omitDeep, split } from "@/lib/helpers/object.isomorphic"; import { WithAnnotation } from "@/lib/marshal/shared/types"; +import { AudienceData } from "../audience"; import { EmailLayoutData } from "../email-layout"; import { GuideData } from "../guide"; import { MessageTypeData } from "../message-type"; @@ -16,6 +17,7 @@ import { WorkflowData } from "../workflow"; * fields. */ type ResourceData = + | AudienceData | EmailLayoutData | PartialData | WorkflowData diff --git a/src/lib/resources.ts b/src/lib/resources.ts index 56ba7b27..07acff87 100644 --- a/src/lib/resources.ts +++ b/src/lib/resources.ts @@ -11,6 +11,8 @@ export type NonHiddenResourceType = Exclude; * first or else the validation and upsert of the layout will fail. */ export const ALL_RESOURCE_TYPES: NonHiddenResourceType[] = [ + // Audiences can be referenced by workflows, so push them early + "audience", // Partials first, as email layouts and workflows may reference them "partial", // Email layouts next, as workflows with email channel steps may reference them @@ -27,6 +29,7 @@ export const ALL_RESOURCE_TYPES: NonHiddenResourceType[] = [ * all resources to/from an environment. */ export const RESOURCE_SUBDIRS: Record = { + audience: "audiences", email_layout: "layouts", partial: "partials", translation: "translations", diff --git a/src/lib/run-context/loader.ts b/src/lib/run-context/loader.ts index 8a06dc7f..1cc723d6 100644 --- a/src/lib/run-context/loader.ts +++ b/src/lib/run-context/loader.ts @@ -5,6 +5,7 @@ */ import * as path from "node:path"; +import * as Audience from "@/lib/marshal/audience"; import * as EmailLayout from "@/lib/marshal/email-layout"; import * as Guide from "@/lib/marshal/guide"; import * as MessageType from "@/lib/marshal/message-type"; @@ -29,6 +30,11 @@ const evaluateRecursively = async ( ): Promise => { // Check if we are inside a resource directory and if so update the context. if (!ctx.resourceDir) { + const isAudienceDir = await Audience.isAudienceDir(currDir); + if (isAudienceDir) { + ctx.resourceDir = buildResourceDirContext("audience", currDir); + } + const isWorkflowDir = await Workflow.isWorkflowDir(currDir); if (isWorkflowDir) { ctx.resourceDir = buildResourceDirContext("workflow", currDir); diff --git a/src/lib/run-context/types.ts b/src/lib/run-context/types.ts index 9fa26959..63a878a9 100644 --- a/src/lib/run-context/types.ts +++ b/src/lib/run-context/types.ts @@ -17,6 +17,7 @@ export type T = RunContext; */ export type ResourceType = + | "audience" | "workflow" | "email_layout" | "translation" @@ -30,6 +31,10 @@ type ResourceDirContextBase = DirContext & { key: string; }; +export type AudienceDirContext = ResourceDirContextBase & { + type: "audience"; +}; + export type WorkflowDirContext = ResourceDirContextBase & { type: "workflow"; }; @@ -59,6 +64,7 @@ export type ReusableStepDirContext = ResourceDirContextBase & { }; export type ResourceDirContext = + | AudienceDirContext | WorkflowDirContext | EmailLayoutDirContext | TranslationDirContext diff --git a/src/lib/urls.ts b/src/lib/urls.ts index 0842efb7..43c0897c 100644 --- a/src/lib/urls.ts +++ b/src/lib/urls.ts @@ -47,3 +47,11 @@ export const viewPartialUrl = ( partialKey: string, ): string => `${dashboardUrl}/${accountSlug}/${envOrBranchSlug.toLowerCase()}/partials/${partialKey}`; + +export const viewAudienceUrl = ( + dashboardUrl: string, + accountSlug: string, + envOrBranchSlug: string, + audienceKey: string, +): string => + `${dashboardUrl}/${accountSlug}/${envOrBranchSlug.toLowerCase()}/audiences/${audienceKey}`; diff --git a/test/commands/audience/archive.test.ts b/test/commands/audience/archive.test.ts new file mode 100644 index 00000000..16231b05 --- /dev/null +++ b/test/commands/audience/archive.test.ts @@ -0,0 +1,112 @@ +import { expect, test } from "@oclif/test"; +import enquirer from "enquirer"; +import { isEqual } from "lodash"; +import * as sinon from "sinon"; + +import { factory } from "@/../test/support"; +import KnockApiV1 from "@/lib/api-v1"; + +describe("commands/audience/archive", () => { + describe("given no audience key arg", () => { + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .command(["audience archive", "--environment", "development"]) + .exit(2) + .it("exits with status 2"); + }); + + describe("given no environment flag", () => { + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .command(["audience archive", "vip-users"]) + .exit(2) + .it("exits with status 2"); + }); + + describe("given an audience key arg with --force flag", () => { + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .stub(KnockApiV1.prototype, "archiveAudience", (stub) => + stub.resolves( + factory.resp({ + data: factory.audience({ key: "vip-users" }), + }), + ), + ) + .stdout() + .command([ + "audience archive", + "vip-users", + "--environment", + "development", + "--force", + ]) + .it("calls apiV1 archiveAudience with correct props", () => { + sinon.assert.calledWith( + KnockApiV1.prototype.archiveAudience as any, + sinon.match( + ({ args, flags }) => + isEqual(args, { + audienceKey: "vip-users", + }) && + isEqual(flags, { + "service-token": "valid-token", + environment: "development", + force: true, + }), + ), + ); + }); + }); + + describe("given confirmation prompt is declined", () => { + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .stub(KnockApiV1.prototype, "archiveAudience", (stub) => + stub.resolves(factory.resp({ data: factory.audience() })), + ) + .stub(enquirer.prototype, "prompt", (stub) => + stub.resolves({ input: false }), + ) + .stdout() + .command([ + "audience archive", + "vip-users", + "--environment", + "development", + ]) + .it("does not call archiveAudience when declined", (ctx) => { + expect(ctx.stdout).to.contain("Archive cancelled"); + sinon.assert.notCalled(KnockApiV1.prototype.archiveAudience as any); + }); + }); + + describe("given archive fails", () => { + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .stub(KnockApiV1.prototype, "archiveAudience", (stub) => + stub.resolves( + factory.resp({ + status: 404, + statusText: "Not found", + data: { + code: "resource_missing", + message: "The resource you requested does not exist", + status: 404, + type: "api_error", + }, + }), + ), + ) + .stdout() + .command([ + "audience archive", + "vip-users", + "--environment", + "development", + "--force", + ]) + .catch("The resource you requested does not exist") + .it("throws an error for resource not found"); + }); +}); diff --git a/test/commands/audience/get.test.ts b/test/commands/audience/get.test.ts new file mode 100644 index 00000000..22b6b76a --- /dev/null +++ b/test/commands/audience/get.test.ts @@ -0,0 +1,154 @@ +import { test } from "@oclif/test"; +import { isEqual } from "lodash"; +import * as sinon from "sinon"; + +import { factory } from "@/../test/support"; +import KnockApiV1 from "@/lib/api-v1"; + +describe("commands/audience/get", () => { + const whoami = { + account_name: "Collab.io", + account_slug: "collab-io", + service_token_name: "My cool token", + }; + + describe("given no audience key arg", () => { + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .command(["audience get"]) + .exit(2) + .it("exits with status 2"); + }); + + describe("given an audience key arg, and no flags", () => { + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .stub(KnockApiV1.prototype, "whoami", (stub) => + stub.resolves(factory.resp({ data: whoami })), + ) + .stub(KnockApiV1.prototype, "getAudience", (stub) => + stub.resolves( + factory.resp({ + data: factory.audience(), + }), + ), + ) + .stdout() + .command(["audience get", "foo"]) + .it("calls apiV1 getAudience with correct props", () => { + sinon.assert.calledWith( + KnockApiV1.prototype.getAudience as any, + sinon.match( + ({ args, flags }) => + isEqual(args, { + audienceKey: "foo", + }) && + isEqual(flags, { + "service-token": "valid-token", + + environment: "development", + }), + ), + ); + }); + }); + + describe("given an audience key arg, and flags", () => { + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .stub(KnockApiV1.prototype, "whoami", (stub) => + stub.resolves(factory.resp({ data: whoami })), + ) + .stub(KnockApiV1.prototype, "getAudience", (stub) => + stub.resolves( + factory.resp({ + data: factory.audience(), + }), + ), + ) + .stdout() + .command([ + "audience get", + "foo", + "--hide-uncommitted-changes", + "--environment", + "staging", + ]) + .it("calls apiV1 getAudience with correct props", () => { + sinon.assert.calledWith( + KnockApiV1.prototype.getAudience as any, + sinon.match( + ({ args, flags }) => + isEqual(args, { + audienceKey: "foo", + }) && + isEqual(flags, { + "service-token": "valid-token", + + "hide-uncommitted-changes": true, + environment: "staging", + }), + ), + ); + }); + }); + + describe("given a branch flag", () => { + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .stub(KnockApiV1.prototype, "whoami", (stub) => + stub.resolves(factory.resp({ data: whoami })), + ) + .stub(KnockApiV1.prototype, "getAudience", (stub) => + stub.resolves( + factory.resp({ + data: factory.audience(), + }), + ), + ) + .stdout() + .command(["audience get", "foo", "--branch", "my-feature-branch-123"]) + .it("calls apiV1 getAudience with expected params", () => { + sinon.assert.calledWith( + KnockApiV1.prototype.getAudience as any, + sinon.match( + ({ args, flags }) => + isEqual(args, { + audienceKey: "foo", + }) && + isEqual(flags, { + "service-token": "valid-token", + environment: "development", + branch: "my-feature-branch-123", + }), + ), + ); + }); + }); + + describe("given an audience key that does not exist", () => { + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .stub(KnockApiV1.prototype, "whoami", (stub) => + stub.resolves(factory.resp({ data: whoami })), + ) + .stub(KnockApiV1.prototype, "getAudience", (stub) => + stub.resolves( + factory.resp({ + status: 404, + statusText: "Not found", + data: { + code: "resource_missing", + message: "The resource you requested does not exist", + status: 404, + type: "api_error", + }, + }), + ), + ) + .stdout() + .command(["audience get", "foo"]) + .catch("The resource you requested does not exist") + .it("throws an error for resource not found"); + }); +}); diff --git a/test/commands/audience/list.test.ts b/test/commands/audience/list.test.ts new file mode 100644 index 00000000..e3a1148d --- /dev/null +++ b/test/commands/audience/list.test.ts @@ -0,0 +1,212 @@ +import { expect, test } from "@oclif/test"; +import enquirer from "enquirer"; +import { isEqual } from "lodash"; +import * as sinon from "sinon"; + +import { factory } from "@/../test/support"; +import KnockApiV1 from "@/lib/api-v1"; + +describe("commands/audience/list", () => { + const emptyAudiencesListResp = factory.resp({ + data: { + page_info: factory.pageInfo(), + entries: [], + }, + }); + + describe("given no flags", () => { + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .stub(KnockApiV1.prototype, "listAudiences", (stub) => + stub.resolves(emptyAudiencesListResp), + ) + .stdout() + .command(["audience list"]) + .it("calls apiV1 listAudiences with correct props", () => { + sinon.assert.calledWith( + KnockApiV1.prototype.listAudiences as any, + sinon.match( + ({ args, flags }) => + isEqual(args, {}) && + isEqual(flags, { + "service-token": "valid-token", + environment: "development", + }), + ), + ); + }); + }); + + describe("given flags", () => { + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .stub(KnockApiV1.prototype, "listAudiences", (stub) => + stub.resolves(emptyAudiencesListResp), + ) + .stdout() + .command([ + "audience list", + "--hide-uncommitted-changes", + "--environment", + "staging", + "--limit", + "5", + "--after", + "xyz", + ]) + .it("calls apiV1 listAudiences with correct props", () => { + sinon.assert.calledWith( + KnockApiV1.prototype.listAudiences as any, + sinon.match( + ({ args, flags }) => + isEqual(args, {}) && + isEqual(flags, { + "service-token": "valid-token", + "hide-uncommitted-changes": true, + environment: "staging", + limit: 5, + after: "xyz", + }), + ), + ); + }); + }); + + describe("given a branch flag", () => { + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .stub(KnockApiV1.prototype, "listAudiences", (stub) => + stub.resolves(emptyAudiencesListResp), + ) + .stdout() + .command(["audience list", "--branch", "my-feature-branch-123"]) + .it("calls apiV1 listAudiences with expected params", () => { + sinon.assert.calledWith( + KnockApiV1.prototype.listAudiences as any, + sinon.match( + ({ args, flags }) => + isEqual(args, {}) && + isEqual(flags, { + "service-token": "valid-token", + environment: "development", + branch: "my-feature-branch-123", + }), + ), + ); + }); + }); + + describe("given a list of audiences in response", () => { + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .stub(KnockApiV1.prototype, "listAudiences", (stub) => + stub.resolves( + factory.resp({ + data: { + page_info: factory.pageInfo(), + entries: [ + factory.audience({ key: "audience-1" }), + factory.audience({ key: "audience-2" }), + factory.audience({ key: "audience-3" }), + ], + }, + }), + ), + ) + .stdout() + .command(["audience list"]) + .it("displays the list of audiences", (ctx) => { + expect(ctx.stdout).to.contain("Showing 3 audiences in"); + expect(ctx.stdout).to.contain("audience-1"); + expect(ctx.stdout).to.contain("audience-2"); + expect(ctx.stdout).to.contain("audience-3"); + + expect(ctx.stdout).to.not.contain("audience-4"); + }); + }); + + describe("given the first page of paginated audiences in resp", () => { + const paginatedAudiencesResp = factory.resp({ + data: { + page_info: factory.pageInfo({ + after: "xyz", + }), + entries: [ + factory.audience({ key: "audience-1" }), + factory.audience({ key: "audience-2" }), + factory.audience({ key: "audience-3" }), + ], + }, + }); + + describe("plus a next page action from the prompt input", () => { + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .stub(KnockApiV1.prototype, "listAudiences", (stub) => + stub.resolves(paginatedAudiencesResp), + ) + .stub(enquirer.prototype, "prompt", (stub) => + stub + .onFirstCall() + .resolves({ input: "n" }) + .onSecondCall() + .resolves({ input: "" }), + ) + .stdout() + .command(["audience list"]) + .it( + "calls apiV1 listAudiences for the second time with page params", + () => { + const listAudiencesFn = KnockApiV1.prototype.listAudiences as any; + + sinon.assert.calledTwice(listAudiencesFn); + + // First call without page params. + sinon.assert.calledWith( + listAudiencesFn.firstCall, + sinon.match( + ({ args, flags }) => + isEqual(args, {}) && + isEqual(flags, { + "service-token": "valid-token", + + environment: "development", + }), + ), + ); + + // Second call with page params to fetch the next page. + sinon.assert.calledWith( + listAudiencesFn.secondCall, + sinon.match( + ({ args, flags }) => + isEqual(args, {}) && + isEqual(flags, { + "service-token": "valid-token", + + environment: "development", + after: "xyz", + }), + ), + ); + }, + ); + }); + + describe("plus a previous page action input from the prompt", () => { + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .stub(KnockApiV1.prototype, "listAudiences", (stub) => + stub.resolves(paginatedAudiencesResp), + ) + .stub(enquirer.prototype, "prompt", (stub) => + stub.onFirstCall().resolves({ input: "p" }), + ) + .stdout() + .command(["audience list"]) + .it("calls apiV1 listAudiences once for the initial page only", () => { + sinon.assert.calledOnce(KnockApiV1.prototype.listAudiences as any); + }); + }); + }); +}); diff --git a/test/commands/audience/new.test.ts b/test/commands/audience/new.test.ts new file mode 100644 index 00000000..eda7f1d0 --- /dev/null +++ b/test/commands/audience/new.test.ts @@ -0,0 +1,153 @@ +import * as path from "node:path"; + +import { expect, test } from "@oclif/test"; +import enquirer from "enquirer"; +import * as fs from "fs-extra"; + +import { sandboxDir } from "@/lib/helpers/const"; +import { AUDIENCE_JSON } from "@/lib/marshal/audience"; + +const currCwd = process.cwd(); + +describe("commands/audience/new", () => { + beforeEach(() => { + fs.removeSync(sandboxDir); + fs.ensureDirSync(sandboxDir); + }); + afterEach(() => { + process.chdir(currCwd); + fs.removeSync(sandboxDir); + }); + + describe("given all flags provided for static audience", () => { + beforeEach(() => { + process.chdir(sandboxDir); + }); + + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .stub(enquirer.prototype, "prompt", (stub) => + stub.resolves({ input: true }), + ) + .stdout() + .command([ + "audience new", + "--name", + "VIP Users", + "--key", + "vip-users", + "--type", + "static", + "--force", + ]) + .it("creates the audience directory with static audience.json", () => { + const audienceJsonPath = path.resolve( + sandboxDir, + "vip-users", + AUDIENCE_JSON, + ); + expect(fs.existsSync(audienceJsonPath)).to.be.true; + + const audienceJson = fs.readJsonSync(audienceJsonPath); + expect(audienceJson.name).to.equal("VIP Users"); + expect(audienceJson.type).to.equal("static"); + expect(audienceJson.segments).to.be.undefined; + }); + }); + + describe("given all flags provided for dynamic audience", () => { + beforeEach(() => { + process.chdir(sandboxDir); + }); + + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .stub(enquirer.prototype, "prompt", (stub) => + stub.resolves({ input: true }), + ) + .stdout() + .command([ + "audience new", + "--name", + "Beta Testers", + "--key", + "beta-testers", + "--type", + "dynamic", + "--description", + "Users in beta program", + "--force", + ]) + .it("creates the audience directory with dynamic audience.json", () => { + const audienceJsonPath = path.resolve( + sandboxDir, + "beta-testers", + AUDIENCE_JSON, + ); + expect(fs.existsSync(audienceJsonPath)).to.be.true; + + const audienceJson = fs.readJsonSync(audienceJsonPath); + expect(audienceJson.name).to.equal("Beta Testers"); + expect(audienceJson.type).to.equal("dynamic"); + expect(audienceJson.description).to.equal("Users in beta program"); + // Dynamic audiences should have segments scaffolded + expect(audienceJson.segments).to.be.an("array"); + }); + }); + + describe("given an invalid audience key", () => { + beforeEach(() => { + process.chdir(sandboxDir); + }); + + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .stdout() + .command([ + "audience new", + "--name", + "Invalid Audience", + "--key", + "INVALID_KEY", + "--type", + "static", + "--force", + ]) + .catch((error) => expect(error.message).to.match(/Invalid audience key/)) + .it("throws an error for invalid key format"); + }); + + describe("given inside an existing resource directory", () => { + beforeEach(() => { + // Create an existing workflow directory + const workflowJsonPath = path.resolve( + sandboxDir, + "my-workflow", + "workflow.json", + ); + fs.outputJsonSync(workflowJsonPath, { name: "My Workflow" }); + + process.chdir(path.resolve(sandboxDir, "my-workflow")); + }); + + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .stdout() + .command([ + "audience new", + "--name", + "Test", + "--key", + "test", + "--type", + "static", + "--force", + ]) + .catch((error) => + expect(error.message).to.match( + /Cannot create a new audience inside an existing/, + ), + ) + .it("throws an error"); + }); +}); diff --git a/test/commands/audience/open.test.ts b/test/commands/audience/open.test.ts new file mode 100644 index 00000000..7c95767d --- /dev/null +++ b/test/commands/audience/open.test.ts @@ -0,0 +1,75 @@ +import { expect, test } from "@oclif/test"; +import * as sinon from "sinon"; + +import { factory } from "@/../test/support"; +import KnockApiV1 from "@/lib/api-v1"; +import { browser } from "@/lib/helpers/browser"; + +describe("commands/audience/open", () => { + const whoami = { + account_name: "Collab.io", + account_slug: "collab-io", + service_token_name: "My cool token", + }; + + describe("given no audience key arg", () => { + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .command(["audience open"]) + .exit(2) + .it("exits with status 2"); + }); + + describe("given an audience key arg", () => { + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .stub(KnockApiV1.prototype, "whoami", (stub) => + stub.resolves(factory.resp({ data: whoami })), + ) + .stub(browser, "openUrl", (stub) => stub.resolves()) + .stdout() + .command(["audience open", "vip-users"]) + .it("opens the audience in the dashboard", (ctx) => { + expect(ctx.stdout).to.contain("Opening audience `vip-users`"); + sinon.assert.calledOnce(browser.openUrl as any); + }); + }); + + describe("given an audience key arg with environment flag", () => { + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .stub(KnockApiV1.prototype, "whoami", (stub) => + stub.resolves(factory.resp({ data: whoami })), + ) + .stub(browser, "openUrl", (stub) => stub.resolves()) + .stdout() + .command(["audience open", "vip-users", "--environment", "staging"]) + .it("opens the audience in the staging environment", (ctx) => { + expect(ctx.stdout).to.contain("Opening audience `vip-users`"); + sinon.assert.calledOnce(browser.openUrl as any); + sinon.assert.calledWith( + browser.openUrl as any, + sinon.match(/staging\/audiences\/vip-users/), + ); + }); + }); + + describe("given an audience key arg with branch flag", () => { + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .stub(KnockApiV1.prototype, "whoami", (stub) => + stub.resolves(factory.resp({ data: whoami })), + ) + .stub(browser, "openUrl", (stub) => stub.resolves()) + .stdout() + .command(["audience open", "vip-users", "--branch", "my-feature-branch"]) + .it("opens the audience with branch in URL", (ctx) => { + expect(ctx.stdout).to.contain("Opening audience `vip-users`"); + sinon.assert.calledOnce(browser.openUrl as any); + sinon.assert.calledWith( + browser.openUrl as any, + sinon.match(/my-feature-branch\/audiences\/vip-users/), + ); + }); + }); +}); diff --git a/test/commands/audience/pull.test.ts b/test/commands/audience/pull.test.ts new file mode 100644 index 00000000..67973a97 --- /dev/null +++ b/test/commands/audience/pull.test.ts @@ -0,0 +1,207 @@ +import * as path from "node:path"; + +import { expect, test } from "@oclif/test"; +import enquirer from "enquirer"; +import * as fs from "fs-extra"; +import { isEqual } from "lodash"; +import * as sinon from "sinon"; + +import { factory } from "@/../test/support"; +import KnockApiV1 from "@/lib/api-v1"; +import { sandboxDir } from "@/lib/helpers/const"; +import { + AUDIENCE_JSON, + AudienceData, + AudienceType, +} from "@/lib/marshal/audience"; +import { WithAnnotation } from "@/lib/marshal/shared/types"; + +const mockAudienceData: AudienceData = { + key: "vip-users", + name: "VIP Users", + type: AudienceType.Dynamic, + description: "Premium subscription users", + segments: [ + { + conditions: [ + { + property: "recipient.plan", + operator: "equal_to", + argument: "premium", + }, + ], + }, + ], + environment: "development", + updated_at: "2023-09-29T19:08:04.129228Z", + created_at: "2023-09-18T18:32:18.398053Z", + sha: "", + __annotation: { + extractable_fields: {}, + readonly_fields: ["environment", "key", "created_at", "updated_at", "sha"], + }, +}; + +const setupWithStub = (attrs = {}) => + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .stub(enquirer.prototype, "prompt", (stub) => + stub.resolves({ input: true }), + ) + .stub(KnockApiV1.prototype, "getAudience", (stub) => + stub.resolves(factory.resp(attrs)), + ); + +const currCwd = process.cwd(); + +describe("commands/audience/pull (a single audience)", () => { + beforeEach(() => { + fs.removeSync(sandboxDir); + fs.ensureDirSync(sandboxDir); + }); + afterEach(() => { + process.chdir(currCwd); + fs.removeSync(sandboxDir); + }); + + describe("given an audience key arg", () => { + beforeEach(() => { + process.chdir(sandboxDir); + }); + + setupWithStub({ data: mockAudienceData }) + .stdout() + .command(["audience pull", "vip-users", "--force"]) + .it("calls apiV1 getAudience with expected props", () => { + sinon.assert.calledWith( + KnockApiV1.prototype.getAudience as any, + sinon.match( + ({ args, flags }) => + isEqual(args, { audienceKey: "vip-users" }) && + isEqual(flags, { + "service-token": "valid-token", + environment: "development", + annotate: true, + force: true, + }), + ), + ); + }); + + setupWithStub({ data: mockAudienceData }) + .stdout() + .command(["audience pull", "vip-users", "--force"]) + .it("creates the audience directory with audience.json", () => { + const audienceJsonPath = path.resolve( + sandboxDir, + "vip-users", + AUDIENCE_JSON, + ); + expect(fs.existsSync(audienceJsonPath)).to.be.true; + + const audienceJson = fs.readJsonSync(audienceJsonPath); + expect(audienceJson.name).to.equal("VIP Users"); + expect(audienceJson.type).to.equal("dynamic"); + }); + + describe("given a branch flag", () => { + setupWithStub({ data: mockAudienceData }) + .stdout() + .command([ + "audience pull", + "vip-users", + "--force", + "--branch", + "my-feature-branch-123", + ]) + .it("calls apiV1 getAudience with expected params", () => { + sinon.assert.calledWith( + KnockApiV1.prototype.getAudience as any, + sinon.match( + ({ args, flags }) => + isEqual(args, { audienceKey: "vip-users" }) && + isEqual(flags, { + "service-token": "valid-token", + environment: "development", + branch: "my-feature-branch-123", + annotate: true, + force: true, + }), + ), + ); + }); + }); + }); + + describe("given no audience key arg and not in an audience directory", () => { + beforeEach(() => { + process.chdir(sandboxDir); + }); + + setupWithStub({ data: mockAudienceData }) + .stdout() + .command(["audience pull", "--force"]) + .exit(2) + .it("exits with status 2"); + }); + + describe("given both audience key arg and --all flag", () => { + setupWithStub({ data: mockAudienceData }) + .stdout() + .command(["audience pull", "vip-users", "--all"]) + .exit(2) + .it("exits with status 2"); + }); +}); + +describe("commands/audience/pull (all audiences)", () => { + beforeEach(() => { + fs.removeSync(sandboxDir); + fs.ensureDirSync(sandboxDir); + }); + afterEach(() => { + process.chdir(currCwd); + fs.removeSync(sandboxDir); + }); + + describe("given --all flag with audiences-dir", () => { + beforeEach(() => { + process.chdir(sandboxDir); + }); + + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .stub(enquirer.prototype, "prompt", (stub) => + stub.resolves({ input: true }), + ) + .stub(KnockApiV1.prototype, "listAudiences", (stub) => + stub.resolves( + factory.resp({ + data: { + page_info: factory.pageInfo(), + entries: [mockAudienceData], + }, + }), + ), + ) + .stdout() + .command([ + "audience pull", + "--all", + "--audiences-dir", + "audiences", + "--force", + ]) + .it("calls apiV1 listAudiences and creates audience directories", () => { + sinon.assert.calledOnce(KnockApiV1.prototype.listAudiences as any); + + const audienceJsonPath = path.resolve( + sandboxDir, + "audiences", + "vip-users", + AUDIENCE_JSON, + ); + expect(fs.existsSync(audienceJsonPath)).to.be.true; + }); + }); +}); diff --git a/test/commands/audience/push.test.ts b/test/commands/audience/push.test.ts new file mode 100644 index 00000000..108c46a7 --- /dev/null +++ b/test/commands/audience/push.test.ts @@ -0,0 +1,288 @@ +import * as path from "node:path"; + +import { expect, test } from "@oclif/test"; +import * as fs from "fs-extra"; +import { isEqual } from "lodash"; +import * as sinon from "sinon"; + +import { factory } from "@/../test/support"; +import AudienceValidate from "@/commands/audience/validate"; +import KnockApiV1 from "@/lib/api-v1"; +import { sandboxDir } from "@/lib/helpers/const"; +import { + AUDIENCE_JSON, + AudienceData, + AudienceType, +} from "@/lib/marshal/audience"; +import { WithAnnotation } from "@/lib/marshal/shared/types"; + +const audienceJsonFile = "default/audience.json"; + +const mockAudienceData: AudienceData = { + key: "default", + name: "Default", + type: AudienceType.Static, + description: "This is a default audience", + environment: "development", + updated_at: "2023-09-29T19:08:04.129228Z", + created_at: "2023-09-18T18:32:18.398053Z", + sha: "", + __annotation: { + extractable_fields: {}, + readonly_fields: ["environment", "key", "created_at", "updated_at", "sha"], + }, +}; + +const setupWithStub = (attrs = {}) => + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .stub(AudienceValidate, "validateAll", (stub) => stub.resolves([])) + .stub(KnockApiV1.prototype, "upsertAudience", (stub) => + stub.resolves(factory.resp(attrs)), + ); + +const currCwd = process.cwd(); + +describe("commands/audience/push", () => { + beforeEach(() => { + fs.removeSync(sandboxDir); + fs.ensureDirSync(sandboxDir); + }); + afterEach(() => { + process.chdir(currCwd); + fs.removeSync(sandboxDir); + }); + + describe("given an audience directory exists, for the audience key", () => { + beforeEach(() => { + const abspath = path.resolve(sandboxDir, audienceJsonFile); + fs.outputJsonSync(abspath, { name: "Default", type: "static" }); + + process.chdir(sandboxDir); + }); + + setupWithStub({ data: { audience: mockAudienceData } }) + .stdout() + .command(["audience push", "default"]) + .it("calls apiV1 upsertAudience with expected props", () => { + sinon.assert.calledWith( + KnockApiV1.prototype.upsertAudience as any, + sinon.match( + ({ args, flags }) => + isEqual(args, { audienceKey: "default" }) && + isEqual(flags, { + "service-token": "valid-token", + environment: "development", + annotate: true, + }), + ), + sinon.match((audience) => + isEqual(audience, { + key: "default", + name: "Default", + type: "static", + }), + ), + ); + }); + + setupWithStub({ data: { audience: mockAudienceData } }) + .stdout() + .command([ + "audience push", + "default", + "--commit", + "-m", + "this is a commit comment!", + ]) + .it("calls apiV1 upsertAudience with commit flags, if provided", () => { + sinon.assert.calledWith( + KnockApiV1.prototype.upsertAudience as any, + sinon.match( + ({ args, flags }) => + isEqual(args, { audienceKey: "default" }) && + isEqual(flags, { + "service-token": "valid-token", + environment: "development", + commit: true, + "commit-message": "this is a commit comment!", + annotate: true, + }), + ), + sinon.match((audience) => + isEqual(audience, { + key: "default", + name: "Default", + type: "static", + }), + ), + ); + }); + + describe("given a branch flag", () => { + setupWithStub({ data: { audience: mockAudienceData } }) + .stdout() + .command([ + "audience push", + "default", + "--branch", + "my-feature-branch-123", + ]) + .it("calls apiV1 upsertAudience with expected params", () => { + sinon.assert.calledWith( + KnockApiV1.prototype.upsertAudience as any, + sinon.match( + ({ args, flags }) => + isEqual(args, { audienceKey: "default" }) && + isEqual(flags, { + "service-token": "valid-token", + environment: "development", + branch: "my-feature-branch-123", + annotate: true, + }), + ), + sinon.match((audience) => + isEqual(audience, { + key: "default", + name: "Default", + type: "static", + }), + ), + ); + }); + }); + + setupWithStub({ data: { audience: mockAudienceData } }) + .stdout() + .command(["audience push", "default"]) + .it("writes the upserted audience data into audience.json", () => { + const abspath = path.resolve(sandboxDir, audienceJsonFile); + const audienceJson = fs.readJsonSync(abspath); + + expect(audienceJson).to.eql({ + $schema: "https://schemas.knock.app/cli/audience.json", + name: "Default", + type: "static", + description: "This is a default audience", + __readonly: { + key: "default", + environment: "development", + created_at: "2023-09-18T18:32:18.398053Z", + }, + }); + }); + }); + + describe("given an audience.json file, with syntax errors", () => { + beforeEach(() => { + const abspath = path.resolve(sandboxDir, audienceJsonFile); + fs.outputFileSync(abspath, '{"name":"default",}'); + + process.chdir(sandboxDir); + }); + + setupWithStub({ data: { audience: mockAudienceData } }) + .stdout() + .command(["audience push", "default"]) + .catch((error) => expect(error.message).to.match(/JsonSyntaxError/)) + .it("throws an error"); + }); + + describe("given a nonexistent audience directory, for the audience key", () => { + beforeEach(() => { + process.chdir(sandboxDir); + }); + + setupWithStub({ data: { audience: mockAudienceData } }) + .stdout() + .command(["audience push", "does-not-exist"]) + .catch((error) => + expect(error.message).to.match(/^Cannot locate an audience directory/), + ) + .it("throws an error"); + }); + + describe("given no audience key arg or --all flag", () => { + setupWithStub({ data: { audience: mockAudienceData } }) + .stdout() + .command(["audience push"]) + .exit(2) + .it("exits with status 2"); + }); + + describe("given both audience key arg and --all flag", () => { + setupWithStub({ data: { audience: mockAudienceData } }) + .stdout() + .command(["audience push", "default", "--all"]) + .exit(2) + .it("exits with status 2"); + }); + + describe("given --all and a nonexistent audiences index directory", () => { + beforeEach(() => { + process.chdir(sandboxDir); + }); + + setupWithStub() + .stdout() + .command(["audience push", "--all", "--audiences-dir", "audiences"]) + .catch((error) => + expect(error.message).to.match(/Cannot locate audience directories in/), + ) + .it("throws an error"); + }); + + describe("given --all and an audiences index directory, without any audiences", () => { + beforeEach(() => { + const indexDirPath = path.resolve(sandboxDir, "audiences"); + fs.ensureDirSync(indexDirPath); + + process.chdir(sandboxDir); + }); + + setupWithStub() + .stdout() + .command(["audience push", "--all", "--audiences-dir", "audiences"]) + .catch((error) => + expect(error.message).to.match(/No audience directories found in/), + ) + .it("throws an error"); + }); + + describe("given --all and an audiences index directory with 2 audiences", () => { + const indexDirPath = path.resolve(sandboxDir, "audiences"); + + beforeEach(() => { + const vipAudienceJson = path.resolve( + indexDirPath, + "vip-users", + AUDIENCE_JSON, + ); + fs.outputJsonSync(vipAudienceJson, { name: "VIP Users", type: "static" }); + + const betaAudienceJson = path.resolve( + indexDirPath, + "beta-testers", + AUDIENCE_JSON, + ); + fs.outputJsonSync(betaAudienceJson, { + name: "Beta Testers", + type: "dynamic", + }); + + process.chdir(sandboxDir); + }); + + setupWithStub({ data: { audience: mockAudienceData } }) + .stdout() + .command(["audience push", "--all", "--audiences-dir", "audiences"]) + .it("calls apiV1 upsertAudience with expected props twice", () => { + // Validate all first + const stub1 = AudienceValidate.validateAll as any; + sinon.assert.calledOnce(stub1); + + const stub2 = KnockApiV1.prototype.upsertAudience as any; + sinon.assert.calledTwice(stub2); + }); + }); +}); diff --git a/test/commands/audience/validate.test.ts b/test/commands/audience/validate.test.ts new file mode 100644 index 00000000..2bc35dfe --- /dev/null +++ b/test/commands/audience/validate.test.ts @@ -0,0 +1,232 @@ +import * as path from "node:path"; + +import { expect, test } from "@oclif/test"; +import * as fs from "fs-extra"; +import { isEqual } from "lodash"; +import * as sinon from "sinon"; + +import { factory } from "@/../test/support"; +import KnockApiV1 from "@/lib/api-v1"; +import { sandboxDir } from "@/lib/helpers/const"; +import { AUDIENCE_JSON } from "@/lib/marshal/audience"; + +const audienceJsonFile = "default/audience.json"; + +const setupWithStub = (attrs = {}) => + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .stub(KnockApiV1.prototype, "validateAudience", (stub) => + stub.resolves(factory.resp(attrs)), + ); + +const currCwd = process.cwd(); + +describe("commands/audience/validate (a single audience)", () => { + beforeEach(() => { + fs.removeSync(sandboxDir); + fs.ensureDirSync(sandboxDir); + }); + afterEach(() => { + process.chdir(currCwd); + fs.removeSync(sandboxDir); + }); + + describe("given an audience directory exists, for the audience key", () => { + beforeEach(() => { + const abspath = path.resolve(sandboxDir, audienceJsonFile); + fs.outputJsonSync(abspath, { name: "Default", type: "static" }); + + process.chdir(sandboxDir); + }); + + setupWithStub() + .stdout() + .command(["audience validate", "default"]) + .it("calls apiV1 validateAudience with expected props", () => { + sinon.assert.calledWith( + KnockApiV1.prototype.validateAudience as any, + sinon.match( + ({ args, flags }) => + isEqual(args, { audienceKey: "default" }) && + isEqual(flags, { + "service-token": "valid-token", + environment: "development", + }), + ), + sinon.match((audience) => + isEqual(audience, { + key: "default", + name: "Default", + type: "static", + }), + ), + ); + }); + + describe("given a branch flag", () => { + setupWithStub() + .stdout() + .command([ + "audience validate", + "default", + "--branch", + "my-feature-branch-123", + ]) + .it("calls apiV1 validateAudience with expected params", () => { + sinon.assert.calledWith( + KnockApiV1.prototype.validateAudience as any, + sinon.match( + ({ args, flags }) => + isEqual(args, { audienceKey: "default" }) && + isEqual(flags, { + "service-token": "valid-token", + environment: "development", + branch: "my-feature-branch-123", + }), + ), + sinon.match((audience) => + isEqual(audience, { + key: "default", + name: "Default", + type: "static", + }), + ), + ); + }); + }); + }); + + describe("given an audience.json file, with syntax errors", () => { + beforeEach(() => { + const abspath = path.resolve(sandboxDir, audienceJsonFile); + fs.outputFileSync(abspath, '{"name":"Default",}'); + + process.chdir(sandboxDir); + }); + + setupWithStub() + .stdout() + .command(["audience validate", "default"]) + .catch((error) => expect(error.message).to.match(/JsonSyntaxError/)) + .it("throws an error"); + }); + + describe("given an audience.json file, with data errors", () => { + beforeEach(() => { + const abspath = path.resolve(sandboxDir, audienceJsonFile); + fs.outputJsonSync(abspath, { name: 123 }); + + process.chdir(sandboxDir); + }); + + setupWithStub({ + status: 422, + data: { errors: [{ field: "name", message: "must be a string" }] }, + }) + .stdout() + .command(["audience validate", "default"]) + .catch((error) => + expect(error.message).to.match( + /JsonDataError.*"name" must be a string/, + ), + ) + .it("throws an error"); + }); + + describe("given a nonexistent audience directory, for the audience key", () => { + beforeEach(() => { + process.chdir(sandboxDir); + }); + + setupWithStub() + .stdout() + .command(["audience validate", "does-not-exist"]) + .catch((error) => + expect(error.message).to.match(/^Cannot locate an audience directory/), + ) + .it("throws an error"); + }); + + describe("given no audience key arg nor --all flag", () => { + setupWithStub() + .stdout() + .command(["audience validate"]) + .exit(2) + .it("exits with status 2"); + }); +}); + +describe("commands/audience/validate (all audiences)", () => { + beforeEach(() => { + fs.removeSync(sandboxDir); + fs.ensureDirSync(sandboxDir); + }); + afterEach(() => { + process.chdir(currCwd); + fs.removeSync(sandboxDir); + }); + + describe("given a nonexistent audiences index directory", () => { + beforeEach(() => { + process.chdir(sandboxDir); + }); + + setupWithStub() + .stdout() + .command(["audience validate", "--all", "--audiences-dir", "audiences"]) + .catch((error) => + expect(error.message).to.match(/Cannot locate audience directories in/), + ) + .it("throws an error"); + }); + + describe("given an audience index directory, without any audiences", () => { + beforeEach(() => { + const indexDirPath = path.resolve(sandboxDir, "audiences"); + fs.ensureDirSync(indexDirPath); + + process.chdir(sandboxDir); + }); + + setupWithStub() + .stdout() + .command(["audience validate", "--all", "--audiences-dir", "audiences"]) + .catch((error) => + expect(error.message).to.match(/No audience directories found in/), + ) + .it("throws an error"); + }); + + describe("given an audiences index directory with 2 valid audiences", () => { + const indexDirPath = path.resolve(sandboxDir, "audiences"); + + beforeEach(() => { + const vipAudienceJson = path.resolve( + indexDirPath, + "vip-users", + AUDIENCE_JSON, + ); + fs.outputJsonSync(vipAudienceJson, { name: "VIP Users", type: "static" }); + + const betaAudienceJson = path.resolve( + indexDirPath, + "beta-testers", + AUDIENCE_JSON, + ); + fs.outputJsonSync(betaAudienceJson, { + name: "Beta Testers", + type: "dynamic", + }); + + process.chdir(sandboxDir); + }); + + setupWithStub() + .stdout() + .command(["audience validate", "--all", "--audiences-dir", "audiences"]) + .it("calls apiV1 validateAudience with expected props twice", () => { + const stub = KnockApiV1.prototype.validateAudience as any; + sinon.assert.calledTwice(stub); + }); + }); +}); diff --git a/test/support/factory.ts b/test/support/factory.ts index 45912b22..4ce491d3 100644 --- a/test/support/factory.ts +++ b/test/support/factory.ts @@ -10,6 +10,7 @@ import { type WhoamiResp } from "@/lib/api-v1"; import { AuthenticatedSession } from "@/lib/auth"; import { BFlags, Props } from "@/lib/base-command"; import { PageInfo, PaginatedResp } from "@/lib/helpers/page"; +import { AudienceData, AudienceType } from "@/lib/marshal/audience"; import { EmailLayoutData } from "@/lib/marshal/email-layout"; import { GuideData } from "@/lib/marshal/guide"; import { MessageTypeData } from "@/lib/marshal/message-type"; @@ -226,6 +227,31 @@ export const partial = (attrs: Partial = {}): PartialData => { }; }; +export const audience = (attrs: Partial = {}): AudienceData => { + return { + key: "vip-users", + name: "VIP Users", + type: AudienceType.Dynamic, + description: "Premium subscription users", + segments: [ + { + conditions: [ + { + property: "recipient.plan", + operator: "equal_to", + argument: "premium", + }, + ], + }, + ], + environment: "development", + created_at: "2022-12-31T12:00:00.000000Z", + updated_at: "2022-12-31T12:00:00.000000Z", + sha: "", + ...attrs, + }; +}; + export const messageType = ( attrs: Partial = {}, ): MessageTypeData => { From fb5233dec57fa9236e235f66f963d77b3f1faf5f Mon Sep 17 00:00:00 2001 From: Meryl Dakin Date: Mon, 9 Mar 2026 10:52:37 -0400 Subject: [PATCH 02/18] test: add audience stubs to pull/push tests Add listAudiences stub to pull.test.ts and AudienceValidate.validateAll stub to push.test.ts to fix test failures caused by audience resource integration. Co-Authored-By: Claude Opus 4.5 --- test/commands/pull.test.ts | 12 ++++++++++++ test/commands/push.test.ts | 6 ++++++ 2 files changed, 18 insertions(+) diff --git a/test/commands/pull.test.ts b/test/commands/pull.test.ts index 094a3524..cc1b15f7 100644 --- a/test/commands/pull.test.ts +++ b/test/commands/pull.test.ts @@ -9,6 +9,7 @@ import * as sinon from "sinon"; import { factory } from "@/../test/support"; import KnockApiV1 from "@/lib/api-v1"; import { sandboxDir } from "@/lib/helpers/const"; +import { AudienceData } from "@/lib/marshal/audience"; import { EmailLayoutData } from "@/lib/marshal/email-layout"; import { GuideData } from "@/lib/marshal/guide"; import { MessageTypeData } from "@/lib/marshal/message-type"; @@ -25,6 +26,7 @@ const setupWithListStubs = ( manyWorkflowAttrs: Partial[], manyMessageTypeAttrs: Partial[], manyGuideAttrs: Partial[], + manyAudienceAttrs: Partial[] = [], // eslint-disable-next-line max-params ) => test @@ -32,6 +34,16 @@ const setupWithListStubs = ( .stub(KnockApiV1.prototype, "whoami", (stub) => stub.resolves(factory.resp({ data: factory.whoami() })), ) + .stub(KnockApiV1.prototype, "listAudiences", (stub) => + stub.resolves( + factory.resp({ + data: { + entries: manyAudienceAttrs.map((attrs) => factory.audience(attrs)), + page_info: factory.pageInfo(), + }, + }), + ), + ) .stub(KnockApiV1.prototype, "listEmailLayouts", (stub) => stub.resolves( factory.resp({ diff --git a/test/commands/push.test.ts b/test/commands/push.test.ts index cdfe9a54..d342c4d3 100644 --- a/test/commands/push.test.ts +++ b/test/commands/push.test.ts @@ -6,6 +6,7 @@ import * as fs from "fs-extra"; import { isEqual } from "lodash"; import * as sinon from "sinon"; +import AudienceValidate from "@/commands/audience/validate"; import GuideValidate from "@/commands/guide/validate"; import EmailLayoutValidate from "@/commands/layout/validate"; import MessageTypeValidate from "@/commands/message-type/validate"; @@ -790,6 +791,8 @@ describe("commands/push", () => { let upsertWorkflowStub: sinon.SinonStub; beforeEach(() => { + sinon.stub(AudienceValidate, "validateAll").resolves([]); + layoutValidateAllStub = sinon .stub(EmailLayoutValidate, "validateAll") .resolves([]); @@ -925,6 +928,8 @@ describe("commands/push", () => { .stub(KnockApiV1.prototype, "whoami") .resolves(factory.resp({ data: factory.whoami() })); + sinon.stub(AudienceValidate, "validateAll").resolves([]); + layoutValidateAllStub = sinon .stub(EmailLayoutValidate, "validateAll") .resolves([]); @@ -1029,6 +1034,7 @@ describe("commands/push", () => { let upsertGuideStub: sinon.SinonStub; beforeEach(() => { + sinon.stub(AudienceValidate, "validateAll").resolves([]); sinon.stub(EmailLayoutValidate, "validateAll").resolves([]); sinon.stub(PartialValidate, "validateAll").resolves([]); sinon.stub(TranslationValidate, "validateAll").resolves([]); From f66cd4af537627589322c470f91da672803617ba Mon Sep 17 00:00:00 2001 From: Meryl Dakin Date: Mon, 9 Mar 2026 11:40:05 -0400 Subject: [PATCH 03/18] fix: remove debug console.log from audience writer Remove leftover debug logging from the error handler in writeAudiencesIndexDir to match the pattern used in other marshal writers (email-layout, workflow). Co-Authored-By: Claude Opus 4.5 --- src/lib/marshal/audience/writer.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/lib/marshal/audience/writer.ts b/src/lib/marshal/audience/writer.ts index cceb4509..63bb9963 100644 --- a/src/lib/marshal/audience/writer.ts +++ b/src/lib/marshal/audience/writer.ts @@ -169,7 +169,6 @@ export const writeAudiencesIndexDir = async ( await Promise.all(writeAudienceDirPromises); } catch (error) { - console.log(error); // In case of any error, wipe the index directory that is likely in a bad // state then restore the backup if one existed before. if (indexDirCtx.exists) { From e81d80fdfb1c91009656226b6015fc13263fca39 Mon Sep 17 00:00:00 2001 From: Meryl Dakin Date: Mon, 9 Mar 2026 11:42:22 -0400 Subject: [PATCH 04/18] fix: address code review feedback for audience implementation 1. Return render() call in list command to properly await pagination 2. Use original dirent.name for filesystem path in pruneAudiencesIndexDir, only use lowercased version for key lookup comparison Co-Authored-By: Claude Opus 4.5 --- src/commands/audience/list.ts | 2 +- src/lib/marshal/audience/writer.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/commands/audience/list.ts b/src/commands/audience/list.ts index f30d5518..73407090 100644 --- a/src/commands/audience/list.ts +++ b/src/commands/audience/list.ts @@ -37,7 +37,7 @@ export default class AudienceList extends BaseCommand { const { flags } = this.props; if (flags.json) return resp.data; - this.render(resp.data); + return this.render(resp.data); } async request( diff --git a/src/lib/marshal/audience/writer.ts b/src/lib/marshal/audience/writer.ts index 63bb9963..625cf572 100644 --- a/src/lib/marshal/audience/writer.ts +++ b/src/lib/marshal/audience/writer.ts @@ -118,10 +118,10 @@ const pruneAudiencesIndexDir = async ( }); const promises = dirents.map(async (dirent) => { - const direntName = dirent.name.toLowerCase(); - const direntPath = path.resolve(indexDirCtx.abspath, direntName); + const direntPath = path.resolve(indexDirCtx.abspath, dirent.name); + const direntKey = dirent.name.toLowerCase(); - if ((await isAudienceDir(direntPath)) && audiencesByKey[direntName]) { + if ((await isAudienceDir(direntPath)) && audiencesByKey[direntKey]) { return; } From 63c4850eaa7ce2babacd608cfadaf884b03781bf Mon Sep 17 00:00:00 2001 From: Meryl Dakin Date: Mon, 9 Mar 2026 11:43:54 -0400 Subject: [PATCH 05/18] fix: use else-if chain in resource dir detection to prevent fallthrough Refactor evaluateRecursively to use else-if chain instead of separate if blocks. This prevents later resource type checks from overwriting an already-detected resource directory context. Co-Authored-By: Claude Opus 4.5 --- src/lib/run-context/loader.ts | 39 +++++++++-------------------------- 1 file changed, 10 insertions(+), 29 deletions(-) diff --git a/src/lib/run-context/loader.ts b/src/lib/run-context/loader.ts index 1cc723d6..061dfdb3 100644 --- a/src/lib/run-context/loader.ts +++ b/src/lib/run-context/loader.ts @@ -30,41 +30,22 @@ const evaluateRecursively = async ( ): Promise => { // Check if we are inside a resource directory and if so update the context. if (!ctx.resourceDir) { - const isAudienceDir = await Audience.isAudienceDir(currDir); - if (isAudienceDir) { + if (await Audience.isAudienceDir(currDir)) { ctx.resourceDir = buildResourceDirContext("audience", currDir); - } - - const isWorkflowDir = await Workflow.isWorkflowDir(currDir); - if (isWorkflowDir) { + } else if (await Workflow.isWorkflowDir(currDir)) { ctx.resourceDir = buildResourceDirContext("workflow", currDir); - } - - const isGuideDir = await Guide.isGuideDir(currDir); - if (isGuideDir) { + } else if (await Guide.isGuideDir(currDir)) { ctx.resourceDir = buildResourceDirContext("guide", currDir); - } - - const isEmailLayoutDir = await EmailLayout.isEmailLayoutDir(currDir); - if (isEmailLayoutDir) { + } else if (await EmailLayout.isEmailLayoutDir(currDir)) { ctx.resourceDir = buildResourceDirContext("email_layout", currDir); - } - - const isPartialDir = await Partial.isPartialDir(currDir); - if (isPartialDir) { + } else if (await Partial.isPartialDir(currDir)) { ctx.resourceDir = buildResourceDirContext("partial", currDir); - } - - const isMessageTypeDir = await MessageType.isMessageTypeDir(currDir); - if (isMessageTypeDir) { + } else if (await MessageType.isMessageTypeDir(currDir)) { ctx.resourceDir = buildResourceDirContext("message_type", currDir); - } - - // NOTE: Must keep this check as last in the order of directory-type checks - // since the `isTranslationDir` only checks that the directory name is a - // valid locale name. - const isTranslationDir = Translation.isTranslationDir(currDir); - if (isTranslationDir) { + } else if (Translation.isTranslationDir(currDir)) { + // NOTE: Must keep this check as last in the order of directory-type checks + // since the `isTranslationDir` only checks that the directory name is a + // valid locale name. ctx.resourceDir = buildResourceDirContext("translation", currDir); } } From 20ee89217a1caa64ef2eaeb0b5817f33c9279683 Mon Sep 17 00:00:00 2001 From: Meryl Dakin Date: Mon, 9 Mar 2026 11:49:16 -0400 Subject: [PATCH 06/18] fix: forward branch and environment flags when pushing from audience new When using --push with audience new, now properly forwards the --branch and --environment flags to AudiencePush.run() so the push targets the correct branch. Co-Authored-By: Claude Opus 4.5 --- src/commands/audience/new.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/commands/audience/new.ts b/src/commands/audience/new.ts index 6524af88..57261d07 100644 --- a/src/commands/audience/new.ts +++ b/src/commands/audience/new.ts @@ -164,8 +164,13 @@ export default class AudienceNew extends BaseCommand { if (flags.push) { spinner.start("‣ Pushing audience to Knock"); + const pushArgs = [key, "--environment", flags.environment]; + if (flags.branch) { + pushArgs.push("--branch", flags.branch); + } + try { - await AudiencePush.run([key]); + await AudiencePush.run(pushArgs); } catch (error) { this.error(`Failed to push audience to Knock: ${error}`); } finally { From 97088bd474e98b0f67c1a0634e31b01a845ea059 Mon Sep 17 00:00:00 2001 From: Meryl Dakin Date: Mon, 9 Mar 2026 11:59:42 -0400 Subject: [PATCH 07/18] fix: allow audience push and validate in any environment Remove the development-only restriction from audience push and validate commands. Unlike some other resources, audiences can be created in any environment. Co-Authored-By: Claude Opus 4.5 --- src/commands/audience/push.ts | 3 +-- src/commands/audience/validate.ts | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/commands/audience/push.ts b/src/commands/audience/push.ts index 149e9e9d..58a73d8e 100644 --- a/src/commands/audience/push.ts +++ b/src/commands/audience/push.ts @@ -21,9 +21,8 @@ export default class AudiencePush extends BaseCommand { static flags = { environment: Flags.string({ summary: - "Pushing an audience is only allowed in the development environment", + "The environment to push the audience to. Defaults to development.", default: KnockEnv.Development, - options: [KnockEnv.Development], }), branch: CustomFlags.branch, all: Flags.boolean({ diff --git a/src/commands/audience/validate.ts b/src/commands/audience/validate.ts index b71d3e78..18461792 100644 --- a/src/commands/audience/validate.ts +++ b/src/commands/audience/validate.ts @@ -21,9 +21,8 @@ export default class AudienceValidate extends BaseCommand< static flags = { environment: Flags.string({ summary: - "Validating an audience is only done in the development environment", + "The environment to validate the audience against. Defaults to development.", default: KnockEnv.Development, - options: [KnockEnv.Development], }), branch: CustomFlags.branch, all: Flags.boolean({ From 34b38bf3afbb60816f0e01520071b841c5adba69 Mon Sep 17 00:00:00 2001 From: Meryl Dakin Date: Mon, 9 Mar 2026 12:38:36 -0400 Subject: [PATCH 08/18] chore: remove orphaned TODO comment from audience validate Remove copied TODO comment that has no associated ticket. Co-Authored-By: Claude Opus 4.5 --- src/commands/audience/validate.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/commands/audience/validate.ts b/src/commands/audience/validate.ts index 18461792..65325d21 100644 --- a/src/commands/audience/validate.ts +++ b/src/commands/audience/validate.ts @@ -89,9 +89,6 @@ export default class AudienceValidate extends BaseCommand< props: Props, audiences: Audience.AudienceDirData[], ): Promise { - // TODO: Throw an error if a non validation error (e.g. authentication error) - // instead of printing out same error messages repeatedly. - const errorPromises = audiences.map(async (audience) => { const resp = await api.validateAudience(props, { ...audience.content, From c37b8c2b2bf3eed3bdb3d6a661bd4ebda227bf10 Mon Sep 17 00:00:00 2001 From: Meryl Dakin Date: Mon, 9 Mar 2026 12:39:53 -0400 Subject: [PATCH 09/18] fix: remove default segments from dynamic audience scaffolding Dynamic audiences should be created with empty segments rather than example conditions. Users can add their own conditions as needed. Co-Authored-By: Claude Opus 4.5 --- src/lib/marshal/audience/generator.ts | 20 -------------------- test/commands/audience/new.test.ts | 2 -- 2 files changed, 22 deletions(-) diff --git a/src/lib/marshal/audience/generator.ts b/src/lib/marshal/audience/generator.ts index 1da9c382..af2ef73a 100644 --- a/src/lib/marshal/audience/generator.ts +++ b/src/lib/marshal/audience/generator.ts @@ -13,21 +13,6 @@ type NewAudienceAttrs = { description?: string; }; -/* - * Returns the default scaffolded segments for a dynamic audience. - */ -const defaultSegmentsForDynamic = () => [ - { - conditions: [ - { - property: "recipient.plan", - operator: "equal_to", - argument: "premium", - }, - ], - }, -]; - /* * Scaffolds a new audience directory bundle with default content. */ @@ -43,11 +28,6 @@ const scaffoldAudienceDirBundle = ( audienceJson.description = attrs.description; } - // For dynamic audiences, include example segments - if (attrs.type === AudienceType.Dynamic) { - audienceJson.segments = defaultSegmentsForDynamic(); - } - return { [AUDIENCE_JSON]: audienceJson, }; diff --git a/test/commands/audience/new.test.ts b/test/commands/audience/new.test.ts index eda7f1d0..9349c9e0 100644 --- a/test/commands/audience/new.test.ts +++ b/test/commands/audience/new.test.ts @@ -90,8 +90,6 @@ describe("commands/audience/new", () => { expect(audienceJson.name).to.equal("Beta Testers"); expect(audienceJson.type).to.equal("dynamic"); expect(audienceJson.description).to.equal("Users in beta program"); - // Dynamic audiences should have segments scaffolded - expect(audienceJson.segments).to.be.an("array"); }); }); From 0510cbcc19eafea85433481832df3b01ef0e34f8 Mon Sep 17 00:00:00 2001 From: Meryl Dakin Date: Mon, 9 Mar 2026 13:06:35 -0400 Subject: [PATCH 10/18] fix: use ux.error instead of throw for consistent error handling Change readAllForCommandTarget to use ux.error() instead of throw new Error() to match the pattern used by other resource readers. This provides clean, formatted CLI error messages instead of stack traces. Co-Authored-By: Claude Opus 4.5 --- src/lib/marshal/audience/reader.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/lib/marshal/audience/reader.ts b/src/lib/marshal/audience/reader.ts index ac6bf55f..db21952f 100644 --- a/src/lib/marshal/audience/reader.ts +++ b/src/lib/marshal/audience/reader.ts @@ -1,5 +1,6 @@ import path from "node:path"; +import { ux } from "@oclif/core"; import * as fs from "fs-extra"; import { formatErrors, SourceError } from "@/lib/helpers/error"; @@ -92,7 +93,7 @@ export const readAllForCommandTarget = async ( ? "an audience directory at" : "audience directories in"; - throw new Error(`Cannot locate ${subject} \`${targetCtx.abspath}\``); + return ux.error(`Cannot locate ${subject} \`${targetCtx.abspath}\``); } switch (targetType) { From 47654e190b40f43c650fec25571a6164eefe8dad Mon Sep 17 00:00:00 2001 From: Meryl Dakin Date: Mon, 9 Mar 2026 13:09:00 -0400 Subject: [PATCH 11/18] fix: add missing error check in pullOneAudience Check isSuccessResp before writing API response to disk. Without this check, an error response (e.g., 404 not found) would be silently written to the filesystem as if it were valid audience data. Co-Authored-By: Claude Opus 4.5 --- src/commands/audience/pull.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/commands/audience/pull.ts b/src/commands/audience/pull.ts index a1aecc0b..4ef0c124 100644 --- a/src/commands/audience/pull.ts +++ b/src/commands/audience/pull.ts @@ -91,6 +91,11 @@ export default class AudiencePull extends BaseCommand { }, ); + if (!isSuccessResp(resp)) { + const message = formatErrorRespMessage(resp); + this.error(new ApiError(message)); + } + await Audience.writeAudienceDirFromData(dirContext, resp.data, { withSchema: true, }); From a169fe019f34ebbcb9feb9d3b4bdfa9c0b1e07b7 Mon Sep 17 00:00:00 2001 From: Meryl Dakin Date: Tue, 10 Mar 2026 12:49:08 -0400 Subject: [PATCH 12/18] update --- package.json | 2 +- src/commands/audience/archive.ts | 21 ++-- src/commands/audience/get.ts | 29 ++--- src/commands/audience/list.ts | 64 ++++++---- src/commands/audience/pull.ts | 120 +++++++++---------- src/commands/audience/push.ts | 46 ++++---- src/commands/audience/validate.ts | 31 ++--- src/lib/api-v1.ts | 132 +++++++++------------ test/commands/audience/archive.test.ts | 42 ++----- test/commands/audience/get.test.ts | 89 ++++---------- test/commands/audience/list.test.ts | 148 +++++++++--------------- test/commands/audience/pull.test.ts | 68 +++++------ test/commands/audience/push.test.ts | 104 +++++++---------- test/commands/audience/validate.test.ts | 64 ++++------ test/commands/pull.test.ts | 22 ++-- yarn.lock | 8 +- 16 files changed, 416 insertions(+), 574 deletions(-) diff --git a/package.json b/package.json index be819224..89533ab2 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "/oclif.manifest.json" ], "dependencies": { - "@knocklabs/mgmt": "^0.16.1", + "@knocklabs/mgmt": "0.21.0", "@oclif/core": "^3", "@oclif/plugin-help": "^6", "@prantlf/jsonlint": "^14.1.0", diff --git a/src/commands/audience/archive.ts b/src/commands/audience/archive.ts index bba0e52e..41fd3017 100644 --- a/src/commands/audience/archive.ts +++ b/src/commands/audience/archive.ts @@ -3,7 +3,6 @@ import { Args, Flags, ux } from "@oclif/core"; import BaseCommand from "@/lib/base-command"; import { ApiError } from "@/lib/helpers/error"; import * as CustomFlags from "@/lib/helpers/flag"; -import { formatErrorRespMessage, isSuccessResp } from "@/lib/helpers/request"; import { promptToConfirm, spinner } from "@/lib/helpers/ux"; export default class AudienceArchive extends BaseCommand< @@ -36,7 +35,7 @@ Use this command with caution. async run(): Promise { const { audienceKey } = this.props.args; - const { force } = this.props.flags; + const { force, environment } = this.props.flags; // Confirm before archiving since this affects all environments if (!force) { @@ -52,17 +51,17 @@ Use this command with caution. spinner.start(`‣ Archiving audience \`${audienceKey}\``); - const resp = await this.apiV1.archiveAudience(this.props); + try { + await this.apiV1.archiveAudience(audienceKey, environment); - spinner.stop(); + spinner.stop(); - if (!isSuccessResp(resp)) { - const message = formatErrorRespMessage(resp); - ux.error(new ApiError(message)); + this.log( + `‣ Successfully archived audience \`${audienceKey}\` across all environments.`, + ); + } catch (error) { + spinner.stop(); + ux.error(new ApiError((error as Error).message)); } - - this.log( - `‣ Successfully archived audience \`${audienceKey}\` across all environments.`, - ); } } diff --git a/src/commands/audience/get.ts b/src/commands/audience/get.ts index 44c83d71..a6088072 100644 --- a/src/commands/audience/get.ts +++ b/src/commands/audience/get.ts @@ -1,12 +1,11 @@ +import { Audience } from "@knocklabs/mgmt/resources/audiences"; import { Args, Flags, ux } from "@oclif/core"; -import * as ApiV1 from "@/lib/api-v1"; import BaseCommand from "@/lib/base-command"; import { formatCommandScope } from "@/lib/helpers/command"; import { formatDateTime } from "@/lib/helpers/date"; import { ApiError } from "@/lib/helpers/error"; import * as CustomFlags from "@/lib/helpers/flag"; -import { formatErrorRespMessage, isSuccessResp } from "@/lib/helpers/request"; import { spinner } from "@/lib/helpers/ux"; export default class AudienceGet extends BaseCommand { @@ -31,7 +30,7 @@ export default class AudienceGet extends BaseCommand { static enableJsonFlag = true; - async run(): Promise { + async run(): Promise { spinner.start("‣ Loading"); const { audience } = await this.loadAudience(); @@ -45,21 +44,25 @@ export default class AudienceGet extends BaseCommand { } private async loadAudience(): Promise<{ - audience: ApiV1.GetAudienceResp; + audience: Audience; }> { - const audienceResp = await this.apiV1.getAudience(this.props); + const { audienceKey } = this.props.args; + const { flags } = this.props; - if (!isSuccessResp(audienceResp)) { - const message = formatErrorRespMessage(audienceResp); - ux.error(new ApiError(message)); - } + try { + const audience = await this.apiV1.getAudience(audienceKey, { + environment: flags.environment, + branch: flags.branch, + hide_uncommitted_changes: flags["hide-uncommitted-changes"], + }); - return { - audience: audienceResp.data, - }; + return { audience }; + } catch (error) { + ux.error(new ApiError((error as Error).message)); + } } - render(audience: ApiV1.GetAudienceResp): void { + render(audience: Audience): void { const { audienceKey } = this.props.args; const { environment: env, "hide-uncommitted-changes": committedOnly } = this.props.flags; diff --git a/src/commands/audience/list.ts b/src/commands/audience/list.ts index 73407090..c5667c08 100644 --- a/src/commands/audience/list.ts +++ b/src/commands/audience/list.ts @@ -1,18 +1,23 @@ +import { Audience } from "@knocklabs/mgmt/resources/audiences"; import { Flags, ux } from "@oclif/core"; -import { AxiosResponse } from "axios"; -import * as ApiV1 from "@/lib/api-v1"; import BaseCommand from "@/lib/base-command"; import { formatCommandScope } from "@/lib/helpers/command"; import { formatDate } from "@/lib/helpers/date"; +import { ApiError } from "@/lib/helpers/error"; import * as CustomFlags from "@/lib/helpers/flag"; -import { merge } from "@/lib/helpers/object.isomorphic"; import { maybePromptPageAction, + PageInfo, pageFlags, paramsForPageAction, } from "@/lib/helpers/page"; -import { withSpinner } from "@/lib/helpers/request"; +import { spinner } from "@/lib/helpers/ux"; + +type ListAudienceData = { + entries: Audience[]; + page_info: PageInfo; +}; export default class AudienceList extends BaseCommand { static summary = "Display all audiences for an environment."; @@ -31,26 +36,43 @@ export default class AudienceList extends BaseCommand { static enableJsonFlag = true; - async run(): Promise { - const resp = await this.request(); + async run(): Promise { + const data = await this.request(); const { flags } = this.props; - if (flags.json) return resp.data; + if (flags.json) return data; - return this.render(resp.data); + return this.render(data); } - async request( - pageParams = {}, - ): Promise> { - const props = merge(this.props, { flags: { ...pageParams } }); + async request(pageParams: { after?: string; before?: string } = {}): Promise { + const { flags } = this.props; - return withSpinner(() => - this.apiV1.listAudiences(props), - ); + spinner.start("‣ Loading"); + + try { + const page = await this.apiV1.listAudiences({ + environment: flags.environment, + branch: flags.branch, + hide_uncommitted_changes: flags["hide-uncommitted-changes"], + limit: flags.limit, + after: pageParams.after ?? flags.after, + before: pageParams.before ?? flags.before, + }); + + spinner.stop(); + + return { + entries: page.entries, + page_info: page.page_info, + }; + } catch (error) { + spinner.stop(); + throw new ApiError((error as Error).message); + } } - async render(data: ApiV1.ListAudienceResp): Promise { + async render(data: ListAudienceData): Promise { const { entries } = data; const { environment: env, "hide-uncommitted-changes": committedOnly } = this.props.flags; @@ -67,7 +89,7 @@ export default class AudienceList extends BaseCommand { * Audiences list table */ - ux.table(entries, { + ux.table(entries as unknown as Record[], { key: { header: "Key", }, @@ -82,14 +104,14 @@ export default class AudienceList extends BaseCommand { }, updated_at: { header: "Updated at", - get: (entry) => formatDate(entry.updated_at), + get: (entry) => formatDate(entry.updated_at as string), }, }); return this.prompt(data); } - async prompt(data: ApiV1.ListAudienceResp): Promise { + async prompt(data: ListAudienceData): Promise { const { page_info } = data; const pageAction = await maybePromptPageAction(page_info); @@ -98,8 +120,8 @@ export default class AudienceList extends BaseCommand { if (pageParams) { this.log("\n"); - const resp = await this.request(pageParams); - return this.render(resp.data); + const nextData = await this.request(pageParams); + return this.render(nextData); } } } diff --git a/src/commands/audience/pull.ts b/src/commands/audience/pull.ts index 4ef0c124..15991483 100644 --- a/src/commands/audience/pull.ts +++ b/src/commands/audience/pull.ts @@ -1,22 +1,14 @@ import * as path from "node:path"; -import { Args, Flags } from "@oclif/core"; +import { Args, Flags, ux } from "@oclif/core"; -import * as ApiV1 from "@/lib/api-v1"; import BaseCommand from "@/lib/base-command"; import { formatCommandScope } from "@/lib/helpers/command"; import { ApiError } from "@/lib/helpers/error"; import * as CustomFlags from "@/lib/helpers/flag"; -import { merge } from "@/lib/helpers/object.isomorphic"; -import { MAX_PAGINATION_LIMIT, PageInfo } from "@/lib/helpers/page"; import { resolveResourceDir } from "@/lib/helpers/project-config"; -import { - formatErrorRespMessage, - isSuccessResp, - withSpinner, -} from "@/lib/helpers/request"; import { promptToConfirm, spinner } from "@/lib/helpers/ux"; -import * as Audience from "@/lib/marshal/audience"; +import * as AudienceMarshal from "@/lib/marshal/audience"; import { WithAnnotation } from "@/lib/marshal/shared/types"; import { AudienceDirContext, @@ -81,30 +73,34 @@ export default class AudiencePull extends BaseCommand { if (!input) return; } - const resp = await withSpinner>( - () => { - const props = merge(this.props, { - args: { audienceKey: dirContext.key }, - flags: { annotate: true }, - }); - return this.apiV1.getAudience(props); - }, - ); + spinner.start("‣ Loading"); - if (!isSuccessResp(resp)) { - const message = formatErrorRespMessage(resp); - this.error(new ApiError(message)); - } + try { + const audience = await this.apiV1.getAudience(dirContext.key, { + environment: flags.environment, + branch: flags.branch, + annotate: true, + hide_uncommitted_changes: flags["hide-uncommitted-changes"], + }); - await Audience.writeAudienceDirFromData(dirContext, resp.data, { - withSchema: true, - }); + spinner.stop(); - const action = dirContext.exists ? "updated" : "created"; - const scope = formatCommandScope(flags); - this.log( - `‣ Successfully ${action} \`${dirContext.key}\` at ${dirContext.abspath} using ${scope}`, - ); + // The SDK doesn't include annotation types, but the API returns them when annotate=true + await AudienceMarshal.writeAudienceDirFromData( + dirContext, + audience as AudienceMarshal.AudienceData, + { withSchema: true }, + ); + + const action = dirContext.exists ? "updated" : "created"; + const scope = formatCommandScope(flags); + this.log( + `‣ Successfully ${action} \`${dirContext.key}\` at ${dirContext.abspath} using ${scope}`, + ); + } catch (error) { + spinner.stop(); + ux.error(new ApiError((error as Error).message)); + } } // Pull all audiences @@ -128,44 +124,40 @@ export default class AudiencePull extends BaseCommand { spinner.start(`‣ Loading`); - const audiences = await this.listAllAudiences(); + try { + const audiences = await this.listAllAudiences(); - await Audience.writeAudiencesIndexDir(targetDirCtx, audiences, { - withSchema: true, - }); - spinner.stop(); + // The SDK doesn't include annotation types, but the API returns them when annotate=true + await AudienceMarshal.writeAudiencesIndexDir( + targetDirCtx, + audiences as AudienceMarshal.AudienceData[], + { withSchema: true }, + ); + spinner.stop(); - const action = targetDirCtx.exists ? "updated" : "created"; - const scope = formatCommandScope(flags); - this.log( - `‣ Successfully ${action} the audiences directory at ${targetDirCtx.abspath} using ${scope}`, - ); + const action = targetDirCtx.exists ? "updated" : "created"; + const scope = formatCommandScope(flags); + this.log( + `‣ Successfully ${action} the audiences directory at ${targetDirCtx.abspath} using ${scope}`, + ); + } catch (error) { + spinner.stop(); + ux.error(new ApiError((error as Error).message)); + } } - async listAllAudiences( - pageParams: Partial = {}, - audiencesFetchedSoFar: Audience.AudienceData[] = [], - ): Promise[]> { - const props = merge(this.props, { - flags: { - ...pageParams, - annotate: true, - limit: MAX_PAGINATION_LIMIT, - }, - }); - - const resp = await this.apiV1.listAudiences(props); - if (!isSuccessResp(resp)) { - const message = formatErrorRespMessage(resp); - this.error(new ApiError(message)); - } + async listAllAudiences(): Promise[]> { + const { flags } = this.props; - const { entries, page_info: pageInfo } = resp.data; - const audiences = [...audiencesFetchedSoFar, ...entries]; + const audiences = await this.apiV1.listAllAudiences({ + environment: flags.environment, + branch: flags.branch, + annotate: true, + hide_uncommitted_changes: flags["hide-uncommitted-changes"], + }); - return pageInfo.after - ? this.listAllAudiences({ after: pageInfo.after }, audiences) - : audiences; + // The SDK doesn't include annotation types, but the API returns them when annotate=true + return audiences as AudienceMarshal.AudienceData[]; } async getAudienceDirContext(): Promise { @@ -196,7 +188,7 @@ export default class AudiencePull extends BaseCommand { // new audience directory in the cwd, or update it if there is one already. if (audienceKey) { const dirPath = path.resolve(audiencesIndexDirCtx.abspath, audienceKey); - const exists = await Audience.isAudienceDir(dirPath); + const exists = await AudienceMarshal.isAudienceDir(dirPath); return { type: "audience", diff --git a/src/commands/audience/push.ts b/src/commands/audience/push.ts index 58a73d8e..acc49034 100644 --- a/src/commands/audience/push.ts +++ b/src/commands/audience/push.ts @@ -5,8 +5,6 @@ import { formatCommandScope } from "@/lib/helpers/command"; import { KnockEnv } from "@/lib/helpers/const"; import { formatError, formatErrors, SourceError } from "@/lib/helpers/error"; import * as CustomFlags from "@/lib/helpers/flag"; -import { merge } from "@/lib/helpers/object.isomorphic"; -import { formatErrorRespMessage, isSuccessResp } from "@/lib/helpers/request"; import { indentString } from "@/lib/helpers/string"; import { spinner } from "@/lib/helpers/ux"; import * as Audience from "@/lib/marshal/audience"; @@ -89,32 +87,34 @@ export default class AudiencePush extends BaseCommand { spinner.start(`‣ Pushing`); for (const audience of audiences) { - const props = merge(this.props, { flags: { annotate: true } }); - - // eslint-disable-next-line no-await-in-loop - const resp = await this.apiV1.upsertAudience(props, { - ...audience.content, - key: audience.key, - }); + try { + // eslint-disable-next-line no-await-in-loop + const resp = await this.apiV1.upsertAudience(audience.key, { + environment: flags.environment, + branch: flags.branch, + annotate: true, + commit: flags.commit, + commit_message: flags["commit-message"], + audience: audience.content as { name: string; type: "static" | "dynamic" }, + }); - if (isSuccessResp(resp)) { // Update the audience directory with the successfully pushed audience // payload from the server. + // The SDK doesn't include annotation types, but the API returns them when annotate=true // eslint-disable-next-line no-await-in-loop - await Audience.writeAudienceDirFromData(audience, resp.data.audience!, { - withSchema: true, - }); - - continue; + await Audience.writeAudienceDirFromData( + audience, + resp.audience as Audience.AudienceData, + { withSchema: true }, + ); + } catch (error) { + const sourceError = new SourceError( + (error as Error).message, + Audience.audienceJsonPath(audience), + "ApiError", + ); + this.error(formatError(sourceError)); } - - const error = new SourceError( - formatErrorRespMessage(resp), - Audience.audienceJsonPath(audience), - "ApiError", - ); - - this.error(formatError(error)); } spinner.stop(); diff --git a/src/commands/audience/validate.ts b/src/commands/audience/validate.ts index 65325d21..53633033 100644 --- a/src/commands/audience/validate.ts +++ b/src/commands/audience/validate.ts @@ -6,7 +6,6 @@ import { formatCommandScope } from "@/lib/helpers/command"; import { KnockEnv } from "@/lib/helpers/const"; import { formatErrors, SourceError } from "@/lib/helpers/error"; import * as CustomFlags from "@/lib/helpers/flag"; -import { formatErrorRespMessage, isSuccessResp } from "@/lib/helpers/request"; import { indentString } from "@/lib/helpers/string"; import { spinner } from "@/lib/helpers/ux"; import * as Audience from "@/lib/marshal/audience"; @@ -89,20 +88,24 @@ export default class AudienceValidate extends BaseCommand< props: Props, audiences: Audience.AudienceDirData[], ): Promise { + const { flags } = props; + const errorPromises = audiences.map(async (audience) => { - const resp = await api.validateAudience(props, { - ...audience.content, - key: audience.key, - }); - - if (isSuccessResp(resp)) return; - - const error = new SourceError( - formatErrorRespMessage(resp), - Audience.audienceJsonPath(audience), - "ApiError", - ); - return error; + try { + await api.validateAudience(audience.key, { + environment: flags.environment, + branch: flags.branch, + audience: audience.content as { name: string; type: "static" | "dynamic" }, + }); + + return undefined; + } catch (error) { + return new SourceError( + (error as Error).message, + Audience.audienceJsonPath(audience), + "ApiError", + ); + } }); const errors = (await Promise.all(errorPromises)).filter( diff --git a/src/lib/api-v1.ts b/src/lib/api-v1.ts index 9c6253b4..22627646 100644 --- a/src/lib/api-v1.ts +++ b/src/lib/api-v1.ts @@ -1,4 +1,15 @@ import KnockMgmt from "@knocklabs/mgmt"; +import { + Audience as SdkAudience, + AudienceArchiveResponse, + AudienceListParams, + AudienceRetrieveParams, + AudiencesEntriesCursor, + AudienceUpsertParams, + AudienceUpsertResponse, + AudienceValidateParams, + AudienceValidateResponse, +} from "@knocklabs/mgmt/resources/audiences"; import type { Branch } from "@knocklabs/mgmt/resources/branches"; import { Channel } from "@knocklabs/mgmt/resources/channels"; import type { Commit } from "@knocklabs/mgmt/resources/commits"; @@ -11,7 +22,6 @@ import { Props } from "@/lib/base-command"; import { InputError } from "@/lib/helpers/error"; import { prune } from "@/lib/helpers/object.isomorphic"; import { PaginatedResp, toPageParams } from "@/lib/helpers/page"; -import * as Audience from "@/lib/marshal/audience"; import * as EmailLayout from "@/lib/marshal/email-layout"; import * as Guide from "@/lib/marshal/guide"; import * as MessageType from "@/lib/marshal/message-type"; @@ -551,77 +561,48 @@ export default class ApiV1 { return this.put(`/guides/${args.guideKey}/activate`, {}, { params }); } - // By resources: Audiences + // By resources: Audiences (using mgmt SDK) - async listAudiences({ - flags, - }: Props): Promise>> { - const params = prune({ - environment: flags.environment, - branch: flags.branch, - hide_uncommitted_changes: flags["hide-uncommitted-changes"], - annotate: flags.annotate, - ...toPageParams(flags), - }); - - return this.get("/audiences", { params }); + async listAudiences( + params: AudienceListParams, + ): Promise { + return this.mgmtClient.audiences.list(params); } - async getAudience({ - args, - flags, - }: Props): Promise>> { - const params = prune({ - environment: flags.environment, - branch: flags.branch, - annotate: flags.annotate, - hide_uncommitted_changes: flags["hide-uncommitted-changes"], - }); - - return this.get(`/audiences/${args.audienceKey}`, { params }); + async listAllAudiences(params: AudienceListParams): Promise { + const audiences: SdkAudience[] = []; + for await (const audience of this.mgmtClient.audiences.list(params)) { + audiences.push(audience); + } + return audiences; } - async upsertAudience( - { flags }: Props, - audience: Audience.AudienceInput, - ): Promise>> { - const params = prune({ - environment: flags.environment, - branch: flags.branch, - annotate: flags.annotate, - commit: flags.commit, - commit_message: flags["commit-message"], - }); - const data = { audience }; + async getAudience( + audienceKey: string, + params: AudienceRetrieveParams, + ): Promise { + return this.mgmtClient.audiences.retrieve(audienceKey, params); + } - return this.put(`/audiences/${audience.key}`, data, { params }); + async upsertAudience( + audienceKey: string, + params: AudienceUpsertParams, + ): Promise { + return this.mgmtClient.audiences.upsert(audienceKey, params); } async validateAudience( - { flags }: Props, - audience: Audience.AudienceInput, - ): Promise> { - const params = prune({ - environment: flags.environment, - branch: flags.branch, - }); - const data = { audience }; - - return this.put(`/audiences/${audience.key}/validate`, data, { - params, - }); + audienceKey: string, + params: AudienceValidateParams, + ): Promise { + return this.mgmtClient.audiences.validate(audienceKey, params); } - async archiveAudience({ - args, - flags, - }: Props): Promise> { - const params = prune({ - environment: flags.environment, - branch: flags.branch, - }); - - return this.put(`/audiences/${args.audienceKey}/archive`, {}, { params }); + async archiveAudience( + audienceKey: string, + environment: string, + ): Promise { + return this.mgmtClient.audiences.archive(audienceKey, { environment }); } async listAllChannels(): Promise { @@ -807,23 +788,14 @@ export type ActivateGuideResp = { export type ListBranchResp = PaginatedResp; -export type ListAudienceResp = - PaginatedResp>; - -export type GetAudienceResp = - Audience.AudienceData; - -export type UpsertAudienceResp = { - audience?: Audience.AudienceData; - errors?: InputError[]; -}; - -export type ValidateAudienceResp = { - audience?: Audience.AudienceData; - errors?: InputError[]; -}; - -export type ArchiveAudienceResp = { - audience?: Audience.AudienceData; - errors?: InputError[]; +// Re-export SDK audience types +export type { + SdkAudience as Audience, + AudienceListParams, + AudienceRetrieveParams, + AudienceUpsertParams, + AudienceUpsertResponse, + AudienceValidateParams, + AudienceValidateResponse, + AudienceArchiveResponse, }; diff --git a/test/commands/audience/archive.test.ts b/test/commands/audience/archive.test.ts index 16231b05..dec43af0 100644 --- a/test/commands/audience/archive.test.ts +++ b/test/commands/audience/archive.test.ts @@ -1,9 +1,7 @@ import { expect, test } from "@oclif/test"; import enquirer from "enquirer"; -import { isEqual } from "lodash"; import * as sinon from "sinon"; -import { factory } from "@/../test/support"; import KnockApiV1 from "@/lib/api-v1"; describe("commands/audience/archive", () => { @@ -27,11 +25,7 @@ describe("commands/audience/archive", () => { test .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) .stub(KnockApiV1.prototype, "archiveAudience", (stub) => - stub.resolves( - factory.resp({ - data: factory.audience({ key: "vip-users" }), - }), - ), + stub.resolves({ result: "success" }), ) .stdout() .command([ @@ -42,20 +36,8 @@ describe("commands/audience/archive", () => { "--force", ]) .it("calls apiV1 archiveAudience with correct props", () => { - sinon.assert.calledWith( - KnockApiV1.prototype.archiveAudience as any, - sinon.match( - ({ args, flags }) => - isEqual(args, { - audienceKey: "vip-users", - }) && - isEqual(flags, { - "service-token": "valid-token", - environment: "development", - force: true, - }), - ), - ); + const archiveStub = KnockApiV1.prototype.archiveAudience as sinon.SinonStub; + sinon.assert.calledWith(archiveStub, "vip-users", "development"); }); }); @@ -63,7 +45,7 @@ describe("commands/audience/archive", () => { test .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) .stub(KnockApiV1.prototype, "archiveAudience", (stub) => - stub.resolves(factory.resp({ data: factory.audience() })), + stub.resolves({ result: "success" }), ) .stub(enquirer.prototype, "prompt", (stub) => stub.resolves({ input: false }), @@ -77,7 +59,8 @@ describe("commands/audience/archive", () => { ]) .it("does not call archiveAudience when declined", (ctx) => { expect(ctx.stdout).to.contain("Archive cancelled"); - sinon.assert.notCalled(KnockApiV1.prototype.archiveAudience as any); + const archiveStub = KnockApiV1.prototype.archiveAudience as sinon.SinonStub; + sinon.assert.notCalled(archiveStub); }); }); @@ -85,18 +68,7 @@ describe("commands/audience/archive", () => { test .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) .stub(KnockApiV1.prototype, "archiveAudience", (stub) => - stub.resolves( - factory.resp({ - status: 404, - statusText: "Not found", - data: { - code: "resource_missing", - message: "The resource you requested does not exist", - status: 404, - type: "api_error", - }, - }), - ), + stub.rejects(new Error("The resource you requested does not exist")), ) .stdout() .command([ diff --git a/test/commands/audience/get.test.ts b/test/commands/audience/get.test.ts index 22b6b76a..bb6178cb 100644 --- a/test/commands/audience/get.test.ts +++ b/test/commands/audience/get.test.ts @@ -1,5 +1,4 @@ import { test } from "@oclif/test"; -import { isEqual } from "lodash"; import * as sinon from "sinon"; import { factory } from "@/../test/support"; @@ -27,28 +26,18 @@ describe("commands/audience/get", () => { stub.resolves(factory.resp({ data: whoami })), ) .stub(KnockApiV1.prototype, "getAudience", (stub) => - stub.resolves( - factory.resp({ - data: factory.audience(), - }), - ), + stub.resolves(factory.audience()), ) .stdout() .command(["audience get", "foo"]) .it("calls apiV1 getAudience with correct props", () => { + const getStub = KnockApiV1.prototype.getAudience as sinon.SinonStub; sinon.assert.calledWith( - KnockApiV1.prototype.getAudience as any, - sinon.match( - ({ args, flags }) => - isEqual(args, { - audienceKey: "foo", - }) && - isEqual(flags, { - "service-token": "valid-token", - - environment: "development", - }), - ), + getStub, + "foo", + sinon.match({ + environment: "development", + }), ); }); }); @@ -60,11 +49,7 @@ describe("commands/audience/get", () => { stub.resolves(factory.resp({ data: whoami })), ) .stub(KnockApiV1.prototype, "getAudience", (stub) => - stub.resolves( - factory.resp({ - data: factory.audience(), - }), - ), + stub.resolves(factory.audience()), ) .stdout() .command([ @@ -75,20 +60,14 @@ describe("commands/audience/get", () => { "staging", ]) .it("calls apiV1 getAudience with correct props", () => { + const getStub = KnockApiV1.prototype.getAudience as sinon.SinonStub; sinon.assert.calledWith( - KnockApiV1.prototype.getAudience as any, - sinon.match( - ({ args, flags }) => - isEqual(args, { - audienceKey: "foo", - }) && - isEqual(flags, { - "service-token": "valid-token", - - "hide-uncommitted-changes": true, - environment: "staging", - }), - ), + getStub, + "foo", + sinon.match({ + environment: "staging", + hide_uncommitted_changes: true, + }), ); }); }); @@ -100,28 +79,19 @@ describe("commands/audience/get", () => { stub.resolves(factory.resp({ data: whoami })), ) .stub(KnockApiV1.prototype, "getAudience", (stub) => - stub.resolves( - factory.resp({ - data: factory.audience(), - }), - ), + stub.resolves(factory.audience()), ) .stdout() .command(["audience get", "foo", "--branch", "my-feature-branch-123"]) .it("calls apiV1 getAudience with expected params", () => { + const getStub = KnockApiV1.prototype.getAudience as sinon.SinonStub; sinon.assert.calledWith( - KnockApiV1.prototype.getAudience as any, - sinon.match( - ({ args, flags }) => - isEqual(args, { - audienceKey: "foo", - }) && - isEqual(flags, { - "service-token": "valid-token", - environment: "development", - branch: "my-feature-branch-123", - }), - ), + getStub, + "foo", + sinon.match({ + environment: "development", + branch: "my-feature-branch-123", + }), ); }); }); @@ -133,18 +103,7 @@ describe("commands/audience/get", () => { stub.resolves(factory.resp({ data: whoami })), ) .stub(KnockApiV1.prototype, "getAudience", (stub) => - stub.resolves( - factory.resp({ - status: 404, - statusText: "Not found", - data: { - code: "resource_missing", - message: "The resource you requested does not exist", - status: 404, - type: "api_error", - }, - }), - ), + stub.rejects(new Error("The resource you requested does not exist")), ) .stdout() .command(["audience get", "foo"]) diff --git a/test/commands/audience/list.test.ts b/test/commands/audience/list.test.ts index e3a1148d..a052108b 100644 --- a/test/commands/audience/list.test.ts +++ b/test/commands/audience/list.test.ts @@ -1,38 +1,31 @@ import { expect, test } from "@oclif/test"; import enquirer from "enquirer"; -import { isEqual } from "lodash"; import * as sinon from "sinon"; import { factory } from "@/../test/support"; import KnockApiV1 from "@/lib/api-v1"; describe("commands/audience/list", () => { - const emptyAudiencesListResp = factory.resp({ - data: { - page_info: factory.pageInfo(), - entries: [], - }, - }); + const emptyAudiencesCursor = { + entries: [], + page_info: { after: undefined }, + }; describe("given no flags", () => { test .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) .stub(KnockApiV1.prototype, "listAudiences", (stub) => - stub.resolves(emptyAudiencesListResp), + stub.resolves(emptyAudiencesCursor), ) .stdout() .command(["audience list"]) .it("calls apiV1 listAudiences with correct props", () => { + const listStub = KnockApiV1.prototype.listAudiences as sinon.SinonStub; sinon.assert.calledWith( - KnockApiV1.prototype.listAudiences as any, - sinon.match( - ({ args, flags }) => - isEqual(args, {}) && - isEqual(flags, { - "service-token": "valid-token", - environment: "development", - }), - ), + listStub, + sinon.match({ + environment: "development", + }), ); }); }); @@ -41,7 +34,7 @@ describe("commands/audience/list", () => { test .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) .stub(KnockApiV1.prototype, "listAudiences", (stub) => - stub.resolves(emptyAudiencesListResp), + stub.resolves(emptyAudiencesCursor), ) .stdout() .command([ @@ -55,19 +48,15 @@ describe("commands/audience/list", () => { "xyz", ]) .it("calls apiV1 listAudiences with correct props", () => { + const listStub = KnockApiV1.prototype.listAudiences as sinon.SinonStub; sinon.assert.calledWith( - KnockApiV1.prototype.listAudiences as any, - sinon.match( - ({ args, flags }) => - isEqual(args, {}) && - isEqual(flags, { - "service-token": "valid-token", - "hide-uncommitted-changes": true, - environment: "staging", - limit: 5, - after: "xyz", - }), - ), + listStub, + sinon.match({ + environment: "staging", + hide_uncommitted_changes: true, + limit: 5, + after: "xyz", + }), ); }); }); @@ -76,22 +65,18 @@ describe("commands/audience/list", () => { test .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) .stub(KnockApiV1.prototype, "listAudiences", (stub) => - stub.resolves(emptyAudiencesListResp), + stub.resolves(emptyAudiencesCursor), ) .stdout() .command(["audience list", "--branch", "my-feature-branch-123"]) .it("calls apiV1 listAudiences with expected params", () => { + const listStub = KnockApiV1.prototype.listAudiences as sinon.SinonStub; sinon.assert.calledWith( - KnockApiV1.prototype.listAudiences as any, - sinon.match( - ({ args, flags }) => - isEqual(args, {}) && - isEqual(flags, { - "service-token": "valid-token", - environment: "development", - branch: "my-feature-branch-123", - }), - ), + listStub, + sinon.match({ + environment: "development", + branch: "my-feature-branch-123", + }), ); }); }); @@ -100,18 +85,14 @@ describe("commands/audience/list", () => { test .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) .stub(KnockApiV1.prototype, "listAudiences", (stub) => - stub.resolves( - factory.resp({ - data: { - page_info: factory.pageInfo(), - entries: [ - factory.audience({ key: "audience-1" }), - factory.audience({ key: "audience-2" }), - factory.audience({ key: "audience-3" }), - ], - }, - }), - ), + stub.resolves({ + entries: [ + factory.audience({ key: "audience-1" }), + factory.audience({ key: "audience-2" }), + factory.audience({ key: "audience-3" }), + ], + page_info: { after: undefined }, + }), ) .stdout() .command(["audience list"]) @@ -126,24 +107,20 @@ describe("commands/audience/list", () => { }); describe("given the first page of paginated audiences in resp", () => { - const paginatedAudiencesResp = factory.resp({ - data: { - page_info: factory.pageInfo({ - after: "xyz", - }), - entries: [ - factory.audience({ key: "audience-1" }), - factory.audience({ key: "audience-2" }), - factory.audience({ key: "audience-3" }), - ], - }, - }); + const paginatedAudiencesCursor = { + entries: [ + factory.audience({ key: "audience-1" }), + factory.audience({ key: "audience-2" }), + factory.audience({ key: "audience-3" }), + ], + page_info: { after: "xyz" }, + }; describe("plus a next page action from the prompt input", () => { test .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) .stub(KnockApiV1.prototype, "listAudiences", (stub) => - stub.resolves(paginatedAudiencesResp), + stub.resolves(paginatedAudiencesCursor), ) .stub(enquirer.prototype, "prompt", (stub) => stub @@ -157,37 +134,25 @@ describe("commands/audience/list", () => { .it( "calls apiV1 listAudiences for the second time with page params", () => { - const listAudiencesFn = KnockApiV1.prototype.listAudiences as any; + const listStub = KnockApiV1.prototype.listAudiences as sinon.SinonStub; - sinon.assert.calledTwice(listAudiencesFn); + sinon.assert.calledTwice(listStub); // First call without page params. sinon.assert.calledWith( - listAudiencesFn.firstCall, - sinon.match( - ({ args, flags }) => - isEqual(args, {}) && - isEqual(flags, { - "service-token": "valid-token", - - environment: "development", - }), - ), + listStub.firstCall, + sinon.match({ + environment: "development", + }), ); // Second call with page params to fetch the next page. sinon.assert.calledWith( - listAudiencesFn.secondCall, - sinon.match( - ({ args, flags }) => - isEqual(args, {}) && - isEqual(flags, { - "service-token": "valid-token", - - environment: "development", - after: "xyz", - }), - ), + listStub.secondCall, + sinon.match({ + environment: "development", + after: "xyz", + }), ); }, ); @@ -197,7 +162,7 @@ describe("commands/audience/list", () => { test .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) .stub(KnockApiV1.prototype, "listAudiences", (stub) => - stub.resolves(paginatedAudiencesResp), + stub.resolves(paginatedAudiencesCursor), ) .stub(enquirer.prototype, "prompt", (stub) => stub.onFirstCall().resolves({ input: "p" }), @@ -205,7 +170,8 @@ describe("commands/audience/list", () => { .stdout() .command(["audience list"]) .it("calls apiV1 listAudiences once for the initial page only", () => { - sinon.assert.calledOnce(KnockApiV1.prototype.listAudiences as any); + const listStub = KnockApiV1.prototype.listAudiences as sinon.SinonStub; + sinon.assert.calledOnce(listStub); }); }); }); diff --git a/test/commands/audience/pull.test.ts b/test/commands/audience/pull.test.ts index 67973a97..ed091724 100644 --- a/test/commands/audience/pull.test.ts +++ b/test/commands/audience/pull.test.ts @@ -3,10 +3,8 @@ import * as path from "node:path"; import { expect, test } from "@oclif/test"; import enquirer from "enquirer"; import * as fs from "fs-extra"; -import { isEqual } from "lodash"; import * as sinon from "sinon"; -import { factory } from "@/../test/support"; import KnockApiV1 from "@/lib/api-v1"; import { sandboxDir } from "@/lib/helpers/const"; import { @@ -42,14 +40,14 @@ const mockAudienceData: AudienceData = { }, }; -const setupWithStub = (attrs = {}) => +const setupWithStub = (audienceData = mockAudienceData) => test .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) .stub(enquirer.prototype, "prompt", (stub) => stub.resolves({ input: true }), ) .stub(KnockApiV1.prototype, "getAudience", (stub) => - stub.resolves(factory.resp(attrs)), + stub.resolves(audienceData), ); const currCwd = process.cwd(); @@ -69,26 +67,22 @@ describe("commands/audience/pull (a single audience)", () => { process.chdir(sandboxDir); }); - setupWithStub({ data: mockAudienceData }) + setupWithStub() .stdout() .command(["audience pull", "vip-users", "--force"]) .it("calls apiV1 getAudience with expected props", () => { + const getStub = KnockApiV1.prototype.getAudience as sinon.SinonStub; sinon.assert.calledWith( - KnockApiV1.prototype.getAudience as any, - sinon.match( - ({ args, flags }) => - isEqual(args, { audienceKey: "vip-users" }) && - isEqual(flags, { - "service-token": "valid-token", - environment: "development", - annotate: true, - force: true, - }), - ), + getStub, + "vip-users", + sinon.match({ + environment: "development", + annotate: true, + }), ); }); - setupWithStub({ data: mockAudienceData }) + setupWithStub() .stdout() .command(["audience pull", "vip-users", "--force"]) .it("creates the audience directory with audience.json", () => { @@ -105,7 +99,7 @@ describe("commands/audience/pull (a single audience)", () => { }); describe("given a branch flag", () => { - setupWithStub({ data: mockAudienceData }) + setupWithStub() .stdout() .command([ "audience pull", @@ -115,19 +109,15 @@ describe("commands/audience/pull (a single audience)", () => { "my-feature-branch-123", ]) .it("calls apiV1 getAudience with expected params", () => { + const getStub = KnockApiV1.prototype.getAudience as sinon.SinonStub; sinon.assert.calledWith( - KnockApiV1.prototype.getAudience as any, - sinon.match( - ({ args, flags }) => - isEqual(args, { audienceKey: "vip-users" }) && - isEqual(flags, { - "service-token": "valid-token", - environment: "development", - branch: "my-feature-branch-123", - annotate: true, - force: true, - }), - ), + getStub, + "vip-users", + sinon.match({ + environment: "development", + branch: "my-feature-branch-123", + annotate: true, + }), ); }); }); @@ -138,7 +128,7 @@ describe("commands/audience/pull (a single audience)", () => { process.chdir(sandboxDir); }); - setupWithStub({ data: mockAudienceData }) + setupWithStub() .stdout() .command(["audience pull", "--force"]) .exit(2) @@ -146,7 +136,7 @@ describe("commands/audience/pull (a single audience)", () => { }); describe("given both audience key arg and --all flag", () => { - setupWithStub({ data: mockAudienceData }) + setupWithStub() .stdout() .command(["audience pull", "vip-users", "--all"]) .exit(2) @@ -174,15 +164,8 @@ describe("commands/audience/pull (all audiences)", () => { .stub(enquirer.prototype, "prompt", (stub) => stub.resolves({ input: true }), ) - .stub(KnockApiV1.prototype, "listAudiences", (stub) => - stub.resolves( - factory.resp({ - data: { - page_info: factory.pageInfo(), - entries: [mockAudienceData], - }, - }), - ), + .stub(KnockApiV1.prototype, "listAllAudiences", (stub) => + stub.resolves([mockAudienceData]), ) .stdout() .command([ @@ -193,7 +176,8 @@ describe("commands/audience/pull (all audiences)", () => { "--force", ]) .it("calls apiV1 listAudiences and creates audience directories", () => { - sinon.assert.calledOnce(KnockApiV1.prototype.listAudiences as any); + const listStub = KnockApiV1.prototype.listAllAudiences as sinon.SinonStub; + sinon.assert.calledOnce(listStub); const audienceJsonPath = path.resolve( sandboxDir, diff --git a/test/commands/audience/push.test.ts b/test/commands/audience/push.test.ts index 108c46a7..569a501c 100644 --- a/test/commands/audience/push.test.ts +++ b/test/commands/audience/push.test.ts @@ -2,10 +2,8 @@ import * as path from "node:path"; import { expect, test } from "@oclif/test"; import * as fs from "fs-extra"; -import { isEqual } from "lodash"; import * as sinon from "sinon"; -import { factory } from "@/../test/support"; import AudienceValidate from "@/commands/audience/validate"; import KnockApiV1 from "@/lib/api-v1"; import { sandboxDir } from "@/lib/helpers/const"; @@ -33,12 +31,12 @@ const mockAudienceData: AudienceData = { }, }; -const setupWithStub = (attrs = {}) => +const setupWithStub = (audienceData = mockAudienceData) => test .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) .stub(AudienceValidate, "validateAll", (stub) => stub.resolves([])) .stub(KnockApiV1.prototype, "upsertAudience", (stub) => - stub.resolves(factory.resp(attrs)), + stub.resolves({ audience: audienceData }), ); const currCwd = process.cwd(); @@ -61,32 +59,26 @@ describe("commands/audience/push", () => { process.chdir(sandboxDir); }); - setupWithStub({ data: { audience: mockAudienceData } }) + setupWithStub() .stdout() .command(["audience push", "default"]) .it("calls apiV1 upsertAudience with expected props", () => { + const upsertStub = KnockApiV1.prototype.upsertAudience as sinon.SinonStub; sinon.assert.calledWith( - KnockApiV1.prototype.upsertAudience as any, - sinon.match( - ({ args, flags }) => - isEqual(args, { audienceKey: "default" }) && - isEqual(flags, { - "service-token": "valid-token", - environment: "development", - annotate: true, - }), - ), - sinon.match((audience) => - isEqual(audience, { - key: "default", + upsertStub, + "default", + sinon.match({ + environment: "development", + annotate: true, + audience: sinon.match({ name: "Default", type: "static", }), - ), + }), ); }); - setupWithStub({ data: { audience: mockAudienceData } }) + setupWithStub() .stdout() .command([ "audience push", @@ -96,31 +88,25 @@ describe("commands/audience/push", () => { "this is a commit comment!", ]) .it("calls apiV1 upsertAudience with commit flags, if provided", () => { + const upsertStub = KnockApiV1.prototype.upsertAudience as sinon.SinonStub; sinon.assert.calledWith( - KnockApiV1.prototype.upsertAudience as any, - sinon.match( - ({ args, flags }) => - isEqual(args, { audienceKey: "default" }) && - isEqual(flags, { - "service-token": "valid-token", - environment: "development", - commit: true, - "commit-message": "this is a commit comment!", - annotate: true, - }), - ), - sinon.match((audience) => - isEqual(audience, { - key: "default", + upsertStub, + "default", + sinon.match({ + environment: "development", + commit: true, + commit_message: "this is a commit comment!", + annotate: true, + audience: sinon.match({ name: "Default", type: "static", }), - ), + }), ); }); describe("given a branch flag", () => { - setupWithStub({ data: { audience: mockAudienceData } }) + setupWithStub() .stdout() .command([ "audience push", @@ -129,30 +115,24 @@ describe("commands/audience/push", () => { "my-feature-branch-123", ]) .it("calls apiV1 upsertAudience with expected params", () => { + const upsertStub = KnockApiV1.prototype.upsertAudience as sinon.SinonStub; sinon.assert.calledWith( - KnockApiV1.prototype.upsertAudience as any, - sinon.match( - ({ args, flags }) => - isEqual(args, { audienceKey: "default" }) && - isEqual(flags, { - "service-token": "valid-token", - environment: "development", - branch: "my-feature-branch-123", - annotate: true, - }), - ), - sinon.match((audience) => - isEqual(audience, { - key: "default", + upsertStub, + "default", + sinon.match({ + environment: "development", + branch: "my-feature-branch-123", + annotate: true, + audience: sinon.match({ name: "Default", type: "static", }), - ), + }), ); }); }); - setupWithStub({ data: { audience: mockAudienceData } }) + setupWithStub() .stdout() .command(["audience push", "default"]) .it("writes the upserted audience data into audience.json", () => { @@ -181,7 +161,7 @@ describe("commands/audience/push", () => { process.chdir(sandboxDir); }); - setupWithStub({ data: { audience: mockAudienceData } }) + setupWithStub() .stdout() .command(["audience push", "default"]) .catch((error) => expect(error.message).to.match(/JsonSyntaxError/)) @@ -193,7 +173,7 @@ describe("commands/audience/push", () => { process.chdir(sandboxDir); }); - setupWithStub({ data: { audience: mockAudienceData } }) + setupWithStub() .stdout() .command(["audience push", "does-not-exist"]) .catch((error) => @@ -203,7 +183,7 @@ describe("commands/audience/push", () => { }); describe("given no audience key arg or --all flag", () => { - setupWithStub({ data: { audience: mockAudienceData } }) + setupWithStub() .stdout() .command(["audience push"]) .exit(2) @@ -211,7 +191,7 @@ describe("commands/audience/push", () => { }); describe("given both audience key arg and --all flag", () => { - setupWithStub({ data: { audience: mockAudienceData } }) + setupWithStub() .stdout() .command(["audience push", "default", "--all"]) .exit(2) @@ -273,16 +253,16 @@ describe("commands/audience/push", () => { process.chdir(sandboxDir); }); - setupWithStub({ data: { audience: mockAudienceData } }) + setupWithStub() .stdout() .command(["audience push", "--all", "--audiences-dir", "audiences"]) .it("calls apiV1 upsertAudience with expected props twice", () => { // Validate all first - const stub1 = AudienceValidate.validateAll as any; - sinon.assert.calledOnce(stub1); + const validateStub = AudienceValidate.validateAll as sinon.SinonStub; + sinon.assert.calledOnce(validateStub); - const stub2 = KnockApiV1.prototype.upsertAudience as any; - sinon.assert.calledTwice(stub2); + const upsertStub = KnockApiV1.prototype.upsertAudience as sinon.SinonStub; + sinon.assert.calledTwice(upsertStub); }); }); }); diff --git a/test/commands/audience/validate.test.ts b/test/commands/audience/validate.test.ts index 2bc35dfe..2a56f619 100644 --- a/test/commands/audience/validate.test.ts +++ b/test/commands/audience/validate.test.ts @@ -2,7 +2,6 @@ import * as path from "node:path"; import { expect, test } from "@oclif/test"; import * as fs from "fs-extra"; -import { isEqual } from "lodash"; import * as sinon from "sinon"; import { factory } from "@/../test/support"; @@ -12,11 +11,11 @@ import { AUDIENCE_JSON } from "@/lib/marshal/audience"; const audienceJsonFile = "default/audience.json"; -const setupWithStub = (attrs = {}) => +const setupWithStub = () => test .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) .stub(KnockApiV1.prototype, "validateAudience", (stub) => - stub.resolves(factory.resp(attrs)), + stub.resolves({ audience: factory.audience() }), ); const currCwd = process.cwd(); @@ -43,23 +42,17 @@ describe("commands/audience/validate (a single audience)", () => { .stdout() .command(["audience validate", "default"]) .it("calls apiV1 validateAudience with expected props", () => { + const validateStub = KnockApiV1.prototype.validateAudience as sinon.SinonStub; sinon.assert.calledWith( - KnockApiV1.prototype.validateAudience as any, - sinon.match( - ({ args, flags }) => - isEqual(args, { audienceKey: "default" }) && - isEqual(flags, { - "service-token": "valid-token", - environment: "development", - }), - ), - sinon.match((audience) => - isEqual(audience, { - key: "default", + validateStub, + "default", + sinon.match({ + environment: "development", + audience: sinon.match({ name: "Default", type: "static", }), - ), + }), ); }); @@ -73,24 +66,18 @@ describe("commands/audience/validate (a single audience)", () => { "my-feature-branch-123", ]) .it("calls apiV1 validateAudience with expected params", () => { + const validateStub = KnockApiV1.prototype.validateAudience as sinon.SinonStub; sinon.assert.calledWith( - KnockApiV1.prototype.validateAudience as any, - sinon.match( - ({ args, flags }) => - isEqual(args, { audienceKey: "default" }) && - isEqual(flags, { - "service-token": "valid-token", - environment: "development", - branch: "my-feature-branch-123", - }), - ), - sinon.match((audience) => - isEqual(audience, { - key: "default", + validateStub, + "default", + sinon.match({ + environment: "development", + branch: "my-feature-branch-123", + audience: sinon.match({ name: "Default", type: "static", }), - ), + }), ); }); }); @@ -119,16 +106,15 @@ describe("commands/audience/validate (a single audience)", () => { process.chdir(sandboxDir); }); - setupWithStub({ - status: 422, - data: { errors: [{ field: "name", message: "must be a string" }] }, - }) + test + .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) + .stub(KnockApiV1.prototype, "validateAudience", (stub) => + stub.rejects(new Error('"name" must be a string')), + ) .stdout() .command(["audience validate", "default"]) .catch((error) => - expect(error.message).to.match( - /JsonDataError.*"name" must be a string/, - ), + expect(error.message).to.match(/"name" must be a string/), ) .it("throws an error"); }); @@ -225,8 +211,8 @@ describe("commands/audience/validate (all audiences)", () => { .stdout() .command(["audience validate", "--all", "--audiences-dir", "audiences"]) .it("calls apiV1 validateAudience with expected props twice", () => { - const stub = KnockApiV1.prototype.validateAudience as any; - sinon.assert.calledTwice(stub); + const validateStub = KnockApiV1.prototype.validateAudience as sinon.SinonStub; + sinon.assert.calledTwice(validateStub); }); }); }); diff --git a/test/commands/pull.test.ts b/test/commands/pull.test.ts index cc1b15f7..eaf59712 100644 --- a/test/commands/pull.test.ts +++ b/test/commands/pull.test.ts @@ -19,6 +19,17 @@ import { WorkflowData } from "@/lib/marshal/workflow"; const currCwd = process.cwd(); +// Helper to create async iterator from array +function createAsyncIterator(items: T[]): AsyncIterable { + return { + [Symbol.asyncIterator]: async function* () { + for (const item of items) { + yield item; + } + }, + }; +} + const setupWithListStubs = ( manyLayoutsAttrs: Partial[], manyPartialsAttrs: Partial[], @@ -34,15 +45,8 @@ const setupWithListStubs = ( .stub(KnockApiV1.prototype, "whoami", (stub) => stub.resolves(factory.resp({ data: factory.whoami() })), ) - .stub(KnockApiV1.prototype, "listAudiences", (stub) => - stub.resolves( - factory.resp({ - data: { - entries: manyAudienceAttrs.map((attrs) => factory.audience(attrs)), - page_info: factory.pageInfo(), - }, - }), - ), + .stub(KnockApiV1.prototype, "listAllAudiences", (stub) => + stub.resolves(manyAudienceAttrs.map((attrs) => factory.audience(attrs))), ) .stub(KnockApiV1.prototype, "listEmailLayouts", (stub) => stub.resolves( diff --git a/yarn.lock b/yarn.lock index f09508fb..f25dbd7f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1194,10 +1194,10 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" -"@knocklabs/mgmt@^0.16.1": - version "0.16.1" - resolved "https://registry.yarnpkg.com/@knocklabs/mgmt/-/mgmt-0.16.1.tgz#101e7efb4726590058d5dfcd972354b0026dad00" - integrity sha512-7Ua1dX4GTcD3zm3mhww8DIRQc96/blrc1bAVQNbee0v1gNr4IJpyvNSqaCFvBA/rbap9DIl8HvchVoV1IlHHig== +"@knocklabs/mgmt@0.21.0": + version "0.21.0" + resolved "https://registry.npmjs.org/@knocklabs/mgmt/-/mgmt-0.21.0.tgz#4d1a6c95f6c62566c42960aa12273814e8a4fcc9" + integrity sha512-XHW7h6ZEL09+Q+K/CNBHn1ptAVEupBuqT2y84YT87H6GQ6mNEdldvtlPZbWVB7uzHRXuW1UfxNRb9zv/1EFD3A== "@napi-rs/nice-android-arm-eabi@1.0.1": version "1.0.1" From 54ebab0951d8cf35fed7a85e7264d4c41a27ddaf Mon Sep 17 00:00:00 2001 From: Meryl Dakin Date: Tue, 10 Mar 2026 12:54:12 -0400 Subject: [PATCH 13/18] update --- src/commands/audience/archive.ts | 4 +- src/commands/audience/get.ts | 13 +++-- src/commands/audience/list.ts | 2 +- src/commands/audience/pull.ts | 26 ++++++---- src/commands/audience/push.ts | 19 ++++--- src/commands/audience/validate.ts | 2 +- src/lib/api-v1.ts | 67 ------------------------- test/commands/audience/archive.test.ts | 23 +++++---- test/commands/audience/get.test.ts | 18 +++---- test/commands/audience/list.test.ts | 31 ++++++------ test/commands/audience/pull.test.ts | 30 +++++++---- test/commands/audience/push.test.ts | 21 ++++---- test/commands/audience/validate.test.ts | 17 +++---- test/commands/pull.test.ts | 9 +++- 14 files changed, 121 insertions(+), 161 deletions(-) diff --git a/src/commands/audience/archive.ts b/src/commands/audience/archive.ts index 41fd3017..4516e5e6 100644 --- a/src/commands/audience/archive.ts +++ b/src/commands/audience/archive.ts @@ -52,7 +52,9 @@ Use this command with caution. spinner.start(`‣ Archiving audience \`${audienceKey}\``); try { - await this.apiV1.archiveAudience(audienceKey, environment); + await this.apiV1.mgmtClient.audiences.archive(audienceKey, { + environment, + }); spinner.stop(); diff --git a/src/commands/audience/get.ts b/src/commands/audience/get.ts index a6088072..642dbbfa 100644 --- a/src/commands/audience/get.ts +++ b/src/commands/audience/get.ts @@ -50,11 +50,14 @@ export default class AudienceGet extends BaseCommand { const { flags } = this.props; try { - const audience = await this.apiV1.getAudience(audienceKey, { - environment: flags.environment, - branch: flags.branch, - hide_uncommitted_changes: flags["hide-uncommitted-changes"], - }); + const audience = await this.apiV1.mgmtClient.audiences.retrieve( + audienceKey, + { + environment: flags.environment, + branch: flags.branch, + hide_uncommitted_changes: flags["hide-uncommitted-changes"], + }, + ); return { audience }; } catch (error) { diff --git a/src/commands/audience/list.ts b/src/commands/audience/list.ts index c5667c08..900843fa 100644 --- a/src/commands/audience/list.ts +++ b/src/commands/audience/list.ts @@ -51,7 +51,7 @@ export default class AudienceList extends BaseCommand { spinner.start("‣ Loading"); try { - const page = await this.apiV1.listAudiences({ + const page = await this.apiV1.mgmtClient.audiences.list({ environment: flags.environment, branch: flags.branch, hide_uncommitted_changes: flags["hide-uncommitted-changes"], diff --git a/src/commands/audience/pull.ts b/src/commands/audience/pull.ts index 15991483..243d30b7 100644 --- a/src/commands/audience/pull.ts +++ b/src/commands/audience/pull.ts @@ -76,12 +76,15 @@ export default class AudiencePull extends BaseCommand { spinner.start("‣ Loading"); try { - const audience = await this.apiV1.getAudience(dirContext.key, { - environment: flags.environment, - branch: flags.branch, - annotate: true, - hide_uncommitted_changes: flags["hide-uncommitted-changes"], - }); + const audience = await this.apiV1.mgmtClient.audiences.retrieve( + dirContext.key, + { + environment: flags.environment, + branch: flags.branch, + annotate: true, + hide_uncommitted_changes: flags["hide-uncommitted-changes"], + }, + ); spinner.stop(); @@ -148,16 +151,19 @@ export default class AudiencePull extends BaseCommand { async listAllAudiences(): Promise[]> { const { flags } = this.props; + const audiences: AudienceMarshal.AudienceData[] = []; - const audiences = await this.apiV1.listAllAudiences({ + for await (const audience of this.apiV1.mgmtClient.audiences.list({ environment: flags.environment, branch: flags.branch, annotate: true, hide_uncommitted_changes: flags["hide-uncommitted-changes"], - }); + })) { + // The SDK doesn't include annotation types, but the API returns them when annotate=true + audiences.push(audience as AudienceMarshal.AudienceData); + } - // The SDK doesn't include annotation types, but the API returns them when annotate=true - return audiences as AudienceMarshal.AudienceData[]; + return audiences; } async getAudienceDirContext(): Promise { diff --git a/src/commands/audience/push.ts b/src/commands/audience/push.ts index acc49034..2c2ab396 100644 --- a/src/commands/audience/push.ts +++ b/src/commands/audience/push.ts @@ -89,14 +89,17 @@ export default class AudiencePush extends BaseCommand { for (const audience of audiences) { try { // eslint-disable-next-line no-await-in-loop - const resp = await this.apiV1.upsertAudience(audience.key, { - environment: flags.environment, - branch: flags.branch, - annotate: true, - commit: flags.commit, - commit_message: flags["commit-message"], - audience: audience.content as { name: string; type: "static" | "dynamic" }, - }); + const resp = await this.apiV1.mgmtClient.audiences.upsert( + audience.key, + { + environment: flags.environment, + branch: flags.branch, + annotate: true, + commit: flags.commit, + commit_message: flags["commit-message"], + audience: audience.content as { name: string; type: "static" | "dynamic" }, + }, + ); // Update the audience directory with the successfully pushed audience // payload from the server. diff --git a/src/commands/audience/validate.ts b/src/commands/audience/validate.ts index 53633033..be6d7588 100644 --- a/src/commands/audience/validate.ts +++ b/src/commands/audience/validate.ts @@ -92,7 +92,7 @@ export default class AudienceValidate extends BaseCommand< const errorPromises = audiences.map(async (audience) => { try { - await api.validateAudience(audience.key, { + await api.mgmtClient.audiences.validate(audience.key, { environment: flags.environment, branch: flags.branch, audience: audience.content as { name: string; type: "static" | "dynamic" }, diff --git a/src/lib/api-v1.ts b/src/lib/api-v1.ts index 22627646..ba3c299f 100644 --- a/src/lib/api-v1.ts +++ b/src/lib/api-v1.ts @@ -1,15 +1,4 @@ import KnockMgmt from "@knocklabs/mgmt"; -import { - Audience as SdkAudience, - AudienceArchiveResponse, - AudienceListParams, - AudienceRetrieveParams, - AudiencesEntriesCursor, - AudienceUpsertParams, - AudienceUpsertResponse, - AudienceValidateParams, - AudienceValidateResponse, -} from "@knocklabs/mgmt/resources/audiences"; import type { Branch } from "@knocklabs/mgmt/resources/branches"; import { Channel } from "@knocklabs/mgmt/resources/channels"; import type { Commit } from "@knocklabs/mgmt/resources/commits"; @@ -561,50 +550,6 @@ export default class ApiV1 { return this.put(`/guides/${args.guideKey}/activate`, {}, { params }); } - // By resources: Audiences (using mgmt SDK) - - async listAudiences( - params: AudienceListParams, - ): Promise { - return this.mgmtClient.audiences.list(params); - } - - async listAllAudiences(params: AudienceListParams): Promise { - const audiences: SdkAudience[] = []; - for await (const audience of this.mgmtClient.audiences.list(params)) { - audiences.push(audience); - } - return audiences; - } - - async getAudience( - audienceKey: string, - params: AudienceRetrieveParams, - ): Promise { - return this.mgmtClient.audiences.retrieve(audienceKey, params); - } - - async upsertAudience( - audienceKey: string, - params: AudienceUpsertParams, - ): Promise { - return this.mgmtClient.audiences.upsert(audienceKey, params); - } - - async validateAudience( - audienceKey: string, - params: AudienceValidateParams, - ): Promise { - return this.mgmtClient.audiences.validate(audienceKey, params); - } - - async archiveAudience( - audienceKey: string, - environment: string, - ): Promise { - return this.mgmtClient.audiences.archive(audienceKey, { environment }); - } - async listAllChannels(): Promise { const channels: Channel[] = []; for await (const channel of this.mgmtClient.channels.list()) { @@ -787,15 +732,3 @@ export type ActivateGuideResp = { }; export type ListBranchResp = PaginatedResp; - -// Re-export SDK audience types -export type { - SdkAudience as Audience, - AudienceListParams, - AudienceRetrieveParams, - AudienceUpsertParams, - AudienceUpsertResponse, - AudienceValidateParams, - AudienceValidateResponse, - AudienceArchiveResponse, -}; diff --git a/test/commands/audience/archive.test.ts b/test/commands/audience/archive.test.ts index dec43af0..a4febbc4 100644 --- a/test/commands/audience/archive.test.ts +++ b/test/commands/audience/archive.test.ts @@ -1,9 +1,8 @@ +import KnockMgmt from "@knocklabs/mgmt"; import { expect, test } from "@oclif/test"; import enquirer from "enquirer"; import * as sinon from "sinon"; -import KnockApiV1 from "@/lib/api-v1"; - describe("commands/audience/archive", () => { describe("given no audience key arg", () => { test @@ -24,7 +23,7 @@ describe("commands/audience/archive", () => { describe("given an audience key arg with --force flag", () => { test .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) - .stub(KnockApiV1.prototype, "archiveAudience", (stub) => + .stub(KnockMgmt.Audiences.prototype, "archive", (stub) => stub.resolves({ result: "success" }), ) .stdout() @@ -36,15 +35,20 @@ describe("commands/audience/archive", () => { "--force", ]) .it("calls apiV1 archiveAudience with correct props", () => { - const archiveStub = KnockApiV1.prototype.archiveAudience as sinon.SinonStub; - sinon.assert.calledWith(archiveStub, "vip-users", "development"); + sinon.assert.calledWith( + KnockMgmt.Audiences.prototype.archive as sinon.SinonStub, + "vip-users", + sinon.match({ + environment: "development", + }), + ); }); }); describe("given confirmation prompt is declined", () => { test .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) - .stub(KnockApiV1.prototype, "archiveAudience", (stub) => + .stub(KnockMgmt.Audiences.prototype, "archive", (stub) => stub.resolves({ result: "success" }), ) .stub(enquirer.prototype, "prompt", (stub) => @@ -59,15 +63,16 @@ describe("commands/audience/archive", () => { ]) .it("does not call archiveAudience when declined", (ctx) => { expect(ctx.stdout).to.contain("Archive cancelled"); - const archiveStub = KnockApiV1.prototype.archiveAudience as sinon.SinonStub; - sinon.assert.notCalled(archiveStub); + sinon.assert.notCalled( + KnockMgmt.Audiences.prototype.archive as sinon.SinonStub, + ); }); }); describe("given archive fails", () => { test .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) - .stub(KnockApiV1.prototype, "archiveAudience", (stub) => + .stub(KnockMgmt.Audiences.prototype, "archive", (stub) => stub.rejects(new Error("The resource you requested does not exist")), ) .stdout() diff --git a/test/commands/audience/get.test.ts b/test/commands/audience/get.test.ts index bb6178cb..a3405035 100644 --- a/test/commands/audience/get.test.ts +++ b/test/commands/audience/get.test.ts @@ -1,3 +1,4 @@ +import KnockMgmt from "@knocklabs/mgmt"; import { test } from "@oclif/test"; import * as sinon from "sinon"; @@ -25,15 +26,14 @@ describe("commands/audience/get", () => { .stub(KnockApiV1.prototype, "whoami", (stub) => stub.resolves(factory.resp({ data: whoami })), ) - .stub(KnockApiV1.prototype, "getAudience", (stub) => + .stub(KnockMgmt.Audiences.prototype, "retrieve", (stub) => stub.resolves(factory.audience()), ) .stdout() .command(["audience get", "foo"]) .it("calls apiV1 getAudience with correct props", () => { - const getStub = KnockApiV1.prototype.getAudience as sinon.SinonStub; sinon.assert.calledWith( - getStub, + KnockMgmt.Audiences.prototype.retrieve as sinon.SinonStub, "foo", sinon.match({ environment: "development", @@ -48,7 +48,7 @@ describe("commands/audience/get", () => { .stub(KnockApiV1.prototype, "whoami", (stub) => stub.resolves(factory.resp({ data: whoami })), ) - .stub(KnockApiV1.prototype, "getAudience", (stub) => + .stub(KnockMgmt.Audiences.prototype, "retrieve", (stub) => stub.resolves(factory.audience()), ) .stdout() @@ -60,9 +60,8 @@ describe("commands/audience/get", () => { "staging", ]) .it("calls apiV1 getAudience with correct props", () => { - const getStub = KnockApiV1.prototype.getAudience as sinon.SinonStub; sinon.assert.calledWith( - getStub, + KnockMgmt.Audiences.prototype.retrieve as sinon.SinonStub, "foo", sinon.match({ environment: "staging", @@ -78,15 +77,14 @@ describe("commands/audience/get", () => { .stub(KnockApiV1.prototype, "whoami", (stub) => stub.resolves(factory.resp({ data: whoami })), ) - .stub(KnockApiV1.prototype, "getAudience", (stub) => + .stub(KnockMgmt.Audiences.prototype, "retrieve", (stub) => stub.resolves(factory.audience()), ) .stdout() .command(["audience get", "foo", "--branch", "my-feature-branch-123"]) .it("calls apiV1 getAudience with expected params", () => { - const getStub = KnockApiV1.prototype.getAudience as sinon.SinonStub; sinon.assert.calledWith( - getStub, + KnockMgmt.Audiences.prototype.retrieve as sinon.SinonStub, "foo", sinon.match({ environment: "development", @@ -102,7 +100,7 @@ describe("commands/audience/get", () => { .stub(KnockApiV1.prototype, "whoami", (stub) => stub.resolves(factory.resp({ data: whoami })), ) - .stub(KnockApiV1.prototype, "getAudience", (stub) => + .stub(KnockMgmt.Audiences.prototype, "retrieve", (stub) => stub.rejects(new Error("The resource you requested does not exist")), ) .stdout() diff --git a/test/commands/audience/list.test.ts b/test/commands/audience/list.test.ts index a052108b..5301136e 100644 --- a/test/commands/audience/list.test.ts +++ b/test/commands/audience/list.test.ts @@ -1,9 +1,9 @@ +import KnockMgmt from "@knocklabs/mgmt"; import { expect, test } from "@oclif/test"; import enquirer from "enquirer"; import * as sinon from "sinon"; import { factory } from "@/../test/support"; -import KnockApiV1 from "@/lib/api-v1"; describe("commands/audience/list", () => { const emptyAudiencesCursor = { @@ -14,15 +14,14 @@ describe("commands/audience/list", () => { describe("given no flags", () => { test .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) - .stub(KnockApiV1.prototype, "listAudiences", (stub) => + .stub(KnockMgmt.Audiences.prototype, "list", (stub) => stub.resolves(emptyAudiencesCursor), ) .stdout() .command(["audience list"]) .it("calls apiV1 listAudiences with correct props", () => { - const listStub = KnockApiV1.prototype.listAudiences as sinon.SinonStub; sinon.assert.calledWith( - listStub, + KnockMgmt.Audiences.prototype.list as sinon.SinonStub, sinon.match({ environment: "development", }), @@ -33,7 +32,7 @@ describe("commands/audience/list", () => { describe("given flags", () => { test .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) - .stub(KnockApiV1.prototype, "listAudiences", (stub) => + .stub(KnockMgmt.Audiences.prototype, "list", (stub) => stub.resolves(emptyAudiencesCursor), ) .stdout() @@ -48,9 +47,8 @@ describe("commands/audience/list", () => { "xyz", ]) .it("calls apiV1 listAudiences with correct props", () => { - const listStub = KnockApiV1.prototype.listAudiences as sinon.SinonStub; sinon.assert.calledWith( - listStub, + KnockMgmt.Audiences.prototype.list as sinon.SinonStub, sinon.match({ environment: "staging", hide_uncommitted_changes: true, @@ -64,15 +62,14 @@ describe("commands/audience/list", () => { describe("given a branch flag", () => { test .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) - .stub(KnockApiV1.prototype, "listAudiences", (stub) => + .stub(KnockMgmt.Audiences.prototype, "list", (stub) => stub.resolves(emptyAudiencesCursor), ) .stdout() .command(["audience list", "--branch", "my-feature-branch-123"]) .it("calls apiV1 listAudiences with expected params", () => { - const listStub = KnockApiV1.prototype.listAudiences as sinon.SinonStub; sinon.assert.calledWith( - listStub, + KnockMgmt.Audiences.prototype.list as sinon.SinonStub, sinon.match({ environment: "development", branch: "my-feature-branch-123", @@ -84,7 +81,7 @@ describe("commands/audience/list", () => { describe("given a list of audiences in response", () => { test .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) - .stub(KnockApiV1.prototype, "listAudiences", (stub) => + .stub(KnockMgmt.Audiences.prototype, "list", (stub) => stub.resolves({ entries: [ factory.audience({ key: "audience-1" }), @@ -119,7 +116,7 @@ describe("commands/audience/list", () => { describe("plus a next page action from the prompt input", () => { test .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) - .stub(KnockApiV1.prototype, "listAudiences", (stub) => + .stub(KnockMgmt.Audiences.prototype, "list", (stub) => stub.resolves(paginatedAudiencesCursor), ) .stub(enquirer.prototype, "prompt", (stub) => @@ -134,7 +131,8 @@ describe("commands/audience/list", () => { .it( "calls apiV1 listAudiences for the second time with page params", () => { - const listStub = KnockApiV1.prototype.listAudiences as sinon.SinonStub; + const listStub = KnockMgmt.Audiences.prototype + .list as sinon.SinonStub; sinon.assert.calledTwice(listStub); @@ -161,7 +159,7 @@ describe("commands/audience/list", () => { describe("plus a previous page action input from the prompt", () => { test .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) - .stub(KnockApiV1.prototype, "listAudiences", (stub) => + .stub(KnockMgmt.Audiences.prototype, "list", (stub) => stub.resolves(paginatedAudiencesCursor), ) .stub(enquirer.prototype, "prompt", (stub) => @@ -170,8 +168,9 @@ describe("commands/audience/list", () => { .stdout() .command(["audience list"]) .it("calls apiV1 listAudiences once for the initial page only", () => { - const listStub = KnockApiV1.prototype.listAudiences as sinon.SinonStub; - sinon.assert.calledOnce(listStub); + sinon.assert.calledOnce( + KnockMgmt.Audiences.prototype.list as sinon.SinonStub, + ); }); }); }); diff --git a/test/commands/audience/pull.test.ts b/test/commands/audience/pull.test.ts index ed091724..ff38814a 100644 --- a/test/commands/audience/pull.test.ts +++ b/test/commands/audience/pull.test.ts @@ -1,11 +1,11 @@ import * as path from "node:path"; +import KnockMgmt from "@knocklabs/mgmt"; import { expect, test } from "@oclif/test"; import enquirer from "enquirer"; import * as fs from "fs-extra"; import * as sinon from "sinon"; -import KnockApiV1 from "@/lib/api-v1"; import { sandboxDir } from "@/lib/helpers/const"; import { AUDIENCE_JSON, @@ -46,7 +46,7 @@ const setupWithStub = (audienceData = mockAudienceData) => .stub(enquirer.prototype, "prompt", (stub) => stub.resolves({ input: true }), ) - .stub(KnockApiV1.prototype, "getAudience", (stub) => + .stub(KnockMgmt.Audiences.prototype, "retrieve", (stub) => stub.resolves(audienceData), ); @@ -71,9 +71,8 @@ describe("commands/audience/pull (a single audience)", () => { .stdout() .command(["audience pull", "vip-users", "--force"]) .it("calls apiV1 getAudience with expected props", () => { - const getStub = KnockApiV1.prototype.getAudience as sinon.SinonStub; sinon.assert.calledWith( - getStub, + KnockMgmt.Audiences.prototype.retrieve as sinon.SinonStub, "vip-users", sinon.match({ environment: "development", @@ -109,9 +108,8 @@ describe("commands/audience/pull (a single audience)", () => { "my-feature-branch-123", ]) .it("calls apiV1 getAudience with expected params", () => { - const getStub = KnockApiV1.prototype.getAudience as sinon.SinonStub; sinon.assert.calledWith( - getStub, + KnockMgmt.Audiences.prototype.retrieve as sinon.SinonStub, "vip-users", sinon.match({ environment: "development", @@ -144,6 +142,17 @@ describe("commands/audience/pull (a single audience)", () => { }); }); +// Helper to create async iterator from array +function createAsyncIterator(items: T[]): AsyncIterable { + return { + [Symbol.asyncIterator]: async function* () { + for (const item of items) { + yield item; + } + }, + }; +} + describe("commands/audience/pull (all audiences)", () => { beforeEach(() => { fs.removeSync(sandboxDir); @@ -164,8 +173,8 @@ describe("commands/audience/pull (all audiences)", () => { .stub(enquirer.prototype, "prompt", (stub) => stub.resolves({ input: true }), ) - .stub(KnockApiV1.prototype, "listAllAudiences", (stub) => - stub.resolves([mockAudienceData]), + .stub(KnockMgmt.Audiences.prototype, "list", (stub) => + stub.returns(createAsyncIterator([mockAudienceData])), ) .stdout() .command([ @@ -176,8 +185,9 @@ describe("commands/audience/pull (all audiences)", () => { "--force", ]) .it("calls apiV1 listAudiences and creates audience directories", () => { - const listStub = KnockApiV1.prototype.listAllAudiences as sinon.SinonStub; - sinon.assert.calledOnce(listStub); + sinon.assert.calledOnce( + KnockMgmt.Audiences.prototype.list as sinon.SinonStub, + ); const audienceJsonPath = path.resolve( sandboxDir, diff --git a/test/commands/audience/push.test.ts b/test/commands/audience/push.test.ts index 569a501c..29f878cb 100644 --- a/test/commands/audience/push.test.ts +++ b/test/commands/audience/push.test.ts @@ -1,11 +1,11 @@ import * as path from "node:path"; +import KnockMgmt from "@knocklabs/mgmt"; import { expect, test } from "@oclif/test"; import * as fs from "fs-extra"; import * as sinon from "sinon"; import AudienceValidate from "@/commands/audience/validate"; -import KnockApiV1 from "@/lib/api-v1"; import { sandboxDir } from "@/lib/helpers/const"; import { AUDIENCE_JSON, @@ -35,7 +35,7 @@ const setupWithStub = (audienceData = mockAudienceData) => test .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) .stub(AudienceValidate, "validateAll", (stub) => stub.resolves([])) - .stub(KnockApiV1.prototype, "upsertAudience", (stub) => + .stub(KnockMgmt.Audiences.prototype, "upsert", (stub) => stub.resolves({ audience: audienceData }), ); @@ -63,9 +63,8 @@ describe("commands/audience/push", () => { .stdout() .command(["audience push", "default"]) .it("calls apiV1 upsertAudience with expected props", () => { - const upsertStub = KnockApiV1.prototype.upsertAudience as sinon.SinonStub; sinon.assert.calledWith( - upsertStub, + KnockMgmt.Audiences.prototype.upsert as sinon.SinonStub, "default", sinon.match({ environment: "development", @@ -88,9 +87,8 @@ describe("commands/audience/push", () => { "this is a commit comment!", ]) .it("calls apiV1 upsertAudience with commit flags, if provided", () => { - const upsertStub = KnockApiV1.prototype.upsertAudience as sinon.SinonStub; sinon.assert.calledWith( - upsertStub, + KnockMgmt.Audiences.prototype.upsert as sinon.SinonStub, "default", sinon.match({ environment: "development", @@ -115,9 +113,8 @@ describe("commands/audience/push", () => { "my-feature-branch-123", ]) .it("calls apiV1 upsertAudience with expected params", () => { - const upsertStub = KnockApiV1.prototype.upsertAudience as sinon.SinonStub; sinon.assert.calledWith( - upsertStub, + KnockMgmt.Audiences.prototype.upsert as sinon.SinonStub, "default", sinon.match({ environment: "development", @@ -258,11 +255,11 @@ describe("commands/audience/push", () => { .command(["audience push", "--all", "--audiences-dir", "audiences"]) .it("calls apiV1 upsertAudience with expected props twice", () => { // Validate all first - const validateStub = AudienceValidate.validateAll as sinon.SinonStub; - sinon.assert.calledOnce(validateStub); + sinon.assert.calledOnce(AudienceValidate.validateAll as sinon.SinonStub); - const upsertStub = KnockApiV1.prototype.upsertAudience as sinon.SinonStub; - sinon.assert.calledTwice(upsertStub); + sinon.assert.calledTwice( + KnockMgmt.Audiences.prototype.upsert as sinon.SinonStub, + ); }); }); }); diff --git a/test/commands/audience/validate.test.ts b/test/commands/audience/validate.test.ts index 2a56f619..dfec8b96 100644 --- a/test/commands/audience/validate.test.ts +++ b/test/commands/audience/validate.test.ts @@ -1,11 +1,11 @@ import * as path from "node:path"; +import KnockMgmt from "@knocklabs/mgmt"; import { expect, test } from "@oclif/test"; import * as fs from "fs-extra"; import * as sinon from "sinon"; import { factory } from "@/../test/support"; -import KnockApiV1 from "@/lib/api-v1"; import { sandboxDir } from "@/lib/helpers/const"; import { AUDIENCE_JSON } from "@/lib/marshal/audience"; @@ -14,7 +14,7 @@ const audienceJsonFile = "default/audience.json"; const setupWithStub = () => test .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) - .stub(KnockApiV1.prototype, "validateAudience", (stub) => + .stub(KnockMgmt.Audiences.prototype, "validate", (stub) => stub.resolves({ audience: factory.audience() }), ); @@ -42,9 +42,8 @@ describe("commands/audience/validate (a single audience)", () => { .stdout() .command(["audience validate", "default"]) .it("calls apiV1 validateAudience with expected props", () => { - const validateStub = KnockApiV1.prototype.validateAudience as sinon.SinonStub; sinon.assert.calledWith( - validateStub, + KnockMgmt.Audiences.prototype.validate as sinon.SinonStub, "default", sinon.match({ environment: "development", @@ -66,9 +65,8 @@ describe("commands/audience/validate (a single audience)", () => { "my-feature-branch-123", ]) .it("calls apiV1 validateAudience with expected params", () => { - const validateStub = KnockApiV1.prototype.validateAudience as sinon.SinonStub; sinon.assert.calledWith( - validateStub, + KnockMgmt.Audiences.prototype.validate as sinon.SinonStub, "default", sinon.match({ environment: "development", @@ -108,7 +106,7 @@ describe("commands/audience/validate (a single audience)", () => { test .env({ KNOCK_SERVICE_TOKEN: "valid-token" }) - .stub(KnockApiV1.prototype, "validateAudience", (stub) => + .stub(KnockMgmt.Audiences.prototype, "validate", (stub) => stub.rejects(new Error('"name" must be a string')), ) .stdout() @@ -211,8 +209,9 @@ describe("commands/audience/validate (all audiences)", () => { .stdout() .command(["audience validate", "--all", "--audiences-dir", "audiences"]) .it("calls apiV1 validateAudience with expected props twice", () => { - const validateStub = KnockApiV1.prototype.validateAudience as sinon.SinonStub; - sinon.assert.calledTwice(validateStub); + sinon.assert.calledTwice( + KnockMgmt.Audiences.prototype.validate as sinon.SinonStub, + ); }); }); }); diff --git a/test/commands/pull.test.ts b/test/commands/pull.test.ts index eaf59712..fa1d16e5 100644 --- a/test/commands/pull.test.ts +++ b/test/commands/pull.test.ts @@ -1,5 +1,6 @@ import * as path from "node:path"; +import KnockMgmt from "@knocklabs/mgmt"; import { expect, test } from "@oclif/test"; import enquirer from "enquirer"; import * as fs from "fs-extra"; @@ -45,8 +46,12 @@ const setupWithListStubs = ( .stub(KnockApiV1.prototype, "whoami", (stub) => stub.resolves(factory.resp({ data: factory.whoami() })), ) - .stub(KnockApiV1.prototype, "listAllAudiences", (stub) => - stub.resolves(manyAudienceAttrs.map((attrs) => factory.audience(attrs))), + .stub(KnockMgmt.Audiences.prototype, "list", (stub) => + stub.returns( + createAsyncIterator( + manyAudienceAttrs.map((attrs) => factory.audience(attrs)), + ), + ), ) .stub(KnockApiV1.prototype, "listEmailLayouts", (stub) => stub.resolves( From da918202cf6277d7d6aacbcb5ddb4b788d7c232a Mon Sep 17 00:00:00 2001 From: Meryl Dakin Date: Tue, 10 Mar 2026 12:57:21 -0400 Subject: [PATCH 14/18] update --- src/commands/audience/list.ts | 6 ++++-- src/commands/audience/pull.ts | 4 +++- src/commands/audience/push.ts | 5 ++++- src/commands/audience/validate.ts | 7 +++++-- test/commands/audience/push.test.ts | 4 +++- 5 files changed, 19 insertions(+), 7 deletions(-) diff --git a/src/commands/audience/list.ts b/src/commands/audience/list.ts index 900843fa..e6c48a9f 100644 --- a/src/commands/audience/list.ts +++ b/src/commands/audience/list.ts @@ -8,8 +8,8 @@ import { ApiError } from "@/lib/helpers/error"; import * as CustomFlags from "@/lib/helpers/flag"; import { maybePromptPageAction, - PageInfo, pageFlags, + PageInfo, paramsForPageAction, } from "@/lib/helpers/page"; import { spinner } from "@/lib/helpers/ux"; @@ -45,7 +45,9 @@ export default class AudienceList extends BaseCommand { return this.render(data); } - async request(pageParams: { after?: string; before?: string } = {}): Promise { + async request( + pageParams: { after?: string; before?: string } = {}, + ): Promise { const { flags } = this.props; spinner.start("‣ Loading"); diff --git a/src/commands/audience/pull.ts b/src/commands/audience/pull.ts index 243d30b7..8586ab7b 100644 --- a/src/commands/audience/pull.ts +++ b/src/commands/audience/pull.ts @@ -149,7 +149,9 @@ export default class AudiencePull extends BaseCommand { } } - async listAllAudiences(): Promise[]> { + async listAllAudiences(): Promise< + AudienceMarshal.AudienceData[] + > { const { flags } = this.props; const audiences: AudienceMarshal.AudienceData[] = []; diff --git a/src/commands/audience/push.ts b/src/commands/audience/push.ts index 2c2ab396..f631f1f6 100644 --- a/src/commands/audience/push.ts +++ b/src/commands/audience/push.ts @@ -97,7 +97,10 @@ export default class AudiencePush extends BaseCommand { annotate: true, commit: flags.commit, commit_message: flags["commit-message"], - audience: audience.content as { name: string; type: "static" | "dynamic" }, + audience: audience.content as { + name: string; + type: "static" | "dynamic"; + }, }, ); diff --git a/src/commands/audience/validate.ts b/src/commands/audience/validate.ts index be6d7588..d228f963 100644 --- a/src/commands/audience/validate.ts +++ b/src/commands/audience/validate.ts @@ -95,10 +95,13 @@ export default class AudienceValidate extends BaseCommand< await api.mgmtClient.audiences.validate(audience.key, { environment: flags.environment, branch: flags.branch, - audience: audience.content as { name: string; type: "static" | "dynamic" }, + audience: audience.content as { + name: string; + type: "static" | "dynamic"; + }, }); - return undefined; + return; } catch (error) { return new SourceError( (error as Error).message, diff --git a/test/commands/audience/push.test.ts b/test/commands/audience/push.test.ts index 29f878cb..c0f17128 100644 --- a/test/commands/audience/push.test.ts +++ b/test/commands/audience/push.test.ts @@ -255,7 +255,9 @@ describe("commands/audience/push", () => { .command(["audience push", "--all", "--audiences-dir", "audiences"]) .it("calls apiV1 upsertAudience with expected props twice", () => { // Validate all first - sinon.assert.calledOnce(AudienceValidate.validateAll as sinon.SinonStub); + sinon.assert.calledOnce( + AudienceValidate.validateAll as sinon.SinonStub, + ); sinon.assert.calledTwice( KnockMgmt.Audiences.prototype.upsert as sinon.SinonStub, From 7f8a6237cb297c22f3726d75e9928f37c8011981 Mon Sep 17 00:00:00 2001 From: Meryl Dakin Date: Tue, 10 Mar 2026 13:06:11 -0400 Subject: [PATCH 15/18] update --- test/lib/marshal/email-layout/reader.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/lib/marshal/email-layout/reader.test.ts b/test/lib/marshal/email-layout/reader.test.ts index 6fb8c3f7..276b2420 100644 --- a/test/lib/marshal/email-layout/reader.test.ts +++ b/test/lib/marshal/email-layout/reader.test.ts @@ -79,7 +79,7 @@ describe("lib/marshal/layout/reader", () => { beforeEach(() => { fs.removeSync(sandboxDir); - fs.ensureDir(emailLayoutDirCtx.abspath); + fs.ensureDirSync(emailLayoutDirCtx.abspath); }); after(() => fs.removeSync(sandboxDir)); From df297744305d8ce2df87107cee944950526f1dfc Mon Sep 17 00:00:00 2001 From: Meryl Dakin Date: Tue, 10 Mar 2026 13:16:14 -0400 Subject: [PATCH 16/18] update --- frogs/audience.json | 31 +++++++++++++++++++ .../marshal/audience/processor.isomorphic.ts | 3 -- 2 files changed, 31 insertions(+), 3 deletions(-) create mode 100644 frogs/audience.json diff --git a/frogs/audience.json b/frogs/audience.json new file mode 100644 index 00000000..056f769e --- /dev/null +++ b/frogs/audience.json @@ -0,0 +1,31 @@ +{ + "created_at": "2026-02-09T17:50:01.607316Z", + "environment": "development", + "key": "frogs", + "name": "frogs", + "segments": [ + { + "conditions": [ + { + "argument": "5", + "operator": "less_than_or_equal_to", + "property": "green" + } + ] + }, + { + "conditions": [ + { + "argument": "0", + "operator": "greater_than_or_equal_to", + "property": "green" + } + ] + } + ], + "sha": "Hr84fFdM7zu/074Ro2Zh79rxUzmVZ0v42BptBviA8D0=", + "type": "dynamic", + "updated_at": "2026-03-10T17:13:13.042019Z", + "__readonly": {}, + "$schema": "https://schemas.knock.app/cli/audience.json" +} diff --git a/src/lib/marshal/audience/processor.isomorphic.ts b/src/lib/marshal/audience/processor.isomorphic.ts index 0e3bec77..a48394d9 100644 --- a/src/lib/marshal/audience/processor.isomorphic.ts +++ b/src/lib/marshal/audience/processor.isomorphic.ts @@ -23,10 +23,7 @@ export const buildAudienceDirBundle = ( _localAudience?: AnyObj, $schema?: string, ): AudienceDirBundle => { - const bundle: AudienceDirBundle = {}; - return { - ...bundle, [AUDIENCE_JSON]: prepareResourceJson(remoteAudience, $schema), }; }; From ebafa61dbd8b9dc95b1c153ef81d7ca4f01cc83c Mon Sep 17 00:00:00 2001 From: Meryl Dakin Date: Tue, 10 Mar 2026 13:16:37 -0400 Subject: [PATCH 17/18] update --- frogs/audience.json | 31 ------------------------------- 1 file changed, 31 deletions(-) delete mode 100644 frogs/audience.json diff --git a/frogs/audience.json b/frogs/audience.json deleted file mode 100644 index 056f769e..00000000 --- a/frogs/audience.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "created_at": "2026-02-09T17:50:01.607316Z", - "environment": "development", - "key": "frogs", - "name": "frogs", - "segments": [ - { - "conditions": [ - { - "argument": "5", - "operator": "less_than_or_equal_to", - "property": "green" - } - ] - }, - { - "conditions": [ - { - "argument": "0", - "operator": "greater_than_or_equal_to", - "property": "green" - } - ] - } - ], - "sha": "Hr84fFdM7zu/074Ro2Zh79rxUzmVZ0v42BptBviA8D0=", - "type": "dynamic", - "updated_at": "2026-03-10T17:13:13.042019Z", - "__readonly": {}, - "$schema": "https://schemas.knock.app/cli/audience.json" -} From 3b645d45acaffc009c93c2f36104af446458a236 Mon Sep 17 00:00:00 2001 From: Meryl Dakin Date: Tue, 10 Mar 2026 15:13:43 -0400 Subject: [PATCH 18/18] update --- src/commands/audience/new.ts | 22 ++-------------------- src/lib/marshal/audience/types.ts | 6 ------ 2 files changed, 2 insertions(+), 26 deletions(-) diff --git a/src/commands/audience/new.ts b/src/commands/audience/new.ts index 57261d07..f7ef967c 100644 --- a/src/commands/audience/new.ts +++ b/src/commands/audience/new.ts @@ -11,11 +11,7 @@ import { slugify } from "@/lib/helpers/string"; import { promptToConfirm, spinner } from "@/lib/helpers/ux"; import * as Audience from "@/lib/marshal/audience"; import { AudienceType } from "@/lib/marshal/audience"; -import { - AudienceDirContext, - ensureResourceDirForTarget, - ResourceTarget, -} from "@/lib/run-context"; +import { AudienceDirContext } from "@/lib/run-context"; import AudiencePush from "./push"; @@ -184,21 +180,7 @@ export default class AudienceNew extends BaseCommand { async getAudienceDirContext( audienceKey?: string, ): Promise { - const { resourceDir, cwd: runCwd } = this.runContext; - - // Inside an existing resource dir, use it if valid for the target audience. - if (resourceDir) { - const target: ResourceTarget = { - commandId: BaseCommand.id, - type: "audience", - key: audienceKey, - }; - - return ensureResourceDirForTarget( - resourceDir, - target, - ) as AudienceDirContext; - } + const { cwd: runCwd } = this.runContext; // Default to knock project config first if present, otherwise cwd. const dirCtx = await resolveResourceDir( diff --git a/src/lib/marshal/audience/types.ts b/src/lib/marshal/audience/types.ts index 3fe088ba..b7f2d1d2 100644 --- a/src/lib/marshal/audience/types.ts +++ b/src/lib/marshal/audience/types.ts @@ -1,5 +1,3 @@ -import { AnyObj } from "@/lib/helpers/object.isomorphic"; - import { MaybeWithAnnotation } from "../shared/types"; export enum AudienceType { @@ -29,7 +27,3 @@ export type AudienceData = A & { updated_at: string; sha: string; }; - -export type AudienceInput = AnyObj & { - key: string; -};