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/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/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/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/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 => {