From f0ef330acf97eeb58235138a3125273eb3fa94ef Mon Sep 17 00:00:00 2001 From: Andy Ford Date: Fri, 17 Jul 2026 16:51:28 +0100 Subject: [PATCH 1/7] feat(integrations): show chat room filter on rule get/list Rules created from Ably Chat rooms populate chatRoomFilter (top-level on the rule, alongside source) instead of source.channelFilter. Surface it in both human-readable and JSON output so `integrations get`/`list` don't silently omit the filter for chat-room-sourced rules. DX-1546 Co-Authored-By: Claude Sonnet 5 --- src/commands/integrations/get.ts | 3 + src/commands/integrations/list.ts | 4 ++ src/services/control-api.ts | 1 + test/fixtures/control-api.ts | 1 + test/unit/commands/integrations/get.test.ts | 69 ++++++++++++++++++++ test/unit/commands/integrations/list.test.ts | 60 +++++++++++++++++ 6 files changed, 138 insertions(+) diff --git a/src/commands/integrations/get.ts b/src/commands/integrations/get.ts index 222288b88..4f8b844e4 100644 --- a/src/commands/integrations/get.ts +++ b/src/commands/integrations/get.ts @@ -59,6 +59,9 @@ export default class IntegrationsGetCommand extends ControlBaseCommand { `${formatLabel("Source Channel Filter")} ${rule.source.channelFilter}`, ); } + if (rule.chatRoomFilter) { + this.log(`${formatLabel("Chat Room Filter")} ${rule.chatRoomFilter}`); + } this.log(`${formatLabel("Source Type")} ${rule.source.type}`); this.log( `${formatLabel("Target")} ${this.formatJsonOutput(structuredClone(rule.target) as Record, flags).replaceAll("\n", "\n ")}`, diff --git a/src/commands/integrations/list.ts b/src/commands/integrations/list.ts index 19451ab7e..f50291718 100644 --- a/src/commands/integrations/list.ts +++ b/src/commands/integrations/list.ts @@ -46,6 +46,7 @@ export default class IntegrationsListCommand extends ControlBaseCommand { hasMore, integrations: integrations.map((integration) => ({ appId: integration.appId, + chatRoomFilter: integration.chatRoomFilter || null, created: new Date(integration.created).toISOString(), id: integration.id, modified: new Date(integration.modified).toISOString(), @@ -80,6 +81,9 @@ export default class IntegrationsListCommand extends ControlBaseCommand { if (integration.source.channelFilter) { this.log(` Channel Filter: ${integration.source.channelFilter}`); } + if (integration.chatRoomFilter) { + this.log(` Chat Room Filter: ${integration.chatRoomFilter}`); + } this.log( ` Target: ${JSON.stringify(integration.target, null, 2).replaceAll("\n", "\n ")}`, ); diff --git a/src/services/control-api.ts b/src/services/control-api.ts index cca9438fc..e8801c335 100644 --- a/src/services/control-api.ts +++ b/src/services/control-api.ts @@ -95,6 +95,7 @@ export interface Rule { channelFilter: string; type: string; }; + chatRoomFilter: string; target: unknown; version: string; } diff --git a/test/fixtures/control-api.ts b/test/fixtures/control-api.ts index d2f50824c..ea64ca834 100644 --- a/test/fixtures/control-api.ts +++ b/test/fixtures/control-api.ts @@ -59,6 +59,7 @@ export interface MockRule { version: string; created: number; modified: number; + chatRoomFilter?: string; source: { channelFilter: string; type: string }; target: Record; } diff --git a/test/unit/commands/integrations/get.test.ts b/test/unit/commands/integrations/get.test.ts index 9ca31401e..7e3d98949 100644 --- a/test/unit/commands/integrations/get.test.ts +++ b/test/unit/commands/integrations/get.test.ts @@ -176,6 +176,75 @@ describe("integrations:get command", () => { expect(stdout).toContain("chat:*"); }); + it("should display chat room filter", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + const mockIntegration = { + id: mockRuleId, + appId, + ruleType: "http", + requestMode: "single", + chatRoomFilter: "room:*", + source: { + type: "room.message", + }, + target: { + url: "https://example.com/webhook", + format: "json", + enveloped: true, + }, + status: "enabled", + version: "1.0", + created: Date.now(), + modified: Date.now(), + }; + + nockControl() + .get(`/v1/apps/${appId}/rules/${mockRuleId}`) + .reply(200, mockIntegration); + + const { stdout } = await runCommand( + ["integrations:get", mockRuleId], + import.meta.url, + ); + + expect(stdout).toContain("Chat Room Filter"); + expect(stdout).toContain("room:*"); + }); + + it("should not display chat room filter when absent", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + const mockIntegration = { + id: mockRuleId, + appId, + ruleType: "http", + requestMode: "single", + source: { + channelFilter: "chat:*", + type: "channel.message", + }, + target: { + url: "https://example.com/webhook", + format: "json", + enveloped: true, + }, + status: "enabled", + version: "1.0", + created: Date.now(), + modified: Date.now(), + }; + + nockControl() + .get(`/v1/apps/${appId}/rules/${mockRuleId}`) + .reply(200, mockIntegration); + + const { stdout } = await runCommand( + ["integrations:get", mockRuleId], + import.meta.url, + ); + + expect(stdout).not.toContain("Chat Room Filter"); + }); + it("should display target information", async () => { const appId = getMockConfigManager().getCurrentAppId()!; const mockIntegration = { diff --git a/test/unit/commands/integrations/list.test.ts b/test/unit/commands/integrations/list.test.ts index a47e6d7f7..c3cb6862f 100644 --- a/test/unit/commands/integrations/list.test.ts +++ b/test/unit/commands/integrations/list.test.ts @@ -101,6 +101,66 @@ describe("integrations:list command", () => { expect(stdout).toContain("Source Type: channel.message"); expect(stdout).toContain("Channel Filter: chat:*"); }); + + it("should display chat room filter in human-readable output", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + const integrationsWithChatRoomFilter = [ + mockRule({ + id: "rule-003", + appId, + chatRoomFilter: "room:*", + source: { channelFilter: "", type: "room.message" }, + target: { url: "https://example.com/webhook", format: "json" }, + }), + ]; + nockControl() + .get(`/v1/apps/${appId}/rules`) + .reply(200, integrationsWithChatRoomFilter); + + const { stdout } = await runCommand( + ["integrations:list"], + import.meta.url, + ); + + expect(stdout).toContain("Chat Room Filter: room:*"); + }); + + it("should include chatRoomFilter in JSON output", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + const integrationsWithChatRoomFilter = [ + mockRule({ + id: "rule-003", + appId, + chatRoomFilter: "room:*", + source: { channelFilter: "", type: "room.message" }, + target: { url: "https://example.com/webhook", format: "json" }, + }), + ]; + nockControl() + .get(`/v1/apps/${appId}/rules`) + .reply(200, integrationsWithChatRoomFilter); + + const { stdout } = await runCommand( + ["integrations:list", "--json"], + import.meta.url, + ); + + const result = parseJsonOutput(stdout); + expect(result.integrations[0]).toHaveProperty("chatRoomFilter", "room:*"); + }); + + it("should output null chatRoomFilter in JSON when absent", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + nockControl().get(`/v1/apps/${appId}/rules`).reply(200, mockIntegrations); + + const { stdout } = await runCommand( + ["integrations:list", "--json"], + import.meta.url, + ); + + const result = parseJsonOutput(stdout); + expect(result.integrations[0]).toHaveProperty("chatRoomFilter", null); + }); }); standardFlagTests("integrations:list", import.meta.url, [ From f423e775a2f3bdf2d8b2b1f931db7e78e3e6e808 Mon Sep 17 00:00:00 2001 From: Andy Ford Date: Fri, 17 Jul 2026 17:08:13 +0100 Subject: [PATCH 2/7] feat(integrations): support setting chatRoomFilter via create/update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rules can be sourced from Ably Chat rooms (source-type chat.message), which requires setting chatRoomFilter rather than the channel-based source.channelFilter. `integrations create`/`update` had no way to set it, even though get/list already knew how to display it. Also corrects the example/test filter values to valid regex syntax (channelFilter/chatRoomFilter are regexps, not globs) — e.g. "room:.*" instead of "room:*". DX-1546 --- src/commands/integrations/create.ts | 13 +++ src/commands/integrations/update.ts | 13 +++ src/services/control-api.ts | 1 + .../unit/commands/integrations/create.test.ts | 88 +++++++++++++++++++ test/unit/commands/integrations/get.test.ts | 4 +- test/unit/commands/integrations/list.test.ts | 11 ++- .../unit/commands/integrations/update.test.ts | 47 ++++++++++ 7 files changed, 171 insertions(+), 6 deletions(-) diff --git a/src/commands/integrations/create.ts b/src/commands/integrations/create.ts index 09dfb4ad8..7e90d8c0f 100644 --- a/src/commands/integrations/create.ts +++ b/src/commands/integrations/create.ts @@ -5,6 +5,7 @@ import { formatLabel, formatResource } from "../../utils/output.js"; // Interface for basic integration data structure interface IntegrationData { + chatRoomFilter: string; requestMode: string; ruleType: string; // API property name source: { @@ -21,6 +22,7 @@ export default class IntegrationsCreateCommand extends ControlBaseCommand { static examples = [ '$ ably integrations create --rule-type "http" --source-type "channel.message" --target-url "https://example.com/webhook"', '$ ably integrations create --rule-type "amqp" --source-type "channel.message" --channel-filter "chat:*"', + '$ ably integrations create --rule-type "http" --source-type "chat.message" --chat-room-filter "room:.*" --target-url "https://example.com/webhook"', '$ ably integrations create --rule-type "http" --source-type "channel.message" --target-url "https://example.com/webhook" --json', ]; @@ -34,6 +36,10 @@ export default class IntegrationsCreateCommand extends ControlBaseCommand { description: "Channel filter pattern", required: false, }), + "chat-room-filter": Flags.string({ + description: "Chat room filter pattern", + required: false, + }), "request-mode": Flags.string({ default: "single", description: "Request mode for the integration", @@ -63,6 +69,7 @@ export default class IntegrationsCreateCommand extends ControlBaseCommand { "channel.presence", "channel.lifecycle", "presence.message", + "chat.message", ], required: true, }), @@ -87,6 +94,7 @@ export default class IntegrationsCreateCommand extends ControlBaseCommand { const controlApi = this.createControlApi(flags); // Prepare integration data const integrationData: IntegrationData = { + chatRoomFilter: flags["chat-room-filter"] || "", requestMode: flags["request-mode"], ruleType: flags["rule-type"], // API property name source: { @@ -169,6 +177,11 @@ export default class IntegrationsCreateCommand extends ControlBaseCommand { `${formatLabel("Source Channel Filter")} ${createdIntegration.source.channelFilter}`, ); } + if (createdIntegration.chatRoomFilter) { + this.log( + `${formatLabel("Chat Room Filter")} ${createdIntegration.chatRoomFilter}`, + ); + } this.log( `${formatLabel("Source Type")} ${createdIntegration.source.type}`, ); diff --git a/src/commands/integrations/update.ts b/src/commands/integrations/update.ts index 103512c70..e68b100d8 100644 --- a/src/commands/integrations/update.ts +++ b/src/commands/integrations/update.ts @@ -2,6 +2,7 @@ import { Args, Flags } from "@oclif/core"; import { ControlBaseCommand } from "../../control-base-command.js"; // Interface for rule update data structure (most fields optional) interface PartialRuleData { + chatRoomFilter?: string; requestMode?: string; ruleType?: string; // Usually shouldn't be updated, but kept for structure source?: { @@ -25,6 +26,7 @@ export default class IntegrationsUpdateCommand extends ControlBaseCommand { static examples = [ "$ ably integrations update rule123 --status disabled", '$ ably integrations update rule123 --channel-filter "chat:*"', + '$ ably integrations update rule123 --chat-room-filter "room:.*"', '$ ably integrations update rule123 --target-url "https://new-example.com/webhook"', "$ ably integrations update rule123 --status disabled --json", ]; @@ -39,6 +41,10 @@ export default class IntegrationsUpdateCommand extends ControlBaseCommand { description: "Channel filter pattern", required: false, }), + "chat-room-filter": Flags.string({ + description: "Chat room filter pattern", + required: false, + }), status: Flags.string({ description: "Status of the rule", options: ["enabled", "disabled"], @@ -96,6 +102,10 @@ export default class IntegrationsUpdateCommand extends ControlBaseCommand { updatePayload.source.channelFilter = flags["channel-filter"]; } + if (flags["chat-room-filter"]) { + updatePayload.chatRoomFilter = flags["chat-room-filter"]; + } + // Update target if it's an HTTP rule and target-url is provided if (existingRule.ruleType === "http" && flags["target-url"]) { // Ensure target exists before assigning to url @@ -130,6 +140,9 @@ export default class IntegrationsUpdateCommand extends ControlBaseCommand { `Source Channel Filter: ${updatedRule.source.channelFilter}`, ); } + if (updatedRule.chatRoomFilter) { + this.log(`Chat Room Filter: ${updatedRule.chatRoomFilter}`); + } this.log(`Source Type: ${updatedRule.source.type}`); if ( typeof updatedRule.target === "object" && diff --git a/src/services/control-api.ts b/src/services/control-api.ts index e8801c335..ec30432d7 100644 --- a/src/services/control-api.ts +++ b/src/services/control-api.ts @@ -102,6 +102,7 @@ export interface Rule { // Define RuleData interface for rule creation and updates export interface RuleData { + chatRoomFilter?: string; requestMode: string; ruleType: string; source: { diff --git a/test/unit/commands/integrations/create.test.ts b/test/unit/commands/integrations/create.test.ts index e6328440e..edd27183e 100644 --- a/test/unit/commands/integrations/create.test.ts +++ b/test/unit/commands/integrations/create.test.ts @@ -61,6 +61,46 @@ describe("integrations:create command", () => { expect(stdout).toContain("http"); }); + it("should display chat room filter in human-readable output", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + nockControl() + .post(`/v1/apps/${appId}/rules`) + .reply(201, { + id: mockRuleId, + appId, + ruleType: "http", + requestMode: "single", + chatRoomFilter: "room:.*", + source: { + channelFilter: "", + type: "chat.message", + }, + target: { + url: "https://example.com/webhook", + format: "json", + }, + status: "enabled", + }); + + const { stdout } = await runCommand( + [ + "integrations:create", + "--rule-type", + "http", + "--source-type", + "chat.message", + "--chat-room-filter", + "room:.*", + "--target-url", + "https://example.com/webhook", + ], + import.meta.url, + ); + + expect(stdout).toContain("Chat Room Filter"); + expect(stdout).toContain("room:.*"); + }); + it("should create an AMQP integration successfully", async () => { const appId = getMockConfigManager().getCurrentAppId()!; nockControl() @@ -435,6 +475,54 @@ describe("integrations:create command", () => { const source = integration.source as Record; expect(source.type).toBe("channel.lifecycle"); }); + + it("should accept chat.message source type with a chat room filter", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + nockControl() + .post(`/v1/apps/${appId}/rules`, (body: Record) => { + return body.chatRoomFilter === "room:.*"; + }) + .reply(201, { + id: mockRuleId, + appId, + ruleType: "http", + requestMode: "single", + chatRoomFilter: "room:.*", + source: { + channelFilter: "", + type: "chat.message", + }, + target: { + url: "https://example.com/webhook", + }, + status: "enabled", + created: 1640995200000, + modified: 1640995200000, + }); + + const { stdout } = await runCommand( + [ + "integrations:create", + "--rule-type", + "http", + "--source-type", + "chat.message", + "--chat-room-filter", + "room:.*", + "--target-url", + "https://example.com/webhook", + "--json", + ], + import.meta.url, + ); + + const result = parseNdjsonLines(stdout).find((r) => r.type === "result")!; + expect(result).toHaveProperty("success", true); + const integration = result.integration as Record; + expect(integration).toHaveProperty("chatRoomFilter", "room:.*"); + const source = integration.source as Record; + expect(source.type).toBe("chat.message"); + }); }); standardHelpTests("integrations:create", import.meta.url); diff --git a/test/unit/commands/integrations/get.test.ts b/test/unit/commands/integrations/get.test.ts index 7e3d98949..86f98a544 100644 --- a/test/unit/commands/integrations/get.test.ts +++ b/test/unit/commands/integrations/get.test.ts @@ -183,7 +183,7 @@ describe("integrations:get command", () => { appId, ruleType: "http", requestMode: "single", - chatRoomFilter: "room:*", + chatRoomFilter: "room:.*", source: { type: "room.message", }, @@ -208,7 +208,7 @@ describe("integrations:get command", () => { ); expect(stdout).toContain("Chat Room Filter"); - expect(stdout).toContain("room:*"); + expect(stdout).toContain("room:.*"); }); it("should not display chat room filter when absent", async () => { diff --git a/test/unit/commands/integrations/list.test.ts b/test/unit/commands/integrations/list.test.ts index c3cb6862f..06647c47e 100644 --- a/test/unit/commands/integrations/list.test.ts +++ b/test/unit/commands/integrations/list.test.ts @@ -108,7 +108,7 @@ describe("integrations:list command", () => { mockRule({ id: "rule-003", appId, - chatRoomFilter: "room:*", + chatRoomFilter: "room:.*", source: { channelFilter: "", type: "room.message" }, target: { url: "https://example.com/webhook", format: "json" }, }), @@ -122,7 +122,7 @@ describe("integrations:list command", () => { import.meta.url, ); - expect(stdout).toContain("Chat Room Filter: room:*"); + expect(stdout).toContain("Chat Room Filter: room:.*"); }); it("should include chatRoomFilter in JSON output", async () => { @@ -131,7 +131,7 @@ describe("integrations:list command", () => { mockRule({ id: "rule-003", appId, - chatRoomFilter: "room:*", + chatRoomFilter: "room:.*", source: { channelFilter: "", type: "room.message" }, target: { url: "https://example.com/webhook", format: "json" }, }), @@ -146,7 +146,10 @@ describe("integrations:list command", () => { ); const result = parseJsonOutput(stdout); - expect(result.integrations[0]).toHaveProperty("chatRoomFilter", "room:*"); + expect(result.integrations[0]).toHaveProperty( + "chatRoomFilter", + "room:.*", + ); }); it("should output null chatRoomFilter in JSON when absent", async () => { diff --git a/test/unit/commands/integrations/update.test.ts b/test/unit/commands/integrations/update.test.ts index 8229295a1..1edb5c75c 100644 --- a/test/unit/commands/integrations/update.test.ts +++ b/test/unit/commands/integrations/update.test.ts @@ -67,6 +67,53 @@ describe("integrations:update command", () => { expect(stdout).toContain(mockRuleId); }); + it("should update chat room filter", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + const mockIntegration = { + id: mockRuleId, + appId, + ruleType: "http", + requestMode: "single", + chatRoomFilter: "room:.*", + source: { + channelFilter: "", + type: "chat.message", + }, + target: { + url: "https://example.com/webhook", + format: "json", + enveloped: true, + }, + status: "enabled", + version: "1.0", + created: Date.now(), + modified: Date.now(), + }; + const updatedIntegration = { + ...mockIntegration, + chatRoomFilter: "rooms:.*", + }; + + nockControl() + .get(`/v1/apps/${appId}/rules/${mockRuleId}`) + .reply(200, mockIntegration); + + nockControl() + .patch( + `/v1/apps/${appId}/rules/${mockRuleId}`, + (body: Record) => body.chatRoomFilter === "rooms:.*", + ) + .reply(200, updatedIntegration); + + const { stdout, stderr } = await runCommand( + ["integrations:update", mockRuleId, "--chat-room-filter", "rooms:.*"], + import.meta.url, + ); + + expect(stderr).toContain("Integration rule updated."); + expect(stdout).toContain("Chat Room Filter: rooms:.*"); + }); + it("should update target URL for HTTP integrations", async () => { const appId = getMockConfigManager().getCurrentAppId()!; const mockIntegration = { From 519f4e1e9212d189d9aad1eafedfbcec65e7a5dc Mon Sep 17 00:00:00 2001 From: Andy Ford Date: Fri, 17 Jul 2026 17:09:58 +0100 Subject: [PATCH 3/7] test(e2e): cover chat-room-sourced integration rule lifecycle Extends the Control API e2e suite to create/get/list/delete a rule with source-type chat.message and --chat-room-filter, verifying chatRoomFilter round-trips through the real API end to end. --- .../e2e/integrations/integrations-e2e.test.ts | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/test/e2e/integrations/integrations-e2e.test.ts b/test/e2e/integrations/integrations-e2e.test.ts index 0a627e5ee..acb6d557c 100644 --- a/test/e2e/integrations/integrations-e2e.test.ts +++ b/test/e2e/integrations/integrations-e2e.test.ts @@ -123,4 +123,99 @@ describe.skipIf(SHOULD_SKIP_CONTROL_E2E)("Integrations E2E Tests", () => { expect(deleteResult.exitCode).toBe(0); }, ); + + it( + "should create, get, list, and delete a chat-room-sourced integration rule", + { timeout: 30000 }, + async () => { + setupTestFailureHandler( + "should create, get, list, and delete a chat-room-sourced integration rule", + ); + + // Create a rule sourced from a chat room rather than a channel + const createResult = await runCommand( + [ + "integrations", + "create", + "--app", + testAppId, + "--rule-type", + "http", + "--source-type", + "chat.message", + "--chat-room-filter", + "room:.*", + "--target-url", + "https://example.com/e2e-chat-room-webhook-test", + "--json", + ], + { + env: { ABLY_ACCESS_TOKEN: E2E_ACCESS_TOKEN || "" }, + }, + ); + + expect(createResult.exitCode).toBe(0); + + const createLines = parseNdjsonLines(createResult.stdout); + const createRecord = createLines.find((r) => r.type === "result"); + expect(createRecord).toBeDefined(); + + const createdRule = (createRecord?.rule ?? createRecord?.integration) as + | Record + | undefined; + const ruleId = (createdRule?.id ?? createdRule?.ruleId ?? "") as string; + expect(ruleId).toBeTruthy(); + expect(createdRule).toHaveProperty("chatRoomFilter", "room:.*"); + + // Get the rule and confirm chatRoomFilter round-trips + const getResult = await runCommand( + ["integrations", "get", ruleId, "--app", testAppId, "--json"], + { + env: { ABLY_ACCESS_TOKEN: E2E_ACCESS_TOKEN || "" }, + }, + ); + + expect(getResult.exitCode).toBe(0); + + const getLines = parseNdjsonLines(getResult.stdout); + const getRecord = getLines.find((r) => r.type === "result"); + expect(getRecord).toBeDefined(); + + const fetchedRule = getRecord?.rule as Record; + expect(fetchedRule).toHaveProperty("chatRoomFilter", "room:.*"); + expect((fetchedRule.source as Record).type).toBe( + "chat.message", + ); + + // List rules and confirm the chat-room rule appears with its filter + const listResult = await runCommand( + ["integrations", "list", "--app", testAppId, "--json"], + { + env: { ABLY_ACCESS_TOKEN: E2E_ACCESS_TOKEN || "" }, + }, + ); + + expect(listResult.exitCode).toBe(0); + + const listRecords = parseNdjsonLines(listResult.stdout); + const listRecord = listRecords.find((r) => r.type === "result"); + expect(listRecord).toBeDefined(); + + const listedRule = ( + listRecord!.integrations as Record[] + ).find((rule) => rule.id === ruleId); + expect(listedRule).toBeDefined(); + expect(listedRule).toHaveProperty("chatRoomFilter", "room:.*"); + + // Delete the integration rule + const deleteResult = await runCommand( + ["integrations", "delete", ruleId, "--app", testAppId, "--force"], + { + env: { ABLY_ACCESS_TOKEN: E2E_ACCESS_TOKEN || "" }, + }, + ); + + expect(deleteResult.exitCode).toBe(0); + }, + ); }); From 522feb4a1da81a9df48916c27b9ebd730ef7084c Mon Sep 17 00:00:00 2001 From: Andy Ford Date: Fri, 17 Jul 2026 17:24:12 +0100 Subject: [PATCH 4/7] fix(integrations): use valid regex syntax in channelFilter examples/tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit channelFilter is a regexp, not a glob, so "chat:*" is invalid — the trailing * has nothing to repeat. Switch examples and test fixtures to "chat:.*" (and similarly for other filter values used in tests). --- src/commands/integrations/create.ts | 2 +- src/commands/integrations/update.ts | 2 +- .../unit/commands/integrations/create.test.ts | 14 ++++++------- .../unit/commands/integrations/delete.test.ts | 10 +++++----- test/unit/commands/integrations/get.test.ts | 16 +++++++-------- test/unit/commands/integrations/list.test.ts | 4 ++-- .../unit/commands/integrations/update.test.ts | 20 +++++++++---------- 7 files changed, 34 insertions(+), 34 deletions(-) diff --git a/src/commands/integrations/create.ts b/src/commands/integrations/create.ts index 7e90d8c0f..77f762b2d 100644 --- a/src/commands/integrations/create.ts +++ b/src/commands/integrations/create.ts @@ -21,7 +21,7 @@ export default class IntegrationsCreateCommand extends ControlBaseCommand { static examples = [ '$ ably integrations create --rule-type "http" --source-type "channel.message" --target-url "https://example.com/webhook"', - '$ ably integrations create --rule-type "amqp" --source-type "channel.message" --channel-filter "chat:*"', + '$ ably integrations create --rule-type "amqp" --source-type "channel.message" --channel-filter "chat:.*"', '$ ably integrations create --rule-type "http" --source-type "chat.message" --chat-room-filter "room:.*" --target-url "https://example.com/webhook"', '$ ably integrations create --rule-type "http" --source-type "channel.message" --target-url "https://example.com/webhook" --json', ]; diff --git a/src/commands/integrations/update.ts b/src/commands/integrations/update.ts index e68b100d8..e0ac60b49 100644 --- a/src/commands/integrations/update.ts +++ b/src/commands/integrations/update.ts @@ -25,7 +25,7 @@ export default class IntegrationsUpdateCommand extends ControlBaseCommand { static examples = [ "$ ably integrations update rule123 --status disabled", - '$ ably integrations update rule123 --channel-filter "chat:*"', + '$ ably integrations update rule123 --channel-filter "chat:.*"', '$ ably integrations update rule123 --chat-room-filter "room:.*"', '$ ably integrations update rule123 --target-url "https://new-example.com/webhook"', "$ ably integrations update rule123 --status disabled --json", diff --git a/test/unit/commands/integrations/create.test.ts b/test/unit/commands/integrations/create.test.ts index edd27183e..64199dd12 100644 --- a/test/unit/commands/integrations/create.test.ts +++ b/test/unit/commands/integrations/create.test.ts @@ -31,7 +31,7 @@ describe("integrations:create command", () => { ruleType: "http", requestMode: "single", source: { - channelFilter: "chat:*", + channelFilter: "chat:.*", type: "channel.message", }, target: { @@ -49,7 +49,7 @@ describe("integrations:create command", () => { "--source-type", "channel.message", "--channel-filter", - "chat:*", + "chat:.*", "--target-url", "https://example.com/webhook", ], @@ -239,7 +239,7 @@ describe("integrations:create command", () => { ruleType: "http", requestMode: "single", source: { - channelFilter: "chat:*", + channelFilter: "chat:.*", type: "channel.message", }, target: { @@ -259,7 +259,7 @@ describe("integrations:create command", () => { "--source-type", "channel.message", "--channel-filter", - "chat:*", + "chat:.*", "--target-url", "https://example.com/webhook", "--json", @@ -292,7 +292,7 @@ describe("integrations:create command", () => { "--source-type", "channel.message", "--channel-filter", - "chat:*", + "chat:.*", "--target-url", "https://example.com/webhook", ], @@ -314,7 +314,7 @@ describe("integrations:create command", () => { "--source-type", "channel.message", "--channel-filter", - "chat:*", + "chat:.*", "--target-url", "https://example.com/webhook", ], @@ -365,7 +365,7 @@ describe("integrations:create command", () => { "--source-type", "channel.message", "--channel-filter", - "chat:*", + "chat:.*", "--target-url", "https://example.com/webhook", ], diff --git a/test/unit/commands/integrations/delete.test.ts b/test/unit/commands/integrations/delete.test.ts index b4dc804c4..a88ec5bc2 100644 --- a/test/unit/commands/integrations/delete.test.ts +++ b/test/unit/commands/integrations/delete.test.ts @@ -27,7 +27,7 @@ describe("integrations:delete command", () => { ruleType: "http", requestMode: "single", source: { - channelFilter: "chat:*", + channelFilter: "chat:.*", type: "channel.message", }, target: { @@ -66,7 +66,7 @@ describe("integrations:delete command", () => { ruleType: "http", requestMode: "single", source: { - channelFilter: "chat:*", + channelFilter: "chat:.*", type: "channel.message", }, target: { @@ -145,7 +145,7 @@ describe("integrations:delete command", () => { ruleType: "http", requestMode: "single", source: { - channelFilter: "chat:*", + channelFilter: "chat:.*", type: "channel.message", }, target: { @@ -196,7 +196,7 @@ describe("integrations:delete command", () => { ruleType: "http", requestMode: "single", source: { - channelFilter: "chat:*", + channelFilter: "chat:.*", type: "channel.message", }, target: { @@ -234,7 +234,7 @@ describe("integrations:delete command", () => { ruleType: "http", requestMode: "single", source: { - channelFilter: "chat:*", + channelFilter: "chat:.*", type: "channel.message", }, target: { diff --git a/test/unit/commands/integrations/get.test.ts b/test/unit/commands/integrations/get.test.ts index 86f98a544..da2c181d4 100644 --- a/test/unit/commands/integrations/get.test.ts +++ b/test/unit/commands/integrations/get.test.ts @@ -28,7 +28,7 @@ describe("integrations:get command", () => { ruleType: "http", requestMode: "single", source: { - channelFilter: "chat:*", + channelFilter: "chat:.*", type: "channel.message", }, target: { @@ -66,7 +66,7 @@ describe("integrations:get command", () => { ruleType: "http", requestMode: "single", source: { - channelFilter: "chat:*", + channelFilter: "chat:.*", type: "channel.message", }, target: { @@ -112,7 +112,7 @@ describe("integrations:get command", () => { ruleType: "http", requestMode: "single", source: { - channelFilter: "chat:*", + channelFilter: "chat:.*", type: "channel.message", }, target: { @@ -150,7 +150,7 @@ describe("integrations:get command", () => { ruleType: "http", requestMode: "single", source: { - channelFilter: "chat:*", + channelFilter: "chat:.*", type: "channel.message", }, target: { @@ -173,7 +173,7 @@ describe("integrations:get command", () => { import.meta.url, ); - expect(stdout).toContain("chat:*"); + expect(stdout).toContain("chat:.*"); }); it("should display chat room filter", async () => { @@ -219,7 +219,7 @@ describe("integrations:get command", () => { ruleType: "http", requestMode: "single", source: { - channelFilter: "chat:*", + channelFilter: "chat:.*", type: "channel.message", }, target: { @@ -253,7 +253,7 @@ describe("integrations:get command", () => { ruleType: "http", requestMode: "single", source: { - channelFilter: "chat:*", + channelFilter: "chat:.*", type: "channel.message", }, target: { @@ -339,7 +339,7 @@ describe("integrations:get command", () => { ruleType: "http", requestMode: "single", source: { - channelFilter: "chat:*", + channelFilter: "chat:.*", type: "channel.message", }, target: { diff --git a/test/unit/commands/integrations/list.test.ts b/test/unit/commands/integrations/list.test.ts index 06647c47e..87eb20393 100644 --- a/test/unit/commands/integrations/list.test.ts +++ b/test/unit/commands/integrations/list.test.ts @@ -19,7 +19,7 @@ describe("integrations:list command", () => { mockRule({ id: "rule-001", appId: "app-123", - source: { channelFilter: "chat:*", type: "channel.message" }, + source: { channelFilter: "chat:.*", type: "channel.message" }, target: { url: "https://example.com/webhook", format: "json" }, }), mockRule({ @@ -99,7 +99,7 @@ describe("integrations:list command", () => { expect(stdout).toContain("Integration ID: rule-001"); expect(stdout).toContain("Request Mode: single"); expect(stdout).toContain("Source Type: channel.message"); - expect(stdout).toContain("Channel Filter: chat:*"); + expect(stdout).toContain("Channel Filter: chat:.*"); }); it("should display chat room filter in human-readable output", async () => { diff --git a/test/unit/commands/integrations/update.test.ts b/test/unit/commands/integrations/update.test.ts index 1edb5c75c..e5d6f1bac 100644 --- a/test/unit/commands/integrations/update.test.ts +++ b/test/unit/commands/integrations/update.test.ts @@ -27,7 +27,7 @@ describe("integrations:update command", () => { ruleType: "http", requestMode: "single", source: { - channelFilter: "chat:*", + channelFilter: "chat:.*", type: "channel.message", }, target: { @@ -44,7 +44,7 @@ describe("integrations:update command", () => { ...mockIntegration, source: { ...mockIntegration.source, - channelFilter: "messages:*", + channelFilter: "messages:.*", }, }; @@ -59,7 +59,7 @@ describe("integrations:update command", () => { .reply(200, updatedIntegration); const { stdout, stderr } = await runCommand( - ["integrations:update", mockRuleId, "--channel-filter", "messages:*"], + ["integrations:update", mockRuleId, "--channel-filter", "messages:.*"], import.meta.url, ); @@ -122,7 +122,7 @@ describe("integrations:update command", () => { ruleType: "http", requestMode: "single", source: { - channelFilter: "chat:*", + channelFilter: "chat:.*", type: "channel.message", }, target: { @@ -168,7 +168,7 @@ describe("integrations:update command", () => { ruleType: "http", requestMode: "single", source: { - channelFilter: "chat:*", + channelFilter: "chat:.*", type: "channel.message", }, target: { @@ -236,7 +236,7 @@ describe("integrations:update command", () => { ruleType: "http", requestMode: "single", source: { - channelFilter: "chat:*", + channelFilter: "chat:.*", type: "channel.message", }, target: { @@ -344,7 +344,7 @@ describe("integrations:update command", () => { ruleType: "http", requestMode: "single", source: { - channelFilter: "chat:*", + channelFilter: "chat:.*", type: "channel.message", }, target: { @@ -397,7 +397,7 @@ describe("integrations:update command", () => { ruleType: "http", requestMode: "single", source: { - channelFilter: "chat:*", + channelFilter: "chat:.*", type: "channel.message", }, target: { @@ -414,7 +414,7 @@ describe("integrations:update command", () => { ...mockIntegration, source: { ...mockIntegration.source, - channelFilter: "new:*", + channelFilter: "new:.*", }, }; @@ -446,7 +446,7 @@ describe("integrations:update command", () => { "--app", appId, "--channel-filter", - "new:*", + "new:.*", ], import.meta.url, ); From d5626d1081e8a73525274410817683bdd2ba3798 Mon Sep 17 00:00:00 2001 From: Andy Ford Date: Thu, 23 Jul 2026 14:33:15 +0100 Subject: [PATCH 5/7] feat(integrations): support creating chat moderation and before-publish rule types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ably Chat rules (hive/text-model-only, hive/dashboard, bodyguard/text-moderation, tisane/text-moderation, azure/text-moderation, aws/lambda/before-publish, and http/before-publish) use a structurally different schema from the classic Reactor rule types: invocationMode + beforePublishConfig instead of requestMode, and vendor-specific target shapes discovered by inspecting existing rules and probing the Control API directly. `integrations create` had no way to create any of these — only the classic channel-sourced rule types were supported. Along the way, found and fixed two bugs that blocked this entirely: chatRoomFilter and source.channelFilter were sent unconditionally (even as empty strings), which the API rejects outright for chat.message-sourced and before-publish rules. modelUrl was sent as an explicit null when unset, which the API also rejects — now omitted instead. DX-1546 Co-Authored-By: Claude Sonnet 5 --- src/commands/integrations/create.ts | 303 ++++++++++- src/commands/integrations/get.ts | 9 +- src/commands/integrations/list.ts | 12 +- src/services/control-api.ts | 24 +- .../unit/commands/integrations/create.test.ts | 491 +++++++++++++++++- 5 files changed, 804 insertions(+), 35 deletions(-) diff --git a/src/commands/integrations/create.ts b/src/commands/integrations/create.ts index 77f762b2d..59cdf6bc5 100644 --- a/src/commands/integrations/create.ts +++ b/src/commands/integrations/create.ts @@ -3,26 +3,72 @@ import { Flags } from "@oclif/core"; import { ControlBaseCommand } from "../../control-base-command.js"; import { formatLabel, formatResource } from "../../utils/output.js"; +// Rule types that are sourced from Ably Chat rooms and invoked either +// before or after a chat message is published, rather than the classic +// Reactor-style channel rules (http, amqp, kinesis, etc.). +const CHAT_RULE_TYPES = new Set([ + "hive/text-model-only", + "hive/dashboard", + "aws/lambda/before-publish", + "http/before-publish", + "bodyguard/text-moderation", + "tisane/text-moderation", + "azure/text-moderation", +]); + +// The only chat rule type invoked after publish; every other chat rule +// type runs before publish and requires a beforePublishConfig. +const AFTER_PUBLISH_CHAT_RULE_TYPES = new Set(["hive/dashboard"]); + +interface BeforePublishConfig { + failedAction: string; + maxRetries: number; + retryTimeout: number; + tooManyRequestsAction: string; +} + // Interface for basic integration data structure interface IntegrationData { - chatRoomFilter: string; - requestMode: string; + beforePublishConfig?: BeforePublishConfig; + chatRoomFilter?: string; + invocationMode?: string; + requestMode?: string; ruleType: string; // API property name source: { - channelFilter: string; + channelFilter?: string; type: string; }; status: "disabled" | "enabled"; target: Record; // Target is highly variable } +// Parses repeatable "key=value" threshold flags into a numeric map, e.g. +// ["bullying=2", "sexual=1"] -> { bullying: 2, sexual: 1 } +function parseThresholds( + entries: string[] | undefined, + fail: (message: string) => never, +): Record { + const thresholds: Record = {}; + for (const entry of entries ?? []) { + const [key, rawValue] = entry.split("="); + if (!key || rawValue === undefined || Number.isNaN(Number(rawValue))) { + fail(`Invalid --threshold value "${entry}". Expected format: key=number`); + } + + thresholds[key] = Number(rawValue); + } + + return thresholds; +} + export default class IntegrationsCreateCommand extends ControlBaseCommand { static description = "Create an integration"; static examples = [ '$ ably integrations create --rule-type "http" --source-type "channel.message" --target-url "https://example.com/webhook"', '$ ably integrations create --rule-type "amqp" --source-type "channel.message" --channel-filter "chat:.*"', - '$ ably integrations create --rule-type "http" --source-type "chat.message" --chat-room-filter "room:.*" --target-url "https://example.com/webhook"', + '$ ably integrations create --rule-type "http/before-publish" --source-type "chat.message" --chat-room-filter "room:.*" --target-url "https://example.com/webhook"', + '$ ably integrations create --rule-type "tisane/text-moderation" --source-type "chat.message" --target-api-key "key" --threshold "profanity=1" --threshold "allegation=1"', '$ ably integrations create --rule-type "http" --source-type "channel.message" --target-url "https://example.com/webhook" --json', ]; @@ -40,12 +86,33 @@ export default class IntegrationsCreateCommand extends ControlBaseCommand { description: "Chat room filter pattern", required: false, }), + "default-language": Flags.string({ + description: + "Default language for text moderation (tisane/text-moderation)", + required: false, + }), + "failed-action": Flags.string({ + default: "REJECT", + description: + "Action to take when the before-publish call fails after retries", + required: false, + }), + "max-retries": Flags.integer({ + default: 3, + description: "Maximum number of retries for a before-publish call", + required: false, + }), "request-mode": Flags.string({ default: "single", description: "Request mode for the integration", options: ["single", "batch"], required: false, }), + "retry-timeout": Flags.integer({ + default: 3000, + description: "Retry timeout in milliseconds for a before-publish call", + required: false, + }), "rule-type": Flags.string({ description: "Type of integration (http, amqp, etc.)", options: [ @@ -59,6 +126,13 @@ export default class IntegrationsCreateCommand extends ControlBaseCommand { "azure-functions", "mqtt", "cloudmqtt", + "http/before-publish", + "hive/text-model-only", + "hive/dashboard", + "aws/lambda/before-publish", + "bodyguard/text-moderation", + "tisane/text-moderation", + "azure/text-moderation", ], required: true, }), @@ -79,41 +153,113 @@ export default class IntegrationsCreateCommand extends ControlBaseCommand { options: ["enabled", "disabled"], required: false, }), + "target-access-key-id": Flags.string({ + description: "AWS access key ID (aws/lambda/before-publish)", + required: false, + }), + "target-api-key": Flags.string({ + description: + "API key for the target service (moderation/dashboard rule types)", + required: false, + }), + "target-channel-id": Flags.string({ + description: + "Channel ID for the target service (bodyguard/text-moderation)", + required: false, + }), + "target-endpoint": Flags.string({ + description: + "Endpoint URL for the target service (azure/text-moderation)", + required: false, + }), + "target-function-name": Flags.string({ + description: "AWS Lambda function name (aws/lambda/before-publish)", + required: false, + }), + "target-model-url": Flags.string({ + description: + "Custom model URL for the target service (hive/text-model-only, tisane/text-moderation)", + required: false, + }), + "target-region": Flags.string({ + description: "AWS region (aws/lambda/before-publish)", + required: false, + }), + "target-secret-access-key": Flags.string({ + description: "AWS secret access key (aws/lambda/before-publish)", + required: false, + }), "target-url": Flags.string({ description: "Target URL for HTTP integrations", required: false, }), + "too-many-requests-action": Flags.string({ + default: "RETRY", + description: + "Action to take when the target rate-limits a before-publish call", + required: false, + }), + threshold: Flags.string({ + description: + "Moderation threshold as key=value, repeatable (e.g. --threshold profanity=1)", + multiple: true, + required: false, + }), }; async run(): Promise { const { flags } = await this.parse(IntegrationsCreateCommand); const appId = await this.requireAppId(flags); + const ruleType = flags["rule-type"]; + const fail = (message: string): never => + this.fail(message, flags, "integrationCreate"); try { const controlApi = this.createControlApi(flags); // Prepare integration data const integrationData: IntegrationData = { - chatRoomFilter: flags["chat-room-filter"] || "", - requestMode: flags["request-mode"], - ruleType: flags["rule-type"], // API property name + ruleType, // API property name source: { - channelFilter: flags["channel-filter"] || "", type: flags["source-type"], + // chat.message sources reject a channelFilter property outright, + // even when empty, so it's only included for channel-based sources. + ...(flags["source-type"] !== "chat.message" && { + channelFilter: flags["channel-filter"] || "", + }), }, status: flags.status === "enabled" ? "enabled" : "disabled", target: {}, }; + if (flags["chat-room-filter"]) { + integrationData.chatRoomFilter = flags["chat-room-filter"]; + } + + if (CHAT_RULE_TYPES.has(ruleType)) { + integrationData.invocationMode = AFTER_PUBLISH_CHAT_RULE_TYPES.has( + ruleType, + ) + ? "AFTER_PUBLISH" + : "BEFORE_PUBLISH"; + + if (integrationData.invocationMode === "BEFORE_PUBLISH") { + integrationData.beforePublishConfig = { + failedAction: flags["failed-action"], + maxRetries: flags["max-retries"], + retryTimeout: flags["retry-timeout"], + tooManyRequestsAction: flags["too-many-requests-action"], + }; + } + } else { + integrationData.requestMode = flags["request-mode"]; + } + // Add target data based on integration type - switch (flags["rule-type"]) { + switch (ruleType) { case "http": { if (!flags["target-url"]) { - this.fail( - "--target-url is required for HTTP integrations", - flags, - "integrationCreate", - ); + fail("--target-url is required for HTTP integrations"); } integrationData.target = { @@ -140,9 +286,121 @@ export default class IntegrationsCreateCommand extends ControlBaseCommand { break; } + case "http/before-publish": { + if (!flags["target-url"]) { + fail( + "--target-url is required for http/before-publish integrations", + ); + } + + integrationData.target = { url: flags["target-url"] }; + break; + } + + case "hive/text-model-only": { + if (!flags["target-api-key"]) { + fail( + "--target-api-key is required for hive/text-model-only integrations", + ); + } + + integrationData.target = { + apiKey: flags["target-api-key"], + thresholds: parseThresholds(flags.threshold, fail), + ...(flags["target-model-url"] && { + modelUrl: flags["target-model-url"], + }), + }; + break; + } + + case "hive/dashboard": { + if (!flags["target-api-key"]) { + fail( + "--target-api-key is required for hive/dashboard integrations", + ); + } + + integrationData.target = { apiKey: flags["target-api-key"] }; + break; + } + + case "bodyguard/text-moderation": { + if (!flags["target-api-key"] || !flags["target-channel-id"]) { + fail( + "--target-api-key and --target-channel-id are required for bodyguard/text-moderation integrations", + ); + } + + integrationData.target = { + apiKey: flags["target-api-key"], + channelId: flags["target-channel-id"], + }; + break; + } + + case "tisane/text-moderation": { + if (!flags["target-api-key"]) { + fail( + "--target-api-key is required for tisane/text-moderation integrations", + ); + } + + integrationData.target = { + apiKey: flags["target-api-key"], + thresholds: parseThresholds(flags.threshold, fail), + ...(flags["default-language"] && { + defaultLanguage: flags["default-language"], + }), + ...(flags["target-model-url"] && { + modelUrl: flags["target-model-url"], + }), + }; + break; + } + + case "azure/text-moderation": { + if (!flags["target-api-key"] || !flags["target-endpoint"]) { + fail( + "--target-api-key and --target-endpoint are required for azure/text-moderation integrations", + ); + } + + integrationData.target = { + apiKey: flags["target-api-key"], + endpoint: flags["target-endpoint"], + thresholds: parseThresholds(flags.threshold, fail), + }; + break; + } + + case "aws/lambda/before-publish": { + if ( + !flags["target-function-name"] || + !flags["target-region"] || + !flags["target-access-key-id"] || + !flags["target-secret-access-key"] + ) { + fail( + "--target-function-name, --target-region, --target-access-key-id, and --target-secret-access-key are required for aws/lambda/before-publish integrations", + ); + } + + integrationData.target = { + authentication: { + accessKeyId: flags["target-access-key-id"], + authenticationMode: "credentials", + secretAccessKey: flags["target-secret-access-key"], + }, + functionName: flags["target-function-name"], + region: flags["target-region"], + }; + break; + } + default: { this.logWarning( - `Using default target for ${flags["rule-type"]}. In a real implementation, more target options would be required.`, + `Using default target for ${ruleType}. In a real implementation, more target options would be required.`, flags, ); integrationData.target = { enveloped: true, format: "json" }; @@ -169,9 +427,18 @@ export default class IntegrationsCreateCommand extends ControlBaseCommand { this.log(`${formatLabel("ID")} ${createdIntegration.id}`); this.log(`${formatLabel("App ID")} ${createdIntegration.appId}`); this.log(`${formatLabel("Type")} ${createdIntegration.ruleType}`); - this.log( - `${formatLabel("Request Mode")} ${createdIntegration.requestMode}`, - ); + if (createdIntegration.requestMode) { + this.log( + `${formatLabel("Request Mode")} ${createdIntegration.requestMode}`, + ); + } + + if (createdIntegration.invocationMode) { + this.log( + `${formatLabel("Invocation Mode")} ${createdIntegration.invocationMode}`, + ); + } + if (createdIntegration.source.channelFilter) { this.log( `${formatLabel("Source Channel Filter")} ${createdIntegration.source.channelFilter}`, diff --git a/src/commands/integrations/get.ts b/src/commands/integrations/get.ts index 4f8b844e4..4ab35be25 100644 --- a/src/commands/integrations/get.ts +++ b/src/commands/integrations/get.ts @@ -53,7 +53,14 @@ export default class IntegrationsGetCommand extends ControlBaseCommand { this.log(`${formatLabel("ID")} ${rule.id}`); this.log(`${formatLabel("App ID")} ${rule.appId}`); this.log(`${formatLabel("Rule Type")} ${rule.ruleType}`); - this.log(`${formatLabel("Request Mode")} ${rule.requestMode}`); + if (rule.requestMode) { + this.log(`${formatLabel("Request Mode")} ${rule.requestMode}`); + } + + if (rule.invocationMode) { + this.log(`${formatLabel("Invocation Mode")} ${rule.invocationMode}`); + } + if (rule.source.channelFilter) { this.log( `${formatLabel("Source Channel Filter")} ${rule.source.channelFilter}`, diff --git a/src/commands/integrations/list.ts b/src/commands/integrations/list.ts index f50291718..65998d330 100644 --- a/src/commands/integrations/list.ts +++ b/src/commands/integrations/list.ts @@ -49,8 +49,9 @@ export default class IntegrationsListCommand extends ControlBaseCommand { chatRoomFilter: integration.chatRoomFilter || null, created: new Date(integration.created).toISOString(), id: integration.id, + invocationMode: integration.invocationMode || null, modified: new Date(integration.modified).toISOString(), - requestMode: integration.requestMode, + requestMode: integration.requestMode || null, source: { channelFilter: integration.source.channelFilter || null, type: integration.source.type, @@ -76,7 +77,14 @@ export default class IntegrationsListCommand extends ControlBaseCommand { this.log(formatHeading(`Integration ID: ${integration.id}`)); this.log(` App ID: ${integration.appId}`); this.log(` Type: ${integration.ruleType}`); - this.log(` Request Mode: ${integration.requestMode}`); + if (integration.requestMode) { + this.log(` Request Mode: ${integration.requestMode}`); + } + + if (integration.invocationMode) { + this.log(` Invocation Mode: ${integration.invocationMode}`); + } + this.log(` Source Type: ${integration.source.type}`); if (integration.source.channelFilter) { this.log(` Channel Filter: ${integration.source.channelFilter}`); diff --git a/src/services/control-api.ts b/src/services/control-api.ts index ec30432d7..23e2b2727 100644 --- a/src/services/control-api.ts +++ b/src/services/control-api.ts @@ -86,27 +86,41 @@ export interface Rule { self: string; }; appId: string; + beforePublishConfig?: { + failedAction: string; + maxRetries: number; + retryTimeout: number; + tooManyRequestsAction: string; + }; created: number; id: string; + invocationMode?: string; modified: number; - requestMode: string; + requestMode?: string; ruleType: string; source: { - channelFilter: string; + channelFilter?: string; type: string; }; - chatRoomFilter: string; + chatRoomFilter?: string; target: unknown; version: string; } // Define RuleData interface for rule creation and updates export interface RuleData { + beforePublishConfig?: { + failedAction: string; + maxRetries: number; + retryTimeout: number; + tooManyRequestsAction: string; + }; chatRoomFilter?: string; - requestMode: string; + invocationMode?: string; + requestMode?: string; ruleType: string; source: { - channelFilter: string; + channelFilter?: string; type: string; }; status?: "disabled" | "enabled"; diff --git a/test/unit/commands/integrations/create.test.ts b/test/unit/commands/integrations/create.test.ts index 64199dd12..96104f40b 100644 --- a/test/unit/commands/integrations/create.test.ts +++ b/test/unit/commands/integrations/create.test.ts @@ -68,11 +68,16 @@ describe("integrations:create command", () => { .reply(201, { id: mockRuleId, appId, - ruleType: "http", - requestMode: "single", + ruleType: "http/before-publish", + invocationMode: "BEFORE_PUBLISH", + beforePublishConfig: { + retryTimeout: 3000, + maxRetries: 3, + failedAction: "REJECT", + tooManyRequestsAction: "RETRY", + }, chatRoomFilter: "room:.*", source: { - channelFilter: "", type: "chat.message", }, target: { @@ -86,7 +91,7 @@ describe("integrations:create command", () => { [ "integrations:create", "--rule-type", - "http", + "http/before-publish", "--source-type", "chat.message", "--chat-room-filter", @@ -99,6 +104,8 @@ describe("integrations:create command", () => { expect(stdout).toContain("Chat Room Filter"); expect(stdout).toContain("room:.*"); + expect(stdout).toContain("Invocation Mode"); + expect(stdout).toContain("BEFORE_PUBLISH"); }); it("should create an AMQP integration successfully", async () => { @@ -480,16 +487,26 @@ describe("integrations:create command", () => { const appId = getMockConfigManager().getCurrentAppId()!; nockControl() .post(`/v1/apps/${appId}/rules`, (body: Record) => { - return body.chatRoomFilter === "room:.*"; + const source = body.source as Record; + return ( + body.chatRoomFilter === "room:.*" && + !("channelFilter" in source) && + body.requestMode === undefined + ); }) .reply(201, { id: mockRuleId, appId, - ruleType: "http", - requestMode: "single", + ruleType: "http/before-publish", + invocationMode: "BEFORE_PUBLISH", + beforePublishConfig: { + retryTimeout: 3000, + maxRetries: 3, + failedAction: "REJECT", + tooManyRequestsAction: "RETRY", + }, chatRoomFilter: "room:.*", source: { - channelFilter: "", type: "chat.message", }, target: { @@ -504,7 +521,7 @@ describe("integrations:create command", () => { [ "integrations:create", "--rule-type", - "http", + "http/before-publish", "--source-type", "chat.message", "--chat-room-filter", @@ -525,6 +542,462 @@ describe("integrations:create command", () => { }); }); + describe("chat rule types", () => { + it("should create a hive/text-model-only rule with thresholds and beforePublishConfig", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + nockControl() + .post(`/v1/apps/${appId}/rules`, (body: Record) => { + const target = body.target as Record; + return ( + body.invocationMode === "BEFORE_PUBLISH" && + JSON.stringify(body.beforePublishConfig) === + JSON.stringify({ + failedAction: "REJECT", + maxRetries: 3, + retryTimeout: 3000, + tooManyRequestsAction: "RETRY", + }) && + target.apiKey === "hive-key" && + JSON.stringify(target.thresholds) === + JSON.stringify({ bullying: 2 }) + ); + }) + .reply(201, { + id: mockRuleId, + appId, + ruleType: "hive/text-model-only", + invocationMode: "BEFORE_PUBLISH", + beforePublishConfig: { + retryTimeout: 3000, + maxRetries: 3, + failedAction: "REJECT", + tooManyRequestsAction: "RETRY", + }, + source: { type: "chat.message" }, + target: { + apiKey: "hive-key", + modelUrl: null, + thresholds: { bullying: 2 }, + }, + status: "enabled", + created: 1640995200000, + modified: 1640995200000, + }); + + const { stdout } = await runCommand( + [ + "integrations:create", + "--rule-type", + "hive/text-model-only", + "--source-type", + "chat.message", + "--target-api-key", + "hive-key", + "--threshold", + "bullying=2", + "--json", + ], + import.meta.url, + ); + + const result = parseNdjsonLines(stdout).find((r) => r.type === "result")!; + expect(result).toHaveProperty("success", true); + }); + + it("should create a hive/dashboard rule with AFTER_PUBLISH and no beforePublishConfig", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + nockControl() + .post(`/v1/apps/${appId}/rules`, (body: Record) => { + return ( + body.invocationMode === "AFTER_PUBLISH" && + !("beforePublishConfig" in body) && + !("requestMode" in body) + ); + }) + .reply(201, { + id: mockRuleId, + appId, + ruleType: "hive/dashboard", + invocationMode: "AFTER_PUBLISH", + source: { type: "chat.message" }, + target: { apiKey: "dash-key" }, + status: "enabled", + created: 1640995200000, + modified: 1640995200000, + }); + + const { stdout } = await runCommand( + [ + "integrations:create", + "--rule-type", + "hive/dashboard", + "--source-type", + "chat.message", + "--target-api-key", + "dash-key", + "--json", + ], + import.meta.url, + ); + + const result = parseNdjsonLines(stdout).find((r) => r.type === "result")!; + expect(result).toHaveProperty("success", true); + }); + + it("should require --target-api-key for hive/dashboard", async () => { + const { error } = await runCommand( + [ + "integrations:create", + "--rule-type", + "hive/dashboard", + "--source-type", + "chat.message", + ], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toMatch( + /target-api-key.*required.*hive\/dashboard/i, + ); + }); + + it("should create a bodyguard/text-moderation rule", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + nockControl() + .post(`/v1/apps/${appId}/rules`, (body: Record) => { + const target = body.target as Record; + return target.apiKey === "bg-key" && target.channelId === "chan-1"; + }) + .reply(201, { + id: mockRuleId, + appId, + ruleType: "bodyguard/text-moderation", + invocationMode: "BEFORE_PUBLISH", + beforePublishConfig: { + retryTimeout: 3000, + maxRetries: 3, + failedAction: "REJECT", + tooManyRequestsAction: "RETRY", + }, + source: { type: "chat.message" }, + target: { apiKey: "bg-key", channelId: "chan-1" }, + status: "enabled", + created: 1640995200000, + modified: 1640995200000, + }); + + const { stdout } = await runCommand( + [ + "integrations:create", + "--rule-type", + "bodyguard/text-moderation", + "--source-type", + "chat.message", + "--target-api-key", + "bg-key", + "--target-channel-id", + "chan-1", + "--json", + ], + import.meta.url, + ); + + const result = parseNdjsonLines(stdout).find((r) => r.type === "result")!; + expect(result).toHaveProperty("success", true); + }); + + it("should require --target-api-key and --target-channel-id for bodyguard/text-moderation", async () => { + const { error } = await runCommand( + [ + "integrations:create", + "--rule-type", + "bodyguard/text-moderation", + "--source-type", + "chat.message", + ], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toMatch(/target-api-key.*target-channel-id/i); + }); + + it("should create a tisane/text-moderation rule with thresholds and default language", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + nockControl() + .post(`/v1/apps/${appId}/rules`, (body: Record) => { + const target = body.target as Record; + return ( + target.apiKey === "tisane-key" && + target.defaultLanguage === "*" && + JSON.stringify(target.thresholds) === + JSON.stringify({ allegation: 1, profanity: 1 }) + ); + }) + .reply(201, { + id: mockRuleId, + appId, + ruleType: "tisane/text-moderation", + invocationMode: "BEFORE_PUBLISH", + beforePublishConfig: { + retryTimeout: 3000, + maxRetries: 3, + failedAction: "REJECT", + tooManyRequestsAction: "RETRY", + }, + source: { type: "chat.message" }, + target: { + apiKey: "tisane-key", + modelUrl: null, + thresholds: { allegation: 1, profanity: 1 }, + defaultLanguage: "*", + }, + status: "enabled", + created: 1640995200000, + modified: 1640995200000, + }); + + const { stdout } = await runCommand( + [ + "integrations:create", + "--rule-type", + "tisane/text-moderation", + "--source-type", + "chat.message", + "--target-api-key", + "tisane-key", + "--threshold", + "allegation=1", + "--threshold", + "profanity=1", + "--default-language", + "*", + "--json", + ], + import.meta.url, + ); + + const result = parseNdjsonLines(stdout).find((r) => r.type === "result")!; + expect(result).toHaveProperty("success", true); + }); + + it("should create an azure/text-moderation rule", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + nockControl() + .post(`/v1/apps/${appId}/rules`, (body: Record) => { + const target = body.target as Record; + return ( + target.apiKey === "azure-key" && + target.endpoint === "https://abc.cognitiveservices.azure.com" && + JSON.stringify(target.thresholds) === JSON.stringify({ Hate: 2 }) + ); + }) + .reply(201, { + id: mockRuleId, + appId, + ruleType: "azure/text-moderation", + invocationMode: "BEFORE_PUBLISH", + beforePublishConfig: { + retryTimeout: 3000, + maxRetries: 3, + failedAction: "REJECT", + tooManyRequestsAction: "RETRY", + }, + source: { type: "chat.message" }, + target: { + apiKey: "azure-key", + endpoint: "https://abc.cognitiveservices.azure.com", + thresholds: { Hate: 2 }, + }, + status: "enabled", + created: 1640995200000, + modified: 1640995200000, + }); + + const { stdout } = await runCommand( + [ + "integrations:create", + "--rule-type", + "azure/text-moderation", + "--source-type", + "chat.message", + "--target-api-key", + "azure-key", + "--target-endpoint", + "https://abc.cognitiveservices.azure.com", + "--threshold", + "Hate=2", + "--json", + ], + import.meta.url, + ); + + const result = parseNdjsonLines(stdout).find((r) => r.type === "result")!; + expect(result).toHaveProperty("success", true); + }); + + it("should require --target-api-key and --target-endpoint for azure/text-moderation", async () => { + const { error } = await runCommand( + [ + "integrations:create", + "--rule-type", + "azure/text-moderation", + "--source-type", + "chat.message", + ], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toMatch(/target-api-key.*target-endpoint/i); + }); + + it("should create an aws/lambda/before-publish rule with credentials authentication", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + nockControl() + .post(`/v1/apps/${appId}/rules`, (body: Record) => { + const target = body.target as Record; + const authentication = target.authentication as Record< + string, + unknown + >; + return ( + target.functionName === "MyFunction" && + target.region === "eu-west-1" && + authentication.authenticationMode === "credentials" && + authentication.accessKeyId === "AKIA123" && + authentication.secretAccessKey === "secret123" + ); + }) + .reply(201, { + id: mockRuleId, + appId, + ruleType: "aws/lambda/before-publish", + invocationMode: "BEFORE_PUBLISH", + beforePublishConfig: { + retryTimeout: 3000, + maxRetries: 3, + failedAction: "REJECT", + tooManyRequestsAction: "RETRY", + }, + source: { type: "chat.message" }, + target: { + functionName: "MyFunction", + region: "eu-west-1", + authentication: { + authenticationMode: "credentials", + accessKeyId: "AKIA123", + }, + }, + status: "enabled", + created: 1640995200000, + modified: 1640995200000, + }); + + const { stdout } = await runCommand( + [ + "integrations:create", + "--rule-type", + "aws/lambda/before-publish", + "--source-type", + "chat.message", + "--target-function-name", + "MyFunction", + "--target-region", + "eu-west-1", + "--target-access-key-id", + "AKIA123", + "--target-secret-access-key", + "secret123", + "--json", + ], + import.meta.url, + ); + + const result = parseNdjsonLines(stdout).find((r) => r.type === "result")!; + expect(result).toHaveProperty("success", true); + }); + + it("should require all target flags for aws/lambda/before-publish", async () => { + const { error } = await runCommand( + [ + "integrations:create", + "--rule-type", + "aws/lambda/before-publish", + "--source-type", + "chat.message", + "--target-function-name", + "MyFunction", + ], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toMatch( + /target-function-name.*target-region.*target-access-key-id.*target-secret-access-key/i, + ); + }); + + it("should override beforePublishConfig defaults with flags", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + nockControl() + .post(`/v1/apps/${appId}/rules`, (body: Record) => { + return ( + JSON.stringify(body.beforePublishConfig) === + JSON.stringify({ + failedAction: "IGNORE", + maxRetries: 5, + retryTimeout: 5000, + tooManyRequestsAction: "DROP", + }) + ); + }) + .reply(201, { + id: mockRuleId, + appId, + ruleType: "http/before-publish", + invocationMode: "BEFORE_PUBLISH", + beforePublishConfig: { + retryTimeout: 5000, + maxRetries: 5, + failedAction: "IGNORE", + tooManyRequestsAction: "DROP", + }, + source: { type: "chat.message" }, + target: { url: "https://example.com/webhook" }, + status: "enabled", + created: 1640995200000, + modified: 1640995200000, + }); + + const { stdout } = await runCommand( + [ + "integrations:create", + "--rule-type", + "http/before-publish", + "--source-type", + "chat.message", + "--target-url", + "https://example.com/webhook", + "--retry-timeout", + "5000", + "--max-retries", + "5", + "--failed-action", + "IGNORE", + "--too-many-requests-action", + "DROP", + "--json", + ], + import.meta.url, + ); + + const result = parseNdjsonLines(stdout).find((r) => r.type === "result")!; + expect(result).toHaveProperty("success", true); + }); + }); + standardHelpTests("integrations:create", import.meta.url); standardArgValidationTests("integrations:create", import.meta.url); standardFlagTests("integrations:create", import.meta.url, ["--json"]); From 84a890660feefce2b30b98c68f2df4a74ffcb8a5 Mon Sep 17 00:00:00 2001 From: Andy Ford Date: Thu, 23 Jul 2026 14:33:30 +0100 Subject: [PATCH 6/7] test(e2e): cover all chat rule types and fix broken chat-room-sourced test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the Control API e2e suite with a full create/get/delete lifecycle for every chat rule type (http/before-publish, hive/text-model-only, hive/dashboard, bodyguard/text-moderation, tisane/text-moderation, azure/text-moderation, aws/lambda/before-publish) against the real API. Also fixes the existing chat-room-sourced rule test, which used the "http" rule type — the real API rejects chatRoomFilter/chat.message on that schema, so this test would have failed had it ever actually run (it was silently skipped locally for lack of E2E_ABLY_ACCESS_TOKEN). Running the new tests against the real API surfaced two more things these tests now account for: the azure/text-moderation target.endpoint is validated via a live DNS lookup at creation time, so a placeholder domain 422s; and rule IDs can start with "-", which oclif misparses as an unknown flag unless a "--" separator precedes the positional arg. DX-1546 Co-Authored-By: Claude Sonnet 5 --- .../e2e/integrations/integrations-e2e.test.ts | 262 +++++++++++++++++- 1 file changed, 254 insertions(+), 8 deletions(-) diff --git a/test/e2e/integrations/integrations-e2e.test.ts b/test/e2e/integrations/integrations-e2e.test.ts index acb6d557c..0cf33d223 100644 --- a/test/e2e/integrations/integrations-e2e.test.ts +++ b/test/e2e/integrations/integrations-e2e.test.ts @@ -102,9 +102,11 @@ describe.skipIf(SHOULD_SKIP_CONTROL_E2E)("Integrations E2E Tests", () => { const ruleId = (rule?.id ?? rule?.ruleId ?? "") as string; expect(ruleId).toBeTruthy(); - // Get the integration rule by ID + // Get the integration rule by ID. The "--" separator is required + // because rule IDs can start with "-" (e.g. "-MYkHg"), which oclif + // would otherwise misparse as an unknown flag. const getResult = await runCommand( - ["integrations", "get", ruleId, "--app", testAppId, "--json"], + ["integrations", "get", "--app", testAppId, "--json", "--", ruleId], { env: { ABLY_ACCESS_TOKEN: E2E_ACCESS_TOKEN || "" }, }, @@ -114,7 +116,7 @@ describe.skipIf(SHOULD_SKIP_CONTROL_E2E)("Integrations E2E Tests", () => { // Delete the integration rule const deleteResult = await runCommand( - ["integrations", "delete", ruleId, "--app", testAppId, "--force"], + ["integrations", "delete", "--app", testAppId, "--force", "--", ruleId], { env: { ABLY_ACCESS_TOKEN: E2E_ACCESS_TOKEN || "" }, }, @@ -132,7 +134,9 @@ describe.skipIf(SHOULD_SKIP_CONTROL_E2E)("Integrations E2E Tests", () => { "should create, get, list, and delete a chat-room-sourced integration rule", ); - // Create a rule sourced from a chat room rather than a channel + // Create a rule sourced from a chat room rather than a channel. Note + // this must use "http/before-publish", not "http" — the plain "http" + // rule schema rejects chatRoomFilter/chat.message sources entirely. const createResult = await runCommand( [ "integrations", @@ -140,7 +144,7 @@ describe.skipIf(SHOULD_SKIP_CONTROL_E2E)("Integrations E2E Tests", () => { "--app", testAppId, "--rule-type", - "http", + "http/before-publish", "--source-type", "chat.message", "--chat-room-filter", @@ -167,9 +171,11 @@ describe.skipIf(SHOULD_SKIP_CONTROL_E2E)("Integrations E2E Tests", () => { expect(ruleId).toBeTruthy(); expect(createdRule).toHaveProperty("chatRoomFilter", "room:.*"); - // Get the rule and confirm chatRoomFilter round-trips + // Get the rule and confirm chatRoomFilter round-trips. The "--" + // separator is required because rule IDs can start with "-", which + // oclif would otherwise misparse as an unknown flag. const getResult = await runCommand( - ["integrations", "get", ruleId, "--app", testAppId, "--json"], + ["integrations", "get", "--app", testAppId, "--json", "--", ruleId], { env: { ABLY_ACCESS_TOKEN: E2E_ACCESS_TOKEN || "" }, }, @@ -209,7 +215,7 @@ describe.skipIf(SHOULD_SKIP_CONTROL_E2E)("Integrations E2E Tests", () => { // Delete the integration rule const deleteResult = await runCommand( - ["integrations", "delete", ruleId, "--app", testAppId, "--force"], + ["integrations", "delete", "--app", testAppId, "--force", "--", ruleId], { env: { ABLY_ACCESS_TOKEN: E2E_ACCESS_TOKEN || "" }, }, @@ -218,4 +224,244 @@ describe.skipIf(SHOULD_SKIP_CONTROL_E2E)("Integrations E2E Tests", () => { expect(deleteResult.exitCode).toBe(0); }, ); + + interface ChatRuleScenario { + createArgs: string[]; + expectBeforePublishConfig: boolean; + expectedInvocationMode: "AFTER_PUBLISH" | "BEFORE_PUBLISH"; + ruleType: string; + verifyTarget: (target: Record) => void; + } + + // A generic Azure hostname known to resolve via DNS, used to satisfy the + // control API's live endpoint-reachability check for azure/text-moderation. + const AZURE_TEST_ENDPOINT = "https://test.cognitiveservices.azure.com"; + + // Every chat-sourced rule type the control API supports, exercised as a + // full create/get/delete lifecycle against the real API. These are + // structurally distinct from the classic Reactor rule types (http, amqp, + // etc.) — they use invocationMode/beforePublishConfig instead of + // requestMode, and each has its own vendor-specific target shape. + const CHAT_RULE_SCENARIOS: ChatRuleScenario[] = [ + { + createArgs: [ + "--chat-room-filter", + "room:.*", + "--target-url", + "https://example.com/e2e-before-publish-webhook-test", + ], + expectBeforePublishConfig: true, + expectedInvocationMode: "BEFORE_PUBLISH", + ruleType: "http/before-publish", + verifyTarget: (target) => { + expect(target.url).toBe( + "https://example.com/e2e-before-publish-webhook-test", + ); + }, + }, + { + createArgs: [ + "--target-api-key", + "e2e-hive-key", + "--threshold", + "bullying=2", + ], + expectBeforePublishConfig: true, + expectedInvocationMode: "BEFORE_PUBLISH", + ruleType: "hive/text-model-only", + verifyTarget: (target) => { + expect(target.apiKey).toBe("e2e-hive-key"); + expect(target.thresholds).toMatchObject({ bullying: 2 }); + }, + }, + { + createArgs: ["--target-api-key", "e2e-dashboard-key"], + expectBeforePublishConfig: false, + expectedInvocationMode: "AFTER_PUBLISH", + ruleType: "hive/dashboard", + verifyTarget: (target) => { + expect(target.apiKey).toBe("e2e-dashboard-key"); + }, + }, + { + createArgs: [ + "--target-api-key", + "e2e-bodyguard-key", + "--target-channel-id", + "e2e-channel-id", + ], + expectBeforePublishConfig: true, + expectedInvocationMode: "BEFORE_PUBLISH", + ruleType: "bodyguard/text-moderation", + verifyTarget: (target) => { + expect(target.apiKey).toBe("e2e-bodyguard-key"); + expect(target.channelId).toBe("e2e-channel-id"); + }, + }, + { + createArgs: [ + "--target-api-key", + "e2e-tisane-key", + "--threshold", + "allegation=1", + "--threshold", + "profanity=1", + "--default-language", + "*", + ], + expectBeforePublishConfig: true, + expectedInvocationMode: "BEFORE_PUBLISH", + ruleType: "tisane/text-moderation", + verifyTarget: (target) => { + expect(target.apiKey).toBe("e2e-tisane-key"); + expect(target.thresholds).toMatchObject({ + allegation: 1, + profanity: 1, + }); + expect(target.defaultLanguage).toBe("*"); + }, + }, + { + // The control API resolves the endpoint's DNS at creation time and + // 422s on ENOTFOUND, so this must be a real, resolvable hostname + // rather than an arbitrary placeholder domain. + createArgs: [ + "--target-api-key", + "e2e-azure-key", + "--target-endpoint", + AZURE_TEST_ENDPOINT, + "--threshold", + "Hate=2", + ], + expectBeforePublishConfig: true, + expectedInvocationMode: "BEFORE_PUBLISH", + ruleType: "azure/text-moderation", + verifyTarget: (target) => { + expect(target.apiKey).toBe("e2e-azure-key"); + expect(target.endpoint).toBe(AZURE_TEST_ENDPOINT); + expect(target.thresholds).toMatchObject({ Hate: 2 }); + }, + }, + { + createArgs: [ + "--target-function-name", + "e2e-test-function", + "--target-region", + "eu-west-1", + "--target-access-key-id", + "AKIAE2ETEST", + "--target-secret-access-key", + "e2e-secret", + ], + expectBeforePublishConfig: true, + expectedInvocationMode: "BEFORE_PUBLISH", + ruleType: "aws/lambda/before-publish", + verifyTarget: (target) => { + expect(target.functionName).toBe("e2e-test-function"); + expect(target.region).toBe("eu-west-1"); + const authentication = target.authentication as Record; + expect(authentication.authenticationMode).toBe("credentials"); + expect(authentication.accessKeyId).toBe("AKIAE2ETEST"); + }, + }, + ]; + + describe.each(CHAT_RULE_SCENARIOS)( + "chat rule type: $ruleType", + (scenario) => { + it( + `should create, get, and delete a ${scenario.ruleType} rule`, + { timeout: 30000 }, + async () => { + setupTestFailureHandler( + `should create, get, and delete a ${scenario.ruleType} rule`, + ); + + const createResult = await runCommand( + [ + "integrations", + "create", + "--app", + testAppId, + "--rule-type", + scenario.ruleType, + "--source-type", + "chat.message", + ...scenario.createArgs, + "--json", + ], + { + env: { ABLY_ACCESS_TOKEN: E2E_ACCESS_TOKEN || "" }, + }, + ); + + expect(createResult.exitCode).toBe(0); + + const createLines = parseNdjsonLines(createResult.stdout); + const createRecord = createLines.find((r) => r.type === "result"); + expect(createRecord).toBeDefined(); + + const createdRule = (createRecord?.rule ?? + createRecord?.integration) as Record | undefined; + const ruleId = (createdRule?.id ?? + createdRule?.ruleId ?? + "") as string; + expect(ruleId).toBeTruthy(); + + expect(createdRule).toHaveProperty("ruleType", scenario.ruleType); + expect(createdRule).toHaveProperty( + "invocationMode", + scenario.expectedInvocationMode, + ); + expect(Boolean(createdRule?.beforePublishConfig)).toBe( + scenario.expectBeforePublishConfig, + ); + + scenario.verifyTarget(createdRule?.target as Record); + + // Get the rule and confirm it round-trips. The "--" separator is + // required because rule IDs can start with "-", which oclif would + // otherwise misparse as an unknown flag. + const getResult = await runCommand( + ["integrations", "get", "--app", testAppId, "--json", "--", ruleId], + { + env: { ABLY_ACCESS_TOKEN: E2E_ACCESS_TOKEN || "" }, + }, + ); + + expect(getResult.exitCode).toBe(0); + + const getLines = parseNdjsonLines(getResult.stdout); + const getRecord = getLines.find((r) => r.type === "result"); + expect(getRecord).toBeDefined(); + + const fetchedRule = getRecord?.rule as Record; + expect(fetchedRule).toHaveProperty("ruleType", scenario.ruleType); + expect(fetchedRule).toHaveProperty( + "invocationMode", + scenario.expectedInvocationMode, + ); + scenario.verifyTarget(fetchedRule.target as Record); + + // Delete the integration rule + const deleteResult = await runCommand( + [ + "integrations", + "delete", + "--app", + testAppId, + "--force", + "--", + ruleId, + ], + { + env: { ABLY_ACCESS_TOKEN: E2E_ACCESS_TOKEN || "" }, + }, + ); + + expect(deleteResult.exitCode).toBe(0); + }, + ); + }, + ); }); From c2e85ad4e81108d65cb29f2c10001b90f04d11bb Mon Sep 17 00:00:00 2001 From: Andy Ford Date: Thu, 23 Jul 2026 17:54:03 +0100 Subject: [PATCH 7/7] fix(integrations): fix undefined display fields and consolidate chat rule config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit update.ts and delete.ts printed "Request Mode: undefined" for chat rule types (they use invocationMode instead of requestMode) and never showed invocationMode at all — the same display bug already fixed in create.ts/ get.ts/list.ts was missed in these two commands. parseThresholds silently accepted "key=" and "key= " as a threshold of 0 instead of rejecting them, and discarded everything after a second "=". create.ts had no validation that a chat rule type is paired with --source-type chat.message (or vice versa), so a mismatched combination only surfaced as an opaque Control API 4xx instead of a clear CLI error. Replaces the CHAT_RULE_TYPES/AFTER_PUBLISH_CHAT_RULE_TYPES sets plus the per-rule-type switch statement with a single declarative config table (invocationMode, required target flags, target builder per rule type), removes the duplicate local IntegrationData/BeforePublishConfig types in favour of importing RuleData from control-api.ts, and extracts a shared buildModerationTarget helper for the three moderation-vendor rule types. DX-1546 Co-Authored-By: Claude Sonnet 5 --- src/commands/integrations/create.ts | 386 +++++++++--------- src/commands/integrations/delete.ts | 11 +- src/commands/integrations/update.ts | 9 +- src/services/control-api.ts | 23 +- .../unit/commands/integrations/create.test.ts | 85 +++- .../unit/commands/integrations/update.test.ts | 41 ++ 6 files changed, 344 insertions(+), 211 deletions(-) diff --git a/src/commands/integrations/create.ts b/src/commands/integrations/create.ts index 59cdf6bc5..a04fd525e 100644 --- a/src/commands/integrations/create.ts +++ b/src/commands/integrations/create.ts @@ -1,66 +1,154 @@ -import { Flags } from "@oclif/core"; +import { Flags, Interfaces } from "@oclif/core"; import { ControlBaseCommand } from "../../control-base-command.js"; +import type { RuleData } from "../../services/control-api.js"; import { formatLabel, formatResource } from "../../utils/output.js"; -// Rule types that are sourced from Ably Chat rooms and invoked either -// before or after a chat message is published, rather than the classic -// Reactor-style channel rules (http, amqp, kinesis, etc.). -const CHAT_RULE_TYPES = new Set([ - "hive/text-model-only", - "hive/dashboard", - "aws/lambda/before-publish", - "http/before-publish", - "bodyguard/text-moderation", - "tisane/text-moderation", - "azure/text-moderation", -]); - -// The only chat rule type invoked after publish; every other chat rule -// type runs before publish and requires a beforePublishConfig. -const AFTER_PUBLISH_CHAT_RULE_TYPES = new Set(["hive/dashboard"]); - -interface BeforePublishConfig { - failedAction: string; - maxRetries: number; - retryTimeout: number; - tooManyRequestsAction: string; -} - -// Interface for basic integration data structure -interface IntegrationData { - beforePublishConfig?: BeforePublishConfig; - chatRoomFilter?: string; - invocationMode?: string; - requestMode?: string; - ruleType: string; // API property name - source: { - channelFilter?: string; - type: string; - }; - status: "disabled" | "enabled"; - target: Record; // Target is highly variable -} +type FailFn = (message: string) => never; +type CreateFlags = Interfaces.InferredFlags< + typeof IntegrationsCreateCommand.flags +>; // Parses repeatable "key=value" threshold flags into a numeric map, e.g. -// ["bullying=2", "sexual=1"] -> { bullying: 2, sexual: 1 } +// ["bullying=2", "hate=1"] -> { bullying: 2, hate: 1 }. Rejects entries +// with a missing/blank/whitespace-only value or more than one "=" rather +// than silently coercing them — Number() trims and parses "" and " " as 0. function parseThresholds( entries: string[] | undefined, - fail: (message: string) => never, + fail: FailFn, ): Record { const thresholds: Record = {}; for (const entry of entries ?? []) { - const [key, rawValue] = entry.split("="); - if (!key || rawValue === undefined || Number.isNaN(Number(rawValue))) { + const parts = entry.split("="); + const [key, rawValue] = parts; + const trimmedValue = rawValue?.trim(); + if ( + parts.length !== 2 || + !key || + !trimmedValue || + Number.isNaN(Number(trimmedValue)) + ) { fail(`Invalid --threshold value "${entry}". Expected format: key=number`); } - thresholds[key] = Number(rawValue); + thresholds[key] = Number(trimmedValue); } return thresholds; } +// Shared base for the moderation-vendor target shapes (hive/text-model-only, +// tisane/text-moderation, azure/text-moderation), which all require an API +// key and numeric thresholds, plus their own vendor-specific fields on top. +function buildModerationTarget( + flags: CreateFlags, + fail: FailFn, + extra: Record = {}, +): Record { + return { + apiKey: flags["target-api-key"], + thresholds: parseThresholds(flags.threshold, fail), + ...extra, + }; +} + +// Joins flag names into a human-readable "required" message, e.g. +// ["a"] -> "--a", ["a", "b"] -> "--a and --b", +// ["a", "b", "c"] -> "--a, --b, and --c" +function joinRequiredFlags(names: string[]): string { + const flagNames = names.map((name) => `--${name}`); + if (flagNames.length <= 2) return flagNames.join(" and "); + return `${flagNames.slice(0, -1).join(", ")}, and ${flagNames.at(-1)}`; +} + +// Chat-sourced rule types use a structurally different schema from the +// classic Reactor-style channel rules (http, amqp, kinesis, etc.): +// invocationMode + beforePublishConfig instead of requestMode, and each has +// its own vendor-specific target shape. This table is the single source of +// truth for that shape — required target flags, the target payload builder, +// and the invocation mode — so a new chat rule type only needs one entry +// here plus a matching --rule-type option, rather than edits scattered +// across parallel lists and a switch statement. +interface ChatRuleTypeConfig { + buildTarget: (flags: CreateFlags, fail: FailFn) => Record; + invocationMode: "AFTER_PUBLISH" | "BEFORE_PUBLISH"; + requiredTargetFlags: string[]; +} + +const CHAT_RULE_TYPES: Record = { + "aws/lambda/before-publish": { + buildTarget: (flags) => ({ + authentication: { + accessKeyId: flags["target-access-key-id"], + authenticationMode: "credentials", + secretAccessKey: flags["target-secret-access-key"], + }, + functionName: flags["target-function-name"], + region: flags["target-region"], + }), + invocationMode: "BEFORE_PUBLISH", + requiredTargetFlags: [ + "target-function-name", + "target-region", + "target-access-key-id", + "target-secret-access-key", + ], + }, + "azure/text-moderation": { + buildTarget: (flags, fail) => + buildModerationTarget(flags, fail, { + endpoint: flags["target-endpoint"], + }), + invocationMode: "BEFORE_PUBLISH", + requiredTargetFlags: ["target-api-key", "target-endpoint"], + }, + "bodyguard/text-moderation": { + buildTarget: (flags) => ({ + apiKey: flags["target-api-key"], + channelId: flags["target-channel-id"], + }), + invocationMode: "BEFORE_PUBLISH", + requiredTargetFlags: ["target-api-key", "target-channel-id"], + }, + "hive/dashboard": { + buildTarget: (flags) => ({ + apiKey: flags["target-api-key"], + }), + invocationMode: "AFTER_PUBLISH", + requiredTargetFlags: ["target-api-key"], + }, + "hive/text-model-only": { + buildTarget: (flags, fail) => + buildModerationTarget( + flags, + fail, + flags["target-model-url"] + ? { modelUrl: flags["target-model-url"] } + : {}, + ), + invocationMode: "BEFORE_PUBLISH", + requiredTargetFlags: ["target-api-key"], + }, + "http/before-publish": { + buildTarget: (flags) => ({ url: flags["target-url"] }), + invocationMode: "BEFORE_PUBLISH", + requiredTargetFlags: ["target-url"], + }, + "tisane/text-moderation": { + buildTarget: (flags, fail) => + buildModerationTarget(flags, fail, { + ...(flags["default-language"] && { + defaultLanguage: flags["default-language"], + }), + ...(flags["target-model-url"] && { + modelUrl: flags["target-model-url"], + }), + }), + invocationMode: "BEFORE_PUBLISH", + requiredTargetFlags: ["target-api-key"], + }, +}; + export default class IntegrationsCreateCommand extends ControlBaseCommand { static description = "Create an integration"; @@ -212,13 +300,24 @@ export default class IntegrationsCreateCommand extends ControlBaseCommand { const appId = await this.requireAppId(flags); const ruleType = flags["rule-type"]; - const fail = (message: string): never => + const chatRuleConfig = CHAT_RULE_TYPES[ruleType]; + const fail: FailFn = (message) => this.fail(message, flags, "integrationCreate"); + if (chatRuleConfig && flags["source-type"] !== "chat.message") { + fail(`--source-type must be "chat.message" for ${ruleType} integrations`); + } + + if (!chatRuleConfig && flags["source-type"] === "chat.message") { + fail( + `--source-type "chat.message" requires a chat rule type (e.g. --rule-type "http/before-publish"); "${ruleType}" is a channel-sourced rule type`, + ); + } + try { const controlApi = this.createControlApi(flags); // Prepare integration data - const integrationData: IntegrationData = { + const integrationData: RuleData = { ruleType, // API property name source: { type: flags["source-type"], @@ -236,14 +335,10 @@ export default class IntegrationsCreateCommand extends ControlBaseCommand { integrationData.chatRoomFilter = flags["chat-room-filter"]; } - if (CHAT_RULE_TYPES.has(ruleType)) { - integrationData.invocationMode = AFTER_PUBLISH_CHAT_RULE_TYPES.has( - ruleType, - ) - ? "AFTER_PUBLISH" - : "BEFORE_PUBLISH"; + if (chatRuleConfig) { + integrationData.invocationMode = chatRuleConfig.invocationMode; - if (integrationData.invocationMode === "BEFORE_PUBLISH") { + if (chatRuleConfig.invocationMode === "BEFORE_PUBLISH") { integrationData.beforePublishConfig = { failedAction: flags["failed-action"], maxRetries: flags["max-retries"], @@ -251,159 +346,58 @@ export default class IntegrationsCreateCommand extends ControlBaseCommand { tooManyRequestsAction: flags["too-many-requests-action"], }; } - } else { - integrationData.requestMode = flags["request-mode"]; - } - - // Add target data based on integration type - switch (ruleType) { - case "http": { - if (!flags["target-url"]) { - fail("--target-url is required for HTTP integrations"); - } - - integrationData.target = { - enveloped: true, - format: "json", - url: flags["target-url"], - }; - break; - } - case "amqp": { - // Simplified AMQP config for demo purposes - integrationData.target = { - enveloped: true, - exchangeName: "ably", - format: "json", - headers: {}, - immediate: false, - mandatory: true, - persistent: true, - queueType: "classic", - routingKey: "events", - }; - break; - } - - case "http/before-publish": { - if (!flags["target-url"]) { - fail( - "--target-url is required for http/before-publish integrations", - ); - } - - integrationData.target = { url: flags["target-url"] }; - break; - } - - case "hive/text-model-only": { - if (!flags["target-api-key"]) { - fail( - "--target-api-key is required for hive/text-model-only integrations", - ); - } - - integrationData.target = { - apiKey: flags["target-api-key"], - thresholds: parseThresholds(flags.threshold, fail), - ...(flags["target-model-url"] && { - modelUrl: flags["target-model-url"], - }), - }; - break; - } - - case "hive/dashboard": { - if (!flags["target-api-key"]) { - fail( - "--target-api-key is required for hive/dashboard integrations", - ); - } - - integrationData.target = { apiKey: flags["target-api-key"] }; - break; + const missingFlags = chatRuleConfig.requiredTargetFlags.filter( + (flagName) => !flags[flagName as keyof CreateFlags], + ); + if (missingFlags.length > 0) { + fail( + `${joinRequiredFlags(missingFlags)} ${missingFlags.length > 1 ? "are" : "is"} required for ${ruleType} integrations`, + ); } - case "bodyguard/text-moderation": { - if (!flags["target-api-key"] || !flags["target-channel-id"]) { - fail( - "--target-api-key and --target-channel-id are required for bodyguard/text-moderation integrations", - ); - } - - integrationData.target = { - apiKey: flags["target-api-key"], - channelId: flags["target-channel-id"], - }; - break; - } + integrationData.target = chatRuleConfig.buildTarget(flags, fail); + } else { + integrationData.requestMode = flags["request-mode"]; - case "tisane/text-moderation": { - if (!flags["target-api-key"]) { - fail( - "--target-api-key is required for tisane/text-moderation integrations", - ); + // Add target data based on integration type + switch (ruleType) { + case "http": { + if (!flags["target-url"]) { + fail("--target-url is required for HTTP integrations"); + } + + integrationData.target = { + enveloped: true, + format: "json", + url: flags["target-url"], + }; + break; } - integrationData.target = { - apiKey: flags["target-api-key"], - thresholds: parseThresholds(flags.threshold, fail), - ...(flags["default-language"] && { - defaultLanguage: flags["default-language"], - }), - ...(flags["target-model-url"] && { - modelUrl: flags["target-model-url"], - }), - }; - break; - } - - case "azure/text-moderation": { - if (!flags["target-api-key"] || !flags["target-endpoint"]) { - fail( - "--target-api-key and --target-endpoint are required for azure/text-moderation integrations", - ); + case "amqp": { + // Simplified AMQP config for demo purposes + integrationData.target = { + enveloped: true, + exchangeName: "ably", + format: "json", + headers: {}, + immediate: false, + mandatory: true, + persistent: true, + queueType: "classic", + routingKey: "events", + }; + break; } - integrationData.target = { - apiKey: flags["target-api-key"], - endpoint: flags["target-endpoint"], - thresholds: parseThresholds(flags.threshold, fail), - }; - break; - } - - case "aws/lambda/before-publish": { - if ( - !flags["target-function-name"] || - !flags["target-region"] || - !flags["target-access-key-id"] || - !flags["target-secret-access-key"] - ) { - fail( - "--target-function-name, --target-region, --target-access-key-id, and --target-secret-access-key are required for aws/lambda/before-publish integrations", + default: { + this.logWarning( + `Using default target for ${ruleType}. In a real implementation, more target options would be required.`, + flags, ); + integrationData.target = { enveloped: true, format: "json" }; } - - integrationData.target = { - authentication: { - accessKeyId: flags["target-access-key-id"], - authenticationMode: "credentials", - secretAccessKey: flags["target-secret-access-key"], - }, - functionName: flags["target-function-name"], - region: flags["target-region"], - }; - break; - } - - default: { - this.logWarning( - `Using default target for ${ruleType}. In a real implementation, more target options would be required.`, - flags, - ); - integrationData.target = { enveloped: true, format: "json" }; } } diff --git a/src/commands/integrations/delete.ts b/src/commands/integrations/delete.ts index 0fdbb219b..b90bb8ba2 100644 --- a/src/commands/integrations/delete.ts +++ b/src/commands/integrations/delete.ts @@ -55,7 +55,16 @@ export default class IntegrationsDeleteCommand extends ControlBaseCommand { this.log(`\nYou are about to delete the following integration:`); this.log(`${formatLabel("Integration ID")} ${integration.id}`); this.log(`${formatLabel("Type")} ${integration.ruleType}`); - this.log(`${formatLabel("Request Mode")} ${integration.requestMode}`); + if (integration.requestMode) { + this.log(`${formatLabel("Request Mode")} ${integration.requestMode}`); + } + + if (integration.invocationMode) { + this.log( + `${formatLabel("Invocation Mode")} ${integration.invocationMode}`, + ); + } + this.log(`${formatLabel("Source Type")} ${integration.source.type}`); if (integration.source.channelFilter) { this.log( diff --git a/src/commands/integrations/update.ts b/src/commands/integrations/update.ts index e0ac60b49..5481c9d37 100644 --- a/src/commands/integrations/update.ts +++ b/src/commands/integrations/update.ts @@ -134,7 +134,14 @@ export default class IntegrationsUpdateCommand extends ControlBaseCommand { this.log(`ID: ${updatedRule.id}`); this.log(`App ID: ${updatedRule.appId}`); this.log(`Rule Type: ${updatedRule.ruleType}`); - this.log(`Request Mode: ${updatedRule.requestMode}`); + if (updatedRule.requestMode) { + this.log(`Request Mode: ${updatedRule.requestMode}`); + } + + if (updatedRule.invocationMode) { + this.log(`Invocation Mode: ${updatedRule.invocationMode}`); + } + if (updatedRule.source.channelFilter) { this.log( `Source Channel Filter: ${updatedRule.source.channelFilter}`, diff --git a/src/services/control-api.ts b/src/services/control-api.ts index 23e2b2727..5cafb11e0 100644 --- a/src/services/control-api.ts +++ b/src/services/control-api.ts @@ -81,17 +81,21 @@ export interface Namespace { tlsOnly?: boolean; } +// Retry/failure policy for rule types invoked with invocationMode +// "BEFORE_PUBLISH" (chat moderation and before-publish webhook rule types). +export interface BeforePublishConfig { + failedAction: string; + maxRetries: number; + retryTimeout: number; + tooManyRequestsAction: string; +} + export interface Rule { _links?: { self: string; }; appId: string; - beforePublishConfig?: { - failedAction: string; - maxRetries: number; - retryTimeout: number; - tooManyRequestsAction: string; - }; + beforePublishConfig?: BeforePublishConfig; created: number; id: string; invocationMode?: string; @@ -109,12 +113,7 @@ export interface Rule { // Define RuleData interface for rule creation and updates export interface RuleData { - beforePublishConfig?: { - failedAction: string; - maxRetries: number; - retryTimeout: number; - tooManyRequestsAction: string; - }; + beforePublishConfig?: BeforePublishConfig; chatRoomFilter?: string; invocationMode?: string; requestMode?: string; diff --git a/test/unit/commands/integrations/create.test.ts b/test/unit/commands/integrations/create.test.ts index 96104f40b..9cd6e360a 100644 --- a/test/unit/commands/integrations/create.test.ts +++ b/test/unit/commands/integrations/create.test.ts @@ -543,6 +543,88 @@ describe("integrations:create command", () => { }); describe("chat rule types", () => { + it("should reject a --threshold value with an empty number", async () => { + const { error } = await runCommand( + [ + "integrations:create", + "--rule-type", + "hive/text-model-only", + "--source-type", + "chat.message", + "--target-api-key", + "hive-key", + "--threshold", + "bullying=", + ], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toMatch(/Invalid --threshold value "bullying="/); + }); + + it("should reject a --threshold value with more than one '='", async () => { + const { error } = await runCommand( + [ + "integrations:create", + "--rule-type", + "hive/text-model-only", + "--source-type", + "chat.message", + "--target-api-key", + "hive-key", + "--threshold", + "bullying=2=3", + ], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toMatch( + /Invalid --threshold value "bullying=2=3"/, + ); + }); + + it("should reject a chat rule type combined with a non-chat.message source type", async () => { + const { error } = await runCommand( + [ + "integrations:create", + "--rule-type", + "http/before-publish", + "--source-type", + "channel.message", + "--target-url", + "https://example.com/webhook", + ], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toMatch( + /--source-type must be "chat.message" for http\/before-publish/, + ); + }); + + it("should reject a channel rule type combined with chat.message source type", async () => { + const { error } = await runCommand( + [ + "integrations:create", + "--rule-type", + "http", + "--source-type", + "chat.message", + "--target-url", + "https://example.com/webhook", + ], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toMatch( + /"chat.message" requires a chat rule type/, + ); + }); + it("should create a hive/text-model-only rule with thresholds and beforePublishConfig", async () => { const appId = getMockConfigManager().getCurrentAppId()!; nockControl() @@ -935,8 +1017,9 @@ describe("integrations:create command", () => { expect(error).toBeDefined(); expect(error?.message).toMatch( - /target-function-name.*target-region.*target-access-key-id.*target-secret-access-key/i, + /target-region.*target-access-key-id.*target-secret-access-key/i, ); + expect(error?.message).not.toMatch(/target-function-name/i); }); it("should override beforePublishConfig defaults with flags", async () => { diff --git a/test/unit/commands/integrations/update.test.ts b/test/unit/commands/integrations/update.test.ts index e5d6f1bac..093927102 100644 --- a/test/unit/commands/integrations/update.test.ts +++ b/test/unit/commands/integrations/update.test.ts @@ -114,6 +114,47 @@ describe("integrations:update command", () => { expect(stdout).toContain("Chat Room Filter: rooms:.*"); }); + it("should not print an undefined request mode and should print invocation mode for chat rule types", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + const mockIntegration = { + id: mockRuleId, + appId, + ruleType: "http/before-publish", + invocationMode: "BEFORE_PUBLISH", + chatRoomFilter: "room:.*", + source: { + type: "chat.message", + }, + target: { + url: "https://example.com/webhook", + }, + status: "enabled", + version: "1.0", + created: Date.now(), + modified: Date.now(), + }; + const updatedIntegration = { + ...mockIntegration, + chatRoomFilter: "rooms:.*", + }; + + nockControl() + .get(`/v1/apps/${appId}/rules/${mockRuleId}`) + .reply(200, mockIntegration); + + nockControl() + .patch(`/v1/apps/${appId}/rules/${mockRuleId}`) + .reply(200, updatedIntegration); + + const { stdout } = await runCommand( + ["integrations:update", mockRuleId, "--chat-room-filter", "rooms:.*"], + import.meta.url, + ); + + expect(stdout).toContain("Invocation Mode: BEFORE_PUBLISH"); + expect(stdout).not.toContain("Request Mode:"); + }); + it("should update target URL for HTTP integrations", async () => { const appId = getMockConfigManager().getCurrentAppId()!; const mockIntegration = {