diff --git a/src/commands/integrations/create.ts b/src/commands/integrations/create.ts index 09dfb4ad8..a04fd525e 100644 --- a/src/commands/integrations/create.ts +++ b/src/commands/integrations/create.ts @@ -1,26 +1,162 @@ -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"; -// Interface for basic integration data structure -interface IntegrationData { - requestMode: string; - ruleType: string; // API property name - source: { - channelFilter: string; - type: string; +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", "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: FailFn, +): Record { + const thresholds: Record = {}; + for (const entry of entries ?? []) { + 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(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, }; - status: "disabled" | "enabled"; - target: Record; // Target is highly variable } +// 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"; 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/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', ]; @@ -34,12 +170,37 @@ export default class IntegrationsCreateCommand extends ControlBaseCommand { description: "Channel filter pattern", required: false, }), + "chat-room-filter": Flags.string({ + 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: [ @@ -53,6 +214,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, }), @@ -63,6 +231,7 @@ export default class IntegrationsCreateCommand extends ControlBaseCommand { "channel.presence", "channel.lifecycle", "presence.message", + "chat.message", ], required: true, }), @@ -72,72 +241,163 @@ 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 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 = { - requestMode: flags["request-mode"], - ruleType: flags["rule-type"], // API property name + const integrationData: RuleData = { + 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: {}, }; - // Add target data based on integration type - switch (flags["rule-type"]) { - case "http": { - if (!flags["target-url"]) { - this.fail( - "--target-url is required for HTTP integrations", - flags, - "integrationCreate", - ); - } + if (flags["chat-room-filter"]) { + integrationData.chatRoomFilter = flags["chat-room-filter"]; + } - integrationData.target = { - enveloped: true, - format: "json", - url: flags["target-url"], - }; - break; - } + if (chatRuleConfig) { + integrationData.invocationMode = chatRuleConfig.invocationMode; - 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", + if (chatRuleConfig.invocationMode === "BEFORE_PUBLISH") { + integrationData.beforePublishConfig = { + failedAction: flags["failed-action"], + maxRetries: flags["max-retries"], + retryTimeout: flags["retry-timeout"], + tooManyRequestsAction: flags["too-many-requests-action"], }; - break; } - default: { - this.logWarning( - `Using default target for ${flags["rule-type"]}. In a real implementation, more target options would be required.`, - flags, + 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`, ); - integrationData.target = { enveloped: true, format: "json" }; + } + + integrationData.target = chatRuleConfig.buildTarget(flags, fail); + } 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; + } + + 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" }; + } } } @@ -161,14 +421,28 @@ 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}`, ); } + 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/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/get.ts b/src/commands/integrations/get.ts index 222288b88..4ab35be25 100644 --- a/src/commands/integrations/get.ts +++ b/src/commands/integrations/get.ts @@ -53,12 +53,22 @@ 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}`, ); } + 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..65998d330 100644 --- a/src/commands/integrations/list.ts +++ b/src/commands/integrations/list.ts @@ -46,10 +46,12 @@ 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, + 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, @@ -75,11 +77,21 @@ 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}`); } + 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/commands/integrations/update.ts b/src/commands/integrations/update.ts index 103512c70..5481c9d37 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?: { @@ -24,7 +25,8 @@ 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", ]; @@ -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 @@ -124,12 +134,22 @@ 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}`, ); } + 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 cca9438fc..5cafb11e0 100644 --- a/src/services/control-api.ts +++ b/src/services/control-api.ts @@ -81,30 +81,45 @@ 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?: BeforePublishConfig; created: number; id: string; + invocationMode?: string; modified: number; - requestMode: string; + requestMode?: string; ruleType: string; source: { - channelFilter: string; + channelFilter?: string; type: string; }; + chatRoomFilter?: string; target: unknown; version: string; } // Define RuleData interface for rule creation and updates export interface RuleData { - requestMode: string; + beforePublishConfig?: BeforePublishConfig; + chatRoomFilter?: string; + invocationMode?: string; + requestMode?: string; ruleType: string; source: { - channelFilter: string; + channelFilter?: string; type: string; }; status?: "disabled" | "enabled"; diff --git a/test/e2e/integrations/integrations-e2e.test.ts b/test/e2e/integrations/integrations-e2e.test.ts index 0a627e5ee..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 || "" }, }, @@ -123,4 +125,343 @@ 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. 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", + "create", + "--app", + testAppId, + "--rule-type", + "http/before-publish", + "--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. 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("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", "--app", testAppId, "--force", "--", ruleId], + { + env: { ABLY_ACCESS_TOKEN: E2E_ACCESS_TOKEN || "" }, + }, + ); + + 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); + }, + ); + }, + ); }); 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/create.test.ts b/test/unit/commands/integrations/create.test.ts index e6328440e..9cd6e360a 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", ], @@ -61,6 +61,53 @@ 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/before-publish", + invocationMode: "BEFORE_PUBLISH", + beforePublishConfig: { + retryTimeout: 3000, + maxRetries: 3, + failedAction: "REJECT", + tooManyRequestsAction: "RETRY", + }, + chatRoomFilter: "room:.*", + source: { + type: "chat.message", + }, + target: { + url: "https://example.com/webhook", + format: "json", + }, + status: "enabled", + }); + + const { stdout } = await runCommand( + [ + "integrations:create", + "--rule-type", + "http/before-publish", + "--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:.*"); + expect(stdout).toContain("Invocation Mode"); + expect(stdout).toContain("BEFORE_PUBLISH"); + }); + it("should create an AMQP integration successfully", async () => { const appId = getMockConfigManager().getCurrentAppId()!; nockControl() @@ -199,7 +246,7 @@ describe("integrations:create command", () => { ruleType: "http", requestMode: "single", source: { - channelFilter: "chat:*", + channelFilter: "chat:.*", type: "channel.message", }, target: { @@ -219,7 +266,7 @@ describe("integrations:create command", () => { "--source-type", "channel.message", "--channel-filter", - "chat:*", + "chat:.*", "--target-url", "https://example.com/webhook", "--json", @@ -252,7 +299,7 @@ describe("integrations:create command", () => { "--source-type", "channel.message", "--channel-filter", - "chat:*", + "chat:.*", "--target-url", "https://example.com/webhook", ], @@ -274,7 +321,7 @@ describe("integrations:create command", () => { "--source-type", "channel.message", "--channel-filter", - "chat:*", + "chat:.*", "--target-url", "https://example.com/webhook", ], @@ -325,7 +372,7 @@ describe("integrations:create command", () => { "--source-type", "channel.message", "--channel-filter", - "chat:*", + "chat:.*", "--target-url", "https://example.com/webhook", ], @@ -435,6 +482,603 @@ 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) => { + const source = body.source as Record; + return ( + body.chatRoomFilter === "room:.*" && + !("channelFilter" in source) && + body.requestMode === undefined + ); + }) + .reply(201, { + id: mockRuleId, + appId, + ruleType: "http/before-publish", + invocationMode: "BEFORE_PUBLISH", + beforePublishConfig: { + retryTimeout: 3000, + maxRetries: 3, + failedAction: "REJECT", + tooManyRequestsAction: "RETRY", + }, + chatRoomFilter: "room:.*", + 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", + "--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"); + }); + }); + + 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() + .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-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 () => { + 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); 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 9ca31401e..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,76 @@ describe("integrations:get command", () => { import.meta.url, ); - expect(stdout).toContain("chat:*"); + 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 () => { @@ -184,7 +253,7 @@ describe("integrations:get command", () => { ruleType: "http", requestMode: "single", source: { - channelFilter: "chat:*", + channelFilter: "chat:.*", type: "channel.message", }, target: { @@ -270,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 a47e6d7f7..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,70 @@ 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 () => { + 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); }); }); diff --git a/test/unit/commands/integrations/update.test.ts b/test/unit/commands/integrations/update.test.ts index 8229295a1..093927102 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, ); @@ -67,6 +67,94 @@ 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 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 = { @@ -75,7 +163,7 @@ describe("integrations:update command", () => { ruleType: "http", requestMode: "single", source: { - channelFilter: "chat:*", + channelFilter: "chat:.*", type: "channel.message", }, target: { @@ -121,7 +209,7 @@ describe("integrations:update command", () => { ruleType: "http", requestMode: "single", source: { - channelFilter: "chat:*", + channelFilter: "chat:.*", type: "channel.message", }, target: { @@ -189,7 +277,7 @@ describe("integrations:update command", () => { ruleType: "http", requestMode: "single", source: { - channelFilter: "chat:*", + channelFilter: "chat:.*", type: "channel.message", }, target: { @@ -297,7 +385,7 @@ describe("integrations:update command", () => { ruleType: "http", requestMode: "single", source: { - channelFilter: "chat:*", + channelFilter: "chat:.*", type: "channel.message", }, target: { @@ -350,7 +438,7 @@ describe("integrations:update command", () => { ruleType: "http", requestMode: "single", source: { - channelFilter: "chat:*", + channelFilter: "chat:.*", type: "channel.message", }, target: { @@ -367,7 +455,7 @@ describe("integrations:update command", () => { ...mockIntegration, source: { ...mockIntegration.source, - channelFilter: "new:*", + channelFilter: "new:.*", }, }; @@ -399,7 +487,7 @@ describe("integrations:update command", () => { "--app", appId, "--channel-filter", - "new:*", + "new:.*", ], import.meta.url, );