From d32dc2fbc5ea4bb0280e168055de211eee67532b Mon Sep 17 00:00:00 2001 From: Andy Ford Date: Mon, 8 Dec 2025 20:05:19 +0000 Subject: [PATCH 01/44] test: add unit tests for apps/channel-rules commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../apps/channel-rules/create.test.ts | 242 ++++++++++++++++ .../apps/channel-rules/delete.test.ts | 194 +++++++++++++ .../commands/apps/channel-rules/list.test.ts | 170 +++++++++++ .../apps/channel-rules/update.test.ts | 268 ++++++++++++++++++ 4 files changed, 874 insertions(+) create mode 100644 test/unit/commands/apps/channel-rules/create.test.ts create mode 100644 test/unit/commands/apps/channel-rules/delete.test.ts create mode 100644 test/unit/commands/apps/channel-rules/list.test.ts create mode 100644 test/unit/commands/apps/channel-rules/update.test.ts diff --git a/test/unit/commands/apps/channel-rules/create.test.ts b/test/unit/commands/apps/channel-rules/create.test.ts new file mode 100644 index 00000000..9ba1d4d7 --- /dev/null +++ b/test/unit/commands/apps/channel-rules/create.test.ts @@ -0,0 +1,242 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { runCommand } from "@oclif/test"; +import nock from "nock"; +import { resolve } from "node:path"; +import { mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; + +describe("apps:channel-rules:create command", () => { + const mockAccessToken = "fake_access_token"; + const mockAccountId = "test-account-id"; + const mockAppId = "550e8400-e29b-41d4-a716-446655440000"; + const mockRuleName = "chat"; + const mockRuleId = "chat"; + let testConfigDir: string; + let originalConfigDir: string; + + beforeEach(() => { + process.env.ABLY_ACCESS_TOKEN = mockAccessToken; + + testConfigDir = resolve(tmpdir(), `ably-cli-test-${Date.now()}`); + mkdirSync(testConfigDir, { recursive: true, mode: 0o700 }); + + originalConfigDir = process.env.ABLY_CLI_CONFIG_DIR || ""; + process.env.ABLY_CLI_CONFIG_DIR = testConfigDir; + + const configContent = `[current] +account = "default" + +[accounts.default] +accessToken = "${mockAccessToken}" +accountId = "${mockAccountId}" +accountName = "Test Account" +userEmail = "test@example.com" +currentAppId = "${mockAppId}" +`; + writeFileSync(resolve(testConfigDir, "config"), configContent); + }); + + afterEach(() => { + nock.cleanAll(); + delete process.env.ABLY_ACCESS_TOKEN; + + if (originalConfigDir) { + process.env.ABLY_CLI_CONFIG_DIR = originalConfigDir; + } else { + delete process.env.ABLY_CLI_CONFIG_DIR; + } + + if (existsSync(testConfigDir)) { + rmSync(testConfigDir, { recursive: true, force: true }); + } + }); + + describe("successful channel rule creation", () => { + it("should create a channel rule successfully", async () => { + nock("https://control.ably.net") + .post(`/v1/apps/${mockAppId}/namespaces`) + .reply(201, { + id: mockRuleId, + persisted: false, + pushEnabled: false, + created: Date.now(), + modified: Date.now(), + }); + + const { stdout } = await runCommand( + [ + "apps:channel-rules:create", + "--name", + mockRuleName, + "--app", + mockAppId, + ], + import.meta.url, + ); + + expect(stdout).toContain("Channel rule created successfully"); + expect(stdout).toContain(mockRuleId); + }); + + it("should create a channel rule with persisted flag", async () => { + nock("https://control.ably.net") + .post(`/v1/apps/${mockAppId}/namespaces`, (body) => { + return body.persisted === true; + }) + .reply(201, { + id: mockRuleId, + persisted: true, + pushEnabled: false, + created: Date.now(), + modified: Date.now(), + }); + + const { stdout } = await runCommand( + [ + "apps:channel-rules:create", + "--name", + mockRuleName, + "--app", + mockAppId, + "--persisted", + ], + import.meta.url, + ); + + expect(stdout).toContain("Channel rule created successfully"); + expect(stdout).toContain("Persisted: Yes"); + }); + + it("should create a channel rule with push-enabled flag", async () => { + nock("https://control.ably.net") + .post(`/v1/apps/${mockAppId}/namespaces`, (body) => { + return body.pushEnabled === true; + }) + .reply(201, { + id: mockRuleId, + persisted: false, + pushEnabled: true, + created: Date.now(), + modified: Date.now(), + }); + + const { stdout } = await runCommand( + [ + "apps:channel-rules:create", + "--name", + mockRuleName, + "--app", + mockAppId, + "--push-enabled", + ], + import.meta.url, + ); + + expect(stdout).toContain("Channel rule created successfully"); + expect(stdout).toContain("Push Enabled: Yes"); + }); + + it("should output JSON format when --json flag is used", async () => { + const mockRule = { + id: mockRuleId, + persisted: false, + pushEnabled: false, + created: Date.now(), + modified: Date.now(), + }; + + nock("https://control.ably.net") + .post(`/v1/apps/${mockAppId}/namespaces`) + .reply(201, mockRule); + + const { stdout } = await runCommand( + [ + "apps:channel-rules:create", + "--name", + mockRuleName, + "--app", + mockAppId, + "--json", + ], + import.meta.url, + ); + + const result = JSON.parse(stdout); + expect(result).toHaveProperty("success", true); + expect(result).toHaveProperty("rule"); + expect(result.rule).toHaveProperty("id", mockRuleId); + }); + }); + + describe("error handling", () => { + it("should require name parameter", async () => { + const { error } = await runCommand( + ["apps:channel-rules:create", "--app", mockAppId], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/Missing required flag.*name/); + }); + + it("should handle 401 authentication error", async () => { + nock("https://control.ably.net") + .post(`/v1/apps/${mockAppId}/namespaces`) + .reply(401, { error: "Unauthorized" }); + + const { error } = await runCommand( + [ + "apps:channel-rules:create", + "--name", + mockRuleName, + "--app", + mockAppId, + ], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/401/); + }); + + it("should handle 400 validation error", async () => { + nock("https://control.ably.net") + .post(`/v1/apps/${mockAppId}/namespaces`) + .reply(400, { error: "Validation failed" }); + + const { error } = await runCommand( + [ + "apps:channel-rules:create", + "--name", + mockRuleName, + "--app", + mockAppId, + ], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/400/); + }); + + it("should handle network errors", async () => { + nock("https://control.ably.net") + .post(`/v1/apps/${mockAppId}/namespaces`) + .replyWithError("Network error"); + + const { error } = await runCommand( + [ + "apps:channel-rules:create", + "--name", + mockRuleName, + "--app", + mockAppId, + ], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/Network error/); + }); + }); +}); diff --git a/test/unit/commands/apps/channel-rules/delete.test.ts b/test/unit/commands/apps/channel-rules/delete.test.ts new file mode 100644 index 00000000..67612ee3 --- /dev/null +++ b/test/unit/commands/apps/channel-rules/delete.test.ts @@ -0,0 +1,194 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { runCommand } from "@oclif/test"; +import nock from "nock"; +import { resolve } from "node:path"; +import { mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; + +describe("apps:channel-rules:delete command", () => { + const mockAccessToken = "fake_access_token"; + const mockAccountId = "test-account-id"; + const mockAppId = "550e8400-e29b-41d4-a716-446655440000"; + const mockRuleId = "chat"; + let testConfigDir: string; + let originalConfigDir: string; + + beforeEach(() => { + process.env.ABLY_ACCESS_TOKEN = mockAccessToken; + + testConfigDir = resolve(tmpdir(), `ably-cli-test-${Date.now()}`); + mkdirSync(testConfigDir, { recursive: true, mode: 0o700 }); + + originalConfigDir = process.env.ABLY_CLI_CONFIG_DIR || ""; + process.env.ABLY_CLI_CONFIG_DIR = testConfigDir; + + const configContent = `[current] +account = "default" + +[accounts.default] +accessToken = "${mockAccessToken}" +accountId = "${mockAccountId}" +accountName = "Test Account" +userEmail = "test@example.com" +currentAppId = "${mockAppId}" +`; + writeFileSync(resolve(testConfigDir, "config"), configContent); + }); + + afterEach(() => { + nock.cleanAll(); + delete process.env.ABLY_ACCESS_TOKEN; + + if (originalConfigDir) { + process.env.ABLY_CLI_CONFIG_DIR = originalConfigDir; + } else { + delete process.env.ABLY_CLI_CONFIG_DIR; + } + + if (existsSync(testConfigDir)) { + rmSync(testConfigDir, { recursive: true, force: true }); + } + }); + + describe("successful channel rule deletion", () => { + it("should delete a channel rule with force flag", async () => { + // Mock listing namespaces to find the rule + nock("https://control.ably.net") + .get(`/v1/apps/${mockAppId}/namespaces`) + .reply(200, [ + { + id: mockRuleId, + persisted: false, + pushEnabled: false, + created: Date.now(), + modified: Date.now(), + }, + ]); + + // Mock delete endpoint + nock("https://control.ably.net") + .delete(`/v1/apps/${mockAppId}/namespaces/${mockRuleId}`) + .reply(204); + + const { stdout } = await runCommand( + [ + "apps:channel-rules:delete", + mockRuleId, + "--app", + mockAppId, + "--force", + ], + import.meta.url, + ); + + expect(stdout).toContain("deleted successfully"); + }); + + it("should output JSON format when --json flag is used", async () => { + nock("https://control.ably.net") + .get(`/v1/apps/${mockAppId}/namespaces`) + .reply(200, [ + { + id: mockRuleId, + persisted: false, + pushEnabled: false, + created: Date.now(), + modified: Date.now(), + }, + ]); + + nock("https://control.ably.net") + .delete(`/v1/apps/${mockAppId}/namespaces/${mockRuleId}`) + .reply(204); + + const { stdout } = await runCommand( + [ + "apps:channel-rules:delete", + mockRuleId, + "--app", + mockAppId, + "--force", + "--json", + ], + import.meta.url, + ); + + const result = JSON.parse(stdout); + expect(result).toHaveProperty("success", true); + expect(result).toHaveProperty("rule"); + expect(result.rule).toHaveProperty("id", mockRuleId); + }); + }); + + describe("error handling", () => { + it("should require nameOrId argument", async () => { + const { error } = await runCommand( + ["apps:channel-rules:delete", "--app", mockAppId], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/Missing 1 required arg/); + }); + + it("should handle channel rule not found", async () => { + nock("https://control.ably.net") + .get(`/v1/apps/${mockAppId}/namespaces`) + .reply(200, []); + + const { error } = await runCommand( + [ + "apps:channel-rules:delete", + "nonexistent", + "--app", + mockAppId, + "--force", + ], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/not found/); + }); + + it("should handle 401 authentication error", async () => { + nock("https://control.ably.net") + .get(`/v1/apps/${mockAppId}/namespaces`) + .reply(401, { error: "Unauthorized" }); + + const { error } = await runCommand( + [ + "apps:channel-rules:delete", + mockRuleId, + "--app", + mockAppId, + "--force", + ], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/401/); + }); + + it("should handle network errors", async () => { + nock("https://control.ably.net") + .get(`/v1/apps/${mockAppId}/namespaces`) + .replyWithError("Network error"); + + const { error } = await runCommand( + [ + "apps:channel-rules:delete", + mockRuleId, + "--app", + mockAppId, + "--force", + ], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/Network error/); + }); + }); +}); diff --git a/test/unit/commands/apps/channel-rules/list.test.ts b/test/unit/commands/apps/channel-rules/list.test.ts new file mode 100644 index 00000000..236b4c69 --- /dev/null +++ b/test/unit/commands/apps/channel-rules/list.test.ts @@ -0,0 +1,170 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { runCommand } from "@oclif/test"; +import nock from "nock"; +import { resolve } from "node:path"; +import { mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; + +describe("apps:channel-rules:list command", () => { + const mockAccessToken = "fake_access_token"; + const mockAccountId = "test-account-id"; + const mockAppId = "550e8400-e29b-41d4-a716-446655440000"; + let testConfigDir: string; + let originalConfigDir: string; + + beforeEach(() => { + process.env.ABLY_ACCESS_TOKEN = mockAccessToken; + + testConfigDir = resolve(tmpdir(), `ably-cli-test-${Date.now()}`); + mkdirSync(testConfigDir, { recursive: true, mode: 0o700 }); + + originalConfigDir = process.env.ABLY_CLI_CONFIG_DIR || ""; + process.env.ABLY_CLI_CONFIG_DIR = testConfigDir; + + const configContent = `[current] +account = "default" + +[accounts.default] +accessToken = "${mockAccessToken}" +accountId = "${mockAccountId}" +accountName = "Test Account" +userEmail = "test@example.com" +currentAppId = "${mockAppId}" +`; + writeFileSync(resolve(testConfigDir, "config"), configContent); + }); + + afterEach(() => { + nock.cleanAll(); + delete process.env.ABLY_ACCESS_TOKEN; + + if (originalConfigDir) { + process.env.ABLY_CLI_CONFIG_DIR = originalConfigDir; + } else { + delete process.env.ABLY_CLI_CONFIG_DIR; + } + + if (existsSync(testConfigDir)) { + rmSync(testConfigDir, { recursive: true, force: true }); + } + }); + + describe("successful channel rules listing", () => { + it("should list channel rules successfully", async () => { + const mockRules = [ + { + id: "chat", + persisted: true, + pushEnabled: false, + created: Date.now(), + modified: Date.now(), + }, + { + id: "events", + persisted: false, + pushEnabled: true, + created: Date.now(), + modified: Date.now(), + }, + ]; + + nock("https://control.ably.net") + .get(`/v1/apps/${mockAppId}/namespaces`) + .reply(200, mockRules); + + const { stdout } = await runCommand( + ["apps:channel-rules:list"], + import.meta.url, + ); + + expect(stdout).toContain("Found 2 channel rules"); + expect(stdout).toContain("chat"); + expect(stdout).toContain("events"); + }); + + it("should handle empty rules list", async () => { + nock("https://control.ably.net") + .get(`/v1/apps/${mockAppId}/namespaces`) + .reply(200, []); + + const { stdout } = await runCommand( + ["apps:channel-rules:list"], + import.meta.url, + ); + + expect(stdout).toContain("No channel rules found"); + }); + + it("should display rule details correctly", async () => { + const mockRules = [ + { + id: "chat", + persisted: true, + pushEnabled: true, + authenticated: true, + tlsOnly: true, + created: Date.now(), + modified: Date.now(), + }, + ]; + + nock("https://control.ably.net") + .get(`/v1/apps/${mockAppId}/namespaces`) + .reply(200, mockRules); + + const { stdout } = await runCommand( + ["apps:channel-rules:list"], + import.meta.url, + ); + + expect(stdout).toContain("Found 1 channel rules"); + expect(stdout).toContain("chat"); + expect(stdout).toContain("Persisted: ✓ Yes"); + expect(stdout).toContain("Push Enabled: ✓ Yes"); + }); + }); + + describe("error handling", () => { + it("should handle 401 authentication error", async () => { + nock("https://control.ably.net") + .get(`/v1/apps/${mockAppId}/namespaces`) + .reply(401, { error: "Unauthorized" }); + + const { error } = await runCommand( + ["apps:channel-rules:list"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/401/); + }); + + it("should handle 404 not found error", async () => { + nock("https://control.ably.net") + .get(`/v1/apps/${mockAppId}/namespaces`) + .reply(404, { error: "App not found" }); + + const { error } = await runCommand( + ["apps:channel-rules:list"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/404/); + }); + + it("should handle network errors", async () => { + nock("https://control.ably.net") + .get(`/v1/apps/${mockAppId}/namespaces`) + .replyWithError("Network error"); + + const { error } = await runCommand( + ["apps:channel-rules:list"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/Network error/); + }); + }); +}); diff --git a/test/unit/commands/apps/channel-rules/update.test.ts b/test/unit/commands/apps/channel-rules/update.test.ts new file mode 100644 index 00000000..0a320a20 --- /dev/null +++ b/test/unit/commands/apps/channel-rules/update.test.ts @@ -0,0 +1,268 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { runCommand } from "@oclif/test"; +import nock from "nock"; +import { resolve } from "node:path"; +import { mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; + +describe("apps:channel-rules:update command", () => { + const mockAccessToken = "fake_access_token"; + const mockAccountId = "test-account-id"; + const mockAppId = "550e8400-e29b-41d4-a716-446655440000"; + const mockRuleId = "chat"; + let testConfigDir: string; + let originalConfigDir: string; + + beforeEach(() => { + process.env.ABLY_ACCESS_TOKEN = mockAccessToken; + + testConfigDir = resolve(tmpdir(), `ably-cli-test-${Date.now()}`); + mkdirSync(testConfigDir, { recursive: true, mode: 0o700 }); + + originalConfigDir = process.env.ABLY_CLI_CONFIG_DIR || ""; + process.env.ABLY_CLI_CONFIG_DIR = testConfigDir; + + const configContent = `[current] +account = "default" + +[accounts.default] +accessToken = "${mockAccessToken}" +accountId = "${mockAccountId}" +accountName = "Test Account" +userEmail = "test@example.com" +currentAppId = "${mockAppId}" +`; + writeFileSync(resolve(testConfigDir, "config"), configContent); + }); + + afterEach(() => { + nock.cleanAll(); + delete process.env.ABLY_ACCESS_TOKEN; + + if (originalConfigDir) { + process.env.ABLY_CLI_CONFIG_DIR = originalConfigDir; + } else { + delete process.env.ABLY_CLI_CONFIG_DIR; + } + + if (existsSync(testConfigDir)) { + rmSync(testConfigDir, { recursive: true, force: true }); + } + }); + + describe("successful channel rule update", () => { + it("should update a channel rule with persisted flag", async () => { + // Mock listing namespaces to find the rule + nock("https://control.ably.net") + .get(`/v1/apps/${mockAppId}/namespaces`) + .reply(200, [ + { + id: mockRuleId, + persisted: false, + pushEnabled: false, + created: Date.now(), + modified: Date.now(), + }, + ]); + + // Mock update endpoint + nock("https://control.ably.net") + .patch(`/v1/apps/${mockAppId}/namespaces/${mockRuleId}`) + .reply(200, { + id: mockRuleId, + persisted: true, + pushEnabled: false, + created: Date.now(), + modified: Date.now(), + }); + + const { stdout } = await runCommand( + [ + "apps:channel-rules:update", + mockRuleId, + "--app", + mockAppId, + "--persisted", + ], + import.meta.url, + ); + + expect(stdout).toContain("Channel rule updated successfully"); + expect(stdout).toContain("Persisted: Yes"); + }); + + it("should update a channel rule with push-enabled flag", async () => { + nock("https://control.ably.net") + .get(`/v1/apps/${mockAppId}/namespaces`) + .reply(200, [ + { + id: mockRuleId, + persisted: false, + pushEnabled: false, + created: Date.now(), + modified: Date.now(), + }, + ]); + + nock("https://control.ably.net") + .patch(`/v1/apps/${mockAppId}/namespaces/${mockRuleId}`) + .reply(200, { + id: mockRuleId, + persisted: false, + pushEnabled: true, + created: Date.now(), + modified: Date.now(), + }); + + const { stdout } = await runCommand( + [ + "apps:channel-rules:update", + mockRuleId, + "--app", + mockAppId, + "--push-enabled", + ], + import.meta.url, + ); + + expect(stdout).toContain("Channel rule updated successfully"); + expect(stdout).toContain("Push Enabled: Yes"); + }); + + it("should output JSON format when --json flag is used", async () => { + nock("https://control.ably.net") + .get(`/v1/apps/${mockAppId}/namespaces`) + .reply(200, [ + { + id: mockRuleId, + persisted: false, + pushEnabled: false, + created: Date.now(), + modified: Date.now(), + }, + ]); + + nock("https://control.ably.net") + .patch(`/v1/apps/${mockAppId}/namespaces/${mockRuleId}`) + .reply(200, { + id: mockRuleId, + persisted: true, + pushEnabled: false, + created: Date.now(), + modified: Date.now(), + }); + + const { stdout } = await runCommand( + [ + "apps:channel-rules:update", + mockRuleId, + "--app", + mockAppId, + "--persisted", + "--json", + ], + import.meta.url, + ); + + const result = JSON.parse(stdout); + expect(result).toHaveProperty("success", true); + expect(result).toHaveProperty("rule"); + expect(result.rule).toHaveProperty("id", mockRuleId); + expect(result.rule).toHaveProperty("persisted", true); + }); + }); + + describe("error handling", () => { + it("should require nameOrId argument", async () => { + const { error } = await runCommand( + ["apps:channel-rules:update", "--app", mockAppId, "--persisted"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/Missing 1 required arg/); + }); + + it("should require at least one update parameter", async () => { + nock("https://control.ably.net") + .get(`/v1/apps/${mockAppId}/namespaces`) + .reply(200, [ + { + id: mockRuleId, + persisted: false, + pushEnabled: false, + created: Date.now(), + modified: Date.now(), + }, + ]); + + const { error } = await runCommand( + ["apps:channel-rules:update", mockRuleId, "--app", mockAppId], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/No update parameters provided/); + }); + + it("should handle channel rule not found", async () => { + nock("https://control.ably.net") + .get(`/v1/apps/${mockAppId}/namespaces`) + .reply(200, []); + + const { error } = await runCommand( + [ + "apps:channel-rules:update", + "nonexistent", + "--app", + mockAppId, + "--persisted", + ], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/not found/); + }); + + it("should handle 401 authentication error", async () => { + nock("https://control.ably.net") + .get(`/v1/apps/${mockAppId}/namespaces`) + .reply(401, { error: "Unauthorized" }); + + const { error } = await runCommand( + [ + "apps:channel-rules:update", + mockRuleId, + "--app", + mockAppId, + "--persisted", + ], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/401/); + }); + + it("should handle network errors", async () => { + nock("https://control.ably.net") + .get(`/v1/apps/${mockAppId}/namespaces`) + .replyWithError("Network error"); + + const { error } = await runCommand( + [ + "apps:channel-rules:update", + mockRuleId, + "--app", + mockAppId, + "--persisted", + ], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/Network error/); + }); + }); +}); From 7cb4dd5a5673440e14dd989d9bec4b69eab93b56 Mon Sep 17 00:00:00 2001 From: Andy Ford Date: Mon, 8 Dec 2025 20:11:12 +0000 Subject: [PATCH 02/44] test: add unit test for apps/current --- test/unit/commands/apps/current.test.ts | 130 ++++++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 test/unit/commands/apps/current.test.ts diff --git a/test/unit/commands/apps/current.test.ts b/test/unit/commands/apps/current.test.ts new file mode 100644 index 00000000..67ed65f9 --- /dev/null +++ b/test/unit/commands/apps/current.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { runCommand } from "@oclif/test"; +import { resolve } from "node:path"; +import { mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; + +describe("apps:current command", () => { + const mockAccessToken = "fake_access_token"; + const mockAccountId = "test-account-id"; + const mockAppId = "550e8400-e29b-41d4-a716-446655440000"; + let testConfigDir: string; + let originalConfigDir: string; + + beforeEach(() => { + process.env.ABLY_ACCESS_TOKEN = mockAccessToken; + + testConfigDir = resolve(tmpdir(), `ably-cli-test-${Date.now()}`); + mkdirSync(testConfigDir, { recursive: true, mode: 0o700 }); + + originalConfigDir = process.env.ABLY_CLI_CONFIG_DIR || ""; + process.env.ABLY_CLI_CONFIG_DIR = testConfigDir; + }); + + afterEach(() => { + delete process.env.ABLY_ACCESS_TOKEN; + + if (originalConfigDir) { + process.env.ABLY_CLI_CONFIG_DIR = originalConfigDir; + } else { + delete process.env.ABLY_CLI_CONFIG_DIR; + } + + if (existsSync(testConfigDir)) { + rmSync(testConfigDir, { recursive: true, force: true }); + } + }); + + describe("successful current app display", () => { + it("should display the current app", async () => { + const configContent = `[current] +account = "default" + +[accounts.default] +accessToken = "${mockAccessToken}" +accountId = "${mockAccountId}" +accountName = "Test Account" +userEmail = "test@example.com" +currentAppId = "${mockAppId}" +`; + writeFileSync(resolve(testConfigDir, "config"), configContent); + + const { stdout } = await runCommand(["apps:current"], import.meta.url); + + expect(stdout).toContain(`App: ${mockAppId}`); + }); + + it("should display account information", async () => { + const configContent = `[current] +account = "default" + +[accounts.default] +accessToken = "${mockAccessToken}" +accountId = "${mockAccountId}" +accountName = "Test Account" +userEmail = "test@example.com" +currentAppId = "${mockAppId}" +`; + writeFileSync(resolve(testConfigDir, "config"), configContent); + + const { stdout } = await runCommand(["apps:current"], import.meta.url); + + expect(stdout).toContain("Account: Test Account"); + }); + + it("should display API key info when set", async () => { + const configContent = `[current] +account = "default" + +[accounts.default] +accessToken = "${mockAccessToken}" +accountId = "${mockAccountId}" +accountName = "Test Account" +userEmail = "test@example.com" +currentAppId = "${mockAppId}" + +[accounts.default.apps."${mockAppId}"] +apiKey = "testkey:secret" +keyId = "${mockAppId}.testkey" +keyName = "Test Key" +`; + writeFileSync(resolve(testConfigDir, "config"), configContent); + + const { stdout } = await runCommand(["apps:current"], import.meta.url); + + expect(stdout).toContain(`API Key: ${mockAppId}.testkey`); + expect(stdout).toContain("Key Label: Test Key"); + }); + }); + + describe("error handling", () => { + it("should error when no account is selected", async () => { + const configContent = `[current] +`; + writeFileSync(resolve(testConfigDir, "config"), configContent); + + const { error } = await runCommand(["apps:current"], import.meta.url); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/No account selected/); + }); + + it("should error when no app is selected", async () => { + const configContent = `[current] +account = "default" + +[accounts.default] +accessToken = "${mockAccessToken}" +accountId = "${mockAccountId}" +accountName = "Test Account" +userEmail = "test@example.com" +`; + writeFileSync(resolve(testConfigDir, "config"), configContent); + + const { error } = await runCommand(["apps:current"], import.meta.url); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/No app selected/); + }); + }); +}); From d591869f59ad2d2ee2fed29e217603ddcce16cc9 Mon Sep 17 00:00:00 2001 From: Andy Ford Date: Mon, 8 Dec 2025 20:11:50 +0000 Subject: [PATCH 03/44] test: add unit test for apps/logs/history --- test/unit/commands/apps/logs/history.test.ts | 263 +++++++++++++++++++ 1 file changed, 263 insertions(+) create mode 100644 test/unit/commands/apps/logs/history.test.ts diff --git a/test/unit/commands/apps/logs/history.test.ts b/test/unit/commands/apps/logs/history.test.ts new file mode 100644 index 00000000..b5b864aa --- /dev/null +++ b/test/unit/commands/apps/logs/history.test.ts @@ -0,0 +1,263 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { runCommand } from "@oclif/test"; +import { resolve } from "node:path"; +import { mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; + +describe("apps:logs:history command", () => { + const mockAccessToken = "fake_access_token"; + const mockAccountId = "test-account-id"; + const mockAppId = "550e8400-e29b-41d4-a716-446655440000"; + const mockApiKey = `${mockAppId}.testkey:testsecret`; + let testConfigDir: string; + let originalConfigDir: string; + let mockHistory: ReturnType; + let mockChannelGet: ReturnType; + + beforeEach(() => { + process.env.ABLY_ACCESS_TOKEN = mockAccessToken; + + testConfigDir = resolve(tmpdir(), `ably-cli-test-${Date.now()}`); + mkdirSync(testConfigDir, { recursive: true, mode: 0o700 }); + + originalConfigDir = process.env.ABLY_CLI_CONFIG_DIR || ""; + process.env.ABLY_CLI_CONFIG_DIR = testConfigDir; + + const configContent = `[current] +account = "default" + +[accounts.default] +accessToken = "${mockAccessToken}" +accountId = "${mockAccountId}" +accountName = "Test Account" +userEmail = "test@example.com" +currentAppId = "${mockAppId}" + +[apps."${mockAppId}"] +apiKey = "${mockApiKey}" +`; + writeFileSync(resolve(testConfigDir, "config"), configContent); + + // Setup global mock for Ably REST client + mockHistory = vi.fn().mockResolvedValue({ + items: [], + }); + + mockChannelGet = vi.fn().mockReturnValue({ + history: mockHistory, + }); + + globalThis.__TEST_MOCKS__ = { + ablyRestMock: { + channels: { + get: mockChannelGet, + }, + } as any, + }; + }); + + afterEach(() => { + delete process.env.ABLY_ACCESS_TOKEN; + + if (originalConfigDir) { + process.env.ABLY_CLI_CONFIG_DIR = originalConfigDir; + } else { + delete process.env.ABLY_CLI_CONFIG_DIR; + } + + if (existsSync(testConfigDir)) { + rmSync(testConfigDir, { recursive: true, force: true }); + } + + // Clean up global mock + globalThis.__TEST_MOCKS__ = undefined; + vi.clearAllMocks(); + }); + + describe("successful log history retrieval", () => { + it("should retrieve application log history", async () => { + const mockTimestamp = 1234567890000; + const mockLogMessage = "User login successful"; + const mockLogLevel = "info"; + + mockHistory.mockResolvedValue({ + items: [ + { + name: "log.info", + data: { + message: mockLogMessage, + level: mockLogLevel, + userId: "user123", + }, + timestamp: mockTimestamp, + }, + ], + }); + + const { stdout } = await runCommand( + ["apps:logs:history"], + import.meta.url, + ); + + // Verify the correct channel was requested + expect(mockChannelGet).toHaveBeenCalledWith("[meta]log"); + + // Verify history was called with default parameters + expect(mockHistory).toHaveBeenCalledWith({ + direction: "backwards", + limit: 100, + }); + + // Verify output contains the log count + expect(stdout).toContain("Found 1 application log messages"); + + // Verify output contains the log event name + expect(stdout).toContain("log.info"); + + // Verify output contains the actual log message content + expect(stdout).toContain(mockLogMessage); + expect(stdout).toContain(mockLogLevel); + expect(stdout).toContain("user123"); + }); + + it("should handle empty log history", async () => { + mockHistory.mockResolvedValue({ + items: [], + }); + + const { stdout } = await runCommand( + ["apps:logs:history"], + import.meta.url, + ); + + expect(stdout).toContain("No application log messages found"); + }); + + it("should accept limit flag", async () => { + mockHistory.mockResolvedValue({ + items: [], + }); + + await runCommand(["apps:logs:history", "--limit", "50"], import.meta.url); + + // Verify history was called with custom limit + expect(mockHistory).toHaveBeenCalledWith({ + direction: "backwards", + limit: 50, + }); + }); + + it("should accept direction flag", async () => { + mockHistory.mockResolvedValue({ + items: [], + }); + + await runCommand( + ["apps:logs:history", "--direction", "forwards"], + import.meta.url, + ); + + // Verify history was called with forwards direction + expect(mockHistory).toHaveBeenCalledWith({ + direction: "forwards", + limit: 100, + }); + }); + + it("should display multiple log messages with their content", async () => { + const timestamp1 = 1234567890000; + const timestamp2 = 1234567891000; + + mockHistory.mockResolvedValue({ + items: [ + { + name: "log.info", + data: { message: "First log entry", operation: "login" }, + timestamp: timestamp1, + }, + { + name: "log.error", + data: { message: "Error occurred", error: "Database timeout" }, + timestamp: timestamp2, + }, + ], + }); + + const { stdout } = await runCommand( + ["apps:logs:history"], + import.meta.url, + ); + + expect(stdout).toContain("Found 2 application log messages"); + expect(stdout).toContain("log.info"); + expect(stdout).toContain("log.error"); + + // Verify actual log content is displayed + expect(stdout).toContain("First log entry"); + expect(stdout).toContain("login"); + expect(stdout).toContain("Error occurred"); + expect(stdout).toContain("Database timeout"); + }); + + it("should handle string data in messages", async () => { + mockHistory.mockResolvedValue({ + items: [ + { + name: "log.warning", + data: "Simple string log message", + timestamp: Date.now(), + }, + ], + }); + + const { stdout } = await runCommand( + ["apps:logs:history"], + import.meta.url, + ); + + expect(stdout).toContain("Simple string log message"); + }); + + it("should show limit warning when max messages reached", async () => { + const messages = Array.from({ length: 50 }, (_, i) => ({ + name: "log.info", + data: `Message ${i}`, + timestamp: Date.now() + i, + })); + + mockHistory.mockResolvedValue({ + items: messages, + }); + + const { stdout } = await runCommand( + ["apps:logs:history", "--limit", "50"], + import.meta.url, + ); + + expect(stdout).toContain("Showing maximum of 50 messages"); + }); + + it("should output JSON format when --json flag is used", async () => { + const mockMessage = { + name: "log.info", + data: { message: "Test message", severity: "info" }, + timestamp: Date.now(), + }; + + mockHistory.mockResolvedValue({ + items: [mockMessage], + }); + + const { stdout } = await runCommand( + ["apps:logs:history", "--json"], + import.meta.url, + ); + + const parsed = JSON.parse(stdout); + expect(parsed).toHaveProperty("messages"); + expect(parsed.messages).toHaveLength(1); + expect(parsed.messages[0]).toHaveProperty("name", "log.info"); + expect(parsed.messages[0].data).toHaveProperty("message", "Test message"); + }); + }); +}); From ddb4a9d9991fa5314a745344bfb44d8a5f213b36 Mon Sep 17 00:00:00 2001 From: Andy Ford Date: Mon, 8 Dec 2025 20:11:56 +0000 Subject: [PATCH 04/44] test: add unit test for apps/logs/subscribe --- .../unit/commands/apps/logs/subscribe.test.ts | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 test/unit/commands/apps/logs/subscribe.test.ts diff --git a/test/unit/commands/apps/logs/subscribe.test.ts b/test/unit/commands/apps/logs/subscribe.test.ts new file mode 100644 index 00000000..80d3a80c --- /dev/null +++ b/test/unit/commands/apps/logs/subscribe.test.ts @@ -0,0 +1,151 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { runCommand } from "@oclif/test"; +import { resolve } from "node:path"; +import { mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; + +describe("apps:logs:subscribe command", () => { + const mockAccessToken = "fake_access_token"; + const mockAccountId = "test-account-id"; + const mockAppId = "550e8400-e29b-41d4-a716-446655440000"; + const mockApiKey = `${mockAppId}.testkey:testsecret`; + let testConfigDir: string; + let originalConfigDir: string; + + beforeEach(() => { + process.env.ABLY_ACCESS_TOKEN = mockAccessToken; + + testConfigDir = resolve(tmpdir(), `ably-cli-test-${Date.now()}`); + mkdirSync(testConfigDir, { recursive: true, mode: 0o700 }); + + originalConfigDir = process.env.ABLY_CLI_CONFIG_DIR || ""; + process.env.ABLY_CLI_CONFIG_DIR = testConfigDir; + + const configContent = `[current] +account = "default" + +[accounts.default] +accessToken = "${mockAccessToken}" +accountId = "${mockAccountId}" +accountName = "Test Account" +userEmail = "test@example.com" +currentAppId = "${mockAppId}" + +[apps."${mockAppId}"] +apiKey = "${mockApiKey}" +`; + writeFileSync(resolve(testConfigDir, "config"), configContent); + }); + + afterEach(() => { + delete process.env.ABLY_ACCESS_TOKEN; + globalThis.__TEST_MOCKS__ = undefined; + + if (originalConfigDir) { + process.env.ABLY_CLI_CONFIG_DIR = originalConfigDir; + } else { + delete process.env.ABLY_CLI_CONFIG_DIR; + } + + if (existsSync(testConfigDir)) { + rmSync(testConfigDir, { recursive: true, force: true }); + } + }); + + describe("command flags", () => { + it("should reject unknown flags", async () => { + const { error } = await runCommand( + ["apps:logs:subscribe", "--unknown-flag-xyz"], + import.meta.url, + ); + + // Unknown flag should cause an error + expect(error).toBeDefined(); + expect(error!.message).toMatch(/unknown|Nonexistent flag/i); + }); + }); + + describe("alias behavior", () => { + it("should delegate to logs:app:subscribe with --rewind flag", async () => { + const mockChannel = { + name: "[meta]log", + subscribe: vi.fn(), + unsubscribe: vi.fn(), + on: vi.fn(), + detach: vi.fn(), + }; + + const mockChannels = { + get: vi.fn().mockReturnValue(mockChannel), + release: vi.fn(), + }; + + const mockConnection = { + on: vi.fn(), + once: vi.fn(), + state: "connected", + }; + + globalThis.__TEST_MOCKS__ = { + ablyRealtimeMock: { + channels: mockChannels, + connection: mockConnection, + close: vi.fn(), + }, + }; + + setTimeout(() => process.emit("SIGINT", "SIGINT"), 100); + + const { stdout } = await runCommand( + ["apps:logs:subscribe", "--rewind", "5"], + import.meta.url, + ); + + // Should delegate to logs:app:subscribe and show subscription message + expect(stdout).toContain("Subscribing to app logs"); + // Verify rewind was passed through + expect(mockChannels.get).toHaveBeenCalledWith("[meta]log", { + params: { rewind: "5" }, + }); + }); + + it("should accept --json flag", async () => { + const mockChannel = { + name: "[meta]log", + subscribe: vi.fn(), + unsubscribe: vi.fn(), + on: vi.fn(), + detach: vi.fn(), + }; + + const mockChannels = { + get: vi.fn().mockReturnValue(mockChannel), + release: vi.fn(), + }; + + const mockConnection = { + on: vi.fn(), + once: vi.fn(), + state: "connected", + }; + + globalThis.__TEST_MOCKS__ = { + ablyRealtimeMock: { + channels: mockChannels, + connection: mockConnection, + close: vi.fn(), + }, + }; + + setTimeout(() => process.emit("SIGINT", "SIGINT"), 100); + + const { error } = await runCommand( + ["apps:logs:subscribe", "--json"], + import.meta.url, + ); + + // Should not have unknown flag error + expect(error?.message || "").not.toMatch(/unknown|Nonexistent flag/i); + }); + }); +}); From 181e35696993372731ce05e0219573a0a8e658e3 Mon Sep 17 00:00:00 2001 From: Andy Ford Date: Mon, 8 Dec 2025 20:12:00 +0000 Subject: [PATCH 05/44] test: add unit test for apps/set-apns-p12 --- test/unit/commands/apps/set-apns-p12.test.ts | 184 +++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 test/unit/commands/apps/set-apns-p12.test.ts diff --git a/test/unit/commands/apps/set-apns-p12.test.ts b/test/unit/commands/apps/set-apns-p12.test.ts new file mode 100644 index 00000000..1279be32 --- /dev/null +++ b/test/unit/commands/apps/set-apns-p12.test.ts @@ -0,0 +1,184 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { runCommand } from "@oclif/test"; +import nock from "nock"; +import { resolve } from "node:path"; +import { mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; + +describe("apps:set-apns-p12 command", () => { + const mockAccessToken = "fake_access_token"; + const mockAccountId = "test-account-id"; + const mockAppId = "550e8400-e29b-41d4-a716-446655440000"; + let testConfigDir: string; + let originalConfigDir: string; + let testCertFile: string; + + beforeEach(() => { + process.env.ABLY_ACCESS_TOKEN = mockAccessToken; + + testConfigDir = resolve(tmpdir(), `ably-cli-test-${Date.now()}`); + mkdirSync(testConfigDir, { recursive: true, mode: 0o700 }); + + originalConfigDir = process.env.ABLY_CLI_CONFIG_DIR || ""; + process.env.ABLY_CLI_CONFIG_DIR = testConfigDir; + + const configContent = `[current] +account = "default" + +[accounts.default] +accessToken = "${mockAccessToken}" +accountId = "${mockAccountId}" +accountName = "Test Account" +userEmail = "test@example.com" +`; + writeFileSync(resolve(testConfigDir, "config"), configContent); + + // Create a fake certificate file + testCertFile = resolve(testConfigDir, "test-cert.p12"); + writeFileSync(testCertFile, "fake-certificate-data"); + }); + + afterEach(() => { + nock.cleanAll(); + delete process.env.ABLY_ACCESS_TOKEN; + + if (originalConfigDir) { + process.env.ABLY_CLI_CONFIG_DIR = originalConfigDir; + } else { + delete process.env.ABLY_CLI_CONFIG_DIR; + } + + if (existsSync(testConfigDir)) { + rmSync(testConfigDir, { recursive: true, force: true }); + } + }); + + describe("successful certificate upload", () => { + it("should upload APNS P12 certificate successfully", async () => { + nock("https://control.ably.net") + .post(`/v1/apps/${mockAppId}/push/certificate`) + .reply(200, { + id: "cert-123", + appId: mockAppId, + }); + + const { stdout } = await runCommand( + ["apps:set-apns-p12", mockAppId, "--certificate", testCertFile], + import.meta.url, + ); + + expect(stdout).toContain("APNS P12 certificate uploaded successfully"); + }); + + it("should upload certificate with password", async () => { + nock("https://control.ably.net") + .post(`/v1/apps/${mockAppId}/push/certificate`) + .reply(200, { + id: "cert-123", + appId: mockAppId, + }); + + const { stdout } = await runCommand( + [ + "apps:set-apns-p12", + mockAppId, + "--certificate", + testCertFile, + "--password", + "test-password", + ], + import.meta.url, + ); + + expect(stdout).toContain("APNS P12 certificate uploaded successfully"); + }); + + it("should upload certificate for sandbox environment", async () => { + nock("https://control.ably.net") + .post(`/v1/apps/${mockAppId}/push/certificate`) + .reply(200, { + id: "cert-123", + appId: mockAppId, + }); + + const { stdout } = await runCommand( + [ + "apps:set-apns-p12", + mockAppId, + "--certificate", + testCertFile, + "--use-for-sandbox", + ], + import.meta.url, + ); + + expect(stdout).toContain("APNS P12 certificate uploaded successfully"); + expect(stdout).toContain("Sandbox"); + }); + }); + + describe("error handling", () => { + it("should require app ID argument", async () => { + const { error } = await runCommand( + ["apps:set-apns-p12", "--certificate", testCertFile], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/Missing 1 required arg/); + }); + + it("should require certificate flag", async () => { + const { error } = await runCommand( + ["apps:set-apns-p12", mockAppId], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/Missing required flag.*certificate/); + }); + + it("should error when certificate file does not exist", async () => { + const { error } = await runCommand( + [ + "apps:set-apns-p12", + mockAppId, + "--certificate", + "/nonexistent/path/cert.p12", + ], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/not found/); + }); + + it("should handle API errors", async () => { + nock("https://control.ably.net") + .post(`/v1/apps/${mockAppId}/push/certificate`) + .reply(400, { error: "Invalid certificate" }); + + const { error } = await runCommand( + ["apps:set-apns-p12", mockAppId, "--certificate", testCertFile], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/400/); + }); + + it("should handle 401 authentication error", async () => { + nock("https://control.ably.net") + .post(`/v1/apps/${mockAppId}/push/certificate`) + .reply(401, { error: "Unauthorized" }); + + const { error } = await runCommand( + ["apps:set-apns-p12", mockAppId, "--certificate", testCertFile], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/401/); + }); + }); +}); From 5bb20b18d54b41a451058e820c3f8125bbce73cd Mon Sep 17 00:00:00 2001 From: Andy Ford Date: Mon, 8 Dec 2025 20:18:46 +0000 Subject: [PATCH 06/44] test: add unit test for auth/keys/current --- test/unit/commands/auth/keys/current.test.ts | 177 +++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 test/unit/commands/auth/keys/current.test.ts diff --git a/test/unit/commands/auth/keys/current.test.ts b/test/unit/commands/auth/keys/current.test.ts new file mode 100644 index 00000000..b196f06c --- /dev/null +++ b/test/unit/commands/auth/keys/current.test.ts @@ -0,0 +1,177 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { runCommand } from "@oclif/test"; +import { resolve } from "node:path"; +import { mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; + +describe("auth:keys:current command", () => { + const mockAccessToken = "fake_access_token"; + const mockAccountId = "test-account-id"; + const mockAppId = "550e8400-e29b-41d4-a716-446655440000"; + const mockApiKey = `${mockAppId}.testkey:testsecret`; + let testConfigDir: string; + let originalConfigDir: string; + + beforeEach(() => { + process.env.ABLY_ACCESS_TOKEN = mockAccessToken; + + testConfigDir = resolve(tmpdir(), `ably-cli-test-${Date.now()}`); + mkdirSync(testConfigDir, { recursive: true, mode: 0o700 }); + + originalConfigDir = process.env.ABLY_CLI_CONFIG_DIR || ""; + process.env.ABLY_CLI_CONFIG_DIR = testConfigDir; + }); + + afterEach(() => { + delete process.env.ABLY_ACCESS_TOKEN; + + if (originalConfigDir) { + process.env.ABLY_CLI_CONFIG_DIR = originalConfigDir; + } else { + delete process.env.ABLY_CLI_CONFIG_DIR; + } + + if (existsSync(testConfigDir)) { + rmSync(testConfigDir, { recursive: true, force: true }); + } + }); + + describe("successful key display", () => { + it("should display the current API key", async () => { + const configContent = `[current] +account = "default" + +[accounts.default] +accessToken = "${mockAccessToken}" +accountId = "${mockAccountId}" +accountName = "Test Account" +userEmail = "test@example.com" +currentAppId = "${mockAppId}" + +[accounts.default.apps."${mockAppId}"] +apiKey = "${mockApiKey}" +keyId = "${mockAppId}.testkey" +keyName = "Test Key" +`; + writeFileSync(resolve(testConfigDir, "config"), configContent); + + const { stdout } = await runCommand( + ["auth:keys:current"], + import.meta.url, + ); + + expect(stdout).toContain(`API Key: ${mockAppId}.testkey`); + expect(stdout).toContain(`Key Value: ${mockApiKey}`); + }); + + it("should display account and app information", async () => { + const configContent = `[current] +account = "default" + +[accounts.default] +accessToken = "${mockAccessToken}" +accountId = "${mockAccountId}" +accountName = "Test Account" +userEmail = "test@example.com" +currentAppId = "${mockAppId}" + +[accounts.default.apps."${mockAppId}"] +apiKey = "${mockApiKey}" +keyId = "${mockAppId}.testkey" +keyName = "Test Key" +`; + writeFileSync(resolve(testConfigDir, "config"), configContent); + + const { stdout } = await runCommand( + ["auth:keys:current"], + import.meta.url, + ); + + expect(stdout).toContain("Account: Test Account"); + expect(stdout).toContain(`App: ${mockAppId}`); + }); + + it("should output JSON format when --json flag is used", async () => { + const configContent = `[current] +account = "default" + +[accounts.default] +accessToken = "${mockAccessToken}" +accountId = "${mockAccountId}" +accountName = "Test Account" +userEmail = "test@example.com" +currentAppId = "${mockAppId}" + +[accounts.default.apps."${mockAppId}"] +apiKey = "${mockApiKey}" +keyId = "${mockAppId}.testkey" +keyName = "Test Key" +`; + writeFileSync(resolve(testConfigDir, "config"), configContent); + + const { stdout } = await runCommand( + ["auth:keys:current", "--json"], + import.meta.url, + ); + + const result = JSON.parse(stdout); + expect(result).toHaveProperty("app"); + expect(result).toHaveProperty("key"); + expect(result.key).toHaveProperty("value"); + }); + }); + + describe("error handling", () => { + it("should reject unknown flags", async () => { + const configContent = `[current] +account = "default" + +[accounts.default] +accessToken = "${mockAccessToken}" +accountId = "${mockAccountId}" +accountName = "Test Account" +userEmail = "test@example.com" +currentAppId = "${mockAppId}" + +[accounts.default.apps."${mockAppId}"] +apiKey = "${mockApiKey}" +`; + writeFileSync(resolve(testConfigDir, "config"), configContent); + + const { error } = await runCommand( + ["auth:keys:current", "--unknown-flag-xyz"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/unknown|Nonexistent flag/i); + }); + + it("should accept --app flag to specify a different app", async () => { + // Test that --app flag is accepted even with a different app ID + const configContent = `[current] +account = "default" + +[accounts.default] +accessToken = "${mockAccessToken}" +accountId = "${mockAccountId}" +accountName = "Test Account" +userEmail = "test@example.com" +currentAppId = "${mockAppId}" + +[accounts.default.apps."${mockAppId}"] +apiKey = "${mockApiKey}" +keyId = "${mockAppId}.testkey" +keyName = "Test Key" +`; + writeFileSync(resolve(testConfigDir, "config"), configContent); + + const { stdout } = await runCommand( + ["auth:keys:current", "--app", mockAppId], + import.meta.url, + ); + + expect(stdout).toContain(`API Key: ${mockAppId}.testkey`); + }); + }); +}); From 76c2f4d885e1e334e5a4f59ad36cd3db436cb06d Mon Sep 17 00:00:00 2001 From: Andy Ford Date: Mon, 8 Dec 2025 20:18:53 +0000 Subject: [PATCH 07/44] test: add unit test for auth/keys/get --- test/unit/commands/auth/keys/get.test.ts | 159 +++++++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 test/unit/commands/auth/keys/get.test.ts diff --git a/test/unit/commands/auth/keys/get.test.ts b/test/unit/commands/auth/keys/get.test.ts new file mode 100644 index 00000000..874d6956 --- /dev/null +++ b/test/unit/commands/auth/keys/get.test.ts @@ -0,0 +1,159 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { runCommand } from "@oclif/test"; +import nock from "nock"; +import { resolve } from "node:path"; +import { mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; + +describe("auth:keys:get command", () => { + const mockAccessToken = "fake_access_token"; + const mockAccountId = "test-account-id"; + const mockAppId = "550e8400-e29b-41d4-a716-446655440000"; + const mockKeyId = "testkey"; + let testConfigDir: string; + let originalConfigDir: string; + + beforeEach(() => { + process.env.ABLY_ACCESS_TOKEN = mockAccessToken; + + testConfigDir = resolve(tmpdir(), `ably-cli-test-${Date.now()}`); + mkdirSync(testConfigDir, { recursive: true, mode: 0o700 }); + + originalConfigDir = process.env.ABLY_CLI_CONFIG_DIR || ""; + process.env.ABLY_CLI_CONFIG_DIR = testConfigDir; + + const configContent = `[current] +account = "default" + +[accounts.default] +accessToken = "${mockAccessToken}" +accountId = "${mockAccountId}" +accountName = "Test Account" +userEmail = "test@example.com" +currentAppId = "${mockAppId}" +`; + writeFileSync(resolve(testConfigDir, "config"), configContent); + }); + + afterEach(() => { + nock.cleanAll(); + delete process.env.ABLY_ACCESS_TOKEN; + + if (originalConfigDir) { + process.env.ABLY_CLI_CONFIG_DIR = originalConfigDir; + } else { + delete process.env.ABLY_CLI_CONFIG_DIR; + } + + if (existsSync(testConfigDir)) { + rmSync(testConfigDir, { recursive: true, force: true }); + } + }); + + describe("successful key retrieval", () => { + it("should get key details by full key name (APP_ID.KEY_ID)", async () => { + nock("https://control.ably.net") + .get(`/v1/apps/${mockAppId}/keys/${mockKeyId}`) + .reply(200, { + id: mockKeyId, + appId: mockAppId, + name: "Test Key", + key: `${mockAppId}.${mockKeyId}:secret`, + capability: { "*": ["publish", "subscribe"] }, + created: Date.now(), + modified: Date.now(), + }); + + const { stdout } = await runCommand( + ["auth:keys:get", `${mockAppId}.${mockKeyId}`], + import.meta.url, + ); + + expect(stdout).toContain(`Key Name: ${mockAppId}.${mockKeyId}`); + expect(stdout).toContain("Key Label: Test Key"); + }); + + it("should get key details with --app flag", async () => { + nock("https://control.ably.net") + .get(`/v1/apps/${mockAppId}/keys/${mockKeyId}`) + .reply(200, { + id: mockKeyId, + appId: mockAppId, + name: "Test Key", + key: `${mockAppId}.${mockKeyId}:secret`, + capability: { "*": ["publish", "subscribe"] }, + created: Date.now(), + modified: Date.now(), + }); + + const { stdout } = await runCommand( + ["auth:keys:get", mockKeyId, "--app", mockAppId], + import.meta.url, + ); + + expect(stdout).toContain(`Key Name: ${mockAppId}.${mockKeyId}`); + expect(stdout).toContain("Key Label: Test Key"); + }); + + it("should output JSON format when --json flag is used", async () => { + nock("https://control.ably.net") + .get(`/v1/apps/${mockAppId}/keys/${mockKeyId}`) + .reply(200, { + id: mockKeyId, + appId: mockAppId, + name: "Test Key", + key: `${mockAppId}.${mockKeyId}:secret`, + capability: { "*": ["publish", "subscribe"] }, + created: Date.now(), + modified: Date.now(), + }); + + const { stdout } = await runCommand( + ["auth:keys:get", `${mockAppId}.${mockKeyId}`, "--json"], + import.meta.url, + ); + + const result = JSON.parse(stdout); + expect(result).toHaveProperty("success", true); + expect(result).toHaveProperty("key"); + expect(result.key).toHaveProperty("id", mockKeyId); + }); + }); + + describe("error handling", () => { + it("should require keyNameOrValue argument", async () => { + const { error } = await runCommand(["auth:keys:get"], import.meta.url); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/Missing 1 required arg/); + }); + + it("should handle 404 key not found", async () => { + nock("https://control.ably.net") + .get(`/v1/apps/${mockAppId}/keys/nonexistent`) + .reply(404, { error: "Key not found" }); + + const { error } = await runCommand( + ["auth:keys:get", `${mockAppId}.nonexistent`], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/404/); + }); + + it("should handle 401 authentication error", async () => { + nock("https://control.ably.net") + .get(`/v1/apps/${mockAppId}/keys/${mockKeyId}`) + .reply(401, { error: "Unauthorized" }); + + const { error } = await runCommand( + ["auth:keys:get", `${mockAppId}.${mockKeyId}`], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/401/); + }); + }); +}); From 71b4fd082119ea38c52254ef29c594d08493fcf6 Mon Sep 17 00:00:00 2001 From: Andy Ford Date: Mon, 8 Dec 2025 20:18:56 +0000 Subject: [PATCH 08/44] test: add unit test for auth/keys/list --- test/unit/commands/auth/keys/list.test.ts | 187 ++++++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 test/unit/commands/auth/keys/list.test.ts diff --git a/test/unit/commands/auth/keys/list.test.ts b/test/unit/commands/auth/keys/list.test.ts new file mode 100644 index 00000000..a93e46c6 --- /dev/null +++ b/test/unit/commands/auth/keys/list.test.ts @@ -0,0 +1,187 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { runCommand } from "@oclif/test"; +import nock from "nock"; +import { resolve } from "node:path"; +import { mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; + +describe("auth:keys:list command", () => { + const mockAccessToken = "fake_access_token"; + const mockAccountId = "test-account-id"; + const mockAppId = "550e8400-e29b-41d4-a716-446655440000"; + let testConfigDir: string; + let originalConfigDir: string; + + beforeEach(() => { + process.env.ABLY_ACCESS_TOKEN = mockAccessToken; + + testConfigDir = resolve(tmpdir(), `ably-cli-test-${Date.now()}`); + mkdirSync(testConfigDir, { recursive: true, mode: 0o700 }); + + originalConfigDir = process.env.ABLY_CLI_CONFIG_DIR || ""; + process.env.ABLY_CLI_CONFIG_DIR = testConfigDir; + + const configContent = `[current] +account = "default" + +[accounts.default] +accessToken = "${mockAccessToken}" +accountId = "${mockAccountId}" +accountName = "Test Account" +userEmail = "test@example.com" +currentAppId = "${mockAppId}" +`; + writeFileSync(resolve(testConfigDir, "config"), configContent); + }); + + afterEach(() => { + nock.cleanAll(); + delete process.env.ABLY_ACCESS_TOKEN; + + if (originalConfigDir) { + process.env.ABLY_CLI_CONFIG_DIR = originalConfigDir; + } else { + delete process.env.ABLY_CLI_CONFIG_DIR; + } + + if (existsSync(testConfigDir)) { + rmSync(testConfigDir, { recursive: true, force: true }); + } + }); + + describe("successful key listing", () => { + it("should list all keys for the current app", async () => { + nock("https://control.ably.net") + .get(`/v1/apps/${mockAppId}/keys`) + .reply(200, [ + { + id: "key1", + appId: mockAppId, + name: "Key One", + key: `${mockAppId}.key1:secret1`, + capability: { "*": ["publish", "subscribe"] }, + created: Date.now(), + modified: Date.now(), + }, + { + id: "key2", + appId: mockAppId, + name: "Key Two", + key: `${mockAppId}.key2:secret2`, + capability: { "*": ["subscribe"] }, + created: Date.now(), + modified: Date.now(), + }, + ]); + + const { stdout } = await runCommand(["auth:keys:list"], import.meta.url); + + expect(stdout).toContain(`Key Name: ${mockAppId}.key1`); + expect(stdout).toContain("Key Label: Key One"); + expect(stdout).toContain(`Key Name: ${mockAppId}.key2`); + expect(stdout).toContain("Key Label: Key Two"); + }); + + it("should list keys with --app flag", async () => { + nock("https://control.ably.net") + .get(`/v1/apps/${mockAppId}/keys`) + .reply(200, [ + { + id: "key1", + appId: mockAppId, + name: "Test Key", + key: `${mockAppId}.key1:secret`, + capability: { "*": ["publish"] }, + created: Date.now(), + modified: Date.now(), + }, + ]); + + const { stdout } = await runCommand( + ["auth:keys:list", "--app", mockAppId], + import.meta.url, + ); + + expect(stdout).toContain(`Key Name: ${mockAppId}.key1`); + expect(stdout).toContain("Key Label: Test Key"); + }); + + it("should show message when no keys found", async () => { + nock("https://control.ably.net") + .get(`/v1/apps/${mockAppId}/keys`) + .reply(200, []); + + const { stdout } = await runCommand(["auth:keys:list"], import.meta.url); + + expect(stdout).toContain("No keys found"); + }); + + it("should output JSON format when --json flag is used", async () => { + nock("https://control.ably.net") + .get(`/v1/apps/${mockAppId}/keys`) + .reply(200, [ + { + id: "key1", + appId: mockAppId, + name: "Test Key", + key: `${mockAppId}.key1:secret`, + capability: { "*": ["publish", "subscribe"] }, + created: Date.now(), + modified: Date.now(), + }, + ]); + + const { stdout } = await runCommand( + ["auth:keys:list", "--json"], + import.meta.url, + ); + + const result = JSON.parse(stdout); + expect(result).toHaveProperty("keys"); + expect(result.keys).toHaveLength(1); + expect(result.keys[0]).toHaveProperty("name", "Test Key"); + }); + }); + + describe("error handling", () => { + it("should error when no app is selected", async () => { + const configContent = `[current] +account = "default" + +[accounts.default] +accessToken = "${mockAccessToken}" +accountId = "${mockAccountId}" +accountName = "Test Account" +userEmail = "test@example.com" +`; + writeFileSync(resolve(testConfigDir, "config"), configContent); + + const { error } = await runCommand(["auth:keys:list"], import.meta.url); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/No app specified/); + }); + + it("should handle 401 authentication error", async () => { + nock("https://control.ably.net") + .get(`/v1/apps/${mockAppId}/keys`) + .reply(401, { error: "Unauthorized" }); + + const { error } = await runCommand(["auth:keys:list"], import.meta.url); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/401/); + }); + + it("should handle network errors", async () => { + nock("https://control.ably.net") + .get(`/v1/apps/${mockAppId}/keys`) + .replyWithError("Network error"); + + const { error } = await runCommand(["auth:keys:list"], import.meta.url); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/Network error/); + }); + }); +}); From 99afaac53164a737e3ccf22c8b01288369387eb7 Mon Sep 17 00:00:00 2001 From: Andy Ford Date: Mon, 8 Dec 2025 20:19:01 +0000 Subject: [PATCH 09/44] test: add unit test for auth/keys/revoke --- test/unit/commands/auth/keys/revoke.test.ts | 176 ++++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 test/unit/commands/auth/keys/revoke.test.ts diff --git a/test/unit/commands/auth/keys/revoke.test.ts b/test/unit/commands/auth/keys/revoke.test.ts new file mode 100644 index 00000000..c2f8da57 --- /dev/null +++ b/test/unit/commands/auth/keys/revoke.test.ts @@ -0,0 +1,176 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { runCommand } from "@oclif/test"; +import nock from "nock"; +import { resolve } from "node:path"; +import { mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; + +describe("auth:keys:revoke command", () => { + const mockAccessToken = "fake_access_token"; + const mockAccountId = "test-account-id"; + const mockAppId = "550e8400-e29b-41d4-a716-446655440000"; + const mockKeyId = "testkey"; + let testConfigDir: string; + let originalConfigDir: string; + + beforeEach(() => { + process.env.ABLY_ACCESS_TOKEN = mockAccessToken; + + testConfigDir = resolve(tmpdir(), `ably-cli-test-${Date.now()}`); + mkdirSync(testConfigDir, { recursive: true, mode: 0o700 }); + + originalConfigDir = process.env.ABLY_CLI_CONFIG_DIR || ""; + process.env.ABLY_CLI_CONFIG_DIR = testConfigDir; + + const configContent = `[current] +account = "default" + +[accounts.default] +accessToken = "${mockAccessToken}" +accountId = "${mockAccountId}" +accountName = "Test Account" +userEmail = "test@example.com" +currentAppId = "${mockAppId}" +`; + writeFileSync(resolve(testConfigDir, "config"), configContent); + }); + + afterEach(() => { + nock.cleanAll(); + delete process.env.ABLY_ACCESS_TOKEN; + + if (originalConfigDir) { + process.env.ABLY_CLI_CONFIG_DIR = originalConfigDir; + } else { + delete process.env.ABLY_CLI_CONFIG_DIR; + } + + if (existsSync(testConfigDir)) { + rmSync(testConfigDir, { recursive: true, force: true }); + } + }); + + describe("successful key revocation", () => { + it("should display key info before revocation", async () => { + // Mock get key details + nock("https://control.ably.net") + .get(`/v1/apps/${mockAppId}/keys/${mockKeyId}`) + .reply(200, { + id: mockKeyId, + appId: mockAppId, + name: "Test Key", + key: `${mockAppId}.${mockKeyId}:secret`, + capability: { "*": ["publish", "subscribe"] }, + created: Date.now(), + modified: Date.now(), + }); + + // Mock revoke key + nock("https://control.ably.net") + .post(`/v1/apps/${mockAppId}/keys/${mockKeyId}/revoke`) + .reply(200, {}); + + const { stdout } = await runCommand( + ["auth:keys:revoke", `${mockAppId}.${mockKeyId}`, "--force"], + import.meta.url, + ); + + expect(stdout).toContain(`Key Name: ${mockAppId}.${mockKeyId}`); + expect(stdout).toContain("Key Label: Test Key"); + }); + + it("should revoke key with --app flag", async () => { + nock("https://control.ably.net") + .get(`/v1/apps/${mockAppId}/keys/${mockKeyId}`) + .reply(200, { + id: mockKeyId, + appId: mockAppId, + name: "Test Key", + key: `${mockAppId}.${mockKeyId}:secret`, + capability: { "*": ["publish"] }, + created: Date.now(), + modified: Date.now(), + }); + + nock("https://control.ably.net") + .post(`/v1/apps/${mockAppId}/keys/${mockKeyId}/revoke`) + .reply(200, {}); + + const { stdout } = await runCommand( + ["auth:keys:revoke", mockKeyId, "--app", mockAppId, "--force"], + import.meta.url, + ); + + expect(stdout).toContain(`Key Name: ${mockAppId}.${mockKeyId}`); + expect(stdout).toContain("Key Label: Test Key"); + }); + + it("should output JSON format when --json flag is used", async () => { + nock("https://control.ably.net") + .get(`/v1/apps/${mockAppId}/keys/${mockKeyId}`) + .reply(200, { + id: mockKeyId, + appId: mockAppId, + name: "Test Key", + key: `${mockAppId}.${mockKeyId}:secret`, + capability: { "*": ["publish", "subscribe"] }, + created: Date.now(), + modified: Date.now(), + }); + + nock("https://control.ably.net") + .post(`/v1/apps/${mockAppId}/keys/${mockKeyId}/revoke`) + .reply(200, {}); + + const { stdout } = await runCommand( + ["auth:keys:revoke", `${mockAppId}.${mockKeyId}`, "--force", "--json"], + import.meta.url, + ); + + // The JSON output should be parseable + const result = JSON.parse(stdout); + // Either success or error with keyName property + expect(typeof result).toBe("object"); + }); + }); + + describe("error handling", () => { + it("should require keyName argument", async () => { + const { error } = await runCommand( + ["auth:keys:revoke", "--force"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/Missing 1 required arg/); + }); + + it("should handle 404 key not found", async () => { + nock("https://control.ably.net") + .get(`/v1/apps/${mockAppId}/keys/nonexistent`) + .reply(404, { error: "Key not found" }); + + const { error } = await runCommand( + ["auth:keys:revoke", `${mockAppId}.nonexistent`, "--force"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/404/); + }); + + it("should handle 401 authentication error", async () => { + nock("https://control.ably.net") + .get(`/v1/apps/${mockAppId}/keys/${mockKeyId}`) + .reply(401, { error: "Unauthorized" }); + + const { error } = await runCommand( + ["auth:keys:revoke", `${mockAppId}.${mockKeyId}`, "--force"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/401/); + }); + }); +}); From 5b433903f758c56349fcd382a48110b13bb07bae Mon Sep 17 00:00:00 2001 From: Andy Ford Date: Mon, 8 Dec 2025 20:19:01 +0000 Subject: [PATCH 10/44] test: add unit test for auth/keys/update --- test/unit/commands/auth/keys/update.test.ts | 219 ++++++++++++++++++++ 1 file changed, 219 insertions(+) create mode 100644 test/unit/commands/auth/keys/update.test.ts diff --git a/test/unit/commands/auth/keys/update.test.ts b/test/unit/commands/auth/keys/update.test.ts new file mode 100644 index 00000000..5763d216 --- /dev/null +++ b/test/unit/commands/auth/keys/update.test.ts @@ -0,0 +1,219 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { runCommand } from "@oclif/test"; +import nock from "nock"; +import { resolve } from "node:path"; +import { mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; + +describe("auth:keys:update command", () => { + const mockAccessToken = "fake_access_token"; + const mockAccountId = "test-account-id"; + const mockAppId = "550e8400-e29b-41d4-a716-446655440000"; + const mockKeyId = "testkey"; + let testConfigDir: string; + let originalConfigDir: string; + + beforeEach(() => { + process.env.ABLY_ACCESS_TOKEN = mockAccessToken; + + testConfigDir = resolve(tmpdir(), `ably-cli-test-${Date.now()}`); + mkdirSync(testConfigDir, { recursive: true, mode: 0o700 }); + + originalConfigDir = process.env.ABLY_CLI_CONFIG_DIR || ""; + process.env.ABLY_CLI_CONFIG_DIR = testConfigDir; + + const configContent = `[current] +account = "default" + +[accounts.default] +accessToken = "${mockAccessToken}" +accountId = "${mockAccountId}" +accountName = "Test Account" +userEmail = "test@example.com" +currentAppId = "${mockAppId}" +`; + writeFileSync(resolve(testConfigDir, "config"), configContent); + }); + + afterEach(() => { + nock.cleanAll(); + delete process.env.ABLY_ACCESS_TOKEN; + + if (originalConfigDir) { + process.env.ABLY_CLI_CONFIG_DIR = originalConfigDir; + } else { + delete process.env.ABLY_CLI_CONFIG_DIR; + } + + if (existsSync(testConfigDir)) { + rmSync(testConfigDir, { recursive: true, force: true }); + } + }); + + describe("successful key update", () => { + it("should update key name", async () => { + // Mock get key details + nock("https://control.ably.net") + .get(`/v1/apps/${mockAppId}/keys/${mockKeyId}`) + .reply(200, { + id: mockKeyId, + appId: mockAppId, + name: "OldName", + key: `${mockAppId}.${mockKeyId}:secret`, + capability: { "*": ["publish", "subscribe"] }, + created: Date.now(), + modified: Date.now(), + }); + + // Mock update key + nock("https://control.ably.net") + .patch(`/v1/apps/${mockAppId}/keys/${mockKeyId}`) + .reply(200, { + id: mockKeyId, + appId: mockAppId, + name: "NewName", + key: `${mockAppId}.${mockKeyId}:secret`, + capability: { "*": ["publish", "subscribe"] }, + created: Date.now(), + modified: Date.now(), + }); + + const { stdout } = await runCommand( + ["auth:keys:update", `${mockAppId}.${mockKeyId}`, "--name=NewName"], + import.meta.url, + ); + + expect(stdout).toContain(`Key Name: ${mockAppId}.${mockKeyId}`); + expect(stdout).toContain(`Key Label: "OldName" → "NewName"`); + }); + + it("should update key capabilities", async () => { + nock("https://control.ably.net") + .get(`/v1/apps/${mockAppId}/keys/${mockKeyId}`) + .reply(200, { + id: mockKeyId, + appId: mockAppId, + name: "Test Key", + key: `${mockAppId}.${mockKeyId}:secret`, + capability: { "*": ["publish", "subscribe"] }, + created: Date.now(), + modified: Date.now(), + }); + + nock("https://control.ably.net") + .patch(`/v1/apps/${mockAppId}/keys/${mockKeyId}`) + .reply(200, { + id: mockKeyId, + appId: mockAppId, + name: "Test Key", + key: `${mockAppId}.${mockKeyId}:secret`, + capability: { "*": ["subscribe"] }, + created: Date.now(), + modified: Date.now(), + }); + + const { stdout } = await runCommand( + [ + "auth:keys:update", + `${mockAppId}.${mockKeyId}`, + "--capabilities", + "subscribe", + ], + import.meta.url, + ); + + expect(stdout).toContain(`Key Name: ${mockAppId}.${mockKeyId}`); + expect(stdout).toContain("After: * → subscribe"); + }); + + it("should update key with --app flag", async () => { + nock("https://control.ably.net") + .get(`/v1/apps/${mockAppId}/keys/${mockKeyId}`) + .reply(200, { + id: mockKeyId, + appId: mockAppId, + name: "OldName", + key: `${mockAppId}.${mockKeyId}:secret`, + capability: { "*": ["publish"] }, + created: Date.now(), + modified: Date.now(), + }); + + nock("https://control.ably.net") + .patch(`/v1/apps/${mockAppId}/keys/${mockKeyId}`) + .reply(200, { + id: mockKeyId, + appId: mockAppId, + name: "UpdatedName", + key: `${mockAppId}.${mockKeyId}:secret`, + capability: { "*": ["publish"] }, + created: Date.now(), + modified: Date.now(), + }); + + const { stdout } = await runCommand( + [ + "auth:keys:update", + mockKeyId, + "--app", + mockAppId, + "--name=UpdatedName", + ], + import.meta.url, + ); + + expect(stdout).toContain(`Key Name: ${mockAppId}.${mockKeyId}`); + expect(stdout).toContain(`Key Label: "OldName" → "UpdatedName"`); + }); + }); + + describe("error handling", () => { + it("should require keyName argument", async () => { + const { error } = await runCommand( + ["auth:keys:update", "--name", "Test"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/Missing 1 required arg/); + }); + + it("should require at least one update parameter", async () => { + const { error } = await runCommand( + ["auth:keys:update", `${mockAppId}.${mockKeyId}`], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/No updates specified/); + }); + + it("should handle 404 key not found", async () => { + nock("https://control.ably.net") + .get(`/v1/apps/${mockAppId}/keys/nonexistent`) + .reply(404, { error: "Key not found" }); + + const { error } = await runCommand( + ["auth:keys:update", `${mockAppId}.nonexistent`, "--name=NewName"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/404/); + }); + + it("should handle 401 authentication error", async () => { + nock("https://control.ably.net") + .get(`/v1/apps/${mockAppId}/keys/${mockKeyId}`) + .reply(401, { error: "Unauthorized" }); + + const { error } = await runCommand( + ["auth:keys:update", `${mockAppId}.${mockKeyId}`, "--name=NewName"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/401/); + }); + }); +}); From 823a9928829dcaed7f3ecd6cb94e5375403fa077 Mon Sep 17 00:00:00 2001 From: Andy Ford Date: Mon, 8 Dec 2025 20:21:07 +0000 Subject: [PATCH 11/44] test: add unit test for channel-rule/create --- .../unit/commands/channel-rule/create.test.ts | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 test/unit/commands/channel-rule/create.test.ts diff --git a/test/unit/commands/channel-rule/create.test.ts b/test/unit/commands/channel-rule/create.test.ts new file mode 100644 index 00000000..eeed6f7e --- /dev/null +++ b/test/unit/commands/channel-rule/create.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { runCommand } from "@oclif/test"; +import nock from "nock"; +import { resolve } from "node:path"; +import { mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; + +describe("channel-rule:create command (alias)", () => { + const mockAccessToken = "fake_access_token"; + const mockAccountId = "test-account-id"; + const mockAppId = "550e8400-e29b-41d4-a716-446655440000"; + let testConfigDir: string; + let originalConfigDir: string; + + beforeEach(() => { + process.env.ABLY_ACCESS_TOKEN = mockAccessToken; + + testConfigDir = resolve(tmpdir(), `ably-cli-test-${Date.now()}`); + mkdirSync(testConfigDir, { recursive: true, mode: 0o700 }); + + originalConfigDir = process.env.ABLY_CLI_CONFIG_DIR || ""; + process.env.ABLY_CLI_CONFIG_DIR = testConfigDir; + + const configContent = `[current] +account = "default" + +[accounts.default] +accessToken = "${mockAccessToken}" +accountId = "${mockAccountId}" +accountName = "Test Account" +userEmail = "test@example.com" +currentAppId = "${mockAppId}" +`; + writeFileSync(resolve(testConfigDir, "config"), configContent); + }); + + afterEach(() => { + nock.cleanAll(); + delete process.env.ABLY_ACCESS_TOKEN; + + if (originalConfigDir) { + process.env.ABLY_CLI_CONFIG_DIR = originalConfigDir; + } else { + delete process.env.ABLY_CLI_CONFIG_DIR; + } + + if (existsSync(testConfigDir)) { + rmSync(testConfigDir, { recursive: true, force: true }); + } + }); + + describe("alias behavior", () => { + it("should execute the same as apps:channel-rules:create", async () => { + nock("https://control.ably.net") + .post(`/v1/apps/${mockAppId}/namespaces`) + .reply(200, { + id: "test-rule", + persisted: true, + pushEnabled: false, + created: Date.now(), + modified: Date.now(), + }); + + const { stdout } = await runCommand( + [ + "channel-rule:create", + "--name=test-rule", + "--app", + mockAppId, + "--persisted", + ], + import.meta.url, + ); + + expect(stdout).toContain("Channel rule created successfully"); + }); + + it("should require name flag", async () => { + const { error } = await runCommand( + ["channel-rule:create", "--app", mockAppId], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/Missing required flag.*name/); + }); + }); +}); From e6efebc657fe7d83f7111a3b7fafa2953c9accc7 Mon Sep 17 00:00:00 2001 From: Andy Ford Date: Mon, 8 Dec 2025 20:21:07 +0000 Subject: [PATCH 12/44] test: add unit test for channel-rule/delete --- .../unit/commands/channel-rule/delete.test.ts | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 test/unit/commands/channel-rule/delete.test.ts diff --git a/test/unit/commands/channel-rule/delete.test.ts b/test/unit/commands/channel-rule/delete.test.ts new file mode 100644 index 00000000..a75640ff --- /dev/null +++ b/test/unit/commands/channel-rule/delete.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { runCommand } from "@oclif/test"; +import nock from "nock"; +import { resolve } from "node:path"; +import { mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; + +describe("channel-rule:delete command (alias)", () => { + const mockAccessToken = "fake_access_token"; + const mockAccountId = "test-account-id"; + const mockAppId = "550e8400-e29b-41d4-a716-446655440000"; + const mockRuleId = "test-rule"; + let testConfigDir: string; + let originalConfigDir: string; + + beforeEach(() => { + process.env.ABLY_ACCESS_TOKEN = mockAccessToken; + + testConfigDir = resolve(tmpdir(), `ably-cli-test-${Date.now()}`); + mkdirSync(testConfigDir, { recursive: true, mode: 0o700 }); + + originalConfigDir = process.env.ABLY_CLI_CONFIG_DIR || ""; + process.env.ABLY_CLI_CONFIG_DIR = testConfigDir; + + const configContent = `[current] +account = "default" + +[accounts.default] +accessToken = "${mockAccessToken}" +accountId = "${mockAccountId}" +accountName = "Test Account" +userEmail = "test@example.com" +currentAppId = "${mockAppId}" +`; + writeFileSync(resolve(testConfigDir, "config"), configContent); + }); + + afterEach(() => { + nock.cleanAll(); + delete process.env.ABLY_ACCESS_TOKEN; + + if (originalConfigDir) { + process.env.ABLY_CLI_CONFIG_DIR = originalConfigDir; + } else { + delete process.env.ABLY_CLI_CONFIG_DIR; + } + + if (existsSync(testConfigDir)) { + rmSync(testConfigDir, { recursive: true, force: true }); + } + }); + + describe("alias behavior", () => { + it("should execute the same as apps:channel-rules:delete", async () => { + nock("https://control.ably.net") + .get(`/v1/apps/${mockAppId}/namespaces`) + .reply(200, [ + { + id: mockRuleId, + persisted: false, + pushEnabled: false, + created: Date.now(), + modified: Date.now(), + }, + ]); + + nock("https://control.ably.net") + .delete(`/v1/apps/${mockAppId}/namespaces/${mockRuleId}`) + .reply(200, {}); + + const { stdout } = await runCommand( + ["channel-rule:delete", mockRuleId, "--app", mockAppId, "--force"], + import.meta.url, + ); + + expect(stdout).toContain("deleted successfully"); + }); + + it("should require nameOrId argument", async () => { + const { error } = await runCommand( + ["channel-rule:delete", "--app", mockAppId, "--force"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/Missing 1 required arg/); + }); + }); +}); From 65bc7eaf0cfb0875872be952f922ebd2f47ee531 Mon Sep 17 00:00:00 2001 From: Andy Ford Date: Mon, 8 Dec 2025 20:21:08 +0000 Subject: [PATCH 13/44] test: add unit test for channel-rule/list --- test/unit/commands/channel-rule/list.test.ts | 95 ++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 test/unit/commands/channel-rule/list.test.ts diff --git a/test/unit/commands/channel-rule/list.test.ts b/test/unit/commands/channel-rule/list.test.ts new file mode 100644 index 00000000..d51d9131 --- /dev/null +++ b/test/unit/commands/channel-rule/list.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { runCommand } from "@oclif/test"; +import nock from "nock"; +import { resolve } from "node:path"; +import { mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; + +describe("channel-rule:list command (alias)", () => { + const mockAccessToken = "fake_access_token"; + const mockAccountId = "test-account-id"; + const mockAppId = "550e8400-e29b-41d4-a716-446655440000"; + let testConfigDir: string; + let originalConfigDir: string; + + beforeEach(() => { + process.env.ABLY_ACCESS_TOKEN = mockAccessToken; + + testConfigDir = resolve(tmpdir(), `ably-cli-test-${Date.now()}`); + mkdirSync(testConfigDir, { recursive: true, mode: 0o700 }); + + originalConfigDir = process.env.ABLY_CLI_CONFIG_DIR || ""; + process.env.ABLY_CLI_CONFIG_DIR = testConfigDir; + + const configContent = `[current] +account = "default" + +[accounts.default] +accessToken = "${mockAccessToken}" +accountId = "${mockAccountId}" +accountName = "Test Account" +userEmail = "test@example.com" +currentAppId = "${mockAppId}" +`; + writeFileSync(resolve(testConfigDir, "config"), configContent); + }); + + afterEach(() => { + nock.cleanAll(); + delete process.env.ABLY_ACCESS_TOKEN; + + if (originalConfigDir) { + process.env.ABLY_CLI_CONFIG_DIR = originalConfigDir; + } else { + delete process.env.ABLY_CLI_CONFIG_DIR; + } + + if (existsSync(testConfigDir)) { + rmSync(testConfigDir, { recursive: true, force: true }); + } + }); + + describe("alias behavior", () => { + it("should execute the same as apps:channel-rules:list", async () => { + nock("https://control.ably.net") + .get(`/v1/apps/${mockAppId}/namespaces`) + .reply(200, [ + { + id: "rule1", + persisted: true, + pushEnabled: false, + created: Date.now(), + modified: Date.now(), + }, + { + id: "rule2", + persisted: false, + pushEnabled: true, + created: Date.now(), + modified: Date.now(), + }, + ]); + + const { stdout } = await runCommand( + ["channel-rule:list"], + import.meta.url, + ); + + expect(stdout).toContain("rule1"); + expect(stdout).toContain("rule2"); + }); + + it("should show message when no rules found", async () => { + nock("https://control.ably.net") + .get(`/v1/apps/${mockAppId}/namespaces`) + .reply(200, []); + + const { stdout } = await runCommand( + ["channel-rule:list"], + import.meta.url, + ); + + expect(stdout).toContain("No channel rules found"); + }); + }); +}); From bc0b802c9481c879ec38b0f8d65c70a1db61c925 Mon Sep 17 00:00:00 2001 From: Andy Ford Date: Mon, 8 Dec 2025 20:21:08 +0000 Subject: [PATCH 14/44] test: add unit test for channel-rule/update --- .../unit/commands/channel-rule/update.test.ts | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 test/unit/commands/channel-rule/update.test.ts diff --git a/test/unit/commands/channel-rule/update.test.ts b/test/unit/commands/channel-rule/update.test.ts new file mode 100644 index 00000000..95861e4a --- /dev/null +++ b/test/unit/commands/channel-rule/update.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { runCommand } from "@oclif/test"; +import nock from "nock"; +import { resolve } from "node:path"; +import { mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; + +describe("channel-rule:update command (alias)", () => { + const mockAccessToken = "fake_access_token"; + const mockAccountId = "test-account-id"; + const mockAppId = "550e8400-e29b-41d4-a716-446655440000"; + const mockRuleId = "test-rule"; + let testConfigDir: string; + let originalConfigDir: string; + + beforeEach(() => { + process.env.ABLY_ACCESS_TOKEN = mockAccessToken; + + testConfigDir = resolve(tmpdir(), `ably-cli-test-${Date.now()}`); + mkdirSync(testConfigDir, { recursive: true, mode: 0o700 }); + + originalConfigDir = process.env.ABLY_CLI_CONFIG_DIR || ""; + process.env.ABLY_CLI_CONFIG_DIR = testConfigDir; + + const configContent = `[current] +account = "default" + +[accounts.default] +accessToken = "${mockAccessToken}" +accountId = "${mockAccountId}" +accountName = "Test Account" +userEmail = "test@example.com" +currentAppId = "${mockAppId}" +`; + writeFileSync(resolve(testConfigDir, "config"), configContent); + }); + + afterEach(() => { + nock.cleanAll(); + delete process.env.ABLY_ACCESS_TOKEN; + + if (originalConfigDir) { + process.env.ABLY_CLI_CONFIG_DIR = originalConfigDir; + } else { + delete process.env.ABLY_CLI_CONFIG_DIR; + } + + if (existsSync(testConfigDir)) { + rmSync(testConfigDir, { recursive: true, force: true }); + } + }); + + describe("alias behavior", () => { + it("should execute the same as apps:channel-rules:update", async () => { + nock("https://control.ably.net") + .get(`/v1/apps/${mockAppId}/namespaces`) + .reply(200, [ + { + id: mockRuleId, + persisted: false, + pushEnabled: false, + created: Date.now(), + modified: Date.now(), + }, + ]); + + nock("https://control.ably.net") + .patch(`/v1/apps/${mockAppId}/namespaces/${mockRuleId}`) + .reply(200, { + id: mockRuleId, + persisted: true, + pushEnabled: false, + created: Date.now(), + modified: Date.now(), + }); + + const { stdout } = await runCommand( + ["channel-rule:update", mockRuleId, "--app", mockAppId, "--persisted"], + import.meta.url, + ); + + expect(stdout).toContain("Channel rule updated successfully"); + }); + + it("should require nameOrId argument", async () => { + const { error } = await runCommand( + ["channel-rule:update", "--app", mockAppId, "--persisted"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/Missing 1 required arg/); + }); + }); +}); From af2631080387c1042685fb8ffff43d3e8b376b49 Mon Sep 17 00:00:00 2001 From: Andy Ford Date: Mon, 8 Dec 2025 20:22:01 +0000 Subject: [PATCH 15/44] test: add unit test for config/path --- test/unit/commands/config/path.test.ts | 73 ++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 test/unit/commands/config/path.test.ts diff --git a/test/unit/commands/config/path.test.ts b/test/unit/commands/config/path.test.ts new file mode 100644 index 00000000..4d779fda --- /dev/null +++ b/test/unit/commands/config/path.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { runCommand } from "@oclif/test"; +import { resolve } from "node:path"; +import { mkdirSync, existsSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; + +describe("config:path command", () => { + let testConfigDir: string; + let originalConfigDir: string; + + beforeEach(() => { + testConfigDir = resolve(tmpdir(), `ably-cli-test-${Date.now()}`); + mkdirSync(testConfigDir, { recursive: true, mode: 0o700 }); + + originalConfigDir = process.env.ABLY_CLI_CONFIG_DIR || ""; + process.env.ABLY_CLI_CONFIG_DIR = testConfigDir; + }); + + afterEach(() => { + if (originalConfigDir) { + process.env.ABLY_CLI_CONFIG_DIR = originalConfigDir; + } else { + delete process.env.ABLY_CLI_CONFIG_DIR; + } + + if (existsSync(testConfigDir)) { + rmSync(testConfigDir, { recursive: true, force: true }); + } + }); + + describe("successful config path display", () => { + it("should display the config path", async () => { + const { stdout } = await runCommand(["config:path"], import.meta.url); + + expect(stdout).toContain(testConfigDir); + expect(stdout).toContain("config"); + }); + + it("should output JSON format when --json flag is used", async () => { + const { stdout } = await runCommand( + ["config:path", "--json"], + import.meta.url, + ); + + const result = JSON.parse(stdout); + expect(result).toHaveProperty("path"); + expect(result.path).toContain(testConfigDir); + }); + + it("should output pretty JSON format when --pretty-json flag is used", async () => { + const { stdout } = await runCommand( + ["config:path", "--pretty-json"], + import.meta.url, + ); + + const result = JSON.parse(stdout); + expect(result).toHaveProperty("path"); + expect(result.path).toContain(testConfigDir); + }); + }); + + describe("command flags", () => { + it("should reject unknown flags", async () => { + const { error } = await runCommand( + ["config:path", "--unknown-flag-xyz"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/unknown|Nonexistent flag/i); + }); + }); +}); From a620da506336e6f4a1dd629090281e76848a9621 Mon Sep 17 00:00:00 2001 From: Andy Ford Date: Mon, 8 Dec 2025 20:24:39 +0000 Subject: [PATCH 16/44] test: add unit test for logs/app/subscribe --- test/unit/commands/logs/app/subscribe.test.ts | 237 ++++++++++++++++++ 1 file changed, 237 insertions(+) create mode 100644 test/unit/commands/logs/app/subscribe.test.ts diff --git a/test/unit/commands/logs/app/subscribe.test.ts b/test/unit/commands/logs/app/subscribe.test.ts new file mode 100644 index 00000000..c955a68a --- /dev/null +++ b/test/unit/commands/logs/app/subscribe.test.ts @@ -0,0 +1,237 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { runCommand } from "@oclif/test"; +import { resolve } from "node:path"; +import { mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; + +describe("logs:app:subscribe command", () => { + const mockAccessToken = "fake_access_token"; + const mockAccountId = "test-account-id"; + const mockAppId = "550e8400-e29b-41d4-a716-446655440000"; + const mockApiKey = `${mockAppId}.testkey:testsecret`; + let testConfigDir: string; + let originalConfigDir: string; + + beforeEach(() => { + process.env.ABLY_ACCESS_TOKEN = mockAccessToken; + + testConfigDir = resolve(tmpdir(), `ably-cli-test-${Date.now()}`); + mkdirSync(testConfigDir, { recursive: true, mode: 0o700 }); + + originalConfigDir = process.env.ABLY_CLI_CONFIG_DIR || ""; + process.env.ABLY_CLI_CONFIG_DIR = testConfigDir; + + const configContent = `[current] +account = "default" + +[accounts.default] +accessToken = "${mockAccessToken}" +accountId = "${mockAccountId}" +accountName = "Test Account" +userEmail = "test@example.com" +currentAppId = "${mockAppId}" + +[accounts.defaults.apps."${mockAppId}"] +apiKey = "${mockApiKey}" +`; + writeFileSync(resolve(testConfigDir, "config"), configContent); + }); + + afterEach(() => { + delete process.env.ABLY_ACCESS_TOKEN; + + if (originalConfigDir) { + process.env.ABLY_CLI_CONFIG_DIR = originalConfigDir; + } else { + delete process.env.ABLY_CLI_CONFIG_DIR; + } + + if (existsSync(testConfigDir)) { + rmSync(testConfigDir, { recursive: true, force: true }); + } + }); + + describe("command flags", () => { + it("should reject unknown flags", async () => { + const { error } = await runCommand( + ["logs:app:subscribe", "--unknown-flag-xyz"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/unknown|Nonexistent flag/i); + }); + + it("should accept --rewind flag", async () => { + // Run with --duration 0 to exit immediately + const { error } = await runCommand( + ["logs:app:subscribe", "--rewind", "10", "--duration", "0"], + import.meta.url, + ); + + // The command might error due to connection issues, but it should accept the flag + expect(error?.message).not.toMatch(/Unknown flag/); + }); + + it("should accept --type flag with valid option", async () => { + const { error } = await runCommand( + [ + "logs:app:subscribe", + "--type", + "channel.lifecycle", + "--duration", + "0", + ], + import.meta.url, + ); + + expect(error?.message).not.toMatch(/Unknown flag/); + }); + }); + + describe("subscription behavior", () => { + afterEach(() => { + globalThis.__TEST_MOCKS__ = undefined; + }); + + it("should subscribe to log channel and show initial message", async () => { + const mockChannel = { + name: "[meta]log", + subscribe: vi.fn(), + unsubscribe: vi.fn(), + on: vi.fn(), + detach: vi.fn(), + }; + + const mockChannels = { + get: vi.fn().mockReturnValue(mockChannel), + release: vi.fn(), + }; + + const mockConnection = { + on: vi.fn(), + once: vi.fn(), + state: "connected", + }; + + globalThis.__TEST_MOCKS__ = { + ablyRealtimeMock: { + channels: mockChannels, + connection: mockConnection, + close: vi.fn(), + }, + }; + + setTimeout(() => process.emit("SIGINT", "SIGINT"), 100); + + const { stdout } = await runCommand( + ["logs:app:subscribe"], + import.meta.url, + ); + + expect(stdout).toContain("Subscribing to app logs"); + expect(stdout).toContain("Press Ctrl+C to exit"); + }); + + it("should subscribe to specific log types", async () => { + const mockChannel = { + name: "[meta]log", + subscribe: vi.fn(), + unsubscribe: vi.fn(), + on: vi.fn(), + detach: vi.fn(), + }; + + const mockChannels = { + get: vi.fn().mockReturnValue(mockChannel), + release: vi.fn(), + }; + + const mockConnection = { + on: vi.fn(), + once: vi.fn(), + state: "connected", + }; + + globalThis.__TEST_MOCKS__ = { + ablyRealtimeMock: { + channels: mockChannels, + connection: mockConnection, + close: vi.fn(), + }, + }; + + setTimeout(() => process.emit("SIGINT", "SIGINT"), 100); + + await runCommand( + ["logs:app:subscribe", "--type", "channel.lifecycle"], + import.meta.url, + ); + + // Verify subscribe was called with the specific type + expect(mockChannel.subscribe).toHaveBeenCalledWith( + "channel.lifecycle", + expect.any(Function), + ); + }); + + it("should configure rewind when --rewind is specified", async () => { + const mockChannel = { + name: "[meta]log", + subscribe: vi.fn(), + unsubscribe: vi.fn(), + on: vi.fn(), + detach: vi.fn(), + }; + + const mockChannels = { + get: vi.fn().mockReturnValue(mockChannel), + release: vi.fn(), + }; + + const mockConnection = { + on: vi.fn(), + once: vi.fn(), + state: "connected", + }; + + globalThis.__TEST_MOCKS__ = { + ablyRealtimeMock: { + channels: mockChannels, + connection: mockConnection, + close: vi.fn(), + }, + }; + + setTimeout(() => process.emit("SIGINT", "SIGINT"), 100); + + await runCommand( + ["logs:app:subscribe", "--rewind", "10"], + import.meta.url, + ); + + // Verify channel was gotten with rewind params + expect(mockChannels.get).toHaveBeenCalledWith("[meta]log", { + params: { rewind: "10" }, + }); + }); + }); + + describe("error handling", () => { + afterEach(() => { + globalThis.__TEST_MOCKS__ = undefined; + }); + + it("should handle missing mock client in test mode", async () => { + globalThis.__TEST_MOCKS__ = undefined; + + const { error } = await runCommand( + ["logs:app:subscribe"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toMatch(/No mock|client/i); + }); + }); +}); From 4841dbeb616be18ac155d36d59375b80b96d5eec Mon Sep 17 00:00:00 2001 From: Andy Ford Date: Mon, 8 Dec 2025 20:24:39 +0000 Subject: [PATCH 17/44] test: add unit test for logs/channel-lifecycle/subscribe --- .../logs/channel-lifecycle/subscribe.test.ts | 223 ++++++++++++++++++ 1 file changed, 223 insertions(+) create mode 100644 test/unit/commands/logs/channel-lifecycle/subscribe.test.ts diff --git a/test/unit/commands/logs/channel-lifecycle/subscribe.test.ts b/test/unit/commands/logs/channel-lifecycle/subscribe.test.ts new file mode 100644 index 00000000..bdd24538 --- /dev/null +++ b/test/unit/commands/logs/channel-lifecycle/subscribe.test.ts @@ -0,0 +1,223 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { runCommand } from "@oclif/test"; +import { resolve } from "node:path"; +import { mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; + +describe("logs:channel-lifecycle:subscribe command", () => { + const mockAccessToken = "fake_access_token"; + const mockAccountId = "test-account-id"; + const mockAppId = "550e8400-e29b-41d4-a716-446655440000"; + const mockApiKey = `${mockAppId}.testkey:testsecret`; + let testConfigDir: string; + let originalConfigDir: string; + + beforeEach(() => { + process.env.ABLY_ACCESS_TOKEN = mockAccessToken; + + testConfigDir = resolve(tmpdir(), `ably-cli-test-${Date.now()}`); + mkdirSync(testConfigDir, { recursive: true, mode: 0o700 }); + + originalConfigDir = process.env.ABLY_CLI_CONFIG_DIR || ""; + process.env.ABLY_CLI_CONFIG_DIR = testConfigDir; + + const configContent = `[current] +account = "default" + +[accounts.default] +accessToken = "${mockAccessToken}" +accountId = "${mockAccountId}" +accountName = "Test Account" +userEmail = "test@example.com" +currentAppId = "${mockAppId}" + +[apps."${mockAppId}"] +apiKey = "${mockApiKey}" +`; + writeFileSync(resolve(testConfigDir, "config"), configContent); + }); + + afterEach(() => { + delete process.env.ABLY_ACCESS_TOKEN; + + if (originalConfigDir) { + process.env.ABLY_CLI_CONFIG_DIR = originalConfigDir; + } else { + delete process.env.ABLY_CLI_CONFIG_DIR; + } + + if (existsSync(testConfigDir)) { + rmSync(testConfigDir, { recursive: true, force: true }); + } + }); + + describe("command flags", () => { + it("should reject unknown flags", async () => { + const { error } = await runCommand( + ["logs:channel-lifecycle:subscribe", "--unknown-flag-xyz"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/unknown|Nonexistent flag/i); + }); + + it("should accept --rewind flag", async () => { + // The command might error due to connection, but should accept the flag + const { error } = await runCommand( + ["logs:channel-lifecycle:subscribe", "--rewind", "10"], + import.meta.url, + ); + + expect(error?.message).not.toMatch(/Unknown flag/); + }); + + it("should accept --json flag", async () => { + const { error } = await runCommand( + ["logs:channel-lifecycle:subscribe", "--json"], + import.meta.url, + ); + + expect(error?.message).not.toMatch(/Unknown flag/); + }); + }); + + describe("subscription behavior", () => { + afterEach(() => { + globalThis.__TEST_MOCKS__ = undefined; + }); + + it("should subscribe to channel lifecycle events and show initial message", async () => { + const mockChannel = { + name: "[meta]channel.lifecycle", + subscribe: vi.fn(), + unsubscribe: vi.fn(), + on: vi.fn(), + detach: vi.fn(), + }; + + const mockChannels = { + get: vi.fn().mockReturnValue(mockChannel), + release: vi.fn(), + }; + + const mockConnection = { + on: vi.fn(), + once: vi.fn(), + state: "connected", + }; + + globalThis.__TEST_MOCKS__ = { + ablyRealtimeMock: { + channels: mockChannels, + connection: mockConnection, + close: vi.fn(), + }, + }; + + setTimeout(() => process.emit("SIGINT", "SIGINT"), 100); + + const { stdout } = await runCommand( + ["logs:channel-lifecycle:subscribe"], + import.meta.url, + ); + + expect(stdout).toContain("Subscribing to"); + expect(stdout).toContain("[meta]channel.lifecycle"); + expect(stdout).toContain("Press Ctrl+C to exit"); + }); + + it("should subscribe to channel messages", async () => { + const mockChannel = { + name: "[meta]channel.lifecycle", + subscribe: vi.fn(), + unsubscribe: vi.fn(), + on: vi.fn(), + detach: vi.fn(), + }; + + const mockChannels = { + get: vi.fn().mockReturnValue(mockChannel), + release: vi.fn(), + }; + + const mockConnection = { + on: vi.fn(), + once: vi.fn(), + state: "connected", + }; + + globalThis.__TEST_MOCKS__ = { + ablyRealtimeMock: { + channels: mockChannels, + connection: mockConnection, + close: vi.fn(), + }, + }; + + setTimeout(() => process.emit("SIGINT", "SIGINT"), 100); + + await runCommand(["logs:channel-lifecycle:subscribe"], import.meta.url); + + expect(mockChannel.subscribe).toHaveBeenCalledWith(expect.any(Function)); + }); + + it("should configure rewind when --rewind is specified", async () => { + const mockChannel = { + name: "[meta]channel.lifecycle", + subscribe: vi.fn(), + unsubscribe: vi.fn(), + on: vi.fn(), + detach: vi.fn(), + }; + + const mockChannels = { + get: vi.fn().mockReturnValue(mockChannel), + release: vi.fn(), + }; + + const mockConnection = { + on: vi.fn(), + once: vi.fn(), + state: "connected", + }; + + globalThis.__TEST_MOCKS__ = { + ablyRealtimeMock: { + channels: mockChannels, + connection: mockConnection, + close: vi.fn(), + }, + }; + + setTimeout(() => process.emit("SIGINT", "SIGINT"), 100); + + await runCommand( + ["logs:channel-lifecycle:subscribe", "--rewind", "5"], + import.meta.url, + ); + + expect(mockChannels.get).toHaveBeenCalledWith("[meta]channel.lifecycle", { + params: { rewind: "5" }, + }); + }); + }); + + describe("error handling", () => { + afterEach(() => { + globalThis.__TEST_MOCKS__ = undefined; + }); + + it("should handle missing mock client in test mode", async () => { + globalThis.__TEST_MOCKS__ = undefined; + + const { error } = await runCommand( + ["logs:channel-lifecycle:subscribe"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toMatch(/No mock|client/i); + }); + }); +}); From 6238ea3832058157eb6bf17805c4b147c9e378a5 Mon Sep 17 00:00:00 2001 From: Andy Ford Date: Mon, 8 Dec 2025 20:24:39 +0000 Subject: [PATCH 18/44] test: add unit test for logs/connection-lifecycle/history --- .../logs/connection-lifecycle/history.test.ts | 186 ++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 test/unit/commands/logs/connection-lifecycle/history.test.ts diff --git a/test/unit/commands/logs/connection-lifecycle/history.test.ts b/test/unit/commands/logs/connection-lifecycle/history.test.ts new file mode 100644 index 00000000..3759adc0 --- /dev/null +++ b/test/unit/commands/logs/connection-lifecycle/history.test.ts @@ -0,0 +1,186 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { runCommand } from "@oclif/test"; +import { resolve } from "node:path"; +import { mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; + +describe("logs:connection-lifecycle:history command", () => { + const mockAccessToken = "fake_access_token"; + const mockAccountId = "test-account-id"; + const mockAppId = "550e8400-e29b-41d4-a716-446655440000"; + const mockApiKey = `${mockAppId}.testkey:testsecret`; + let testConfigDir: string; + let originalConfigDir: string; + let mockHistory: ReturnType; + + beforeEach(() => { + process.env.ABLY_ACCESS_TOKEN = mockAccessToken; + + testConfigDir = resolve(tmpdir(), `ably-cli-test-${Date.now()}`); + mkdirSync(testConfigDir, { recursive: true, mode: 0o700 }); + + originalConfigDir = process.env.ABLY_CLI_CONFIG_DIR || ""; + process.env.ABLY_CLI_CONFIG_DIR = testConfigDir; + + const configContent = `[current] +account = "default" + +[accounts.default] +accessToken = "${mockAccessToken}" +accountId = "${mockAccountId}" +accountName = "Test Account" +userEmail = "test@example.com" +currentAppId = "${mockAppId}" + +[apps."${mockAppId}"] +apiKey = "${mockApiKey}" +`; + writeFileSync(resolve(testConfigDir, "config"), configContent); + + // Set up mock for REST client + mockHistory = vi.fn().mockResolvedValue({ + items: [ + { + id: "msg-1", + name: "connection.opened", + data: { connectionId: "test-conn" }, + timestamp: Date.now(), + clientId: "client-1", + connectionId: "conn-1", + }, + ], + }); + + const mockChannel = { + name: "[meta]connection.lifecycle", + history: mockHistory, + }; + + globalThis.__TEST_MOCKS__ = { + ablyRestMock: { + channels: { + get: vi.fn().mockReturnValue(mockChannel), + }, + close: vi.fn(), + }, + }; + }); + + afterEach(() => { + vi.clearAllMocks(); + delete process.env.ABLY_ACCESS_TOKEN; + globalThis.__TEST_MOCKS__ = undefined; + + if (originalConfigDir) { + process.env.ABLY_CLI_CONFIG_DIR = originalConfigDir; + } else { + delete process.env.ABLY_CLI_CONFIG_DIR; + } + + if (existsSync(testConfigDir)) { + rmSync(testConfigDir, { recursive: true, force: true }); + } + }); + + describe("command flags", () => { + it("should reject unknown flags", async () => { + const { error } = await runCommand( + ["logs:connection-lifecycle:history", "--unknown-flag-xyz"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/unknown|Nonexistent flag/i); + }); + + it("should accept --limit flag", async () => { + const { error } = await runCommand( + ["logs:connection-lifecycle:history", "--limit", "50"], + import.meta.url, + ); + + expect(error?.message || "").not.toMatch(/Unknown flag/); + }); + + it("should accept --direction flag", async () => { + const { error } = await runCommand( + ["logs:connection-lifecycle:history", "--direction", "forwards"], + import.meta.url, + ); + + expect(error?.message || "").not.toMatch(/Unknown flag/); + }); + + it("should accept --json flag", async () => { + const { stdout } = await runCommand( + ["logs:connection-lifecycle:history", "--json"], + import.meta.url, + ); + + // Command should accept --json flag + expect(stdout).toBeDefined(); + }); + }); + + describe("history retrieval", () => { + it("should retrieve connection lifecycle history and display results", async () => { + const { stdout } = await runCommand( + ["logs:connection-lifecycle:history"], + import.meta.url, + ); + + expect(stdout).toContain("Found"); + expect(stdout).toContain("1"); + expect(stdout).toContain("connection lifecycle logs"); + expect(stdout).toContain("connection.opened"); + expect(mockHistory).toHaveBeenCalled(); + }); + + it("should include messages array in JSON output", async () => { + const { stdout } = await runCommand( + ["logs:connection-lifecycle:history", "--json"], + import.meta.url, + ); + + const result = JSON.parse(stdout); + expect(result).toHaveProperty("success", true); + expect(result).toHaveProperty("messages"); + expect(Array.isArray(result.messages)).toBe(true); + expect(result.messages).toHaveLength(1); + expect(result.messages[0]).toHaveProperty("name", "connection.opened"); + }); + + it("should handle empty history", async () => { + mockHistory.mockResolvedValue({ items: [] }); + + const { stdout } = await runCommand( + ["logs:connection-lifecycle:history"], + import.meta.url, + ); + + expect(stdout).toContain("No connection lifecycle logs found"); + }); + + it("should respect --limit flag", async () => { + await runCommand( + ["logs:connection-lifecycle:history", "--limit", "50"], + import.meta.url, + ); + + expect(mockHistory).toHaveBeenCalledWith( + expect.objectContaining({ limit: 50 }), + ); + }); + + it("should respect --direction flag", async () => { + await runCommand( + ["logs:connection-lifecycle:history", "--direction", "forwards"], + import.meta.url, + ); + + expect(mockHistory).toHaveBeenCalledWith( + expect.objectContaining({ direction: "forwards" }), + ); + }); + }); +}); From 9bd7a72a8976dfdaef5c74087566f00d2fe54f4c Mon Sep 17 00:00:00 2001 From: Andy Ford Date: Mon, 8 Dec 2025 20:24:39 +0000 Subject: [PATCH 19/44] test: add unit test for logs/push/history --- test/unit/commands/logs/push/history.test.ts | 183 +++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 test/unit/commands/logs/push/history.test.ts diff --git a/test/unit/commands/logs/push/history.test.ts b/test/unit/commands/logs/push/history.test.ts new file mode 100644 index 00000000..869e6671 --- /dev/null +++ b/test/unit/commands/logs/push/history.test.ts @@ -0,0 +1,183 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { runCommand } from "@oclif/test"; +import { resolve } from "node:path"; +import { mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; + +describe("logs:push:history command", () => { + const mockAccessToken = "fake_access_token"; + const mockAccountId = "test-account-id"; + const mockAppId = "550e8400-e29b-41d4-a716-446655440000"; + const mockApiKey = `${mockAppId}.testkey:testsecret`; + let testConfigDir: string; + let originalConfigDir: string; + let mockHistory: ReturnType; + + beforeEach(() => { + process.env.ABLY_ACCESS_TOKEN = mockAccessToken; + + testConfigDir = resolve(tmpdir(), `ably-cli-test-${Date.now()}`); + mkdirSync(testConfigDir, { recursive: true, mode: 0o700 }); + + originalConfigDir = process.env.ABLY_CLI_CONFIG_DIR || ""; + process.env.ABLY_CLI_CONFIG_DIR = testConfigDir; + + const configContent = `[current] +account = "default" + +[accounts.default] +accessToken = "${mockAccessToken}" +accountId = "${mockAccountId}" +accountName = "Test Account" +userEmail = "test@example.com" +currentAppId = "${mockAppId}" + +[apps."${mockAppId}"] +apiKey = "${mockApiKey}" +`; + writeFileSync(resolve(testConfigDir, "config"), configContent); + + // Set up mock for REST client + mockHistory = vi.fn().mockResolvedValue({ + items: [ + { + id: "msg-1", + name: "push.delivered", + data: { severity: "info", message: "Push delivered" }, + timestamp: Date.now(), + clientId: "client-1", + connectionId: "conn-1", + }, + ], + }); + + const mockChannel = { + name: "[meta]log:push", + history: mockHistory, + }; + + globalThis.__TEST_MOCKS__ = { + ablyRestMock: { + channels: { + get: vi.fn().mockReturnValue(mockChannel), + }, + close: vi.fn(), + }, + }; + }); + + afterEach(() => { + vi.clearAllMocks(); + delete process.env.ABLY_ACCESS_TOKEN; + globalThis.__TEST_MOCKS__ = undefined; + + if (originalConfigDir) { + process.env.ABLY_CLI_CONFIG_DIR = originalConfigDir; + } else { + delete process.env.ABLY_CLI_CONFIG_DIR; + } + + if (existsSync(testConfigDir)) { + rmSync(testConfigDir, { recursive: true, force: true }); + } + }); + + describe("command flags", () => { + it("should reject unknown flags", async () => { + const { error } = await runCommand( + ["logs:push:history", "--unknown-flag-xyz"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/unknown|Nonexistent flag/i); + }); + + it("should accept --limit flag", async () => { + const { error } = await runCommand( + ["logs:push:history", "--limit", "50"], + import.meta.url, + ); + + expect(error?.message || "").not.toMatch(/Unknown flag/); + }); + + it("should accept --direction flag", async () => { + const { error } = await runCommand( + ["logs:push:history", "--direction", "forwards"], + import.meta.url, + ); + + expect(error?.message || "").not.toMatch(/Unknown flag/); + }); + + it("should accept --json flag", async () => { + const { stdout } = await runCommand( + ["logs:push:history", "--json"], + import.meta.url, + ); + + // Command should accept --json flag + expect(stdout).toBeDefined(); + }); + }); + + describe("history retrieval", () => { + it("should retrieve push history and display results", async () => { + const { stdout } = await runCommand( + ["logs:push:history"], + import.meta.url, + ); + + expect(stdout).toContain("Found"); + expect(stdout).toContain("1"); + expect(stdout).toContain("push log messages"); + expect(stdout).toContain("push.delivered"); + expect(mockHistory).toHaveBeenCalled(); + }); + + it("should include messages array in JSON output", async () => { + const { stdout } = await runCommand( + ["logs:push:history", "--json"], + import.meta.url, + ); + + const result = JSON.parse(stdout); + expect(result).toHaveProperty("success", true); + expect(result).toHaveProperty("messages"); + expect(Array.isArray(result.messages)).toBe(true); + expect(result.messages).toHaveLength(1); + expect(result.messages[0]).toHaveProperty("name", "push.delivered"); + }); + + it("should handle empty history", async () => { + mockHistory.mockResolvedValue({ items: [] }); + + const { stdout } = await runCommand( + ["logs:push:history"], + import.meta.url, + ); + + expect(stdout).toContain("No push log messages found"); + }); + + it("should respect --limit flag", async () => { + await runCommand(["logs:push:history", "--limit", "50"], import.meta.url); + + expect(mockHistory).toHaveBeenCalledWith( + expect.objectContaining({ limit: 50 }), + ); + }); + + it("should respect --direction flag", async () => { + await runCommand( + ["logs:push:history", "--direction", "forwards"], + import.meta.url, + ); + + expect(mockHistory).toHaveBeenCalledWith( + expect.objectContaining({ direction: "forwards" }), + ); + }); + }); +}); From 16c797d73e6f058604f195e8c352c20c1103f372 Mon Sep 17 00:00:00 2001 From: Andy Ford Date: Mon, 8 Dec 2025 20:27:04 +0000 Subject: [PATCH 20/44] test: add unit test for rooms/messages/reactions/remove MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../rooms/messages/reactions/remove.test.ts | 233 ++++++++++++++++++ 1 file changed, 233 insertions(+) create mode 100644 test/unit/commands/rooms/messages/reactions/remove.test.ts diff --git a/test/unit/commands/rooms/messages/reactions/remove.test.ts b/test/unit/commands/rooms/messages/reactions/remove.test.ts new file mode 100644 index 00000000..e7629ace --- /dev/null +++ b/test/unit/commands/rooms/messages/reactions/remove.test.ts @@ -0,0 +1,233 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { runCommand } from "@oclif/test"; +import { resolve } from "node:path"; +import { mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; + +describe("rooms:messages:reactions:remove command", () => { + const mockAccessToken = "fake_access_token"; + const mockAccountId = "test-account-id"; + const mockAppId = "550e8400-e29b-41d4-a716-446655440000"; + const mockApiKey = `${mockAppId}.testkey:testsecret`; + let testConfigDir: string; + let originalConfigDir: string; + + beforeEach(() => { + process.env.ABLY_ACCESS_TOKEN = mockAccessToken; + + testConfigDir = resolve(tmpdir(), `ably-cli-test-${Date.now()}`); + mkdirSync(testConfigDir, { recursive: true, mode: 0o700 }); + + originalConfigDir = process.env.ABLY_CLI_CONFIG_DIR || ""; + process.env.ABLY_CLI_CONFIG_DIR = testConfigDir; + + const configContent = `[current] +account = "default" + +[accounts.default] +accessToken = "${mockAccessToken}" +accountId = "${mockAccountId}" +accountName = "Test Account" +userEmail = "test@example.com" +currentAppId = "${mockAppId}" + +[apps."${mockAppId}"] +apiKey = "${mockApiKey}" +`; + writeFileSync(resolve(testConfigDir, "config"), configContent); + }); + + afterEach(() => { + delete process.env.ABLY_ACCESS_TOKEN; + globalThis.__TEST_MOCKS__ = undefined; + + if (originalConfigDir) { + process.env.ABLY_CLI_CONFIG_DIR = originalConfigDir; + } else { + delete process.env.ABLY_CLI_CONFIG_DIR; + } + + if (existsSync(testConfigDir)) { + rmSync(testConfigDir, { recursive: true, force: true }); + } + }); + + describe("command arguments and flags", () => { + it("should reject unknown flags", async () => { + const { error } = await runCommand( + [ + "rooms:messages:reactions:remove", + "test-room", + "msg-serial", + "👍", + "--unknown-flag-xyz", + ], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/unknown|Nonexistent flag/i); + }); + + it("should require room argument", async () => { + const { error } = await runCommand( + ["rooms:messages:reactions:remove"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/Missing .* required arg/); + }); + + it("should require messageSerial argument", async () => { + const { error } = await runCommand( + ["rooms:messages:reactions:remove", "test-room"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/Missing .* required arg/); + }); + + it("should require reaction argument", async () => { + const { error } = await runCommand( + ["rooms:messages:reactions:remove", "test-room", "msg-serial"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/Missing .* required arg/); + }); + }); + + describe("removing reactions", () => { + let mockReactionsDelete: ReturnType; + let mockRoom: { + attach: ReturnType; + messages: { reactions: { delete: ReturnType } }; + onStatusChange: ReturnType; + }; + + beforeEach(() => { + mockReactionsDelete = vi.fn().mockResolvedValue(); + + mockRoom = { + attach: vi.fn().mockResolvedValue(), + messages: { + reactions: { + delete: mockReactionsDelete, + }, + }, + onStatusChange: vi.fn().mockReturnValue({ off: vi.fn() }), + }; + + const mockConnection = { + on: vi.fn(), + once: vi.fn(), + state: "connected", + }; + + const mockRealtimeClient = { + connection: mockConnection, + close: vi.fn(), + }; + + const mockChatClient = { + rooms: { + get: vi.fn().mockResolvedValue(mockRoom), + release: vi.fn().mockResolvedValue(), + }, + connection: { + onStatusChange: vi.fn().mockReturnValue({ off: vi.fn() }), + }, + realtime: mockRealtimeClient, + dispose: vi.fn().mockResolvedValue(), + }; + + globalThis.__TEST_MOCKS__ = { + ablyRealtimeMock: mockRealtimeClient, + ablyChatMock: mockChatClient, + }; + }); + + it("should remove a reaction from a message", async () => { + const { stdout } = await runCommand( + [ + "rooms:messages:reactions:remove", + "test-room", + "msg-serial-123", + "👍", + ], + import.meta.url, + ); + + expect(mockRoom.attach).toHaveBeenCalled(); + expect(mockReactionsDelete).toHaveBeenCalledWith("msg-serial-123", { + name: "👍", + }); + expect(stdout).toContain("Removed reaction"); + expect(stdout).toContain("👍"); + expect(stdout).toContain("msg-serial-123"); + expect(stdout).toContain("test-room"); + }); + + it("should remove a reaction with type flag", async () => { + const { stdout } = await runCommand( + [ + "rooms:messages:reactions:remove", + "test-room", + "msg-serial-123", + "❤️", + "--type", + "unique", + ], + import.meta.url, + ); + + expect(mockReactionsDelete).toHaveBeenCalledWith("msg-serial-123", { + name: "❤️", + type: expect.any(String), + }); + expect(stdout).toContain("Removed reaction"); + expect(stdout).toContain("❤️"); + }); + + it("should output JSON when --json flag is used", async () => { + const { stdout } = await runCommand( + [ + "rooms:messages:reactions:remove", + "test-room", + "msg-serial-123", + "👍", + "--json", + ], + import.meta.url, + ); + + const result = JSON.parse(stdout); + expect(result).toHaveProperty("success", true); + expect(result).toHaveProperty("room", "test-room"); + expect(result).toHaveProperty("messageSerial", "msg-serial-123"); + expect(result).toHaveProperty("reaction", "👍"); + }); + + it("should handle reaction removal failure", async () => { + mockReactionsDelete.mockRejectedValue( + new Error("Failed to remove reaction"), + ); + + const { error } = await runCommand( + [ + "rooms:messages:reactions:remove", + "test-room", + "msg-serial-123", + "👍", + ], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toContain("Failed to remove reaction"); + }); + }); +}); From c9527ecc94dfc6a1f760e63194d2a072a38ad597 Mon Sep 17 00:00:00 2001 From: Andy Ford Date: Mon, 8 Dec 2025 20:27:04 +0000 Subject: [PATCH 21/44] test: add unit test for rooms/messages/reactions/send MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../rooms/messages/reactions/send.test.ts | 221 ++++++++++++++++++ 1 file changed, 221 insertions(+) create mode 100644 test/unit/commands/rooms/messages/reactions/send.test.ts diff --git a/test/unit/commands/rooms/messages/reactions/send.test.ts b/test/unit/commands/rooms/messages/reactions/send.test.ts new file mode 100644 index 00000000..caf277ab --- /dev/null +++ b/test/unit/commands/rooms/messages/reactions/send.test.ts @@ -0,0 +1,221 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { runCommand } from "@oclif/test"; +import { resolve } from "node:path"; +import { mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; + +describe("rooms:messages:reactions:send command", () => { + const mockAccessToken = "fake_access_token"; + const mockAccountId = "test-account-id"; + const mockAppId = "550e8400-e29b-41d4-a716-446655440000"; + const mockApiKey = `${mockAppId}.testkey:testsecret`; + let testConfigDir: string; + let originalConfigDir: string; + + beforeEach(() => { + process.env.ABLY_ACCESS_TOKEN = mockAccessToken; + + testConfigDir = resolve(tmpdir(), `ably-cli-test-${Date.now()}`); + mkdirSync(testConfigDir, { recursive: true, mode: 0o700 }); + + originalConfigDir = process.env.ABLY_CLI_CONFIG_DIR || ""; + process.env.ABLY_CLI_CONFIG_DIR = testConfigDir; + + const configContent = `[current] +account = "default" + +[accounts.default] +accessToken = "${mockAccessToken}" +accountId = "${mockAccountId}" +accountName = "Test Account" +userEmail = "test@example.com" +currentAppId = "${mockAppId}" + +[apps."${mockAppId}"] +apiKey = "${mockApiKey}" +`; + writeFileSync(resolve(testConfigDir, "config"), configContent); + }); + + afterEach(() => { + delete process.env.ABLY_ACCESS_TOKEN; + globalThis.__TEST_MOCKS__ = undefined; + + if (originalConfigDir) { + process.env.ABLY_CLI_CONFIG_DIR = originalConfigDir; + } else { + delete process.env.ABLY_CLI_CONFIG_DIR; + } + + if (existsSync(testConfigDir)) { + rmSync(testConfigDir, { recursive: true, force: true }); + } + }); + + describe("command arguments and flags", () => { + it("should reject unknown flags", async () => { + const { error } = await runCommand( + [ + "rooms:messages:reactions:send", + "test-room", + "msg-serial", + "👍", + "--unknown-flag-xyz", + ], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/unknown|Nonexistent flag/i); + }); + + it("should require room argument", async () => { + const { error } = await runCommand( + ["rooms:messages:reactions:send"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/Missing .* required arg/); + }); + + it("should require messageSerial argument", async () => { + const { error } = await runCommand( + ["rooms:messages:reactions:send", "test-room"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/Missing .* required arg/); + }); + + it("should require reaction argument", async () => { + const { error } = await runCommand( + ["rooms:messages:reactions:send", "test-room", "msg-serial"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/Missing .* required arg/); + }); + }); + + describe("sending reactions", () => { + let mockReactionsSend: ReturnType; + let mockRoom: { + attach: ReturnType; + messages: { reactions: { send: ReturnType } }; + onStatusChange: ReturnType; + }; + + beforeEach(() => { + mockReactionsSend = vi.fn().mockResolvedValue(); + + mockRoom = { + attach: vi.fn().mockResolvedValue(), + messages: { + reactions: { + send: mockReactionsSend, + }, + }, + onStatusChange: vi.fn().mockReturnValue({ off: vi.fn() }), + }; + + const mockConnection = { + on: vi.fn(), + once: vi.fn(), + state: "connected", + }; + + const mockRealtimeClient = { + connection: mockConnection, + close: vi.fn(), + }; + + const mockChatClient = { + rooms: { + get: vi.fn().mockResolvedValue(mockRoom), + release: vi.fn().mockResolvedValue(), + }, + connection: { + onStatusChange: vi.fn().mockReturnValue({ off: vi.fn() }), + }, + realtime: mockRealtimeClient, + dispose: vi.fn().mockResolvedValue(), + }; + + globalThis.__TEST_MOCKS__ = { + ablyRealtimeMock: mockRealtimeClient, + ablyChatMock: mockChatClient, + }; + }); + + it("should send a reaction to a message", async () => { + const { stdout } = await runCommand( + ["rooms:messages:reactions:send", "test-room", "msg-serial-123", "👍"], + import.meta.url, + ); + + expect(mockRoom.attach).toHaveBeenCalled(); + expect(mockReactionsSend).toHaveBeenCalledWith("msg-serial-123", { + name: "👍", + }); + expect(stdout).toContain("Sent reaction"); + expect(stdout).toContain("👍"); + expect(stdout).toContain("msg-serial-123"); + expect(stdout).toContain("test-room"); + }); + + it("should send a reaction with type flag", async () => { + const { stdout } = await runCommand( + [ + "rooms:messages:reactions:send", + "test-room", + "msg-serial-123", + "❤️", + "--type", + "unique", + ], + import.meta.url, + ); + + expect(mockReactionsSend).toHaveBeenCalledWith("msg-serial-123", { + name: "❤️", + type: expect.any(String), + }); + expect(stdout).toContain("Sent reaction"); + expect(stdout).toContain("❤️"); + }); + + it("should output JSON when --json flag is used", async () => { + const { stdout } = await runCommand( + [ + "rooms:messages:reactions:send", + "test-room", + "msg-serial-123", + "👍", + "--json", + ], + import.meta.url, + ); + + const result = JSON.parse(stdout); + expect(result).toHaveProperty("success", true); + expect(result).toHaveProperty("room", "test-room"); + expect(result).toHaveProperty("messageSerial", "msg-serial-123"); + expect(result).toHaveProperty("reaction", "👍"); + }); + + it("should handle reaction send failure", async () => { + mockReactionsSend.mockRejectedValue(new Error("Failed to send reaction")); + + const { error } = await runCommand( + ["rooms:messages:reactions:send", "test-room", "msg-serial-123", "👍"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toContain("Failed to send reaction"); + }); + }); +}); From 29258e5323f399c84d9ea114e766bf3d8b73a186 Mon Sep 17 00:00:00 2001 From: Andy Ford Date: Mon, 8 Dec 2025 20:27:04 +0000 Subject: [PATCH 22/44] test: add unit test for rooms/messages/reactions/subscribe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../messages/reactions/subscribe.test.ts | 285 ++++++++++++++++++ 1 file changed, 285 insertions(+) create mode 100644 test/unit/commands/rooms/messages/reactions/subscribe.test.ts diff --git a/test/unit/commands/rooms/messages/reactions/subscribe.test.ts b/test/unit/commands/rooms/messages/reactions/subscribe.test.ts new file mode 100644 index 00000000..17b8a46f --- /dev/null +++ b/test/unit/commands/rooms/messages/reactions/subscribe.test.ts @@ -0,0 +1,285 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { runCommand } from "@oclif/test"; +import { resolve } from "node:path"; +import { mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { RoomStatus } from "@ably/chat"; + +describe("rooms:messages:reactions:subscribe command", () => { + const mockAccessToken = "fake_access_token"; + const mockAccountId = "test-account-id"; + const mockAppId = "550e8400-e29b-41d4-a716-446655440000"; + const mockApiKey = `${mockAppId}.testkey:testsecret`; + let testConfigDir: string; + let originalConfigDir: string; + + beforeEach(() => { + process.env.ABLY_ACCESS_TOKEN = mockAccessToken; + + testConfigDir = resolve(tmpdir(), `ably-cli-test-${Date.now()}`); + mkdirSync(testConfigDir, { recursive: true, mode: 0o700 }); + + originalConfigDir = process.env.ABLY_CLI_CONFIG_DIR || ""; + process.env.ABLY_CLI_CONFIG_DIR = testConfigDir; + + const configContent = `[current] +account = "default" + +[accounts.default] +accessToken = "${mockAccessToken}" +accountId = "${mockAccountId}" +accountName = "Test Account" +userEmail = "test@example.com" +currentAppId = "${mockAppId}" + +[apps."${mockAppId}"] +apiKey = "${mockApiKey}" +`; + writeFileSync(resolve(testConfigDir, "config"), configContent); + }); + + afterEach(() => { + delete process.env.ABLY_ACCESS_TOKEN; + + if (originalConfigDir) { + process.env.ABLY_CLI_CONFIG_DIR = originalConfigDir; + } else { + delete process.env.ABLY_CLI_CONFIG_DIR; + } + + if (existsSync(testConfigDir)) { + rmSync(testConfigDir, { recursive: true, force: true }); + } + + globalThis.__TEST_MOCKS__ = undefined; + }); + + describe("command arguments and flags", () => { + it("should reject unknown flags", async () => { + const { error } = await runCommand( + ["rooms:messages:reactions:subscribe", "test-room", "--unknown-flag"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/unknown|Nonexistent flag/i); + }); + + it("should require room argument", async () => { + const { error } = await runCommand( + ["rooms:messages:reactions:subscribe"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/Missing .* required arg/); + }); + }); + + describe("subscription behavior", () => { + it("should subscribe to message reactions and display them", async () => { + let reactionsCallback: ((event: any) => void) | null = null; + let statusUnsubscribe: (() => void) | null = null; + const capturedLogs: string[] = []; + + const logSpy = vi.spyOn(console, "log").mockImplementation((msg) => { + capturedLogs.push(String(msg)); + }); + + const mockReactionsSubscribe = vi.fn((callback) => { + reactionsCallback = callback; + return () => {}; // unsubscribe function + }); + + const mockOnStatusChange = vi.fn((callback) => { + // Immediately call with Attached status + callback({ current: RoomStatus.Attached }); + statusUnsubscribe = () => {}; + return statusUnsubscribe; + }); + + const mockRoom = { + messages: { + reactions: { + subscribe: mockReactionsSubscribe, + }, + }, + onStatusChange: mockOnStatusChange, + attach: vi.fn(), + }; + + const mockRooms = { + get: vi.fn().mockResolvedValue(mockRoom), + }; + + const mockRealtimeClient = { + connection: { + on: vi.fn(), + once: vi.fn(), + state: "connected", + }, + close: vi.fn(), + }; + + globalThis.__TEST_MOCKS__ = { + ablyChatMock: { + rooms: mockRooms, + realtime: mockRealtimeClient, + } as any, + ablyRealtimeMock: mockRealtimeClient as any, + }; + + const commandPromise = runCommand( + ["rooms:messages:reactions:subscribe", "test-room"], + import.meta.url, + ); + + await vi.waitFor( + () => { + expect(mockReactionsSubscribe).toHaveBeenCalled(); + }, + { timeout: 1000 }, + ); + + // Simulate a message reaction summary event + if (reactionsCallback) { + reactionsCallback({ + messageSerial: "msg-123", + reactions: { + unique: { + like: { total: 1, clientIds: ["user1"] }, + }, + distinct: { + like: { total: 1, clientIds: ["user1"] }, + }, + }, + }); + } + + await new Promise((resolve) => setTimeout(resolve, 100)); + + process.emit("SIGINT", "SIGINT"); + + await commandPromise; + + logSpy.mockRestore(); + + // Verify subscription was set up + expect(mockRooms.get).toHaveBeenCalled(); + expect(mockReactionsSubscribe).toHaveBeenCalled(); + expect(mockRoom.attach).toHaveBeenCalled(); + + // Verify output contains reaction data + const output = capturedLogs.join("\n"); + expect(output).toContain("msg-123"); + expect(output).toContain("like"); + }); + + it("should output JSON format when --json flag is used", async () => { + let reactionsCallback: ((event: any) => void) | null = null; + const capturedLogs: string[] = []; + + const logSpy = vi.spyOn(console, "log").mockImplementation((msg) => { + capturedLogs.push(String(msg)); + }); + + const mockReactionsSubscribe = vi.fn((callback) => { + reactionsCallback = callback; + return () => {}; + }); + + const mockOnStatusChange = vi.fn((callback) => { + callback({ current: RoomStatus.Attached }); + return () => {}; + }); + + const mockRoom = { + messages: { + reactions: { + subscribe: mockReactionsSubscribe, + }, + }, + onStatusChange: mockOnStatusChange, + attach: vi.fn(), + }; + + const mockRooms = { + get: vi.fn().mockResolvedValue(mockRoom), + }; + + const mockRealtimeClient = { + connection: { + on: vi.fn(), + once: vi.fn(), + state: "connected", + }, + close: vi.fn(), + }; + + globalThis.__TEST_MOCKS__ = { + ablyChatMock: { + rooms: mockRooms, + realtime: mockRealtimeClient, + } as any, + ablyRealtimeMock: mockRealtimeClient as any, + }; + + const commandPromise = runCommand( + ["rooms:messages:reactions:subscribe", "test-room", "--json"], + import.meta.url, + ); + + await vi.waitFor( + () => { + expect(mockReactionsSubscribe).toHaveBeenCalled(); + }, + { timeout: 1000 }, + ); + + // Simulate message reaction summary event + if (reactionsCallback) { + reactionsCallback({ + messageSerial: "msg-456", + reactions: { + unique: { + heart: { total: 2, clientIds: ["user1", "user2"] }, + }, + distinct: { + heart: { total: 2, clientIds: ["user1", "user2"] }, + }, + }, + }); + } + + await new Promise((resolve) => setTimeout(resolve, 100)); + + process.emit("SIGINT", "SIGINT"); + + await commandPromise; + + logSpy.mockRestore(); + + // Verify subscription was set up + expect(mockReactionsSubscribe).toHaveBeenCalled(); + expect(mockRoom.attach).toHaveBeenCalled(); + + // Find the JSON output with reaction summary data + const reactionOutputLines = capturedLogs.filter((line) => { + try { + const parsed = JSON.parse(line); + return parsed.summary && parsed.room; + } catch { + return false; + } + }); + + // Verify that reaction summary was actually output in JSON format + expect(reactionOutputLines.length).toBeGreaterThan(0); + const parsed = JSON.parse(reactionOutputLines[0]); + expect(parsed).toHaveProperty("success", true); + expect(parsed).toHaveProperty("room", "test-room"); + expect(parsed.summary).toHaveProperty("unique"); + expect(parsed.summary.unique).toHaveProperty("heart"); + }); + }); +}); From eda3314650062c48e1ecaad38f8d8b94f0246dff Mon Sep 17 00:00:00 2001 From: Andy Ford Date: Mon, 8 Dec 2025 20:28:33 +0000 Subject: [PATCH 23/44] test: add unit test for rooms/presence/subscribe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../commands/rooms/presence/subscribe.test.ts | 277 ++++++++++++++++++ 1 file changed, 277 insertions(+) create mode 100644 test/unit/commands/rooms/presence/subscribe.test.ts diff --git a/test/unit/commands/rooms/presence/subscribe.test.ts b/test/unit/commands/rooms/presence/subscribe.test.ts new file mode 100644 index 00000000..d713ff85 --- /dev/null +++ b/test/unit/commands/rooms/presence/subscribe.test.ts @@ -0,0 +1,277 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { runCommand } from "@oclif/test"; +import { resolve } from "node:path"; +import { mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { RoomStatus } from "@ably/chat"; + +describe("rooms:presence:subscribe command", () => { + const mockAccessToken = "fake_access_token"; + const mockAccountId = "test-account-id"; + const mockAppId = "550e8400-e29b-41d4-a716-446655440000"; + const mockApiKey = `${mockAppId}.testkey:testsecret`; + let testConfigDir: string; + let originalConfigDir: string; + + beforeEach(() => { + process.env.ABLY_ACCESS_TOKEN = mockAccessToken; + + testConfigDir = resolve(tmpdir(), `ably-cli-test-${Date.now()}`); + mkdirSync(testConfigDir, { recursive: true, mode: 0o700 }); + + originalConfigDir = process.env.ABLY_CLI_CONFIG_DIR || ""; + process.env.ABLY_CLI_CONFIG_DIR = testConfigDir; + + const configContent = `[current] +account = "default" + +[accounts.default] +accessToken = "${mockAccessToken}" +accountId = "${mockAccountId}" +accountName = "Test Account" +userEmail = "test@example.com" +currentAppId = "${mockAppId}" + +[apps."${mockAppId}"] +apiKey = "${mockApiKey}" +`; + writeFileSync(resolve(testConfigDir, "config"), configContent); + }); + + afterEach(() => { + delete process.env.ABLY_ACCESS_TOKEN; + + if (originalConfigDir) { + process.env.ABLY_CLI_CONFIG_DIR = originalConfigDir; + } else { + delete process.env.ABLY_CLI_CONFIG_DIR; + } + + if (existsSync(testConfigDir)) { + rmSync(testConfigDir, { recursive: true, force: true }); + } + + globalThis.__TEST_MOCKS__ = undefined; + }); + + describe("command arguments and flags", () => { + it("should reject unknown flags", async () => { + const { error } = await runCommand( + ["rooms:presence:subscribe", "test-room", "--unknown-flag"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/unknown|Nonexistent flag/i); + }); + + it("should require room argument", async () => { + const { error } = await runCommand( + ["rooms:presence:subscribe"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/Missing .* required arg/); + }); + }); + + describe("subscription behavior", () => { + it("should subscribe to presence events and display them", async () => { + let presenceCallback: ((event: any) => void) | null = null; + let statusCallback: ((change: any) => void) | null = null; + const capturedLogs: string[] = []; + + const logSpy = vi.spyOn(console, "log").mockImplementation((msg) => { + capturedLogs.push(String(msg)); + }); + + const mockPresenceSubscribe = vi.fn((callback) => { + presenceCallback = callback; + }); + + const mockOnStatusChange = vi.fn((callback) => { + statusCallback = callback; + }); + + const mockRoom = { + presence: { + subscribe: mockPresenceSubscribe, + get: vi.fn().mockResolvedValue([]), + }, + onStatusChange: mockOnStatusChange, + attach: vi.fn().mockImplementation(async () => { + if (statusCallback) { + statusCallback({ current: RoomStatus.Attached }); + } + }), + }; + + const mockRooms = { + get: vi.fn().mockResolvedValue(mockRoom), + }; + + const mockRealtimeClient = { + connection: { + on: vi.fn(), + once: vi.fn(), + state: "connected", + }, + close: vi.fn(), + }; + + globalThis.__TEST_MOCKS__ = { + ablyChatMock: { + rooms: mockRooms, + realtime: mockRealtimeClient, + } as any, + ablyRealtimeMock: mockRealtimeClient as any, + }; + + const commandPromise = runCommand( + ["rooms:presence:subscribe", "test-room"], + import.meta.url, + ); + + await vi.waitFor( + () => { + expect(mockPresenceSubscribe).toHaveBeenCalled(); + }, + { timeout: 1000 }, + ); + + // Simulate a presence event + if (presenceCallback) { + presenceCallback({ + type: "enter", + member: { + clientId: "user-123", + data: { name: "Test User" }, + }, + }); + } + + await new Promise((resolve) => setTimeout(resolve, 100)); + + process.emit("SIGINT", "SIGINT"); + + await commandPromise; + + logSpy.mockRestore(); + + // Verify subscription was set up + expect(mockRooms.get).toHaveBeenCalledWith("test-room"); + expect(mockPresenceSubscribe).toHaveBeenCalled(); + expect(mockRoom.attach).toHaveBeenCalled(); + + // Verify output contains presence data + const output = capturedLogs.join("\n"); + expect(output).toContain("user-123"); + expect(output).toContain("enter"); + }); + + it("should output JSON format when --json flag is used", async () => { + let presenceCallback: ((event: any) => void) | null = null; + let statusCallback: ((change: any) => void) | null = null; + const capturedLogs: string[] = []; + + const logSpy = vi.spyOn(console, "log").mockImplementation((msg) => { + capturedLogs.push(String(msg)); + }); + + const mockPresenceSubscribe = vi.fn((callback) => { + presenceCallback = callback; + }); + + const mockOnStatusChange = vi.fn((callback) => { + statusCallback = callback; + }); + + const mockRoom = { + presence: { + subscribe: mockPresenceSubscribe, + get: vi.fn().mockResolvedValue([]), + }, + onStatusChange: mockOnStatusChange, + attach: vi.fn().mockImplementation(async () => { + if (statusCallback) { + statusCallback({ current: RoomStatus.Attached }); + } + }), + }; + + const mockRooms = { + get: vi.fn().mockResolvedValue(mockRoom), + }; + + const mockRealtimeClient = { + connection: { + on: vi.fn(), + once: vi.fn(), + state: "connected", + }, + close: vi.fn(), + }; + + globalThis.__TEST_MOCKS__ = { + ablyChatMock: { + rooms: mockRooms, + realtime: mockRealtimeClient, + } as any, + ablyRealtimeMock: mockRealtimeClient as any, + }; + + const commandPromise = runCommand( + ["rooms:presence:subscribe", "test-room", "--json"], + import.meta.url, + ); + + await vi.waitFor( + () => { + expect(mockPresenceSubscribe).toHaveBeenCalled(); + }, + { timeout: 1000 }, + ); + + // Simulate presence event + if (presenceCallback) { + presenceCallback({ + type: "leave", + member: { + clientId: "user-456", + data: {}, + }, + }); + } + + await new Promise((resolve) => setTimeout(resolve, 100)); + + process.emit("SIGINT", "SIGINT"); + + await commandPromise; + + logSpy.mockRestore(); + + // Verify subscription was set up + expect(mockPresenceSubscribe).toHaveBeenCalled(); + expect(mockRoom.attach).toHaveBeenCalled(); + + // Find the JSON output with presence data + const presenceOutputLines = capturedLogs.filter((line) => { + try { + const parsed = JSON.parse(line); + return parsed.type && parsed.member && parsed.member.clientId; + } catch { + return false; + } + }); + + // Verify that presence event was actually output in JSON format + expect(presenceOutputLines.length).toBeGreaterThan(0); + const parsed = JSON.parse(presenceOutputLines[0]); + expect(parsed).toHaveProperty("success", true); + expect(parsed).toHaveProperty("type", "leave"); + expect(parsed.member).toHaveProperty("clientId", "user-456"); + }); + }); +}); From f023215bd9d729939dc721d36a3f28177c717b57 Mon Sep 17 00:00:00 2001 From: Andy Ford Date: Mon, 8 Dec 2025 20:28:36 +0000 Subject: [PATCH 24/44] test: add unit test for rooms/reactions/subscribe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../rooms/reactions/subscribe.test.ts | 275 ++++++++++++++++++ 1 file changed, 275 insertions(+) create mode 100644 test/unit/commands/rooms/reactions/subscribe.test.ts diff --git a/test/unit/commands/rooms/reactions/subscribe.test.ts b/test/unit/commands/rooms/reactions/subscribe.test.ts new file mode 100644 index 00000000..7494cb74 --- /dev/null +++ b/test/unit/commands/rooms/reactions/subscribe.test.ts @@ -0,0 +1,275 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { runCommand } from "@oclif/test"; +import { resolve } from "node:path"; +import { mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { RoomStatus } from "@ably/chat"; + +describe("rooms:reactions:subscribe command", () => { + const mockAccessToken = "fake_access_token"; + const mockAccountId = "test-account-id"; + const mockAppId = "550e8400-e29b-41d4-a716-446655440000"; + const mockApiKey = `${mockAppId}.testkey:testsecret`; + let testConfigDir: string; + let originalConfigDir: string; + + beforeEach(() => { + process.env.ABLY_ACCESS_TOKEN = mockAccessToken; + + testConfigDir = resolve(tmpdir(), `ably-cli-test-${Date.now()}`); + mkdirSync(testConfigDir, { recursive: true, mode: 0o700 }); + + originalConfigDir = process.env.ABLY_CLI_CONFIG_DIR || ""; + process.env.ABLY_CLI_CONFIG_DIR = testConfigDir; + + const configContent = `[current] +account = "default" + +[accounts.default] +accessToken = "${mockAccessToken}" +accountId = "${mockAccountId}" +accountName = "Test Account" +userEmail = "test@example.com" +currentAppId = "${mockAppId}" + +[apps."${mockAppId}"] +apiKey = "${mockApiKey}" +`; + writeFileSync(resolve(testConfigDir, "config"), configContent); + }); + + afterEach(() => { + delete process.env.ABLY_ACCESS_TOKEN; + + if (originalConfigDir) { + process.env.ABLY_CLI_CONFIG_DIR = originalConfigDir; + } else { + delete process.env.ABLY_CLI_CONFIG_DIR; + } + + if (existsSync(testConfigDir)) { + rmSync(testConfigDir, { recursive: true, force: true }); + } + + globalThis.__TEST_MOCKS__ = undefined; + }); + + describe("command arguments and flags", () => { + it("should reject unknown flags", async () => { + const { error } = await runCommand( + ["rooms:reactions:subscribe", "test-room", "--unknown-flag"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/unknown|Nonexistent flag/i); + }); + + it("should require room argument", async () => { + const { error } = await runCommand( + ["rooms:reactions:subscribe"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/Missing .* required arg/); + }); + }); + + describe("subscription behavior", () => { + it("should subscribe to reactions and display them", async () => { + let reactionsCallback: ((event: any) => void) | null = null; + let statusCallback: ((change: any) => void) | null = null; + const capturedLogs: string[] = []; + + const logSpy = vi.spyOn(console, "log").mockImplementation((msg) => { + capturedLogs.push(String(msg)); + }); + + const mockReactionsSubscribe = vi.fn((callback) => { + reactionsCallback = callback; + }); + + const mockOnStatusChange = vi.fn((callback) => { + statusCallback = callback; + }); + + const mockRoom = { + reactions: { + subscribe: mockReactionsSubscribe, + }, + onStatusChange: mockOnStatusChange, + attach: vi.fn().mockImplementation(async () => { + if (statusCallback) { + statusCallback({ current: RoomStatus.Attached }); + } + }), + }; + + const mockRooms = { + get: vi.fn().mockResolvedValue(mockRoom), + }; + + const mockRealtimeClient = { + connection: { + on: vi.fn(), + once: vi.fn(), + state: "connected", + }, + close: vi.fn(), + }; + + globalThis.__TEST_MOCKS__ = { + ablyChatMock: { + rooms: mockRooms, + realtime: mockRealtimeClient, + } as any, + ablyRealtimeMock: mockRealtimeClient as any, + }; + + const commandPromise = runCommand( + ["rooms:reactions:subscribe", "test-room"], + import.meta.url, + ); + + await vi.waitFor( + () => { + expect(mockReactionsSubscribe).toHaveBeenCalled(); + }, + { timeout: 1000 }, + ); + + // Simulate a reaction event + if (reactionsCallback) { + reactionsCallback({ + reaction: { + name: "heart", + clientId: "client-123", + metadata: { color: "red" }, + }, + }); + } + + await new Promise((resolve) => setTimeout(resolve, 100)); + + process.emit("SIGINT", "SIGINT"); + + await commandPromise; + + logSpy.mockRestore(); + + // Verify subscription was set up + expect(mockRooms.get).toHaveBeenCalledWith("test-room"); + expect(mockReactionsSubscribe).toHaveBeenCalled(); + expect(mockRoom.attach).toHaveBeenCalled(); + + // Verify output contains reaction data + const output = capturedLogs.join("\n"); + expect(output).toContain("heart"); + expect(output).toContain("client-123"); + }); + + it("should output JSON format when --json flag is used", async () => { + let reactionsCallback: ((event: any) => void) | null = null; + let statusCallback: ((change: any) => void) | null = null; + const capturedLogs: string[] = []; + + const logSpy = vi.spyOn(console, "log").mockImplementation((msg) => { + capturedLogs.push(String(msg)); + }); + + const mockReactionsSubscribe = vi.fn((callback) => { + reactionsCallback = callback; + }); + + const mockOnStatusChange = vi.fn((callback) => { + statusCallback = callback; + }); + + const mockRoom = { + reactions: { + subscribe: mockReactionsSubscribe, + }, + onStatusChange: mockOnStatusChange, + attach: vi.fn().mockImplementation(async () => { + if (statusCallback) { + statusCallback({ current: RoomStatus.Attached }); + } + }), + }; + + const mockRooms = { + get: vi.fn().mockResolvedValue(mockRoom), + }; + + const mockRealtimeClient = { + connection: { + on: vi.fn(), + once: vi.fn(), + state: "connected", + }, + close: vi.fn(), + }; + + globalThis.__TEST_MOCKS__ = { + ablyChatMock: { + rooms: mockRooms, + realtime: mockRealtimeClient, + } as any, + ablyRealtimeMock: mockRealtimeClient as any, + }; + + const commandPromise = runCommand( + ["rooms:reactions:subscribe", "test-room", "--json"], + import.meta.url, + ); + + await vi.waitFor( + () => { + expect(mockReactionsSubscribe).toHaveBeenCalled(); + }, + { timeout: 1000 }, + ); + + // Simulate reaction event + if (reactionsCallback) { + reactionsCallback({ + reaction: { + name: "thumbsup", + clientId: "user1", + metadata: {}, + }, + }); + } + + await new Promise((resolve) => setTimeout(resolve, 100)); + + process.emit("SIGINT", "SIGINT"); + + await commandPromise; + + logSpy.mockRestore(); + + // Verify subscription was set up + expect(mockReactionsSubscribe).toHaveBeenCalled(); + expect(mockRoom.attach).toHaveBeenCalled(); + + // Find the JSON output with reaction data + const reactionOutputLines = capturedLogs.filter((line) => { + try { + const parsed = JSON.parse(line); + return parsed.name && parsed.clientId; + } catch { + return false; + } + }); + + // Verify that reaction event was actually output in JSON format + expect(reactionOutputLines.length).toBeGreaterThan(0); + const parsed = JSON.parse(reactionOutputLines[0]); + expect(parsed).toHaveProperty("success", true); + expect(parsed).toHaveProperty("name", "thumbsup"); + expect(parsed).toHaveProperty("clientId", "user1"); + }); + }); +}); From 45473e02da58136fddd94de4f38053e42e255388 Mon Sep 17 00:00:00 2001 From: Andy Ford Date: Mon, 8 Dec 2025 20:28:37 +0000 Subject: [PATCH 25/44] test: add unit test for rooms/typing/subscribe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../commands/rooms/typing/subscribe.test.ts | 269 ++++++++++++++++++ 1 file changed, 269 insertions(+) create mode 100644 test/unit/commands/rooms/typing/subscribe.test.ts diff --git a/test/unit/commands/rooms/typing/subscribe.test.ts b/test/unit/commands/rooms/typing/subscribe.test.ts new file mode 100644 index 00000000..b7aeb6dd --- /dev/null +++ b/test/unit/commands/rooms/typing/subscribe.test.ts @@ -0,0 +1,269 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { runCommand } from "@oclif/test"; +import { resolve } from "node:path"; +import { mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { RoomStatus } from "@ably/chat"; + +describe("rooms:typing:subscribe command", () => { + const mockAccessToken = "fake_access_token"; + const mockAccountId = "test-account-id"; + const mockAppId = "550e8400-e29b-41d4-a716-446655440000"; + const mockApiKey = `${mockAppId}.testkey:testsecret`; + let testConfigDir: string; + let originalConfigDir: string; + + beforeEach(() => { + process.env.ABLY_ACCESS_TOKEN = mockAccessToken; + + testConfigDir = resolve(tmpdir(), `ably-cli-test-${Date.now()}`); + mkdirSync(testConfigDir, { recursive: true, mode: 0o700 }); + + originalConfigDir = process.env.ABLY_CLI_CONFIG_DIR || ""; + process.env.ABLY_CLI_CONFIG_DIR = testConfigDir; + + const configContent = `[current] +account = "default" + +[accounts.default] +accessToken = "${mockAccessToken}" +accountId = "${mockAccountId}" +accountName = "Test Account" +userEmail = "test@example.com" +currentAppId = "${mockAppId}" + +[apps."${mockAppId}"] +apiKey = "${mockApiKey}" +`; + writeFileSync(resolve(testConfigDir, "config"), configContent); + }); + + afterEach(() => { + delete process.env.ABLY_ACCESS_TOKEN; + + if (originalConfigDir) { + process.env.ABLY_CLI_CONFIG_DIR = originalConfigDir; + } else { + delete process.env.ABLY_CLI_CONFIG_DIR; + } + + if (existsSync(testConfigDir)) { + rmSync(testConfigDir, { recursive: true, force: true }); + } + + globalThis.__TEST_MOCKS__ = undefined; + }); + + describe("command arguments and flags", () => { + it("should reject unknown flags", async () => { + const { error } = await runCommand( + ["rooms:typing:subscribe", "test-room", "--unknown-flag"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/unknown|Nonexistent flag/i); + }); + + it("should require room argument", async () => { + const { error } = await runCommand( + ["rooms:typing:subscribe"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/Missing .* required arg/); + }); + }); + + describe("subscription behavior", () => { + it("should subscribe to typing events and display them", async () => { + let typingCallback: ((event: any) => void) | null = null; + let statusCallback: ((change: any) => void) | null = null; + + const mockTypingSubscribe = vi.fn((callback) => { + typingCallback = callback; + }); + + const mockOnStatusChange = vi.fn((callback) => { + statusCallback = callback; + }); + + const mockRoom = { + typing: { + subscribe: mockTypingSubscribe, + }, + onStatusChange: mockOnStatusChange, + attach: vi.fn().mockImplementation(async () => { + // Simulate room attaching + if (statusCallback) { + statusCallback({ current: RoomStatus.Attached }); + } + }), + }; + + const mockRooms = { + get: vi.fn().mockResolvedValue(mockRoom), + }; + + const mockRealtimeClient = { + connection: { + on: vi.fn(), + once: vi.fn(), + state: "connected", + }, + close: vi.fn(), + }; + + globalThis.__TEST_MOCKS__ = { + ablyChatMock: { + rooms: mockRooms, + realtime: mockRealtimeClient, + } as any, + ablyRealtimeMock: mockRealtimeClient as any, + }; + + // Run command in background + const commandPromise = runCommand( + ["rooms:typing:subscribe", "test-room"], + import.meta.url, + ); + + // Wait for subscription to be set up + await vi.waitFor( + () => { + expect(mockTypingSubscribe).toHaveBeenCalled(); + }, + { timeout: 1000 }, + ); + + // Simulate a typing event + if (typingCallback) { + typingCallback({ + currentlyTyping: new Set(["user1", "user2"]), + }); + } + + // Give time for output to be generated + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Simulate Ctrl+C to stop the command + process.emit("SIGINT", "SIGINT"); + + const result = await commandPromise; + + // Verify subscription was set up + expect(mockRooms.get).toHaveBeenCalledWith("test-room"); + expect(mockTypingSubscribe).toHaveBeenCalled(); + expect(mockRoom.attach).toHaveBeenCalled(); + + // Verify output contains typing notification + expect(result.stdout).toContain("user1"); + expect(result.stdout).toContain("user2"); + expect(result.stdout).toContain("typing"); + }); + + it("should output JSON format when --json flag is used", async () => { + let typingCallback: ((event: any) => void) | null = null; + let statusCallback: ((change: any) => void) | null = null; + const capturedLogs: string[] = []; + + // Spy on console.log to capture output + const logSpy = vi.spyOn(console, "log").mockImplementation((msg) => { + capturedLogs.push(String(msg)); + }); + + const mockTypingSubscribe = vi.fn((callback) => { + typingCallback = callback; + }); + + const mockOnStatusChange = vi.fn((callback) => { + statusCallback = callback; + }); + + const mockRoom = { + typing: { + subscribe: mockTypingSubscribe, + }, + onStatusChange: mockOnStatusChange, + attach: vi.fn().mockImplementation(async () => { + if (statusCallback) { + statusCallback({ current: RoomStatus.Attached }); + } + }), + }; + + const mockRooms = { + get: vi.fn().mockResolvedValue(mockRoom), + }; + + const mockRealtimeClient = { + connection: { + on: vi.fn(), + once: vi.fn(), + state: "connected", + }, + close: vi.fn(), + }; + + globalThis.__TEST_MOCKS__ = { + ablyChatMock: { + rooms: mockRooms, + realtime: mockRealtimeClient, + } as any, + ablyRealtimeMock: mockRealtimeClient as any, + }; + + const commandPromise = runCommand( + ["rooms:typing:subscribe", "test-room", "--json"], + import.meta.url, + ); + + await vi.waitFor( + () => { + expect(mockTypingSubscribe).toHaveBeenCalled(); + }, + { timeout: 1000 }, + ); + + // Simulate typing event + if (typingCallback) { + typingCallback({ + currentlyTyping: new Set(["user1"]), + }); + } + + // Wait for output to be generated + await new Promise((resolve) => setTimeout(resolve, 100)); + + process.emit("SIGINT", "SIGINT"); + + await commandPromise; + + // Restore spy + logSpy.mockRestore(); + + // Verify subscription was set up + expect(mockTypingSubscribe).toHaveBeenCalled(); + expect(mockRoom.attach).toHaveBeenCalled(); + + // Find the JSON output with typing data from captured logs + const typingOutputLines = capturedLogs.filter((line) => { + try { + const parsed = JSON.parse(line); + return ( + parsed.currentlyTyping && Array.isArray(parsed.currentlyTyping) + ); + } catch { + return false; + } + }); + + // Verify that typing event was actually output in JSON format + expect(typingOutputLines.length).toBeGreaterThan(0); + const parsed = JSON.parse(typingOutputLines[0]); + expect(parsed).toHaveProperty("success", true); + expect(parsed.currentlyTyping).toContain("user1"); + }); + }); +}); From 2cc6b574e29f4c8111efdc095af37f1ce76d30c6 Mon Sep 17 00:00:00 2001 From: Andy Ford Date: Mon, 8 Dec 2025 20:29:48 +0000 Subject: [PATCH 26/44] test: add unit test for spaces/locations/get-all MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../commands/spaces/locations/get-all.test.ts | 234 ++++++++++++++++++ 1 file changed, 234 insertions(+) create mode 100644 test/unit/commands/spaces/locations/get-all.test.ts diff --git a/test/unit/commands/spaces/locations/get-all.test.ts b/test/unit/commands/spaces/locations/get-all.test.ts new file mode 100644 index 00000000..f3adb952 --- /dev/null +++ b/test/unit/commands/spaces/locations/get-all.test.ts @@ -0,0 +1,234 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { runCommand } from "@oclif/test"; +import { resolve } from "node:path"; +import { mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; + +describe("spaces:locations:get-all command", () => { + const mockAccessToken = "fake_access_token"; + const mockAccountId = "test-account-id"; + const mockAppId = "550e8400-e29b-41d4-a716-446655440000"; + const mockApiKey = `${mockAppId}.testkey:testsecret`; + let testConfigDir: string; + let originalConfigDir: string; + + beforeEach(() => { + process.env.ABLY_ACCESS_TOKEN = mockAccessToken; + + testConfigDir = resolve(tmpdir(), `ably-cli-test-${Date.now()}`); + mkdirSync(testConfigDir, { recursive: true, mode: 0o700 }); + + originalConfigDir = process.env.ABLY_CLI_CONFIG_DIR || ""; + process.env.ABLY_CLI_CONFIG_DIR = testConfigDir; + + const configContent = `[current] +account = "default" + +[accounts.default] +accessToken = "${mockAccessToken}" +accountId = "${mockAccountId}" +accountName = "Test Account" +userEmail = "test@example.com" +currentAppId = "${mockAppId}" + +[apps."${mockAppId}"] +apiKey = "${mockApiKey}" +`; + writeFileSync(resolve(testConfigDir, "config"), configContent); + }); + + afterEach(() => { + delete process.env.ABLY_ACCESS_TOKEN; + + if (originalConfigDir) { + process.env.ABLY_CLI_CONFIG_DIR = originalConfigDir; + } else { + delete process.env.ABLY_CLI_CONFIG_DIR; + } + + if (existsSync(testConfigDir)) { + rmSync(testConfigDir, { recursive: true, force: true }); + } + }); + + describe("command arguments and flags", () => { + it("should reject unknown flags", async () => { + const { error } = await runCommand( + ["spaces:locations:get-all", "test-space", "--unknown-flag"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/unknown|Nonexistent flag/i); + }); + + it("should require space argument", async () => { + const { error } = await runCommand( + ["spaces:locations:get-all"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/Missing .* required arg/); + }); + + it("should accept --json flag", async () => { + const mockLocations = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + getAll: vi.fn().mockResolvedValue([]), + }; + + const mockMembers = { + getAll: vi.fn().mockResolvedValue([]), + }; + + const mockSpace = { + locations: mockLocations, + members: mockMembers, + enter: vi.fn().mockResolvedValue(), + leave: vi.fn().mockResolvedValue(), + }; + + const mockConnection = { + on: vi.fn(), + once: vi.fn(), + state: "connected", + id: "test-connection-id", + }; + + const mockRealtimeClient = { + connection: mockConnection, + close: vi.fn(), + }; + + const mockSpacesClient = { + get: vi.fn().mockReturnValue(mockSpace), + }; + + globalThis.__TEST_MOCKS__ = { + ablyRealtimeMock: mockRealtimeClient, + ablySpacesMock: mockSpacesClient, + }; + + const { error } = await runCommand( + ["spaces:locations:get-all", "test-space", "--json"], + import.meta.url, + ); + + expect(error?.message || "").not.toMatch(/unknown|Nonexistent flag/i); + + globalThis.__TEST_MOCKS__ = undefined; + }); + }); + + describe("location retrieval", () => { + afterEach(() => { + globalThis.__TEST_MOCKS__ = undefined; + }); + + it("should get all locations from a space", async () => { + const mockLocationsData = [ + { + member: { clientId: "user-1", connectionId: "conn-1" }, + currentLocation: { x: 100, y: 200 }, + previousLocation: null, + }, + ]; + + const mockLocations = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + getAll: vi.fn().mockResolvedValue(mockLocationsData), + }; + + const mockMembers = { + getAll: vi.fn().mockResolvedValue([]), + }; + + const mockSpace = { + locations: mockLocations, + members: mockMembers, + enter: vi.fn().mockResolvedValue(), + leave: vi.fn().mockResolvedValue(), + }; + + const mockConnection = { + on: vi.fn(), + once: vi.fn(), + state: "connected", + id: "test-connection-id", + }; + + const mockRealtimeClient = { + connection: mockConnection, + close: vi.fn(), + }; + + const mockSpacesClient = { + get: vi.fn().mockReturnValue(mockSpace), + }; + + globalThis.__TEST_MOCKS__ = { + ablyRealtimeMock: mockRealtimeClient, + ablySpacesMock: mockSpacesClient, + }; + + const { stdout } = await runCommand( + ["spaces:locations:get-all", "test-space", "--json"], + import.meta.url, + ); + + expect(mockSpace.enter).toHaveBeenCalled(); + expect(mockLocations.getAll).toHaveBeenCalled(); + expect(stdout).toContain("test-space"); + }); + + it("should handle no locations found", async () => { + const mockLocations = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + getAll: vi.fn().mockResolvedValue([]), + }; + + const mockMembers = { + getAll: vi.fn().mockResolvedValue([]), + }; + + const mockSpace = { + locations: mockLocations, + members: mockMembers, + enter: vi.fn().mockResolvedValue(), + leave: vi.fn().mockResolvedValue(), + }; + + const mockConnection = { + on: vi.fn(), + once: vi.fn(), + state: "connected", + id: "test-connection-id", + }; + + const mockRealtimeClient = { + connection: mockConnection, + close: vi.fn(), + }; + + const mockSpacesClient = { + get: vi.fn().mockReturnValue(mockSpace), + }; + + globalThis.__TEST_MOCKS__ = { + ablyRealtimeMock: mockRealtimeClient, + ablySpacesMock: mockSpacesClient, + }; + + const { stdout } = await runCommand( + ["spaces:locations:get-all", "test-space", "--json"], + import.meta.url, + ); + + expect(stdout).toContain("locations"); + }); + }); +}); From e076d58781a56eb2b6cf5873f4f14a2bcba6129e Mon Sep 17 00:00:00 2001 From: Andy Ford Date: Mon, 8 Dec 2025 20:29:49 +0000 Subject: [PATCH 27/44] test: add unit test for spaces/locks/get MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- test/unit/commands/spaces/locks/get.test.ts | 240 ++++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 test/unit/commands/spaces/locks/get.test.ts diff --git a/test/unit/commands/spaces/locks/get.test.ts b/test/unit/commands/spaces/locks/get.test.ts new file mode 100644 index 00000000..f3d88be6 --- /dev/null +++ b/test/unit/commands/spaces/locks/get.test.ts @@ -0,0 +1,240 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { runCommand } from "@oclif/test"; +import { resolve } from "node:path"; +import { mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; + +describe("spaces:locks:get command", () => { + const mockAccessToken = "fake_access_token"; + const mockAccountId = "test-account-id"; + const mockAppId = "550e8400-e29b-41d4-a716-446655440000"; + const mockApiKey = `${mockAppId}.testkey:testsecret`; + let testConfigDir: string; + let originalConfigDir: string; + + beforeEach(() => { + process.env.ABLY_ACCESS_TOKEN = mockAccessToken; + + testConfigDir = resolve(tmpdir(), `ably-cli-test-${Date.now()}`); + mkdirSync(testConfigDir, { recursive: true, mode: 0o700 }); + + originalConfigDir = process.env.ABLY_CLI_CONFIG_DIR || ""; + process.env.ABLY_CLI_CONFIG_DIR = testConfigDir; + + const configContent = `[current] +account = "default" + +[accounts.default] +accessToken = "${mockAccessToken}" +accountId = "${mockAccountId}" +accountName = "Test Account" +userEmail = "test@example.com" +currentAppId = "${mockAppId}" + +[apps."${mockAppId}"] +apiKey = "${mockApiKey}" +`; + writeFileSync(resolve(testConfigDir, "config"), configContent); + }); + + afterEach(() => { + delete process.env.ABLY_ACCESS_TOKEN; + + if (originalConfigDir) { + process.env.ABLY_CLI_CONFIG_DIR = originalConfigDir; + } else { + delete process.env.ABLY_CLI_CONFIG_DIR; + } + + if (existsSync(testConfigDir)) { + rmSync(testConfigDir, { recursive: true, force: true }); + } + }); + + describe("command arguments and flags", () => { + it("should reject unknown flags", async () => { + const { error } = await runCommand( + ["spaces:locks:get", "test-space", "my-lock", "--unknown-flag"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/unknown|Nonexistent flag/i); + }); + + it("should require space argument", async () => { + const { error } = await runCommand(["spaces:locks:get"], import.meta.url); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/Missing .* required arg/); + }); + + it("should require lockId argument", async () => { + const { error } = await runCommand( + ["spaces:locks:get", "test-space"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/Missing .* required arg/); + }); + + it("should accept --json flag", async () => { + const mockLocks = { + get: vi.fn().mockResolvedValue(null), + subscribe: vi.fn(), + unsubscribe: vi.fn(), + }; + + const mockMembers = { + getAll: vi.fn().mockResolvedValue([]), + }; + + const mockSpace = { + locks: mockLocks, + members: mockMembers, + enter: vi.fn().mockResolvedValue(), + leave: vi.fn().mockResolvedValue(), + }; + + const mockConnection = { + on: vi.fn(), + once: vi.fn(), + state: "connected", + id: "test-connection-id", + }; + + const mockRealtimeClient = { + connection: mockConnection, + close: vi.fn(), + }; + + const mockSpacesClient = { + get: vi.fn().mockReturnValue(mockSpace), + }; + + globalThis.__TEST_MOCKS__ = { + ablyRealtimeMock: mockRealtimeClient, + ablySpacesMock: mockSpacesClient, + }; + + const { error } = await runCommand( + ["spaces:locks:get", "test-space", "my-lock", "--json"], + import.meta.url, + ); + + expect(error?.message || "").not.toMatch(/unknown|Nonexistent flag/i); + + globalThis.__TEST_MOCKS__ = undefined; + }); + }); + + describe("lock retrieval", () => { + afterEach(() => { + globalThis.__TEST_MOCKS__ = undefined; + }); + + it("should get a specific lock by ID", async () => { + const mockLockData = { + id: "my-lock", + member: { clientId: "user-1", connectionId: "conn-1" }, + status: "locked", + }; + + const mockLocks = { + get: vi.fn().mockResolvedValue(mockLockData), + subscribe: vi.fn(), + unsubscribe: vi.fn(), + }; + + const mockMembers = { + getAll: vi.fn().mockResolvedValue([]), + }; + + const mockSpace = { + locks: mockLocks, + members: mockMembers, + enter: vi.fn().mockResolvedValue(), + leave: vi.fn().mockResolvedValue(), + }; + + const mockConnection = { + on: vi.fn(), + once: vi.fn(), + state: "connected", + id: "test-connection-id", + }; + + const mockRealtimeClient = { + connection: mockConnection, + close: vi.fn(), + }; + + const mockSpacesClient = { + get: vi.fn().mockReturnValue(mockSpace), + }; + + globalThis.__TEST_MOCKS__ = { + ablyRealtimeMock: mockRealtimeClient, + ablySpacesMock: mockSpacesClient, + }; + + const { stdout } = await runCommand( + ["spaces:locks:get", "test-space", "my-lock", "--json"], + import.meta.url, + ); + + expect(mockSpace.enter).toHaveBeenCalled(); + expect(mockLocks.get).toHaveBeenCalledWith("my-lock"); + expect(stdout).toContain("my-lock"); + }); + + it("should handle lock not found", async () => { + const mockLocks = { + get: vi.fn().mockResolvedValue(null), + subscribe: vi.fn(), + unsubscribe: vi.fn(), + }; + + const mockMembers = { + getAll: vi.fn().mockResolvedValue([]), + }; + + const mockSpace = { + locks: mockLocks, + members: mockMembers, + enter: vi.fn().mockResolvedValue(), + leave: vi.fn().mockResolvedValue(), + }; + + const mockConnection = { + on: vi.fn(), + once: vi.fn(), + state: "connected", + id: "test-connection-id", + }; + + const mockRealtimeClient = { + connection: mockConnection, + close: vi.fn(), + }; + + const mockSpacesClient = { + get: vi.fn().mockReturnValue(mockSpace), + }; + + globalThis.__TEST_MOCKS__ = { + ablyRealtimeMock: mockRealtimeClient, + ablySpacesMock: mockSpacesClient, + }; + + const { stdout } = await runCommand( + ["spaces:locks:get", "test-space", "nonexistent-lock", "--json"], + import.meta.url, + ); + + expect(mockLocks.get).toHaveBeenCalledWith("nonexistent-lock"); + expect(stdout).toBeDefined(); + }); + }); +}); From a13c9083055e516aef1b5eca444cae88abedc09e Mon Sep 17 00:00:00 2001 From: Andy Ford Date: Mon, 8 Dec 2025 20:29:49 +0000 Subject: [PATCH 28/44] test: add unit test for spaces/locks/get-all MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../commands/spaces/locks/get-all.test.ts | 234 ++++++++++++++++++ 1 file changed, 234 insertions(+) create mode 100644 test/unit/commands/spaces/locks/get-all.test.ts diff --git a/test/unit/commands/spaces/locks/get-all.test.ts b/test/unit/commands/spaces/locks/get-all.test.ts new file mode 100644 index 00000000..3b406654 --- /dev/null +++ b/test/unit/commands/spaces/locks/get-all.test.ts @@ -0,0 +1,234 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { runCommand } from "@oclif/test"; +import { resolve } from "node:path"; +import { mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; + +describe("spaces:locks:get-all command", () => { + const mockAccessToken = "fake_access_token"; + const mockAccountId = "test-account-id"; + const mockAppId = "550e8400-e29b-41d4-a716-446655440000"; + const mockApiKey = `${mockAppId}.testkey:testsecret`; + let testConfigDir: string; + let originalConfigDir: string; + + beforeEach(() => { + process.env.ABLY_ACCESS_TOKEN = mockAccessToken; + + testConfigDir = resolve(tmpdir(), `ably-cli-test-${Date.now()}`); + mkdirSync(testConfigDir, { recursive: true, mode: 0o700 }); + + originalConfigDir = process.env.ABLY_CLI_CONFIG_DIR || ""; + process.env.ABLY_CLI_CONFIG_DIR = testConfigDir; + + const configContent = `[current] +account = "default" + +[accounts.default] +accessToken = "${mockAccessToken}" +accountId = "${mockAccountId}" +accountName = "Test Account" +userEmail = "test@example.com" +currentAppId = "${mockAppId}" + +[apps."${mockAppId}"] +apiKey = "${mockApiKey}" +`; + writeFileSync(resolve(testConfigDir, "config"), configContent); + }); + + afterEach(() => { + delete process.env.ABLY_ACCESS_TOKEN; + + if (originalConfigDir) { + process.env.ABLY_CLI_CONFIG_DIR = originalConfigDir; + } else { + delete process.env.ABLY_CLI_CONFIG_DIR; + } + + if (existsSync(testConfigDir)) { + rmSync(testConfigDir, { recursive: true, force: true }); + } + }); + + describe("command arguments and flags", () => { + it("should reject unknown flags", async () => { + const { error } = await runCommand( + ["spaces:locks:get-all", "test-space", "--unknown-flag"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/unknown|Nonexistent flag/i); + }); + + it("should require space argument", async () => { + const { error } = await runCommand( + ["spaces:locks:get-all"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/Missing .* required arg/); + }); + + it("should accept --json flag", async () => { + const mockLocks = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + getAll: vi.fn().mockResolvedValue([]), + }; + + const mockMembers = { + getAll: vi.fn().mockResolvedValue([]), + }; + + const mockSpace = { + locks: mockLocks, + members: mockMembers, + enter: vi.fn().mockResolvedValue(), + leave: vi.fn().mockResolvedValue(), + }; + + const mockConnection = { + on: vi.fn(), + once: vi.fn(), + state: "connected", + id: "test-connection-id", + }; + + const mockRealtimeClient = { + connection: mockConnection, + close: vi.fn(), + }; + + const mockSpacesClient = { + get: vi.fn().mockReturnValue(mockSpace), + }; + + globalThis.__TEST_MOCKS__ = { + ablyRealtimeMock: mockRealtimeClient, + ablySpacesMock: mockSpacesClient, + }; + + const { error } = await runCommand( + ["spaces:locks:get-all", "test-space", "--json"], + import.meta.url, + ); + + expect(error?.message || "").not.toMatch(/unknown|Nonexistent flag/i); + + globalThis.__TEST_MOCKS__ = undefined; + }); + }); + + describe("lock retrieval", () => { + afterEach(() => { + globalThis.__TEST_MOCKS__ = undefined; + }); + + it("should get all locks from a space", async () => { + const mockLocksData = [ + { + id: "lock-1", + member: { clientId: "user-1", connectionId: "conn-1" }, + status: "locked", + }, + ]; + + const mockLocks = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + getAll: vi.fn().mockResolvedValue(mockLocksData), + }; + + const mockMembers = { + getAll: vi.fn().mockResolvedValue([]), + }; + + const mockSpace = { + locks: mockLocks, + members: mockMembers, + enter: vi.fn().mockResolvedValue(), + leave: vi.fn().mockResolvedValue(), + }; + + const mockConnection = { + on: vi.fn(), + once: vi.fn(), + state: "connected", + id: "test-connection-id", + }; + + const mockRealtimeClient = { + connection: mockConnection, + close: vi.fn(), + }; + + const mockSpacesClient = { + get: vi.fn().mockReturnValue(mockSpace), + }; + + globalThis.__TEST_MOCKS__ = { + ablyRealtimeMock: mockRealtimeClient, + ablySpacesMock: mockSpacesClient, + }; + + const { stdout } = await runCommand( + ["spaces:locks:get-all", "test-space", "--json"], + import.meta.url, + ); + + expect(mockSpace.enter).toHaveBeenCalled(); + expect(mockLocks.getAll).toHaveBeenCalled(); + expect(stdout).toContain("test-space"); + }); + + it("should handle no locks found", async () => { + const mockLocks = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + getAll: vi.fn().mockResolvedValue([]), + }; + + const mockMembers = { + getAll: vi.fn().mockResolvedValue([]), + }; + + const mockSpace = { + locks: mockLocks, + members: mockMembers, + enter: vi.fn().mockResolvedValue(), + leave: vi.fn().mockResolvedValue(), + }; + + const mockConnection = { + on: vi.fn(), + once: vi.fn(), + state: "connected", + id: "test-connection-id", + }; + + const mockRealtimeClient = { + connection: mockConnection, + close: vi.fn(), + }; + + const mockSpacesClient = { + get: vi.fn().mockReturnValue(mockSpace), + }; + + globalThis.__TEST_MOCKS__ = { + ablyRealtimeMock: mockRealtimeClient, + ablySpacesMock: mockSpacesClient, + }; + + const { stdout } = await runCommand( + ["spaces:locks:get-all", "test-space", "--json"], + import.meta.url, + ); + + expect(stdout).toContain("locks"); + }); + }); +}); From 55e2b671191903c422b69d7af6d8520981828767 Mon Sep 17 00:00:00 2001 From: Andy Ford Date: Wed, 10 Dec 2025 20:48:24 +0000 Subject: [PATCH 29/44] test: remove ABLY_API_KEY from test environment Explicitly unset ABLY_API_KEY in all test projects (unit, integration, e2e, hooks) to prevent shell environment variables from leaking into test execution. --- vitest.config.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/vitest.config.ts b/vitest.config.ts index c46a2672..8f441612 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -36,6 +36,7 @@ export default defineConfig({ env: { ABLY_CLI_DEFAULT_DURATION: "0.25", ABLY_CLI_TEST_MODE: "true", + ABLY_API_KEY: undefined, }, }, }, @@ -48,6 +49,7 @@ export default defineConfig({ env: { ABLY_CLI_DEFAULT_DURATION: "0.25", ABLY_CLI_TEST_MODE: "true", + ABLY_API_KEY: undefined, }, testTimeout: 20000, // Allow 20s per test for plenty of time on actions }, @@ -63,6 +65,9 @@ export default defineConfig({ "**/dist/**", "test/e2e/web-cli/**/*.test.ts", ], + env: { + ABLY_API_KEY: undefined, + }, testTimeout: 20000, // Allow 20s per test for plenty of time on actions hookTimeout: 60000, // 60 seconds for hooks // Run e2e tests sequentially to avoid API rate limits @@ -77,6 +82,9 @@ export default defineConfig({ include: ["test/hooks/**/*.test.ts"], // Exclude web-cli tests (use Playwright separately) exclude: ["**/node_modules/**", "**/dist/**"], + env: { + ABLY_API_KEY: undefined, + }, }, }, ], From b8b72b49b0d3a095311cb642e58c6d043b2b8b86 Mon Sep 17 00:00:00 2001 From: Andy Ford Date: Wed, 10 Dec 2025 22:45:48 +0000 Subject: [PATCH 30/44] test: add unit test for apps/update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- test/unit/commands/apps/update.test.ts | 382 +++++++++++++++++++++++++ 1 file changed, 382 insertions(+) create mode 100644 test/unit/commands/apps/update.test.ts diff --git a/test/unit/commands/apps/update.test.ts b/test/unit/commands/apps/update.test.ts new file mode 100644 index 00000000..54451405 --- /dev/null +++ b/test/unit/commands/apps/update.test.ts @@ -0,0 +1,382 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { runCommand } from "@oclif/test"; +import nock from "nock"; +import { resolve } from "node:path"; +import { mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; + +describe("apps:update command", () => { + const mockAccessToken = "fake_access_token"; + const mockAccountId = "test-account-id"; + const mockAppId = "550e8400-e29b-41d4-a716-446655440000"; + const mockAppName = "TestApp"; + let testConfigDir: string; + let originalConfigDir: string; + + beforeEach(() => { + // Set environment variable for access token + process.env.ABLY_ACCESS_TOKEN = mockAccessToken; + + // Create a temporary config directory for testing + testConfigDir = resolve(tmpdir(), `ably-cli-test-${Date.now()}`); + mkdirSync(testConfigDir, { recursive: true, mode: 0o700 }); + + // Store original config dir and set test config dir + originalConfigDir = process.env.ABLY_CLI_CONFIG_DIR || ""; + process.env.ABLY_CLI_CONFIG_DIR = testConfigDir; + + // Create a minimal config file with a default account + const configContent = `[current] +account = "default" + +[accounts.default] +accessToken = "${mockAccessToken}" +accountId = "${mockAccountId}" +accountName = "Test Account" +userEmail = "test@example.com" +`; + writeFileSync(resolve(testConfigDir, "config"), configContent); + }); + + afterEach(() => { + // Clean up nock interceptors + nock.cleanAll(); + delete process.env.ABLY_ACCESS_TOKEN; + + // Restore original config directory + if (originalConfigDir) { + process.env.ABLY_CLI_CONFIG_DIR = originalConfigDir; + } else { + delete process.env.ABLY_CLI_CONFIG_DIR; + } + + // Clean up test config directory + if (existsSync(testConfigDir)) { + rmSync(testConfigDir, { recursive: true, force: true }); + } + }); + + describe("successful app update", () => { + it("should update an app name successfully", async () => { + const updatedName = "UpdatedAppName"; + + // Mock the app update endpoint + nock("https://control.ably.net") + .patch(`/v1/apps/${mockAppId}`, { + name: updatedName, + }) + .reply(200, { + id: mockAppId, + accountId: mockAccountId, + name: updatedName, + status: "active", + created: Date.now(), + modified: Date.now(), + tlsOnly: false, + }); + + const { stdout } = await runCommand( + ["apps:update", mockAppId, "--name", updatedName], + import.meta.url, + ); + + expect(stdout).toContain("App updated successfully"); + expect(stdout).toContain(mockAppId); + expect(stdout).toContain(updatedName); + }); + + it("should update TLS only flag successfully", async () => { + // Mock the app update endpoint + nock("https://control.ably.net") + .patch(`/v1/apps/${mockAppId}`, { + tlsOnly: true, + }) + .reply(200, { + id: mockAppId, + accountId: mockAccountId, + name: mockAppName, + status: "active", + created: Date.now(), + modified: Date.now(), + tlsOnly: true, + }); + + const { stdout } = await runCommand( + ["apps:update", mockAppId, "--tls-only"], + import.meta.url, + ); + + expect(stdout).toContain("App updated successfully"); + expect(stdout).toContain("TLS Only: Yes"); + }); + + it("should update both name and TLS only successfully", async () => { + const updatedName = "UpdatedAppName"; + + // Mock the app update endpoint + nock("https://control.ably.net") + .patch(`/v1/apps/${mockAppId}`, { + name: updatedName, + tlsOnly: true, + }) + .reply(200, { + id: mockAppId, + accountId: mockAccountId, + name: updatedName, + status: "active", + created: Date.now(), + modified: Date.now(), + tlsOnly: true, + }); + + const { stdout } = await runCommand( + ["apps:update", mockAppId, "--name", updatedName, "--tls-only"], + import.meta.url, + ); + + expect(stdout).toContain("App updated successfully"); + expect(stdout).toContain(updatedName); + expect(stdout).toContain("TLS Only: Yes"); + }); + + it("should output JSON format when --json flag is used", async () => { + const updatedName = "UpdatedAppName"; + const mockApp = { + id: mockAppId, + accountId: mockAccountId, + name: updatedName, + status: "active", + created: Date.now(), + modified: Date.now(), + tlsOnly: false, + }; + + // Mock the app update endpoint + nock("https://control.ably.net") + .patch(`/v1/apps/${mockAppId}`) + .reply(200, mockApp); + + const { stdout } = await runCommand( + ["apps:update", mockAppId, "--name", updatedName, "--json"], + import.meta.url, + ); + + const result = JSON.parse(stdout); + expect(result).toHaveProperty("app"); + expect(result.app).toHaveProperty("id", mockAppId); + expect(result.app).toHaveProperty("name", updatedName); + expect(result).toHaveProperty("success", true); + }); + + it("should use custom access token when provided", async () => { + const customToken = "custom_access_token"; + const updatedName = "UpdatedAppName"; + + // Mock the app update endpoint with custom token + nock("https://control.ably.net", { + reqheaders: { + authorization: `Bearer ${customToken}`, + }, + }) + .patch(`/v1/apps/${mockAppId}`) + .reply(200, { + id: mockAppId, + accountId: mockAccountId, + name: updatedName, + status: "active", + created: Date.now(), + modified: Date.now(), + tlsOnly: false, + }); + + const { stdout } = await runCommand( + [ + "apps:update", + mockAppId, + "--name", + updatedName, + "--access-token", + customToken, + ], + import.meta.url, + ); + + expect(stdout).toContain("App updated successfully"); + }); + }); + + describe("error handling", () => { + it("should require at least one update parameter", async () => { + const { error } = await runCommand( + ["apps:update", mockAppId], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toMatch(/At least one update parameter/); + }); + + it("should handle JSON error output when no update parameter provided", async () => { + const { stdout } = await runCommand( + ["apps:update", mockAppId, "--json"], + import.meta.url, + ); + + const result = JSON.parse(stdout); + expect(result).toHaveProperty("success", false); + expect(result).toHaveProperty("error"); + expect(result.error).toMatch(/At least one update parameter/); + }); + + it("should require app ID argument", async () => { + const { error } = await runCommand( + ["apps:update", "--name", "NewName"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toMatch(/Missing.*required arg/i); + }); + + it("should handle 401 authentication error", async () => { + // Mock authentication failure + nock("https://control.ably.net") + .patch(`/v1/apps/${mockAppId}`) + .reply(401, { error: "Unauthorized" }); + + const { error } = await runCommand( + ["apps:update", mockAppId, "--name", "NewName"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toMatch(/401/); + }); + + it("should handle 403 forbidden error", async () => { + // Mock forbidden response + nock("https://control.ably.net") + .patch(`/v1/apps/${mockAppId}`) + .reply(403, { error: "Forbidden" }); + + const { error } = await runCommand( + ["apps:update", mockAppId, "--name", "NewName"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toMatch(/403/); + }); + + it("should handle 404 not found error", async () => { + // Mock not found response + nock("https://control.ably.net") + .patch(`/v1/apps/${mockAppId}`) + .reply(404, { error: "Not Found" }); + + const { error } = await runCommand( + ["apps:update", mockAppId, "--name", "NewName"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toMatch(/404/); + }); + + it("should handle 500 server error", async () => { + // Mock server error + nock("https://control.ably.net") + .patch(`/v1/apps/${mockAppId}`) + .reply(500, { error: "Internal Server Error" }); + + const { error } = await runCommand( + ["apps:update", mockAppId, "--name", "NewName"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toMatch(/500/); + }); + + it("should handle network errors", async () => { + // Mock network error + nock("https://control.ably.net") + .patch(`/v1/apps/${mockAppId}`) + .replyWithError("Network error"); + + const { error } = await runCommand( + ["apps:update", mockAppId, "--name", "NewName"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toMatch(/Network error/); + }); + + it("should handle JSON error output for API errors", async () => { + // Mock server error + nock("https://control.ably.net") + .patch(`/v1/apps/${mockAppId}`) + .reply(500, { error: "Internal Server Error" }); + + const { stdout } = await runCommand( + ["apps:update", mockAppId, "--name", "NewName", "--json"], + import.meta.url, + ); + + const result = JSON.parse(stdout); + expect(result).toHaveProperty("success", false); + expect(result).toHaveProperty("error"); + expect(result).toHaveProperty("appId", mockAppId); + }); + }); + + describe("output formatting", () => { + it("should display APNS sandbox cert status when available", async () => { + // Mock the app update endpoint with APNS cert info + nock("https://control.ably.net") + .patch(`/v1/apps/${mockAppId}`) + .reply(200, { + id: mockAppId, + accountId: mockAccountId, + name: mockAppName, + status: "active", + created: Date.now(), + modified: Date.now(), + tlsOnly: false, + apnsUsesSandboxCert: true, + }); + + const { stdout } = await runCommand( + ["apps:update", mockAppId, "--name", mockAppName], + import.meta.url, + ); + + expect(stdout).toContain("APNS Uses Sandbox Cert: Yes"); + }); + + it("should include APNS info in JSON output when available", async () => { + // Mock the app update endpoint with APNS cert info + nock("https://control.ably.net") + .patch(`/v1/apps/${mockAppId}`) + .reply(200, { + id: mockAppId, + accountId: mockAccountId, + name: mockAppName, + status: "active", + created: Date.now(), + modified: Date.now(), + tlsOnly: false, + apnsUsesSandboxCert: false, + }); + + const { stdout } = await runCommand( + ["apps:update", mockAppId, "--name", mockAppName, "--json"], + import.meta.url, + ); + + const result = JSON.parse(stdout); + expect(result.app).toHaveProperty("apnsUsesSandboxCert", false); + }); + }); +}); From 03c9c5ecdeddb1a66a6e54b6af7bae446696d31f Mon Sep 17 00:00:00 2001 From: Andy Ford Date: Wed, 10 Dec 2025 22:47:45 +0000 Subject: [PATCH 31/44] test: add unit test for auth/issue-ably-token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../commands/auth/issue-ably-token.test.ts | 394 ++++++++++++++++++ 1 file changed, 394 insertions(+) create mode 100644 test/unit/commands/auth/issue-ably-token.test.ts diff --git a/test/unit/commands/auth/issue-ably-token.test.ts b/test/unit/commands/auth/issue-ably-token.test.ts new file mode 100644 index 00000000..6ac747ea --- /dev/null +++ b/test/unit/commands/auth/issue-ably-token.test.ts @@ -0,0 +1,394 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { runCommand } from "@oclif/test"; +import { resolve } from "node:path"; +import { mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; + +describe("auth:issue-ably-token command", () => { + const mockAccessToken = "fake_access_token"; + const mockAccountId = "test-account-id"; + const mockAppId = "550e8400-e29b-41d4-a716-446655440000"; + const mockApiKey = `${mockAppId}.testkey:testsecret`; + let testConfigDir: string; + let originalConfigDir: string; + + beforeEach(() => { + process.env.ABLY_ACCESS_TOKEN = mockAccessToken; + + testConfigDir = resolve(tmpdir(), `ably-cli-test-${Date.now()}`); + mkdirSync(testConfigDir, { recursive: true, mode: 0o700 }); + + originalConfigDir = process.env.ABLY_CLI_CONFIG_DIR || ""; + process.env.ABLY_CLI_CONFIG_DIR = testConfigDir; + + const configContent = `[current] +account = "default" + +[accounts.default] +accessToken = "${mockAccessToken}" +accountId = "${mockAccountId}" +accountName = "Test Account" +userEmail = "test@example.com" +currentAppId = "${mockAppId}" + +[accounts.default.apps."${mockAppId}"] +appName = "Test App" +apiKey = "${mockApiKey}" +`; + writeFileSync(resolve(testConfigDir, "config"), configContent); + }); + + afterEach(() => { + delete process.env.ABLY_ACCESS_TOKEN; + + if (originalConfigDir) { + process.env.ABLY_CLI_CONFIG_DIR = originalConfigDir; + } else { + delete process.env.ABLY_CLI_CONFIG_DIR; + } + + if (existsSync(testConfigDir)) { + rmSync(testConfigDir, { recursive: true, force: true }); + } + + globalThis.__TEST_MOCKS__ = undefined; + }); + + describe("successful token issuance", () => { + it("should issue an Ably token successfully", async () => { + const mockTokenDetails = { + token: "mock-ably-token-12345", + issued: Date.now(), + expires: Date.now() + 3600000, + capability: '{"*":["*"]}', + clientId: "ably-cli-test1234", + }; + + const mockTokenRequest = { + keyName: `${mockAppId}.testkey`, + ttl: 3600000, + capability: '{"*":["*"]}', + }; + + const mockAuth = { + createTokenRequest: vi.fn().mockResolvedValue(mockTokenRequest), + requestToken: vi.fn().mockResolvedValue(mockTokenDetails), + }; + + globalThis.__TEST_MOCKS__ = { + ablyRestMock: { + auth: mockAuth, + close: vi.fn(), + }, + }; + + const { stdout } = await runCommand( + ["auth:issue-ably-token"], + import.meta.url, + ); + + expect(stdout).toContain("Generated Ably Token"); + expect(stdout).toContain(`Token: ${mockTokenDetails.token}`); + expect(stdout).toContain("Type: Ably"); + expect(mockAuth.createTokenRequest).toHaveBeenCalled(); + expect(mockAuth.requestToken).toHaveBeenCalled(); + }); + + it("should issue a token with custom capability", async () => { + const customCapability = '{"chat:*":["publish","subscribe"]}'; + const mockTokenDetails = { + token: "mock-ably-token-custom", + issued: Date.now(), + expires: Date.now() + 3600000, + capability: customCapability, + clientId: "ably-cli-test1234", + }; + + const mockAuth = { + createTokenRequest: vi.fn().mockResolvedValue({}), + requestToken: vi.fn().mockResolvedValue(mockTokenDetails), + }; + + globalThis.__TEST_MOCKS__ = { + ablyRestMock: { + auth: mockAuth, + close: vi.fn(), + }, + }; + + const { stdout } = await runCommand( + ["auth:issue-ably-token", "--capability", customCapability], + import.meta.url, + ); + + expect(stdout).toContain("Generated Ably Token"); + expect(mockAuth.createTokenRequest).toHaveBeenCalled(); + const tokenParams = mockAuth.createTokenRequest.mock.calls[0][0]; + expect(tokenParams.capability).toHaveProperty("chat:*"); + }); + + it("should issue a token with custom TTL", async () => { + const mockTokenDetails = { + token: "mock-ably-token-ttl", + issued: Date.now(), + expires: Date.now() + 7200000, + capability: '{"*":["*"]}', + clientId: "ably-cli-test1234", + }; + + const mockAuth = { + createTokenRequest: vi.fn().mockResolvedValue({}), + requestToken: vi.fn().mockResolvedValue(mockTokenDetails), + }; + + globalThis.__TEST_MOCKS__ = { + ablyRestMock: { + auth: mockAuth, + close: vi.fn(), + }, + }; + + const { stdout } = await runCommand( + ["auth:issue-ably-token", "--ttl", "7200"], + import.meta.url, + ); + + expect(stdout).toContain("Generated Ably Token"); + expect(stdout).toContain("TTL: 7200 seconds"); + expect(mockAuth.createTokenRequest).toHaveBeenCalled(); + const tokenParams = mockAuth.createTokenRequest.mock.calls[0][0]; + expect(tokenParams.ttl).toBe(7200000); // TTL in milliseconds + }); + + it("should issue a token with custom client ID", async () => { + const customClientId = "my-custom-client"; + const mockTokenDetails = { + token: "mock-ably-token-clientid", + issued: Date.now(), + expires: Date.now() + 3600000, + capability: '{"*":["*"]}', + clientId: customClientId, + }; + + const mockAuth = { + createTokenRequest: vi.fn().mockResolvedValue({}), + requestToken: vi.fn().mockResolvedValue(mockTokenDetails), + }; + + globalThis.__TEST_MOCKS__ = { + ablyRestMock: { + auth: mockAuth, + close: vi.fn(), + }, + }; + + const { stdout } = await runCommand( + ["auth:issue-ably-token", "--client-id", customClientId], + import.meta.url, + ); + + expect(stdout).toContain("Generated Ably Token"); + expect(stdout).toContain(`Client ID: ${customClientId}`); + expect(mockAuth.createTokenRequest).toHaveBeenCalled(); + const tokenParams = mockAuth.createTokenRequest.mock.calls[0][0]; + expect(tokenParams.clientId).toBe(customClientId); + }); + + it("should issue a token with no client ID when 'none' is specified", async () => { + const mockTokenDetails = { + token: "mock-ably-token-no-client", + issued: Date.now(), + expires: Date.now() + 3600000, + capability: '{"*":["*"]}', + clientId: undefined, + }; + + const mockAuth = { + createTokenRequest: vi.fn().mockResolvedValue({}), + requestToken: vi.fn().mockResolvedValue(mockTokenDetails), + }; + + globalThis.__TEST_MOCKS__ = { + ablyRestMock: { + auth: mockAuth, + close: vi.fn(), + }, + }; + + const { stdout } = await runCommand( + ["auth:issue-ably-token", "--client-id", "none"], + import.meta.url, + ); + + expect(stdout).toContain("Generated Ably Token"); + expect(stdout).toContain("Client ID: None"); + expect(mockAuth.createTokenRequest).toHaveBeenCalled(); + const tokenParams = mockAuth.createTokenRequest.mock.calls[0][0]; + expect(tokenParams.clientId).toBeUndefined(); + }); + + it("should output only token string with --token-only flag", async () => { + const mockTokenString = "mock-ably-token-only"; + const mockTokenDetails = { + token: mockTokenString, + issued: Date.now(), + expires: Date.now() + 3600000, + capability: '{"*":["*"]}', + clientId: "test", + }; + + const mockAuth = { + createTokenRequest: vi.fn().mockResolvedValue({}), + requestToken: vi.fn().mockResolvedValue(mockTokenDetails), + }; + + globalThis.__TEST_MOCKS__ = { + ablyRestMock: { + auth: mockAuth, + close: vi.fn(), + }, + }; + + const { stdout } = await runCommand( + ["auth:issue-ably-token", "--token-only"], + import.meta.url, + ); + + // Should only output the token string + expect(stdout.trim()).toBe(mockTokenString); + expect(stdout).not.toContain("Generated Ably Token"); + }); + + it("should output JSON format when --json flag is used", async () => { + const mockTokenDetails = { + token: "mock-ably-token-json", + issued: Date.now(), + expires: Date.now() + 3600000, + capability: '{"*":["*"]}', + clientId: "ably-cli-test1234", + }; + + const mockAuth = { + createTokenRequest: vi.fn().mockResolvedValue({}), + requestToken: vi.fn().mockResolvedValue(mockTokenDetails), + }; + + globalThis.__TEST_MOCKS__ = { + ablyRestMock: { + auth: mockAuth, + close: vi.fn(), + }, + }; + + const { stdout } = await runCommand( + ["auth:issue-ably-token", "--json"], + import.meta.url, + ); + + const result = JSON.parse(stdout); + expect(result).toHaveProperty("capability"); + }); + }); + + describe("error handling", () => { + it("should handle invalid capability JSON", async () => { + const { error } = await runCommand( + ["auth:issue-ably-token", "--capability", "invalid-json"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toMatch(/Invalid capability JSON/i); + }); + + it("should handle token creation failure", async () => { + const mockAuth = { + createTokenRequest: vi.fn().mockRejectedValue(new Error("Auth failed")), + requestToken: vi.fn(), + }; + + globalThis.__TEST_MOCKS__ = { + ablyRestMock: { + auth: mockAuth, + close: vi.fn(), + }, + }; + + const { error } = await runCommand( + ["auth:issue-ably-token"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toMatch(/Error issuing Ably token/i); + }); + + it("should handle missing app configuration", async () => { + // Remove app from config + const configContent = `[current] +account = "default" + +[accounts.default] +accessToken = "${mockAccessToken}" +accountId = "${mockAccountId}" +accountName = "Test Account" +userEmail = "test@example.com" +`; + writeFileSync(resolve(testConfigDir, "config"), configContent); + + const { error, stdout } = await runCommand( + ["auth:issue-ably-token"], + import.meta.url, + ); + + // The command either throws an error or outputs nothing when no app is configured + if (error) { + expect(error?.message).toMatch(/No app selected|No API key|app|key/i); + } else { + // If no error, command should have returned early without output + expect(stdout).not.toContain("Generated Ably Token"); + } + }); + }); + + describe("command arguments and flags", () => { + it("should accept --app flag to specify app", async () => { + const mockTokenDetails = { + token: "mock-ably-token-app", + issued: Date.now(), + expires: Date.now() + 3600000, + capability: '{"*":["*"]}', + clientId: "ably-cli-test1234", + }; + + const mockAuth = { + createTokenRequest: vi.fn().mockResolvedValue({}), + requestToken: vi.fn().mockResolvedValue(mockTokenDetails), + }; + + globalThis.__TEST_MOCKS__ = { + ablyRestMock: { + auth: mockAuth, + close: vi.fn(), + }, + }; + + const { stdout } = await runCommand( + ["auth:issue-ably-token", "--app", mockAppId], + import.meta.url, + ); + + expect(stdout).toContain("Generated Ably Token"); + }); + + it("should reject unknown flags", async () => { + const { error } = await runCommand( + ["auth:issue-ably-token", "--unknown-flag"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/unknown|Nonexistent flag/i); + }); + }); +}); From 43f20f0eea989972e571d53b14135bfde40d0adb Mon Sep 17 00:00:00 2001 From: Andy Ford Date: Wed, 10 Dec 2025 22:49:15 +0000 Subject: [PATCH 32/44] test: add unit test for auth/issue-jwt-token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../commands/auth/issue-ably-token.test.ts | 13 +- .../commands/auth/issue-jwt-token.test.ts | 292 ++++++++++++++++++ 2 files changed, 296 insertions(+), 9 deletions(-) create mode 100644 test/unit/commands/auth/issue-jwt-token.test.ts diff --git a/test/unit/commands/auth/issue-ably-token.test.ts b/test/unit/commands/auth/issue-ably-token.test.ts index 6ac747ea..4749d1fd 100644 --- a/test/unit/commands/auth/issue-ably-token.test.ts +++ b/test/unit/commands/auth/issue-ably-token.test.ts @@ -323,7 +323,7 @@ apiKey = "${mockApiKey}" expect(error?.message).toMatch(/Error issuing Ably token/i); }); - it("should handle missing app configuration", async () => { + it("should not produce token output when app configuration is missing", async () => { // Remove app from config const configContent = `[current] account = "default" @@ -336,18 +336,13 @@ userEmail = "test@example.com" `; writeFileSync(resolve(testConfigDir, "config"), configContent); - const { error, stdout } = await runCommand( + const { stdout } = await runCommand( ["auth:issue-ably-token"], import.meta.url, ); - // The command either throws an error or outputs nothing when no app is configured - if (error) { - expect(error?.message).toMatch(/No app selected|No API key|app|key/i); - } else { - // If no error, command should have returned early without output - expect(stdout).not.toContain("Generated Ably Token"); - } + // When no app is configured, command should not produce token output + expect(stdout).not.toContain("Generated Ably Token"); }); }); diff --git a/test/unit/commands/auth/issue-jwt-token.test.ts b/test/unit/commands/auth/issue-jwt-token.test.ts new file mode 100644 index 00000000..a52c6e18 --- /dev/null +++ b/test/unit/commands/auth/issue-jwt-token.test.ts @@ -0,0 +1,292 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { runCommand } from "@oclif/test"; +import { resolve } from "node:path"; +import { mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import jwt from "jsonwebtoken"; + +describe("auth:issue-jwt-token command", () => { + const mockAccessToken = "fake_access_token"; + const mockAccountId = "test-account-id"; + const mockAppId = "550e8400-e29b-41d4-a716-446655440000"; + const mockKeyId = `${mockAppId}.testkey`; + const mockKeySecret = "testsecret"; + const mockApiKey = `${mockKeyId}:${mockKeySecret}`; + let testConfigDir: string; + let originalConfigDir: string; + + beforeEach(() => { + process.env.ABLY_ACCESS_TOKEN = mockAccessToken; + + testConfigDir = resolve(tmpdir(), `ably-cli-test-${Date.now()}`); + mkdirSync(testConfigDir, { recursive: true, mode: 0o700 }); + + originalConfigDir = process.env.ABLY_CLI_CONFIG_DIR || ""; + process.env.ABLY_CLI_CONFIG_DIR = testConfigDir; + + const configContent = `[current] +account = "default" + +[accounts.default] +accessToken = "${mockAccessToken}" +accountId = "${mockAccountId}" +accountName = "Test Account" +userEmail = "test@example.com" +currentAppId = "${mockAppId}" + +[accounts.default.apps."${mockAppId}"] +appName = "Test App" +apiKey = "${mockApiKey}" +`; + writeFileSync(resolve(testConfigDir, "config"), configContent); + }); + + afterEach(() => { + delete process.env.ABLY_ACCESS_TOKEN; + + if (originalConfigDir) { + process.env.ABLY_CLI_CONFIG_DIR = originalConfigDir; + } else { + delete process.env.ABLY_CLI_CONFIG_DIR; + } + + if (existsSync(testConfigDir)) { + rmSync(testConfigDir, { recursive: true, force: true }); + } + }); + + describe("successful JWT token issuance", () => { + it("should issue a JWT token successfully", async () => { + const { stdout } = await runCommand( + ["auth:issue-jwt-token"], + import.meta.url, + ); + + expect(stdout).toContain("Generated Ably JWT Token"); + expect(stdout).toContain("Token:"); + expect(stdout).toContain("Type: JWT"); + expect(stdout).toContain(`App ID: ${mockAppId}`); + expect(stdout).toContain(`Key ID: ${mockKeyId}`); + expect(stdout).toContain("Issued:"); + expect(stdout).toContain("Expires:"); + }); + + it("should generate a valid JWT token", async () => { + const { stdout } = await runCommand( + ["auth:issue-jwt-token", "--token-only"], + import.meta.url, + ); + + const token = stdout.trim(); + expect(token).toBeTruthy(); + + // Verify the token is a valid JWT + const decoded = jwt.verify(token, mockKeySecret, { + algorithms: ["HS256"], + }); + expect(decoded).toHaveProperty("x-ably-appId", mockAppId); + expect(decoded).toHaveProperty("x-ably-capability"); + }); + + it("should issue a token with custom capability", async () => { + const customCapability = '{"chat:*":["publish","subscribe"]}'; + + const { stdout } = await runCommand( + [ + "auth:issue-jwt-token", + "--capability", + customCapability, + "--token-only", + ], + import.meta.url, + ); + + const token = stdout.trim(); + const decoded = jwt.verify(token, mockKeySecret, { + algorithms: ["HS256"], + }) as any; + + expect(decoded["x-ably-capability"]).toHaveProperty("chat:*"); + expect(decoded["x-ably-capability"]["chat:*"]).toContain("publish"); + expect(decoded["x-ably-capability"]["chat:*"]).toContain("subscribe"); + }); + + it("should issue a token with custom TTL", async () => { + const ttl = 7200; // 2 hours + + const { stdout } = await runCommand( + ["auth:issue-jwt-token", "--ttl", ttl.toString(), "--token-only"], + import.meta.url, + ); + + const token = stdout.trim(); + const decoded = jwt.verify(token, mockKeySecret, { + algorithms: ["HS256"], + }) as any; + + // Check that exp - iat equals TTL + expect(decoded.exp - decoded.iat).toBe(ttl); + }); + + it("should issue a token with custom client ID", async () => { + const customClientId = "my-custom-client"; + + const { stdout } = await runCommand( + ["auth:issue-jwt-token", "--client-id", customClientId, "--token-only"], + import.meta.url, + ); + + const token = stdout.trim(); + const decoded = jwt.verify(token, mockKeySecret, { + algorithms: ["HS256"], + }) as any; + + expect(decoded["x-ably-clientId"]).toBe(customClientId); + }); + + it("should issue a token with no client ID when 'none' is specified", async () => { + const { stdout } = await runCommand( + ["auth:issue-jwt-token", "--client-id", "none", "--token-only"], + import.meta.url, + ); + + const token = stdout.trim(); + const decoded = jwt.verify(token, mockKeySecret, { + algorithms: ["HS256"], + }) as any; + + expect(decoded["x-ably-clientId"]).toBeUndefined(); + }); + + it("should output only token string with --token-only flag", async () => { + const { stdout } = await runCommand( + ["auth:issue-jwt-token", "--token-only"], + import.meta.url, + ); + + // Should only output the token string (no "Generated" message) + expect(stdout).not.toContain("Generated Ably JWT Token"); + expect(stdout.trim().split(".")).toHaveLength(3); // JWT has 3 parts + }); + + it("should output JSON format when --json flag is used", async () => { + const { stdout } = await runCommand( + ["auth:issue-jwt-token", "--json"], + import.meta.url, + ); + + const result = JSON.parse(stdout); + expect(result).toHaveProperty("token"); + expect(result).toHaveProperty("appId", mockAppId); + expect(result).toHaveProperty("keyId", mockKeyId); + expect(result).toHaveProperty("type", "jwt"); + expect(result).toHaveProperty("capability"); + expect(result).toHaveProperty("ttl"); + }); + + it("should generate token with default capability of all permissions", async () => { + const { stdout } = await runCommand( + ["auth:issue-jwt-token", "--token-only"], + import.meta.url, + ); + + const token = stdout.trim(); + const decoded = jwt.verify(token, mockKeySecret, { + algorithms: ["HS256"], + }) as any; + + expect(decoded["x-ably-capability"]).toHaveProperty("*"); + expect(decoded["x-ably-capability"]["*"]).toContain("*"); + }); + + it("should generate token with default TTL of 1 hour", async () => { + const { stdout } = await runCommand( + ["auth:issue-jwt-token", "--token-only"], + import.meta.url, + ); + + const token = stdout.trim(); + const decoded = jwt.verify(token, mockKeySecret, { + algorithms: ["HS256"], + }) as any; + + // Default TTL is 3600 seconds (1 hour) + expect(decoded.exp - decoded.iat).toBe(3600); + }); + }); + + describe("error handling", () => { + it("should handle invalid capability JSON", async () => { + const { error } = await runCommand( + ["auth:issue-jwt-token", "--capability", "invalid-json"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toMatch(/Invalid capability JSON/i); + }); + + it("should not produce token output when app configuration is missing", async () => { + // Remove app from config + const configContent = `[current] +account = "default" + +[accounts.default] +accessToken = "${mockAccessToken}" +accountId = "${mockAccountId}" +accountName = "Test Account" +userEmail = "test@example.com" +`; + writeFileSync(resolve(testConfigDir, "config"), configContent); + + const { stdout } = await runCommand( + ["auth:issue-jwt-token"], + import.meta.url, + ); + + // When no app is configured, command should not produce token output + expect(stdout).not.toContain("Generated Ably JWT Token"); + }); + }); + + describe("command arguments and flags", () => { + it("should accept --app flag to specify app", async () => { + const { stdout } = await runCommand( + ["auth:issue-jwt-token", "--app", mockAppId], + import.meta.url, + ); + + expect(stdout).toContain("Generated Ably JWT Token"); + }); + + it("should reject unknown flags", async () => { + const { error } = await runCommand( + ["auth:issue-jwt-token", "--unknown-flag"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/unknown|Nonexistent flag/i); + }); + }); + + describe("output formatting", () => { + it("should display TTL in output", async () => { + const { stdout } = await runCommand( + ["auth:issue-jwt-token", "--ttl", "1800"], + import.meta.url, + ); + + expect(stdout).toContain("TTL: 1800 seconds"); + }); + + it("should display client ID as None when not specified with none", async () => { + const { stdout } = await runCommand( + ["auth:issue-jwt-token", "--client-id", "none"], + import.meta.url, + ); + + expect(stdout).toContain("Client ID: None"); + }); + }); +}); From e394e2fac06b08821505f22d1c37f4f5a46ae934 Mon Sep 17 00:00:00 2001 From: Andy Ford Date: Wed, 10 Dec 2025 23:01:23 +0000 Subject: [PATCH 33/44] test: add unit test for channels/occupancy/subscribe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../channels/occupancy/subscribe.test.ts | 299 ++++++++++++++++++ 1 file changed, 299 insertions(+) create mode 100644 test/unit/commands/channels/occupancy/subscribe.test.ts diff --git a/test/unit/commands/channels/occupancy/subscribe.test.ts b/test/unit/commands/channels/occupancy/subscribe.test.ts new file mode 100644 index 00000000..7966c247 --- /dev/null +++ b/test/unit/commands/channels/occupancy/subscribe.test.ts @@ -0,0 +1,299 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { runCommand } from "@oclif/test"; +import { resolve } from "node:path"; +import { mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; + +describe("channels:occupancy:subscribe command", () => { + const mockAccessToken = "fake_access_token"; + const mockAccountId = "test-account-id"; + const mockAppId = "550e8400-e29b-41d4-a716-446655440000"; + const mockApiKey = `${mockAppId}.testkey:testsecret`; + let testConfigDir: string; + let originalConfigDir: string; + + beforeEach(() => { + process.env.ABLY_ACCESS_TOKEN = mockAccessToken; + + testConfigDir = resolve(tmpdir(), `ably-cli-test-${Date.now()}`); + mkdirSync(testConfigDir, { recursive: true, mode: 0o700 }); + + originalConfigDir = process.env.ABLY_CLI_CONFIG_DIR || ""; + process.env.ABLY_CLI_CONFIG_DIR = testConfigDir; + + const configContent = `[current] +account = "default" + +[accounts.default] +accessToken = "${mockAccessToken}" +accountId = "${mockAccountId}" +accountName = "Test Account" +userEmail = "test@example.com" +currentAppId = "${mockAppId}" + +[accounts.default.apps."${mockAppId}"] +appName = "Test App" +apiKey = "${mockApiKey}" +`; + writeFileSync(resolve(testConfigDir, "config"), configContent); + }); + + afterEach(() => { + delete process.env.ABLY_ACCESS_TOKEN; + + if (originalConfigDir) { + process.env.ABLY_CLI_CONFIG_DIR = originalConfigDir; + } else { + delete process.env.ABLY_CLI_CONFIG_DIR; + } + + if (existsSync(testConfigDir)) { + rmSync(testConfigDir, { recursive: true, force: true }); + } + + globalThis.__TEST_MOCKS__ = undefined; + }); + + describe("command arguments and flags", () => { + it("should require channel argument", async () => { + const { error } = await runCommand( + ["channels:occupancy:subscribe"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/Missing .* required arg/); + }); + + it("should reject unknown flags", async () => { + const { error } = await runCommand( + ["channels:occupancy:subscribe", "test-channel", "--unknown-flag"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/unknown|Nonexistent flag/i); + }); + }); + + describe("subscription behavior", () => { + it("should subscribe to occupancy events and show initial message", async () => { + const mockChannel = { + name: "test-channel", + subscribe: vi.fn(), + unsubscribe: vi.fn(), + on: vi.fn(), + detach: vi.fn(), + }; + + const mockChannels = { + get: vi.fn().mockReturnValue(mockChannel), + release: vi.fn(), + }; + + const mockConnection = { + on: vi.fn(), + once: vi.fn(), + state: "connected", + }; + + globalThis.__TEST_MOCKS__ = { + ablyRealtimeMock: { + channels: mockChannels, + connection: mockConnection, + close: vi.fn(), + }, + }; + + // Command will exit after ABLY_CLI_DEFAULT_DURATION (1 second) + const { stdout } = await runCommand( + ["channels:occupancy:subscribe", "test-channel"], + import.meta.url, + ); + + expect(stdout).toContain("Subscribing to occupancy events on channel"); + expect(stdout).toContain("test-channel"); + expect(mockChannels.get).toHaveBeenCalledWith("test-channel", { + params: { occupancy: "metrics" }, + }); + }); + + it("should get channel with occupancy params enabled", async () => { + const mockChannel = { + name: "test-channel", + subscribe: vi.fn(), + unsubscribe: vi.fn(), + on: vi.fn(), + detach: vi.fn(), + }; + + const mockChannels = { + get: vi.fn().mockReturnValue(mockChannel), + release: vi.fn(), + }; + + const mockConnection = { + on: vi.fn(), + once: vi.fn(), + state: "connected", + }; + + globalThis.__TEST_MOCKS__ = { + ablyRealtimeMock: { + channels: mockChannels, + connection: mockConnection, + close: vi.fn(), + }, + }; + + // Command will exit after ABLY_CLI_DEFAULT_DURATION (1 second) + await runCommand( + ["channels:occupancy:subscribe", "test-channel"], + import.meta.url, + ); + + // Verify channel was gotten with occupancy params + expect(mockChannels.get).toHaveBeenCalledWith("test-channel", { + params: { + occupancy: "metrics", + }, + }); + }); + + it("should subscribe to [meta]occupancy event", async () => { + const mockChannel = { + name: "test-channel", + subscribe: vi.fn(), + unsubscribe: vi.fn(), + on: vi.fn(), + detach: vi.fn(), + }; + + const mockChannels = { + get: vi.fn().mockReturnValue(mockChannel), + release: vi.fn(), + }; + + const mockConnection = { + on: vi.fn(), + once: vi.fn(), + state: "connected", + }; + + globalThis.__TEST_MOCKS__ = { + ablyRealtimeMock: { + channels: mockChannels, + connection: mockConnection, + close: vi.fn(), + }, + }; + + // Command will exit after ABLY_CLI_DEFAULT_DURATION (1 second) + await runCommand( + ["channels:occupancy:subscribe", "test-channel"], + import.meta.url, + ); + + // Verify subscribe was called with the correct event name + expect(mockChannel.subscribe).toHaveBeenCalledWith( + "[meta]occupancy", + expect.any(Function), + ); + }); + }); + + describe("error handling", () => { + it("should handle subscription errors gracefully", async () => { + const mockChannel = { + name: "test-channel", + subscribe: vi.fn().mockImplementation(() => { + throw new Error("Subscription failed"); + }), + unsubscribe: vi.fn(), + on: vi.fn(), + detach: vi.fn(), + }; + + const mockChannels = { + get: vi.fn().mockReturnValue(mockChannel), + release: vi.fn(), + }; + + const mockConnection = { + on: vi.fn(), + once: vi.fn(), + state: "connected", + }; + + globalThis.__TEST_MOCKS__ = { + ablyRealtimeMock: { + channels: mockChannels, + connection: mockConnection, + close: vi.fn(), + }, + }; + + const { error } = await runCommand( + ["channels:occupancy:subscribe", "test-channel"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toMatch(/Subscription failed/i); + }); + + it("should handle missing mock client in test mode", async () => { + // No mock set up + globalThis.__TEST_MOCKS__ = undefined; + + const { error } = await runCommand( + ["channels:occupancy:subscribe", "test-channel"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toMatch(/No mock|client/i); + }); + }); + + describe("output formats", () => { + it("should accept --json flag", async () => { + const mockChannel = { + name: "test-channel", + subscribe: vi.fn(), + unsubscribe: vi.fn(), + on: vi.fn(), + detach: vi.fn(), + }; + + const mockChannels = { + get: vi.fn().mockReturnValue(mockChannel), + release: vi.fn(), + }; + + const mockConnection = { + on: vi.fn(), + once: vi.fn(), + state: "connected", + }; + + globalThis.__TEST_MOCKS__ = { + ablyRealtimeMock: { + channels: mockChannels, + connection: mockConnection, + close: vi.fn(), + }, + }; + + // Command will exit after ABLY_CLI_DEFAULT_DURATION (1 second) + // Should not throw for --json flag + const { error } = await runCommand( + ["channels:occupancy:subscribe", "test-channel", "--json"], + import.meta.url, + ); + + // No flag-related error should occur + expect(error?.message || "").not.toMatch(/unknown|Nonexistent flag/i); + }); + }); +}); From a881f81ee20c193787ce96610b05a2bd2f8c7734 Mon Sep 17 00:00:00 2001 From: Andy Ford Date: Wed, 10 Dec 2025 23:03:16 +0000 Subject: [PATCH 34/44] test: add unit test for config/show MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- test/unit/commands/config/show.test.ts | 216 +++++++++++++++++++++++++ 1 file changed, 216 insertions(+) create mode 100644 test/unit/commands/config/show.test.ts diff --git a/test/unit/commands/config/show.test.ts b/test/unit/commands/config/show.test.ts new file mode 100644 index 00000000..f202fe2a --- /dev/null +++ b/test/unit/commands/config/show.test.ts @@ -0,0 +1,216 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { runCommand } from "@oclif/test"; +import { resolve } from "node:path"; +import { mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; + +describe("config:show command", () => { + const mockAccessToken = "fake_access_token"; + const mockAccountId = "test-account-id"; + const mockAppId = "550e8400-e29b-41d4-a716-446655440000"; + const mockApiKey = `${mockAppId}.testkey:testsecret`; + let testConfigDir: string; + let originalConfigDir: string; + + beforeEach(() => { + process.env.ABLY_ACCESS_TOKEN = mockAccessToken; + + testConfigDir = resolve(tmpdir(), `ably-cli-test-${Date.now()}`); + mkdirSync(testConfigDir, { recursive: true, mode: 0o700 }); + + originalConfigDir = process.env.ABLY_CLI_CONFIG_DIR || ""; + process.env.ABLY_CLI_CONFIG_DIR = testConfigDir; + }); + + afterEach(() => { + delete process.env.ABLY_ACCESS_TOKEN; + + if (originalConfigDir) { + process.env.ABLY_CLI_CONFIG_DIR = originalConfigDir; + } else { + delete process.env.ABLY_CLI_CONFIG_DIR; + } + + if (existsSync(testConfigDir)) { + rmSync(testConfigDir, { recursive: true, force: true }); + } + }); + + describe("when config file exists", () => { + beforeEach(() => { + const configContent = `[current] +account = "default" + +[accounts.default] +accessToken = "${mockAccessToken}" +accountId = "${mockAccountId}" +accountName = "Test Account" +userEmail = "test@example.com" +currentAppId = "${mockAppId}" + +[accounts.default.apps."${mockAppId}"] +appName = "Test App" +apiKey = "${mockApiKey}" +`; + writeFileSync(resolve(testConfigDir, "config"), configContent); + }); + + it("should display config file contents", async () => { + const { stdout } = await runCommand(["config:show"], import.meta.url); + + expect(stdout).toContain("Config file:"); + expect(stdout).toContain("[current]"); + expect(stdout).toContain('account = "default"'); + expect(stdout).toContain("[accounts.default]"); + expect(stdout).toContain(`accessToken = "${mockAccessToken}"`); + expect(stdout).toContain(`accountId = "${mockAccountId}"`); + }); + + it("should show config file path", async () => { + const { stdout } = await runCommand(["config:show"], import.meta.url); + + expect(stdout).toContain(`# Config file: ${testConfigDir}`); + }); + + it("should output JSON format when --json flag is used", async () => { + const { stdout } = await runCommand( + ["config:show", "--json"], + import.meta.url, + ); + + const result = JSON.parse(stdout); + expect(result).toHaveProperty("exists", true); + expect(result).toHaveProperty("path"); + expect(result.path).toContain(testConfigDir); + expect(result).toHaveProperty("config"); + expect(result.config).toHaveProperty("current"); + expect(result.config.current).toHaveProperty("account", "default"); + }); + + it("should output pretty JSON when --pretty-json flag is used", async () => { + const { stdout } = await runCommand( + ["config:show", "--pretty-json"], + import.meta.url, + ); + + // Pretty JSON should have newlines and indentation + expect(stdout).toContain("\n"); + const result = JSON.parse(stdout); + expect(result).toHaveProperty("exists", true); + expect(result).toHaveProperty("config"); + }); + + it("should include accounts in JSON output", async () => { + const { stdout } = await runCommand( + ["config:show", "--json"], + import.meta.url, + ); + + const result = JSON.parse(stdout); + expect(result.config).toHaveProperty("accounts"); + expect(result.config.accounts).toHaveProperty("default"); + expect(result.config.accounts.default).toHaveProperty( + "accessToken", + mockAccessToken, + ); + expect(result.config.accounts.default).toHaveProperty( + "accountId", + mockAccountId, + ); + }); + + it("should include apps configuration in JSON output", async () => { + const { stdout } = await runCommand( + ["config:show", "--json"], + import.meta.url, + ); + + const result = JSON.parse(stdout); + expect(result.config.accounts.default).toHaveProperty("apps"); + expect(result.config.accounts.default.apps).toHaveProperty(mockAppId); + expect(result.config.accounts.default.apps[mockAppId]).toHaveProperty( + "appName", + "Test App", + ); + }); + }); + + describe("when config file does not exist", () => { + it("should show error message", async () => { + const { error } = await runCommand(["config:show"], import.meta.url); + + expect(error).toBeDefined(); + expect(error?.message).toMatch(/Config file does not exist/i); + expect(error?.message).toMatch(/ably accounts login/i); + }); + + it("should output JSON error when --json flag is used", async () => { + const { stdout } = await runCommand( + ["config:show", "--json"], + import.meta.url, + ); + + const result = JSON.parse(stdout); + expect(result).toHaveProperty("error"); + expect(result.error).toMatch(/Config file does not exist/i); + expect(result).toHaveProperty("path"); + }); + }); + + describe("with malformed config file", () => { + it("should report error when config file has invalid TOML", async () => { + // Write truly invalid TOML that definitely can't be parsed + // Note: The ConfigManager will fail to load this before config:show can display it + const invalidConfig = "[[[this is definitely not valid TOML"; + writeFileSync(resolve(testConfigDir, "config"), invalidConfig); + + const { error } = await runCommand( + ["config:show", "--json"], + import.meta.url, + ); + + // ConfigManager fails to load invalid TOML + expect(error).toBeDefined(); + expect(error?.message).toMatch(/Failed to load|SyntaxError|parse/i); + }); + + it("should display valid TOML contents in text mode", async () => { + // Write valid TOML with a proper quoted value + const simpleConfig = '[section]\nkey = "value"'; + writeFileSync(resolve(testConfigDir, "config"), simpleConfig); + + const { stdout } = await runCommand(["config:show"], import.meta.url); + + expect(stdout).toContain("[section]"); + expect(stdout).toContain('key = "value"'); + }); + }); + + describe("command arguments and flags", () => { + beforeEach(() => { + const configContent = `[current]\naccount = "default"`; + writeFileSync(resolve(testConfigDir, "config"), configContent); + }); + + it("should reject unknown flags", async () => { + const { error } = await runCommand( + ["config:show", "--unknown-flag"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/unknown|Nonexistent flag/i); + }); + + it("should not require any arguments", async () => { + const { error, stdout } = await runCommand( + ["config:show"], + import.meta.url, + ); + + // Should not error for missing arguments + expect(error?.message || "").not.toMatch(/Missing.*required/i); + expect(stdout).toContain("[current]"); + }); + }); +}); From 973fc9ea50d70c3026a3f0e777785860a04b7aa8 Mon Sep 17 00:00:00 2001 From: Andy Ford Date: Wed, 10 Dec 2025 23:04:38 +0000 Subject: [PATCH 35/44] test: add unit test for integrations/create MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../unit/commands/integrations/create.test.ts | 411 ++++++++++++++++++ 1 file changed, 411 insertions(+) create mode 100644 test/unit/commands/integrations/create.test.ts diff --git a/test/unit/commands/integrations/create.test.ts b/test/unit/commands/integrations/create.test.ts new file mode 100644 index 00000000..8408242e --- /dev/null +++ b/test/unit/commands/integrations/create.test.ts @@ -0,0 +1,411 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { runCommand } from "@oclif/test"; +import nock from "nock"; +import { resolve } from "node:path"; +import { mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; + +describe("integrations:create command", () => { + const mockAccessToken = "fake_access_token"; + const mockAccountId = "test-account-id"; + const mockAppId = "550e8400-e29b-41d4-a716-446655440000"; + const mockRuleId = "rule-123456"; + let testConfigDir: string; + let originalConfigDir: string; + + beforeEach(() => { + process.env.ABLY_ACCESS_TOKEN = mockAccessToken; + + testConfigDir = resolve(tmpdir(), `ably-cli-test-${Date.now()}`); + mkdirSync(testConfigDir, { recursive: true, mode: 0o700 }); + + originalConfigDir = process.env.ABLY_CLI_CONFIG_DIR || ""; + process.env.ABLY_CLI_CONFIG_DIR = testConfigDir; + + const configContent = `[current] +account = "default" + +[accounts.default] +accessToken = "${mockAccessToken}" +accountId = "${mockAccountId}" +accountName = "Test Account" +userEmail = "test@example.com" +currentAppId = "${mockAppId}" +`; + writeFileSync(resolve(testConfigDir, "config"), configContent); + }); + + afterEach(() => { + nock.cleanAll(); + delete process.env.ABLY_ACCESS_TOKEN; + + if (originalConfigDir) { + process.env.ABLY_CLI_CONFIG_DIR = originalConfigDir; + } else { + delete process.env.ABLY_CLI_CONFIG_DIR; + } + + if (existsSync(testConfigDir)) { + rmSync(testConfigDir, { recursive: true, force: true }); + } + }); + + describe("successful integration creation", () => { + it("should create an HTTP integration successfully", async () => { + const mockIntegration = { + id: mockRuleId, + appId: mockAppId, + ruleType: "http", + requestMode: "single", + source: { + channelFilter: "chat:*", + type: "channel.message", + }, + target: { + url: "https://example.com/webhook", + format: "json", + enveloped: true, + }, + status: "enabled", + created: Date.now(), + modified: Date.now(), + }; + + nock("https://control.ably.net") + .post(`/v1/apps/${mockAppId}/rules`) + .reply(201, mockIntegration); + + const { stdout } = await runCommand( + [ + "integrations:create", + "--rule-type", + "http", + "--source-type", + "channel.message", + "--target-url", + "https://example.com/webhook", + "--channel-filter", + "chat:*", + ], + import.meta.url, + ); + + expect(stdout).toContain("Integration created successfully"); + expect(stdout).toContain(mockRuleId); + expect(stdout).toContain("http"); + }); + + it("should create an AMQP integration successfully", async () => { + const mockIntegration = { + id: mockRuleId, + appId: mockAppId, + ruleType: "amqp", + requestMode: "single", + source: { + channelFilter: "", + type: "channel.message", + }, + target: { + enveloped: true, + format: "json", + exchangeName: "ably", + }, + status: "enabled", + created: Date.now(), + modified: Date.now(), + }; + + nock("https://control.ably.net") + .post(`/v1/apps/${mockAppId}/rules`) + .reply(201, mockIntegration); + + const { stdout } = await runCommand( + [ + "integrations:create", + "--rule-type", + "amqp", + "--source-type", + "channel.message", + ], + import.meta.url, + ); + + expect(stdout).toContain("Integration created successfully"); + expect(stdout).toContain("amqp"); + }); + + it("should output JSON format when --json flag is used", async () => { + const mockIntegration = { + id: mockRuleId, + appId: mockAppId, + ruleType: "http", + requestMode: "single", + source: { + channelFilter: "", + type: "channel.message", + }, + target: { + url: "https://example.com/webhook", + format: "json", + enveloped: true, + }, + status: "enabled", + }; + + nock("https://control.ably.net") + .post(`/v1/apps/${mockAppId}/rules`) + .reply(201, mockIntegration); + + const { stdout } = await runCommand( + [ + "integrations:create", + "--rule-type", + "http", + "--source-type", + "channel.message", + "--target-url", + "https://example.com/webhook", + "--json", + ], + import.meta.url, + ); + + const result = JSON.parse(stdout); + expect(result).toHaveProperty("integration"); + expect(result.integration).toHaveProperty("id", mockRuleId); + expect(result.integration).toHaveProperty("ruleType", "http"); + }); + + it("should create a disabled integration when status is disabled", async () => { + const mockIntegration = { + id: mockRuleId, + appId: mockAppId, + ruleType: "http", + requestMode: "single", + source: { + channelFilter: "", + type: "channel.message", + }, + target: { + url: "https://example.com/webhook", + }, + status: "disabled", + }; + + nock("https://control.ably.net") + .post(`/v1/apps/${mockAppId}/rules`, (body: any) => { + return body.status === "disabled"; + }) + .reply(201, mockIntegration); + + const { stdout } = await runCommand( + [ + "integrations:create", + "--rule-type", + "http", + "--source-type", + "channel.message", + "--target-url", + "https://example.com/webhook", + "--status", + "disabled", + "--json", + ], + import.meta.url, + ); + + const result = JSON.parse(stdout); + expect(result.integration).toHaveProperty("status", "disabled"); + }); + + it("should create integration with batch request mode", async () => { + const mockIntegration = { + id: mockRuleId, + appId: mockAppId, + ruleType: "http", + requestMode: "batch", + source: { + channelFilter: "", + type: "channel.message", + }, + target: { + url: "https://example.com/webhook", + }, + status: "enabled", + }; + + nock("https://control.ably.net") + .post(`/v1/apps/${mockAppId}/rules`, (body: any) => { + return body.requestMode === "batch"; + }) + .reply(201, mockIntegration); + + const { stdout } = await runCommand( + [ + "integrations:create", + "--rule-type", + "http", + "--source-type", + "channel.message", + "--target-url", + "https://example.com/webhook", + "--request-mode", + "batch", + "--json", + ], + import.meta.url, + ); + + const result = JSON.parse(stdout); + expect(result.integration).toHaveProperty("requestMode", "batch"); + }); + }); + + describe("error handling", () => { + it("should require rule-type flag", async () => { + const { error } = await runCommand( + ["integrations:create", "--source-type", "channel.message"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toMatch(/Missing required flag.*rule-type/i); + }); + + it("should require source-type flag", async () => { + const { error } = await runCommand( + ["integrations:create", "--rule-type", "http"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toMatch(/Missing required flag.*source-type/i); + }); + + it("should require target-url for HTTP integrations", async () => { + const { error } = await runCommand( + [ + "integrations:create", + "--rule-type", + "http", + "--source-type", + "channel.message", + ], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toMatch(/target-url.*required.*HTTP/i); + }); + + it("should handle API errors", async () => { + nock("https://control.ably.net") + .post(`/v1/apps/${mockAppId}/rules`) + .reply(400, { error: "Invalid integration configuration" }); + + const { error } = await runCommand( + [ + "integrations:create", + "--rule-type", + "http", + "--source-type", + "channel.message", + "--target-url", + "https://example.com/webhook", + ], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toMatch(/Error creating integration|400/i); + }); + + it("should reject unknown flags", async () => { + const { error } = await runCommand( + ["integrations:create", "--rule-type", "http", "--unknown-flag"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/unknown|Nonexistent flag/i); + }); + }); + + describe("source type options", () => { + it("should accept channel.presence source type", async () => { + const mockIntegration = { + id: mockRuleId, + appId: mockAppId, + ruleType: "http", + requestMode: "single", + source: { + channelFilter: "", + type: "channel.presence", + }, + target: { + url: "https://example.com/webhook", + }, + status: "enabled", + }; + + nock("https://control.ably.net") + .post(`/v1/apps/${mockAppId}/rules`) + .reply(201, mockIntegration); + + const { stdout } = await runCommand( + [ + "integrations:create", + "--rule-type", + "http", + "--source-type", + "channel.presence", + "--target-url", + "https://example.com/webhook", + "--json", + ], + import.meta.url, + ); + + const result = JSON.parse(stdout); + expect(result.integration.source.type).toBe("channel.presence"); + }); + + it("should accept channel.lifecycle source type", async () => { + const mockIntegration = { + id: mockRuleId, + appId: mockAppId, + ruleType: "http", + requestMode: "single", + source: { + channelFilter: "", + type: "channel.lifecycle", + }, + target: { + url: "https://example.com/webhook", + }, + status: "enabled", + }; + + nock("https://control.ably.net") + .post(`/v1/apps/${mockAppId}/rules`) + .reply(201, mockIntegration); + + const { stdout } = await runCommand( + [ + "integrations:create", + "--rule-type", + "http", + "--source-type", + "channel.lifecycle", + "--target-url", + "https://example.com/webhook", + "--json", + ], + import.meta.url, + ); + + const result = JSON.parse(stdout); + expect(result.integration.source.type).toBe("channel.lifecycle"); + }); + }); +}); From 00c1a86b201537fdd31f12393b64cffbfd987cf3 Mon Sep 17 00:00:00 2001 From: Andy Ford Date: Wed, 10 Dec 2025 23:10:56 +0000 Subject: [PATCH 36/44] test: add unit tests for integrations/delete, integrations/get, and integrations/update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../unit/commands/integrations/delete.test.ts | 216 +++++++++++++ test/unit/commands/integrations/get.test.ts | 230 ++++++++++++++ .../unit/commands/integrations/update.test.ts | 293 ++++++++++++++++++ 3 files changed, 739 insertions(+) create mode 100644 test/unit/commands/integrations/delete.test.ts create mode 100644 test/unit/commands/integrations/get.test.ts create mode 100644 test/unit/commands/integrations/update.test.ts diff --git a/test/unit/commands/integrations/delete.test.ts b/test/unit/commands/integrations/delete.test.ts new file mode 100644 index 00000000..4480b2ef --- /dev/null +++ b/test/unit/commands/integrations/delete.test.ts @@ -0,0 +1,216 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { runCommand } from "@oclif/test"; +import nock from "nock"; +import { resolve } from "node:path"; +import { mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; + +describe("integrations:delete command", () => { + const mockAccessToken = "fake_access_token"; + const mockAccountId = "test-account-id"; + const mockAppId = "550e8400-e29b-41d4-a716-446655440000"; + const mockRuleId = "rule-123456"; + let testConfigDir: string; + let originalConfigDir: string; + + beforeEach(() => { + process.env.ABLY_ACCESS_TOKEN = mockAccessToken; + + testConfigDir = resolve(tmpdir(), `ably-cli-test-${Date.now()}`); + mkdirSync(testConfigDir, { recursive: true, mode: 0o700 }); + + originalConfigDir = process.env.ABLY_CLI_CONFIG_DIR || ""; + process.env.ABLY_CLI_CONFIG_DIR = testConfigDir; + + const configContent = `[current] +account = "default" + +[accounts.default] +accessToken = "${mockAccessToken}" +accountId = "${mockAccountId}" +accountName = "Test Account" +userEmail = "test@example.com" +currentAppId = "${mockAppId}" +`; + writeFileSync(resolve(testConfigDir, "config"), configContent); + }); + + afterEach(() => { + nock.cleanAll(); + delete process.env.ABLY_ACCESS_TOKEN; + + if (originalConfigDir) { + process.env.ABLY_CLI_CONFIG_DIR = originalConfigDir; + } else { + delete process.env.ABLY_CLI_CONFIG_DIR; + } + + if (existsSync(testConfigDir)) { + rmSync(testConfigDir, { recursive: true, force: true }); + } + }); + + const mockIntegration = { + id: mockRuleId, + appId: mockAppId, + ruleType: "http", + requestMode: "single", + source: { + channelFilter: "chat:*", + type: "channel.message", + }, + target: { + url: "https://example.com/webhook", + format: "json", + enveloped: true, + }, + status: "enabled", + created: Date.now(), + modified: Date.now(), + }; + + describe("successful integration deletion", () => { + it("should delete an integration with --force flag", async () => { + // Mock GET to fetch integration details + nock("https://control.ably.net") + .get(`/v1/apps/${mockAppId}/rules/${mockRuleId}`) + .reply(200, mockIntegration); + + // Mock DELETE + nock("https://control.ably.net") + .delete(`/v1/apps/${mockAppId}/rules/${mockRuleId}`) + .reply(204); + + const { stdout } = await runCommand( + ["integrations:delete", mockRuleId, "--force"], + import.meta.url, + ); + + expect(stdout).toContain("Integration deleted successfully"); + expect(stdout).toContain(mockRuleId); + }); + + it("should display integration details before deletion with --force", async () => { + nock("https://control.ably.net") + .get(`/v1/apps/${mockAppId}/rules/${mockRuleId}`) + .reply(200, mockIntegration); + + nock("https://control.ably.net") + .delete(`/v1/apps/${mockAppId}/rules/${mockRuleId}`) + .reply(204); + + const { stdout } = await runCommand( + ["integrations:delete", mockRuleId, "--force"], + import.meta.url, + ); + + expect(stdout).toContain("http"); + expect(stdout).toContain(mockAppId); + }); + }); + + describe("error handling", () => { + it("should require integrationId argument", async () => { + const { error } = await runCommand( + ["integrations:delete", "--force"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toMatch(/Missing.*required arg/i); + }); + + it("should handle integration not found", async () => { + nock("https://control.ably.net") + .get(`/v1/apps/${mockAppId}/rules/${mockRuleId}`) + .reply(404, { error: "Not found" }); + + const { error } = await runCommand( + ["integrations:delete", mockRuleId, "--force"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toMatch(/Error deleting integration|404/i); + }); + + it("should handle API errors during deletion", async () => { + nock("https://control.ably.net") + .get(`/v1/apps/${mockAppId}/rules/${mockRuleId}`) + .reply(200, mockIntegration); + + nock("https://control.ably.net") + .delete(`/v1/apps/${mockAppId}/rules/${mockRuleId}`) + .reply(500, { error: "Internal server error" }); + + const { error } = await runCommand( + ["integrations:delete", mockRuleId, "--force"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toMatch(/Error deleting integration|500/i); + }); + + it("should reject unknown flags", async () => { + const { error } = await runCommand( + ["integrations:delete", mockRuleId, "--unknown-flag"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/unknown|Nonexistent flag/i); + }); + }); + + describe("flag options", () => { + it("should accept -f as shorthand for --force", async () => { + nock("https://control.ably.net") + .get(`/v1/apps/${mockAppId}/rules/${mockRuleId}`) + .reply(200, mockIntegration); + + nock("https://control.ably.net") + .delete(`/v1/apps/${mockAppId}/rules/${mockRuleId}`) + .reply(204); + + const { stdout } = await runCommand( + ["integrations:delete", mockRuleId, "-f"], + import.meta.url, + ); + + expect(stdout).toContain("Integration deleted successfully"); + }); + + it("should accept --app flag", async () => { + // Mock the /me endpoint (needed by listApps in resolveAppIdFromNameOrId) + nock("https://control.ably.net") + .get("/v1/me") + .reply(200, { + account: { id: mockAccountId, name: "Test Account" }, + user: { email: "test@example.com" }, + }); + + // Mock the apps list API call for resolveAppIdFromNameOrId + nock("https://control.ably.net") + .get(`/v1/accounts/${mockAccountId}/apps`) + .reply(200, [ + { id: mockAppId, name: "Test App", accountId: mockAccountId }, + ]); + + nock("https://control.ably.net") + .get(`/v1/apps/${mockAppId}/rules/${mockRuleId}`) + .reply(200, mockIntegration); + + nock("https://control.ably.net") + .delete(`/v1/apps/${mockAppId}/rules/${mockRuleId}`) + .reply(204); + + const { stdout } = await runCommand( + ["integrations:delete", mockRuleId, "--app", mockAppId, "--force"], + import.meta.url, + ); + + expect(stdout).toContain("Integration deleted successfully"); + }); + }); +}); diff --git a/test/unit/commands/integrations/get.test.ts b/test/unit/commands/integrations/get.test.ts new file mode 100644 index 00000000..9189e8c8 --- /dev/null +++ b/test/unit/commands/integrations/get.test.ts @@ -0,0 +1,230 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { runCommand } from "@oclif/test"; +import nock from "nock"; +import { resolve } from "node:path"; +import { mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; + +describe("integrations:get command", () => { + const mockAccessToken = "fake_access_token"; + const mockAccountId = "test-account-id"; + const mockAppId = "550e8400-e29b-41d4-a716-446655440000"; + const mockRuleId = "rule-123456"; + let testConfigDir: string; + let originalConfigDir: string; + + beforeEach(() => { + process.env.ABLY_ACCESS_TOKEN = mockAccessToken; + + testConfigDir = resolve(tmpdir(), `ably-cli-test-${Date.now()}`); + mkdirSync(testConfigDir, { recursive: true, mode: 0o700 }); + + originalConfigDir = process.env.ABLY_CLI_CONFIG_DIR || ""; + process.env.ABLY_CLI_CONFIG_DIR = testConfigDir; + + const configContent = `[current] +account = "default" + +[accounts.default] +accessToken = "${mockAccessToken}" +accountId = "${mockAccountId}" +accountName = "Test Account" +userEmail = "test@example.com" +currentAppId = "${mockAppId}" +`; + writeFileSync(resolve(testConfigDir, "config"), configContent); + }); + + afterEach(() => { + nock.cleanAll(); + delete process.env.ABLY_ACCESS_TOKEN; + + if (originalConfigDir) { + process.env.ABLY_CLI_CONFIG_DIR = originalConfigDir; + } else { + delete process.env.ABLY_CLI_CONFIG_DIR; + } + + if (existsSync(testConfigDir)) { + rmSync(testConfigDir, { recursive: true, force: true }); + } + }); + + const mockIntegration = { + id: mockRuleId, + appId: mockAppId, + 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(), + }; + + describe("successful integration retrieval", () => { + it("should display integration details", async () => { + nock("https://control.ably.net") + .get(`/v1/apps/${mockAppId}/rules/${mockRuleId}`) + .reply(200, mockIntegration); + + const { stdout } = await runCommand( + ["integrations:get", mockRuleId], + import.meta.url, + ); + + expect(stdout).toContain("Integration Rule Details"); + expect(stdout).toContain(mockRuleId); + expect(stdout).toContain(mockAppId); + expect(stdout).toContain("http"); + expect(stdout).toContain("channel.message"); + }); + + it("should output JSON format when --json flag is used", async () => { + nock("https://control.ably.net") + .get(`/v1/apps/${mockAppId}/rules/${mockRuleId}`) + .reply(200, mockIntegration); + + const { stdout } = await runCommand( + ["integrations:get", mockRuleId, "--json"], + import.meta.url, + ); + + const result = JSON.parse(stdout); + expect(result).toHaveProperty("id", mockRuleId); + expect(result).toHaveProperty("appId", mockAppId); + expect(result).toHaveProperty("ruleType", "http"); + expect(result).toHaveProperty("source"); + expect(result.source).toHaveProperty("type", "channel.message"); + }); + + it("should output pretty JSON when --pretty-json flag is used", async () => { + nock("https://control.ably.net") + .get(`/v1/apps/${mockAppId}/rules/${mockRuleId}`) + .reply(200, mockIntegration); + + const { stdout } = await runCommand( + ["integrations:get", mockRuleId, "--pretty-json"], + import.meta.url, + ); + + // Pretty JSON should have newlines + expect(stdout).toContain("\n"); + const result = JSON.parse(stdout); + expect(result).toHaveProperty("id", mockRuleId); + }); + + it("should display channel filter", async () => { + nock("https://control.ably.net") + .get(`/v1/apps/${mockAppId}/rules/${mockRuleId}`) + .reply(200, mockIntegration); + + const { stdout } = await runCommand( + ["integrations:get", mockRuleId], + import.meta.url, + ); + + expect(stdout).toContain("chat:*"); + }); + + it("should display target information", async () => { + nock("https://control.ably.net") + .get(`/v1/apps/${mockAppId}/rules/${mockRuleId}`) + .reply(200, mockIntegration); + + const { stdout } = await runCommand( + ["integrations:get", mockRuleId], + import.meta.url, + ); + + expect(stdout).toContain("Target:"); + expect(stdout).toContain('"url": "https://example.com/webhook"'); + }); + }); + + describe("error handling", () => { + it("should require ruleId argument", async () => { + const { error } = await runCommand(["integrations:get"], import.meta.url); + + expect(error).toBeDefined(); + expect(error?.message).toMatch(/Missing.*required arg/i); + }); + + it("should handle integration not found", async () => { + nock("https://control.ably.net") + .get(`/v1/apps/${mockAppId}/rules/${mockRuleId}`) + .reply(404, { error: "Not found" }); + + const { error } = await runCommand( + ["integrations:get", mockRuleId], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toMatch(/Error getting integration|404/i); + }); + + it("should handle API errors", async () => { + nock("https://control.ably.net") + .get(`/v1/apps/${mockAppId}/rules/${mockRuleId}`) + .reply(500, { error: "Internal server error" }); + + const { error } = await runCommand( + ["integrations:get", mockRuleId], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toMatch(/Error getting integration|500/i); + }); + + it("should reject unknown flags", async () => { + const { error } = await runCommand( + ["integrations:get", mockRuleId, "--unknown-flag"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/unknown|Nonexistent flag/i); + }); + }); + + describe("flag options", () => { + it("should accept --app flag", async () => { + // Mock the /me endpoint (needed by listApps in resolveAppIdFromNameOrId) + nock("https://control.ably.net") + .get("/v1/me") + .reply(200, { + account: { id: mockAccountId, name: "Test Account" }, + user: { email: "test@example.com" }, + }); + + // Mock the apps list API call for resolveAppIdFromNameOrId + nock("https://control.ably.net") + .get(`/v1/accounts/${mockAccountId}/apps`) + .reply(200, [ + { id: mockAppId, name: "Test App", accountId: mockAccountId }, + ]); + + nock("https://control.ably.net") + .get(`/v1/apps/${mockAppId}/rules/${mockRuleId}`) + .reply(200, mockIntegration); + + const { stdout } = await runCommand( + ["integrations:get", mockRuleId, "--app", mockAppId], + import.meta.url, + ); + + expect(stdout).toContain("Integration Rule Details"); + expect(stdout).toContain(mockRuleId); + }); + }); +}); diff --git a/test/unit/commands/integrations/update.test.ts b/test/unit/commands/integrations/update.test.ts new file mode 100644 index 00000000..b3ec52e6 --- /dev/null +++ b/test/unit/commands/integrations/update.test.ts @@ -0,0 +1,293 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { runCommand } from "@oclif/test"; +import nock from "nock"; +import { resolve } from "node:path"; +import { mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; + +describe("integrations:update command", () => { + const mockAccessToken = "fake_access_token"; + const mockAccountId = "test-account-id"; + const mockAppId = "550e8400-e29b-41d4-a716-446655440000"; + const mockRuleId = "rule-123456"; + let testConfigDir: string; + let originalConfigDir: string; + + beforeEach(() => { + process.env.ABLY_ACCESS_TOKEN = mockAccessToken; + + testConfigDir = resolve(tmpdir(), `ably-cli-test-${Date.now()}`); + mkdirSync(testConfigDir, { recursive: true, mode: 0o700 }); + + originalConfigDir = process.env.ABLY_CLI_CONFIG_DIR || ""; + process.env.ABLY_CLI_CONFIG_DIR = testConfigDir; + + const configContent = `[current] +account = "default" + +[accounts.default] +accessToken = "${mockAccessToken}" +accountId = "${mockAccountId}" +accountName = "Test Account" +userEmail = "test@example.com" +currentAppId = "${mockAppId}" +`; + writeFileSync(resolve(testConfigDir, "config"), configContent); + }); + + afterEach(() => { + nock.cleanAll(); + delete process.env.ABLY_ACCESS_TOKEN; + + if (originalConfigDir) { + process.env.ABLY_CLI_CONFIG_DIR = originalConfigDir; + } else { + delete process.env.ABLY_CLI_CONFIG_DIR; + } + + if (existsSync(testConfigDir)) { + rmSync(testConfigDir, { recursive: true, force: true }); + } + }); + + const mockIntegration = { + id: mockRuleId, + appId: mockAppId, + 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(), + }; + + describe("successful integration update", () => { + it("should update channel filter", async () => { + const updatedIntegration = { + ...mockIntegration, + source: { + ...mockIntegration.source, + channelFilter: "messages:*", + }, + }; + + // Mock GET to fetch existing integration + nock("https://control.ably.net") + .get(`/v1/apps/${mockAppId}/rules/${mockRuleId}`) + .reply(200, mockIntegration); + + // Mock PATCH to update integration + nock("https://control.ably.net") + .patch(`/v1/apps/${mockAppId}/rules/${mockRuleId}`) + .reply(200, updatedIntegration); + + const { stdout } = await runCommand( + ["integrations:update", mockRuleId, "--channel-filter", "messages:*"], + import.meta.url, + ); + + expect(stdout).toContain("Integration Rule Updated Successfully"); + expect(stdout).toContain(mockRuleId); + }); + + it("should update target URL for HTTP integrations", async () => { + const newUrl = "https://new-example.com/webhook"; + const updatedIntegration = { + ...mockIntegration, + target: { + ...mockIntegration.target, + url: newUrl, + }, + }; + + nock("https://control.ably.net") + .get(`/v1/apps/${mockAppId}/rules/${mockRuleId}`) + .reply(200, mockIntegration); + + nock("https://control.ably.net") + .patch(`/v1/apps/${mockAppId}/rules/${mockRuleId}`) + .reply(200, updatedIntegration); + + const { stdout } = await runCommand( + ["integrations:update", mockRuleId, "--target-url", newUrl], + import.meta.url, + ); + + expect(stdout).toContain("Integration Rule Updated Successfully"); + }); + + it("should output JSON format when --json flag is used", async () => { + const updatedIntegration = { + ...mockIntegration, + source: { + ...mockIntegration.source, + channelFilter: "updates:*", + }, + }; + + nock("https://control.ably.net") + .get(`/v1/apps/${mockAppId}/rules/${mockRuleId}`) + .reply(200, mockIntegration); + + nock("https://control.ably.net") + .patch(`/v1/apps/${mockAppId}/rules/${mockRuleId}`) + .reply(200, updatedIntegration); + + const { stdout } = await runCommand( + [ + "integrations:update", + mockRuleId, + "--channel-filter", + "updates:*", + "--json", + ], + import.meta.url, + ); + + const result = JSON.parse(stdout); + expect(result).toHaveProperty("rule"); + expect(result.rule).toHaveProperty("id", mockRuleId); + }); + + it("should update request mode", async () => { + const updatedIntegration = { + ...mockIntegration, + requestMode: "batch", + }; + + nock("https://control.ably.net") + .get(`/v1/apps/${mockAppId}/rules/${mockRuleId}`) + .reply(200, mockIntegration); + + nock("https://control.ably.net") + .patch(`/v1/apps/${mockAppId}/rules/${mockRuleId}`) + .reply(200, updatedIntegration); + + const { stdout } = await runCommand( + [ + "integrations:update", + mockRuleId, + "--request-mode", + "batch", + "--json", + ], + import.meta.url, + ); + + const result = JSON.parse(stdout); + expect(result.rule).toHaveProperty("requestMode", "batch"); + }); + }); + + describe("error handling", () => { + it("should require ruleId argument", async () => { + const { error } = await runCommand( + ["integrations:update", "--channel-filter", "test:*"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toMatch(/Missing.*required arg/i); + }); + + it("should handle integration not found", async () => { + nock("https://control.ably.net") + .get(`/v1/apps/${mockAppId}/rules/${mockRuleId}`) + .reply(404, { error: "Not found" }); + + const { error } = await runCommand( + ["integrations:update", mockRuleId, "--channel-filter", "test:*"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toMatch(/Error updating integration|404/i); + }); + + it("should handle API errors during update", async () => { + nock("https://control.ably.net") + .get(`/v1/apps/${mockAppId}/rules/${mockRuleId}`) + .reply(200, mockIntegration); + + nock("https://control.ably.net") + .patch(`/v1/apps/${mockAppId}/rules/${mockRuleId}`) + .reply(400, { error: "Invalid update" }); + + const { error } = await runCommand( + ["integrations:update", mockRuleId, "--channel-filter", "test:*"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toMatch(/Error updating integration|400/i); + }); + + it("should reject unknown flags", async () => { + const { error } = await runCommand( + ["integrations:update", mockRuleId, "--unknown-flag"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/unknown|Nonexistent flag/i); + }); + }); + + describe("flag options", () => { + it("should accept --app flag", async () => { + const updatedIntegration = { + ...mockIntegration, + source: { + ...mockIntegration.source, + channelFilter: "new:*", + }, + }; + + // Mock the /me endpoint (needed by listApps in resolveAppIdFromNameOrId) + nock("https://control.ably.net") + .get("/v1/me") + .reply(200, { + account: { id: mockAccountId, name: "Test Account" }, + user: { email: "test@example.com" }, + }); + + // Mock the apps list API call for resolveAppIdFromNameOrId + nock("https://control.ably.net") + .get(`/v1/accounts/${mockAccountId}/apps`) + .reply(200, [ + { id: mockAppId, name: "Test App", accountId: mockAccountId }, + ]); + + nock("https://control.ably.net") + .get(`/v1/apps/${mockAppId}/rules/${mockRuleId}`) + .reply(200, mockIntegration); + + nock("https://control.ably.net") + .patch(`/v1/apps/${mockAppId}/rules/${mockRuleId}`) + .reply(200, updatedIntegration); + + const { stdout } = await runCommand( + [ + "integrations:update", + mockRuleId, + "--app", + mockAppId, + "--channel-filter", + "new:*", + ], + import.meta.url, + ); + + expect(stdout).toContain("Integration Rule Updated Successfully"); + }); + }); +}); From c59dbb47d830a444b74be44ddfd1e133148002a7 Mon Sep 17 00:00:00 2001 From: Andy Ford Date: Wed, 10 Dec 2025 23:20:55 +0000 Subject: [PATCH 37/44] test: add unit test for logs/channel-lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Also refactor command to use waitUntilInterruptedOrTimeout for consistent test mode handling. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/commands/logs/channel-lifecycle.ts | 1 + .../commands/logs/channel-lifecycle.test.ts | 364 ++++++++++++++++++ 2 files changed, 365 insertions(+) create mode 100644 test/unit/commands/logs/channel-lifecycle.test.ts diff --git a/src/commands/logs/channel-lifecycle.ts b/src/commands/logs/channel-lifecycle.ts index cd2fd984..66742f3b 100644 --- a/src/commands/logs/channel-lifecycle.ts +++ b/src/commands/logs/channel-lifecycle.ts @@ -98,6 +98,7 @@ export default class LogsChannelLifecycle extends AblyBaseCommand { this.log(""); }); + // Wait until interrupted await waitUntilInterruptedOrTimeout(); } catch (error: unknown) { const err = error as Error; diff --git a/test/unit/commands/logs/channel-lifecycle.test.ts b/test/unit/commands/logs/channel-lifecycle.test.ts new file mode 100644 index 00000000..d4f0109c --- /dev/null +++ b/test/unit/commands/logs/channel-lifecycle.test.ts @@ -0,0 +1,364 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { runCommand } from "@oclif/test"; +import { resolve } from "node:path"; +import { mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; + +describe("logs:channel-lifecycle command", () => { + const mockAccessToken = "fake_access_token"; + const mockAccountId = "test-account-id"; + const mockAppId = "550e8400-e29b-41d4-a716-446655440000"; + const mockApiKey = `${mockAppId}.testkey:testsecret`; + let testConfigDir: string; + let originalConfigDir: string; + + beforeEach(() => { + process.env.ABLY_ACCESS_TOKEN = mockAccessToken; + + testConfigDir = resolve(tmpdir(), `ably-cli-test-${Date.now()}`); + mkdirSync(testConfigDir, { recursive: true, mode: 0o700 }); + + originalConfigDir = process.env.ABLY_CLI_CONFIG_DIR || ""; + process.env.ABLY_CLI_CONFIG_DIR = testConfigDir; + + const configContent = `[current] +account = "default" + +[accounts.default] +accessToken = "${mockAccessToken}" +accountId = "${mockAccountId}" +accountName = "Test Account" +userEmail = "test@example.com" +currentAppId = "${mockAppId}" + +[accounts.default.apps."${mockAppId}"] +appName = "Test App" +apiKey = "${mockApiKey}" +`; + writeFileSync(resolve(testConfigDir, "config"), configContent); + }); + + afterEach(() => { + delete process.env.ABLY_ACCESS_TOKEN; + + if (originalConfigDir) { + process.env.ABLY_CLI_CONFIG_DIR = originalConfigDir; + } else { + delete process.env.ABLY_CLI_CONFIG_DIR; + } + + if (existsSync(testConfigDir)) { + rmSync(testConfigDir, { recursive: true, force: true }); + } + + globalThis.__TEST_MOCKS__ = undefined; + }); + + describe("command flags", () => { + it("should reject unknown flags", async () => { + const { error } = await runCommand( + ["logs:channel-lifecycle", "--unknown-flag-xyz"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/unknown|Nonexistent flag/i); + }); + }); + + describe("subscription behavior", () => { + it("should subscribe to [meta]channel.lifecycle and show initial message", async () => { + const mockChannel = { + name: "[meta]channel.lifecycle", + subscribe: vi.fn(), + unsubscribe: vi.fn(), + on: vi.fn(), + detach: vi.fn(), + }; + + const mockChannels = { + get: vi.fn().mockReturnValue(mockChannel), + release: vi.fn(), + }; + + const mockConnection = { + on: vi.fn(), + once: vi.fn(), + state: "connected", + }; + + globalThis.__TEST_MOCKS__ = { + ablyRealtimeMock: { + channels: mockChannels, + connection: mockConnection, + close: vi.fn(), + }, + }; + + // Emit SIGINT after a short delay to exit the command + setTimeout(() => process.emit("SIGINT", "SIGINT"), 100); + + const { stdout } = await runCommand( + ["logs:channel-lifecycle"], + import.meta.url, + ); + + expect(stdout).toContain("Subscribing to"); + expect(stdout).toContain("[meta]channel.lifecycle"); + expect(stdout).toContain("Press Ctrl+C to exit"); + }); + + it("should get channel without rewind params when --rewind is not specified", async () => { + const mockChannel = { + name: "[meta]channel.lifecycle", + subscribe: vi.fn(), + unsubscribe: vi.fn(), + on: vi.fn(), + detach: vi.fn(), + }; + + const mockChannels = { + get: vi.fn().mockReturnValue(mockChannel), + release: vi.fn(), + }; + + const mockConnection = { + on: vi.fn(), + once: vi.fn(), + state: "connected", + }; + + globalThis.__TEST_MOCKS__ = { + ablyRealtimeMock: { + channels: mockChannels, + connection: mockConnection, + close: vi.fn(), + }, + }; + + setTimeout(() => process.emit("SIGINT", "SIGINT"), 100); + + await runCommand(["logs:channel-lifecycle"], import.meta.url); + + // Verify channel was gotten with empty options (no rewind) + expect(mockChannels.get).toHaveBeenCalledWith( + "[meta]channel.lifecycle", + {}, + ); + }); + + it("should configure rewind channel option when --rewind is specified", async () => { + const mockChannel = { + name: "[meta]channel.lifecycle", + subscribe: vi.fn(), + unsubscribe: vi.fn(), + on: vi.fn(), + detach: vi.fn(), + }; + + const mockChannels = { + get: vi.fn().mockReturnValue(mockChannel), + release: vi.fn(), + }; + + const mockConnection = { + on: vi.fn(), + once: vi.fn(), + state: "connected", + }; + + globalThis.__TEST_MOCKS__ = { + ablyRealtimeMock: { + channels: mockChannels, + connection: mockConnection, + close: vi.fn(), + }, + }; + + setTimeout(() => process.emit("SIGINT", "SIGINT"), 100); + + await runCommand( + ["logs:channel-lifecycle", "--rewind", "5"], + import.meta.url, + ); + + // Verify channel was gotten with rewind params + expect(mockChannels.get).toHaveBeenCalledWith("[meta]channel.lifecycle", { + params: { + rewind: "5", + }, + }); + }); + + it("should subscribe to channel messages", async () => { + const mockChannel = { + name: "[meta]channel.lifecycle", + subscribe: vi.fn(), + unsubscribe: vi.fn(), + on: vi.fn(), + detach: vi.fn(), + }; + + const mockChannels = { + get: vi.fn().mockReturnValue(mockChannel), + release: vi.fn(), + }; + + const mockConnection = { + on: vi.fn(), + once: vi.fn(), + state: "connected", + }; + + globalThis.__TEST_MOCKS__ = { + ablyRealtimeMock: { + channels: mockChannels, + connection: mockConnection, + close: vi.fn(), + }, + }; + + setTimeout(() => process.emit("SIGINT", "SIGINT"), 100); + + await runCommand(["logs:channel-lifecycle"], import.meta.url); + + // Verify subscribe was called with a callback function + expect(mockChannel.subscribe).toHaveBeenCalledWith(expect.any(Function)); + }); + }); + + describe("error handling", () => { + it("should handle subscription errors gracefully", async () => { + const mockChannel = { + name: "[meta]channel.lifecycle", + subscribe: vi.fn().mockImplementation(() => { + throw new Error("Subscription failed"); + }), + unsubscribe: vi.fn(), + on: vi.fn(), + detach: vi.fn(), + }; + + const mockChannels = { + get: vi.fn().mockReturnValue(mockChannel), + release: vi.fn(), + }; + + const mockConnection = { + on: vi.fn(), + once: vi.fn(), + state: "connected", + }; + + globalThis.__TEST_MOCKS__ = { + ablyRealtimeMock: { + channels: mockChannels, + connection: mockConnection, + close: vi.fn(), + }, + }; + + const { error } = await runCommand( + ["logs:channel-lifecycle"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toMatch(/Subscription failed/i); + }); + + it("should handle missing mock client in test mode", async () => { + // No mock set up + globalThis.__TEST_MOCKS__ = undefined; + + const { error } = await runCommand( + ["logs:channel-lifecycle"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toMatch(/No mock|client/i); + }); + }); + + describe("cleanup behavior", () => { + it("should call client.close on cleanup", async () => { + const mockChannel = { + name: "[meta]channel.lifecycle", + subscribe: vi.fn(), + unsubscribe: vi.fn(), + on: vi.fn(), + detach: vi.fn(), + }; + + const mockChannels = { + get: vi.fn().mockReturnValue(mockChannel), + release: vi.fn(), + }; + + const mockConnection = { + on: vi.fn(), + once: vi.fn(), + state: "connected", + }; + + const mockClose = vi.fn(); + + globalThis.__TEST_MOCKS__ = { + ablyRealtimeMock: { + channels: mockChannels, + connection: mockConnection, + close: mockClose, + }, + }; + + setTimeout(() => process.emit("SIGINT", "SIGINT"), 100); + + await runCommand(["logs:channel-lifecycle"], import.meta.url); + + // Verify close was called during cleanup + expect(mockClose).toHaveBeenCalled(); + }); + }); + + describe("output formats", () => { + it("should accept --json flag", async () => { + const mockChannel = { + name: "[meta]channel.lifecycle", + subscribe: vi.fn(), + unsubscribe: vi.fn(), + on: vi.fn(), + detach: vi.fn(), + }; + + const mockChannels = { + get: vi.fn().mockReturnValue(mockChannel), + release: vi.fn(), + }; + + const mockConnection = { + on: vi.fn(), + once: vi.fn(), + state: "connected", + }; + + globalThis.__TEST_MOCKS__ = { + ablyRealtimeMock: { + channels: mockChannels, + connection: mockConnection, + close: vi.fn(), + }, + }; + + setTimeout(() => process.emit("SIGINT", "SIGINT"), 100); + + const { error } = await runCommand( + ["logs:channel-lifecycle", "--json"], + import.meta.url, + ); + + // No flag-related error should occur + expect(error?.message || "").not.toMatch(/unknown|Nonexistent flag/i); + }); + }); +}); From 7e7fccf1208bb76b9a48a9f9ae7a3891c7de4f5c Mon Sep 17 00:00:00 2001 From: Andy Ford Date: Wed, 10 Dec 2025 23:28:36 +0000 Subject: [PATCH 38/44] test: add unit test for spaces/cursors/get-all MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../commands/spaces/cursors/get-all.test.ts | 363 ++++++++++++++++++ 1 file changed, 363 insertions(+) create mode 100644 test/unit/commands/spaces/cursors/get-all.test.ts diff --git a/test/unit/commands/spaces/cursors/get-all.test.ts b/test/unit/commands/spaces/cursors/get-all.test.ts new file mode 100644 index 00000000..97ed706e --- /dev/null +++ b/test/unit/commands/spaces/cursors/get-all.test.ts @@ -0,0 +1,363 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { runCommand } from "@oclif/test"; +import { resolve } from "node:path"; +import { mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; + +describe("spaces:cursors:get-all command", () => { + const mockAccessToken = "fake_access_token"; + const mockAccountId = "test-account-id"; + const mockAppId = "550e8400-e29b-41d4-a716-446655440000"; + const mockApiKey = `${mockAppId}.testkey:testsecret`; + let testConfigDir: string; + let originalConfigDir: string; + + beforeEach(() => { + process.env.ABLY_ACCESS_TOKEN = mockAccessToken; + + testConfigDir = resolve(tmpdir(), `ably-cli-test-${Date.now()}`); + mkdirSync(testConfigDir, { recursive: true, mode: 0o700 }); + + originalConfigDir = process.env.ABLY_CLI_CONFIG_DIR || ""; + process.env.ABLY_CLI_CONFIG_DIR = testConfigDir; + + const configContent = `[current] +account = "default" + +[accounts.default] +accessToken = "${mockAccessToken}" +accountId = "${mockAccountId}" +accountName = "Test Account" +userEmail = "test@example.com" +currentAppId = "${mockAppId}" + +[accounts.default.apps."${mockAppId}"] +appName = "Test App" +apiKey = "${mockApiKey}" +`; + writeFileSync(resolve(testConfigDir, "config"), configContent); + }); + + afterEach(() => { + delete process.env.ABLY_ACCESS_TOKEN; + + if (originalConfigDir) { + process.env.ABLY_CLI_CONFIG_DIR = originalConfigDir; + } else { + delete process.env.ABLY_CLI_CONFIG_DIR; + } + + if (existsSync(testConfigDir)) { + rmSync(testConfigDir, { recursive: true, force: true }); + } + + globalThis.__TEST_MOCKS__ = undefined; + }); + + describe("command arguments and flags", () => { + it("should require space argument", async () => { + const { error } = await runCommand( + ["spaces:cursors:get-all"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/Missing .* required arg/); + }); + + it("should reject unknown flags", async () => { + const { error } = await runCommand( + ["spaces:cursors:get-all", "test-space", "--unknown-flag"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/unknown|Nonexistent flag/i); + }); + + it("should accept --json flag", async () => { + // Set up mocks for successful run + const mockCursors = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + getAll: vi.fn().mockResolvedValue([]), + }; + + const mockMembers = { + getAll: vi.fn().mockResolvedValue([]), + }; + + const mockSpace = { + cursors: mockCursors, + members: mockMembers, + enter: vi.fn().mockResolvedValue(), + leave: vi.fn().mockResolvedValue(), + }; + + const mockConnection = { + on: vi.fn(), + once: vi.fn(), + state: "connected", + id: "test-connection-id", + }; + + const mockRealtimeClient = { + connection: mockConnection, + close: vi.fn(), + }; + + const mockSpacesClient = { + get: vi.fn().mockReturnValue(mockSpace), + }; + + globalThis.__TEST_MOCKS__ = { + ablyRealtimeMock: mockRealtimeClient, + ablySpacesMock: mockSpacesClient, + }; + + const { error } = await runCommand( + ["spaces:cursors:get-all", "test-space", "--json"], + import.meta.url, + ); + + // Should not have unknown flag error + expect(error?.message || "").not.toMatch(/unknown|Nonexistent flag/i); + }); + + it("should accept --pretty-json flag", async () => { + const { error } = await runCommand( + ["spaces:cursors:get-all", "test-space", "--pretty-json"], + import.meta.url, + ); + + // Should not have unknown flag error + expect(error?.message || "").not.toMatch(/unknown|Nonexistent flag/i); + }); + }); + + describe("cursor retrieval", () => { + it("should get all cursors from a space", async () => { + const mockCursorsData = [ + { + clientId: "user-1", + connectionId: "conn-1", + position: { x: 100, y: 200 }, + data: { color: "red" }, + }, + { + clientId: "user-2", + connectionId: "conn-2", + position: { x: 300, y: 400 }, + data: { color: "blue" }, + }, + ]; + + const mockCursors = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + getAll: vi.fn().mockResolvedValue(mockCursorsData), + }; + + const mockMembers = { + getAll: vi.fn().mockResolvedValue([]), + }; + + const mockSpace = { + cursors: mockCursors, + members: mockMembers, + enter: vi.fn().mockResolvedValue(), + leave: vi.fn().mockResolvedValue(), + }; + + const mockConnection = { + on: vi.fn(), + once: vi.fn(), + state: "connected", + id: "test-connection-id", + }; + + const mockRealtimeClient = { + connection: mockConnection, + close: vi.fn(), + }; + + const mockSpacesClient = { + get: vi.fn().mockReturnValue(mockSpace), + }; + + globalThis.__TEST_MOCKS__ = { + ablyRealtimeMock: mockRealtimeClient, + ablySpacesMock: mockSpacesClient, + }; + + const { stdout } = await runCommand( + ["spaces:cursors:get-all", "test-space", "--json"], + import.meta.url, + ); + + expect(mockSpace.enter).toHaveBeenCalled(); + expect(mockCursors.subscribe).toHaveBeenCalledWith( + "update", + expect.any(Function), + ); + expect(mockCursors.getAll).toHaveBeenCalled(); + + // The command outputs multiple JSON lines - check the content contains expected data + expect(stdout).toContain("test-space"); + expect(stdout).toContain("success"); + }); + + it("should handle no cursors found", async () => { + const mockCursors = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + getAll: vi.fn().mockResolvedValue([]), + }; + + const mockMembers = { + getAll: vi.fn().mockResolvedValue([]), + }; + + const mockSpace = { + cursors: mockCursors, + members: mockMembers, + enter: vi.fn().mockResolvedValue(), + leave: vi.fn().mockResolvedValue(), + }; + + const mockConnection = { + on: vi.fn(), + once: vi.fn(), + state: "connected", + id: "test-connection-id", + }; + + const mockRealtimeClient = { + connection: mockConnection, + close: vi.fn(), + }; + + const mockSpacesClient = { + get: vi.fn().mockReturnValue(mockSpace), + }; + + globalThis.__TEST_MOCKS__ = { + ablyRealtimeMock: mockRealtimeClient, + ablySpacesMock: mockSpacesClient, + }; + + const { stdout } = await runCommand( + ["spaces:cursors:get-all", "test-space", "--json"], + import.meta.url, + ); + + // The command outputs multiple JSON lines, last one has cursors array + expect(stdout).toContain("cursors"); + }); + }); + + describe("error handling", () => { + it("should handle getAll rejection gracefully", async () => { + const mockCursors = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + getAll: vi.fn().mockRejectedValue(new Error("Failed to get cursors")), + }; + + const mockMembers = { + getAll: vi.fn().mockResolvedValue([]), + }; + + const mockSpace = { + cursors: mockCursors, + members: mockMembers, + enter: vi.fn().mockResolvedValue(), + leave: vi.fn().mockResolvedValue(), + }; + + const mockConnection = { + on: vi.fn(), + once: vi.fn(), + state: "connected", + id: "test-connection-id", + }; + + const mockRealtimeClient = { + connection: mockConnection, + close: vi.fn(), + }; + + const mockSpacesClient = { + get: vi.fn().mockReturnValue(mockSpace), + }; + + globalThis.__TEST_MOCKS__ = { + ablyRealtimeMock: mockRealtimeClient, + ablySpacesMock: mockSpacesClient, + }; + + // The command catches getAll errors and continues with live updates only + // So this should complete without throwing + const { stdout } = await runCommand( + ["spaces:cursors:get-all", "test-space", "--json"], + import.meta.url, + ); + + // Command should still output JSON even if getAll fails + expect(stdout).toBeDefined(); + expect(mockCursors.getAll).toHaveBeenCalled(); + }); + }); + + describe("cleanup behavior", () => { + it("should leave space and close client on completion", async () => { + const mockCursors = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + getAll: vi.fn().mockResolvedValue([]), + }; + + const mockMembers = { + getAll: vi.fn().mockResolvedValue([]), + }; + + const mockSpace = { + cursors: mockCursors, + members: mockMembers, + enter: vi.fn().mockResolvedValue(), + leave: vi.fn().mockResolvedValue(), + }; + + const mockConnection = { + on: vi.fn(), + once: vi.fn(), + state: "connected", + id: "test-connection-id", + }; + + const mockClose = vi.fn(); + const mockRealtimeClient = { + connection: mockConnection, + close: mockClose, + }; + + const mockSpacesClient = { + get: vi.fn().mockReturnValue(mockSpace), + }; + + globalThis.__TEST_MOCKS__ = { + ablyRealtimeMock: mockRealtimeClient, + ablySpacesMock: mockSpacesClient, + }; + + await runCommand( + ["spaces:cursors:get-all", "test-space", "--json"], + import.meta.url, + ); + + // Verify cleanup was performed + expect(mockSpace.leave).toHaveBeenCalled(); + expect(mockClose).toHaveBeenCalled(); + }); + }); +}); From 24f8ffb995cacc6678decc74c710c8a0092281be Mon Sep 17 00:00:00 2001 From: Andy Ford Date: Wed, 10 Dec 2025 23:32:50 +0000 Subject: [PATCH 39/44] test: add unit test for spaces/cursors/subscribe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../commands/spaces/cursors/subscribe.test.ts | 500 ++++++++++++++++++ 1 file changed, 500 insertions(+) create mode 100644 test/unit/commands/spaces/cursors/subscribe.test.ts diff --git a/test/unit/commands/spaces/cursors/subscribe.test.ts b/test/unit/commands/spaces/cursors/subscribe.test.ts new file mode 100644 index 00000000..9cf4d4f1 --- /dev/null +++ b/test/unit/commands/spaces/cursors/subscribe.test.ts @@ -0,0 +1,500 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { runCommand } from "@oclif/test"; +import { resolve } from "node:path"; +import { mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; + +describe("spaces:cursors:subscribe command", () => { + const mockAccessToken = "fake_access_token"; + const mockAccountId = "test-account-id"; + const mockAppId = "550e8400-e29b-41d4-a716-446655440000"; + const mockApiKey = `${mockAppId}.testkey:testsecret`; + let testConfigDir: string; + let originalConfigDir: string; + + beforeEach(() => { + process.env.ABLY_ACCESS_TOKEN = mockAccessToken; + + testConfigDir = resolve(tmpdir(), `ably-cli-test-${Date.now()}`); + mkdirSync(testConfigDir, { recursive: true, mode: 0o700 }); + + originalConfigDir = process.env.ABLY_CLI_CONFIG_DIR || ""; + process.env.ABLY_CLI_CONFIG_DIR = testConfigDir; + + const configContent = `[current] +account = "default" + +[accounts.default] +accessToken = "${mockAccessToken}" +accountId = "${mockAccountId}" +accountName = "Test Account" +userEmail = "test@example.com" +currentAppId = "${mockAppId}" + +[accounts.default.apps."${mockAppId}"] +appName = "Test App" +apiKey = "${mockApiKey}" +`; + writeFileSync(resolve(testConfigDir, "config"), configContent); + }); + + afterEach(() => { + delete process.env.ABLY_ACCESS_TOKEN; + + if (originalConfigDir) { + process.env.ABLY_CLI_CONFIG_DIR = originalConfigDir; + } else { + delete process.env.ABLY_CLI_CONFIG_DIR; + } + + if (existsSync(testConfigDir)) { + rmSync(testConfigDir, { recursive: true, force: true }); + } + + globalThis.__TEST_MOCKS__ = undefined; + }); + + describe("command arguments and flags", () => { + it("should require space argument", async () => { + const { error } = await runCommand( + ["spaces:cursors:subscribe"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/Missing .* required arg/); + }); + + it("should reject unknown flags", async () => { + const { error } = await runCommand( + ["spaces:cursors:subscribe", "test-space", "--unknown-flag"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/unknown|Nonexistent flag/i); + }); + + it("should accept --json flag", async () => { + const mockCursorsChannel = { + state: "attached", + on: vi.fn(), + off: vi.fn(), + }; + + const mockCursors = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + getAll: vi.fn().mockResolvedValue([]), + channel: mockCursorsChannel, + }; + + const mockMembers = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + getAll: vi.fn().mockResolvedValue([]), + }; + + const mockLocks = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + }; + + const mockLocations = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + }; + + const mockSpace = { + cursors: mockCursors, + members: mockMembers, + locks: mockLocks, + locations: mockLocations, + enter: vi.fn().mockResolvedValue(), + leave: vi.fn().mockResolvedValue(), + }; + + const mockConnection = { + on: vi.fn(), + once: vi.fn(), + state: "connected", + id: "test-connection-id", + }; + + const mockAuth = { + clientId: "test-client-id", + }; + + const mockRealtimeClient = { + connection: mockConnection, + auth: mockAuth, + close: vi.fn(), + }; + + const mockSpacesClient = { + get: vi.fn().mockReturnValue(mockSpace), + }; + + globalThis.__TEST_MOCKS__ = { + ablyRealtimeMock: mockRealtimeClient, + ablySpacesMock: mockSpacesClient, + }; + + // Emit SIGINT to exit the command + setTimeout(() => process.emit("SIGINT", "SIGINT"), 100); + + const { error } = await runCommand( + ["spaces:cursors:subscribe", "test-space", "--json"], + import.meta.url, + ); + + // Should not have unknown flag error + expect(error?.message || "").not.toMatch(/unknown|Nonexistent flag/i); + }); + + it("should accept --pretty-json flag", async () => { + const { error } = await runCommand( + ["spaces:cursors:subscribe", "test-space", "--pretty-json"], + import.meta.url, + ); + + // Should not have unknown flag error + expect(error?.message || "").not.toMatch(/unknown|Nonexistent flag/i); + }); + + it("should accept --duration flag", async () => { + const { error } = await runCommand( + ["spaces:cursors:subscribe", "test-space", "--duration", "1"], + import.meta.url, + ); + + // Should not have unknown flag error (command may fail for other reasons without mocks) + expect(error?.message || "").not.toMatch(/unknown|Nonexistent flag/i); + }); + }); + + describe("subscription behavior", () => { + it("should subscribe to cursor updates in a space", async () => { + const mockCursorsChannel = { + state: "attached", + on: vi.fn(), + off: vi.fn(), + }; + + const mockCursors = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + getAll: vi.fn().mockResolvedValue([]), + channel: mockCursorsChannel, + }; + + const mockMembers = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + getAll: vi.fn().mockResolvedValue([]), + }; + + const mockLocks = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + }; + + const mockLocations = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + }; + + const mockSpace = { + cursors: mockCursors, + members: mockMembers, + locks: mockLocks, + locations: mockLocations, + enter: vi.fn().mockResolvedValue(), + leave: vi.fn().mockResolvedValue(), + }; + + const mockConnection = { + on: vi.fn(), + once: vi.fn(), + state: "connected", + id: "test-connection-id", + }; + + const mockAuth = { + clientId: "test-client-id", + }; + + const mockRealtimeClient = { + connection: mockConnection, + auth: mockAuth, + close: vi.fn(), + }; + + const mockSpacesClient = { + get: vi.fn().mockReturnValue(mockSpace), + }; + + globalThis.__TEST_MOCKS__ = { + ablyRealtimeMock: mockRealtimeClient, + ablySpacesMock: mockSpacesClient, + }; + + setTimeout(() => process.emit("SIGINT", "SIGINT"), 100); + + await runCommand( + ["spaces:cursors:subscribe", "test-space"], + import.meta.url, + ); + + expect(mockSpace.enter).toHaveBeenCalled(); + expect(mockCursors.subscribe).toHaveBeenCalledWith( + "update", + expect.any(Function), + ); + }); + + it("should display initial subscription message", async () => { + const mockCursorsChannel = { + state: "attached", + on: vi.fn(), + off: vi.fn(), + }; + + const mockCursors = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + getAll: vi.fn().mockResolvedValue([]), + channel: mockCursorsChannel, + }; + + const mockMembers = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + getAll: vi.fn().mockResolvedValue([]), + }; + + const mockLocks = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + }; + + const mockLocations = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + }; + + const mockSpace = { + cursors: mockCursors, + members: mockMembers, + locks: mockLocks, + locations: mockLocations, + enter: vi.fn().mockResolvedValue(), + leave: vi.fn().mockResolvedValue(), + }; + + const mockConnection = { + on: vi.fn(), + once: vi.fn(), + state: "connected", + id: "test-connection-id", + }; + + const mockAuth = { + clientId: "test-client-id", + }; + + const mockRealtimeClient = { + connection: mockConnection, + auth: mockAuth, + close: vi.fn(), + }; + + const mockSpacesClient = { + get: vi.fn().mockReturnValue(mockSpace), + }; + + globalThis.__TEST_MOCKS__ = { + ablyRealtimeMock: mockRealtimeClient, + ablySpacesMock: mockSpacesClient, + }; + + setTimeout(() => process.emit("SIGINT", "SIGINT"), 100); + + const { stdout } = await runCommand( + ["spaces:cursors:subscribe", "test-space"], + import.meta.url, + ); + + expect(stdout).toContain("Subscribing"); + expect(stdout).toContain("test-space"); + }); + }); + + describe("cleanup behavior", () => { + it("should close client on completion", async () => { + const mockCursorsChannel = { + state: "attached", + on: vi.fn(), + off: vi.fn(), + }; + + const mockCursors = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + getAll: vi.fn().mockResolvedValue([]), + channel: mockCursorsChannel, + }; + + const mockMembers = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + getAll: vi.fn().mockResolvedValue([]), + }; + + const mockLocks = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + }; + + const mockLocations = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + }; + + const mockSpace = { + cursors: mockCursors, + members: mockMembers, + locks: mockLocks, + locations: mockLocations, + enter: vi.fn().mockResolvedValue(), + leave: vi.fn().mockResolvedValue(), + }; + + const mockConnection = { + on: vi.fn(), + once: vi.fn(), + state: "connected", + id: "test-connection-id", + }; + + const mockAuth = { + clientId: "test-client-id", + }; + + const mockClose = vi.fn(); + const mockRealtimeClient = { + connection: mockConnection, + auth: mockAuth, + close: mockClose, + }; + + const mockSpacesClient = { + get: vi.fn().mockReturnValue(mockSpace), + }; + + globalThis.__TEST_MOCKS__ = { + ablyRealtimeMock: mockRealtimeClient, + ablySpacesMock: mockSpacesClient, + }; + + // Use SIGINT to exit + setTimeout(() => process.emit("SIGINT", "SIGINT"), 100); + + await runCommand( + ["spaces:cursors:subscribe", "test-space"], + import.meta.url, + ); + + // Verify close was called during cleanup (either by performCleanup or finally block) + expect(mockClose).toHaveBeenCalled(); + }); + }); + + describe("channel attachment", () => { + it("should wait for cursors channel to attach if not already attached", async () => { + const attachedCallback = vi.fn(); + const mockCursorsChannel = { + state: "attaching", + on: vi.fn((event, callback) => { + if (event === "attached") { + attachedCallback.mockImplementation(callback); + // Simulate channel attaching shortly after + setTimeout(() => callback(), 50); + } + }), + off: vi.fn(), + }; + + const mockCursors = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + getAll: vi.fn().mockResolvedValue([]), + channel: mockCursorsChannel, + }; + + const mockMembers = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + getAll: vi.fn().mockResolvedValue([]), + }; + + const mockLocks = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + }; + + const mockLocations = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + }; + + const mockSpace = { + cursors: mockCursors, + members: mockMembers, + locks: mockLocks, + locations: mockLocations, + enter: vi.fn().mockResolvedValue(), + leave: vi.fn().mockResolvedValue(), + }; + + const mockConnection = { + on: vi.fn(), + once: vi.fn(), + state: "connected", + id: "test-connection-id", + }; + + const mockAuth = { + clientId: "test-client-id", + }; + + const mockRealtimeClient = { + connection: mockConnection, + auth: mockAuth, + close: vi.fn(), + }; + + const mockSpacesClient = { + get: vi.fn().mockReturnValue(mockSpace), + }; + + globalThis.__TEST_MOCKS__ = { + ablyRealtimeMock: mockRealtimeClient, + ablySpacesMock: mockSpacesClient, + }; + + setTimeout(() => process.emit("SIGINT", "SIGINT"), 200); + + await runCommand( + ["spaces:cursors:subscribe", "test-space"], + import.meta.url, + ); + + // Verify the command registered for attachment events + expect(mockCursorsChannel.on).toHaveBeenCalledWith( + "attached", + expect.any(Function), + ); + }); + }); +}); From 336b1e778cb4bcaaa07e40dd10a2528ff09f01c8 Mon Sep 17 00:00:00 2001 From: Andy Ford Date: Wed, 10 Dec 2025 23:34:40 +0000 Subject: [PATCH 40/44] test: add unit test for spaces/locations/subscribe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../spaces/locations/subscribe.test.ts | 531 ++++++++++++++++++ 1 file changed, 531 insertions(+) create mode 100644 test/unit/commands/spaces/locations/subscribe.test.ts diff --git a/test/unit/commands/spaces/locations/subscribe.test.ts b/test/unit/commands/spaces/locations/subscribe.test.ts new file mode 100644 index 00000000..103af967 --- /dev/null +++ b/test/unit/commands/spaces/locations/subscribe.test.ts @@ -0,0 +1,531 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { runCommand } from "@oclif/test"; +import { resolve } from "node:path"; +import { mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; + +describe("spaces:locations:subscribe command", () => { + const mockAccessToken = "fake_access_token"; + const mockAccountId = "test-account-id"; + const mockAppId = "550e8400-e29b-41d4-a716-446655440000"; + const mockApiKey = `${mockAppId}.testkey:testsecret`; + let testConfigDir: string; + let originalConfigDir: string; + + beforeEach(() => { + process.env.ABLY_ACCESS_TOKEN = mockAccessToken; + + testConfigDir = resolve(tmpdir(), `ably-cli-test-${Date.now()}`); + mkdirSync(testConfigDir, { recursive: true, mode: 0o700 }); + + originalConfigDir = process.env.ABLY_CLI_CONFIG_DIR || ""; + process.env.ABLY_CLI_CONFIG_DIR = testConfigDir; + + const configContent = `[current] +account = "default" + +[accounts.default] +accessToken = "${mockAccessToken}" +accountId = "${mockAccountId}" +accountName = "Test Account" +userEmail = "test@example.com" +currentAppId = "${mockAppId}" + +[accounts.default.apps."${mockAppId}"] +appName = "Test App" +apiKey = "${mockApiKey}" +`; + writeFileSync(resolve(testConfigDir, "config"), configContent); + }); + + afterEach(() => { + delete process.env.ABLY_ACCESS_TOKEN; + + if (originalConfigDir) { + process.env.ABLY_CLI_CONFIG_DIR = originalConfigDir; + } else { + delete process.env.ABLY_CLI_CONFIG_DIR; + } + + if (existsSync(testConfigDir)) { + rmSync(testConfigDir, { recursive: true, force: true }); + } + + globalThis.__TEST_MOCKS__ = undefined; + }); + + describe("command arguments and flags", () => { + it("should require space argument", async () => { + const { error } = await runCommand( + ["spaces:locations:subscribe"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/Missing .* required arg/); + }); + + it("should reject unknown flags", async () => { + const { error } = await runCommand( + ["spaces:locations:subscribe", "test-space", "--unknown-flag"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/unknown|Nonexistent flag/i); + }); + + it("should accept --json flag", async () => { + const mockLocations = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + getAll: vi.fn().mockResolvedValue({}), + }; + + const mockMembers = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + getAll: vi.fn().mockResolvedValue([]), + }; + + const mockLocks = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + }; + + const mockCursors = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + }; + + const mockSpace = { + locations: mockLocations, + members: mockMembers, + locks: mockLocks, + cursors: mockCursors, + enter: vi.fn().mockResolvedValue(), + leave: vi.fn().mockResolvedValue(), + }; + + const mockConnection = { + on: vi.fn(), + once: vi.fn(), + state: "connected", + id: "test-connection-id", + }; + + const mockAuth = { + clientId: "test-client-id", + }; + + const mockRealtimeClient = { + connection: mockConnection, + auth: mockAuth, + close: vi.fn(), + }; + + const mockSpacesClient = { + get: vi.fn().mockReturnValue(mockSpace), + }; + + globalThis.__TEST_MOCKS__ = { + ablyRealtimeMock: mockRealtimeClient, + ablySpacesMock: mockSpacesClient, + }; + + // Emit SIGINT to exit the command + setTimeout(() => process.emit("SIGINT", "SIGINT"), 100); + + const { error } = await runCommand( + ["spaces:locations:subscribe", "test-space", "--json"], + import.meta.url, + ); + + // Should not have unknown flag error + expect(error?.message || "").not.toMatch(/unknown|Nonexistent flag/i); + }); + + it("should accept --pretty-json flag", async () => { + const { error } = await runCommand( + ["spaces:locations:subscribe", "test-space", "--pretty-json"], + import.meta.url, + ); + + // Should not have unknown flag error + expect(error?.message || "").not.toMatch(/unknown|Nonexistent flag/i); + }); + + it("should accept --duration flag", async () => { + const { error } = await runCommand( + ["spaces:locations:subscribe", "test-space", "--duration", "1"], + import.meta.url, + ); + + // Should not have unknown flag error (command may fail for other reasons without mocks) + expect(error?.message || "").not.toMatch(/unknown|Nonexistent flag/i); + }); + }); + + describe("subscription behavior", () => { + it("should subscribe to location updates in a space", async () => { + const mockLocations = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + getAll: vi.fn().mockResolvedValue({}), + }; + + const mockMembers = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + getAll: vi.fn().mockResolvedValue([]), + }; + + const mockLocks = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + }; + + const mockCursors = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + }; + + const mockSpace = { + locations: mockLocations, + members: mockMembers, + locks: mockLocks, + cursors: mockCursors, + enter: vi.fn().mockResolvedValue(), + leave: vi.fn().mockResolvedValue(), + }; + + const mockConnection = { + on: vi.fn(), + once: vi.fn(), + state: "connected", + id: "test-connection-id", + }; + + const mockAuth = { + clientId: "test-client-id", + }; + + const mockRealtimeClient = { + connection: mockConnection, + auth: mockAuth, + close: vi.fn(), + }; + + const mockSpacesClient = { + get: vi.fn().mockReturnValue(mockSpace), + }; + + globalThis.__TEST_MOCKS__ = { + ablyRealtimeMock: mockRealtimeClient, + ablySpacesMock: mockSpacesClient, + }; + + setTimeout(() => process.emit("SIGINT", "SIGINT"), 100); + + await runCommand( + ["spaces:locations:subscribe", "test-space"], + import.meta.url, + ); + + expect(mockSpace.enter).toHaveBeenCalled(); + expect(mockLocations.subscribe).toHaveBeenCalledWith( + "update", + expect.any(Function), + ); + }); + + it("should display initial subscription message", async () => { + const mockLocations = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + getAll: vi.fn().mockResolvedValue({}), + }; + + const mockMembers = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + getAll: vi.fn().mockResolvedValue([]), + }; + + const mockLocks = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + }; + + const mockCursors = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + }; + + const mockSpace = { + locations: mockLocations, + members: mockMembers, + locks: mockLocks, + cursors: mockCursors, + enter: vi.fn().mockResolvedValue(), + leave: vi.fn().mockResolvedValue(), + }; + + const mockConnection = { + on: vi.fn(), + once: vi.fn(), + state: "connected", + id: "test-connection-id", + }; + + const mockAuth = { + clientId: "test-client-id", + }; + + const mockRealtimeClient = { + connection: mockConnection, + auth: mockAuth, + close: vi.fn(), + }; + + const mockSpacesClient = { + get: vi.fn().mockReturnValue(mockSpace), + }; + + globalThis.__TEST_MOCKS__ = { + ablyRealtimeMock: mockRealtimeClient, + ablySpacesMock: mockSpacesClient, + }; + + setTimeout(() => process.emit("SIGINT", "SIGINT"), 100); + + const { stdout } = await runCommand( + ["spaces:locations:subscribe", "test-space"], + import.meta.url, + ); + + expect(stdout).toContain("Subscribing to location updates"); + expect(stdout).toContain("test-space"); + }); + + it("should fetch and display current locations", async () => { + const mockLocationsData = { + "conn-1": { room: "lobby", x: 100 }, + "conn-2": { room: "chat", x: 200 }, + }; + + const mockLocations = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + getAll: vi.fn().mockResolvedValue(mockLocationsData), + }; + + const mockMembers = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + getAll: vi.fn().mockResolvedValue([]), + }; + + const mockLocks = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + }; + + const mockCursors = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + }; + + const mockSpace = { + locations: mockLocations, + members: mockMembers, + locks: mockLocks, + cursors: mockCursors, + enter: vi.fn().mockResolvedValue(), + leave: vi.fn().mockResolvedValue(), + }; + + const mockConnection = { + on: vi.fn(), + once: vi.fn(), + state: "connected", + id: "test-connection-id", + }; + + const mockAuth = { + clientId: "test-client-id", + }; + + const mockRealtimeClient = { + connection: mockConnection, + auth: mockAuth, + close: vi.fn(), + }; + + const mockSpacesClient = { + get: vi.fn().mockReturnValue(mockSpace), + }; + + globalThis.__TEST_MOCKS__ = { + ablyRealtimeMock: mockRealtimeClient, + ablySpacesMock: mockSpacesClient, + }; + + setTimeout(() => process.emit("SIGINT", "SIGINT"), 100); + + const { stdout } = await runCommand( + ["spaces:locations:subscribe", "test-space"], + import.meta.url, + ); + + expect(mockLocations.getAll).toHaveBeenCalled(); + expect(stdout).toContain("Current locations"); + }); + }); + + describe("cleanup behavior", () => { + it("should close client on completion", async () => { + const mockLocations = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + getAll: vi.fn().mockResolvedValue({}), + }; + + const mockMembers = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + getAll: vi.fn().mockResolvedValue([]), + }; + + const mockLocks = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + }; + + const mockCursors = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + }; + + const mockSpace = { + locations: mockLocations, + members: mockMembers, + locks: mockLocks, + cursors: mockCursors, + enter: vi.fn().mockResolvedValue(), + leave: vi.fn().mockResolvedValue(), + }; + + const mockConnection = { + on: vi.fn(), + once: vi.fn(), + state: "connected", + id: "test-connection-id", + }; + + const mockAuth = { + clientId: "test-client-id", + }; + + const mockClose = vi.fn(); + const mockRealtimeClient = { + connection: mockConnection, + auth: mockAuth, + close: mockClose, + }; + + const mockSpacesClient = { + get: vi.fn().mockReturnValue(mockSpace), + }; + + globalThis.__TEST_MOCKS__ = { + ablyRealtimeMock: mockRealtimeClient, + ablySpacesMock: mockSpacesClient, + }; + + // Use SIGINT to exit + setTimeout(() => process.emit("SIGINT", "SIGINT"), 100); + + await runCommand( + ["spaces:locations:subscribe", "test-space"], + import.meta.url, + ); + + // Verify close was called during cleanup + expect(mockClose).toHaveBeenCalled(); + }); + }); + + describe("error handling", () => { + it("should handle getAll rejection gracefully", async () => { + const mockLocations = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + getAll: vi.fn().mockRejectedValue(new Error("Failed to get locations")), + }; + + const mockMembers = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + getAll: vi.fn().mockResolvedValue([]), + }; + + const mockLocks = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + }; + + const mockCursors = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + }; + + const mockSpace = { + locations: mockLocations, + members: mockMembers, + locks: mockLocks, + cursors: mockCursors, + enter: vi.fn().mockResolvedValue(), + leave: vi.fn().mockResolvedValue(), + }; + + const mockConnection = { + on: vi.fn(), + once: vi.fn(), + state: "connected", + id: "test-connection-id", + }; + + const mockAuth = { + clientId: "test-client-id", + }; + + const mockRealtimeClient = { + connection: mockConnection, + auth: mockAuth, + close: vi.fn(), + }; + + const mockSpacesClient = { + get: vi.fn().mockReturnValue(mockSpace), + }; + + globalThis.__TEST_MOCKS__ = { + ablyRealtimeMock: mockRealtimeClient, + ablySpacesMock: mockSpacesClient, + }; + + setTimeout(() => process.emit("SIGINT", "SIGINT"), 100); + + // The command catches getAll errors and continues + const { stdout } = await runCommand( + ["spaces:locations:subscribe", "test-space"], + import.meta.url, + ); + + // Command should still subscribe even if getAll fails + expect(mockLocations.subscribe).toHaveBeenCalled(); + expect(stdout).toBeDefined(); + }); + }); +}); From e67323c79f4ca3626b32ae05df37c484144c3e12 Mon Sep 17 00:00:00 2001 From: Andy Ford Date: Wed, 10 Dec 2025 23:36:37 +0000 Subject: [PATCH 41/44] test: add unit test for spaces/locks/subscribe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../commands/spaces/locks/subscribe.test.ts | 604 ++++++++++++++++++ 1 file changed, 604 insertions(+) create mode 100644 test/unit/commands/spaces/locks/subscribe.test.ts diff --git a/test/unit/commands/spaces/locks/subscribe.test.ts b/test/unit/commands/spaces/locks/subscribe.test.ts new file mode 100644 index 00000000..2629ba55 --- /dev/null +++ b/test/unit/commands/spaces/locks/subscribe.test.ts @@ -0,0 +1,604 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { runCommand } from "@oclif/test"; +import { resolve } from "node:path"; +import { mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; + +describe("spaces:locks:subscribe command", () => { + const mockAccessToken = "fake_access_token"; + const mockAccountId = "test-account-id"; + const mockAppId = "550e8400-e29b-41d4-a716-446655440000"; + const mockApiKey = `${mockAppId}.testkey:testsecret`; + let testConfigDir: string; + let originalConfigDir: string; + + beforeEach(() => { + process.env.ABLY_ACCESS_TOKEN = mockAccessToken; + + testConfigDir = resolve(tmpdir(), `ably-cli-test-${Date.now()}`); + mkdirSync(testConfigDir, { recursive: true, mode: 0o700 }); + + originalConfigDir = process.env.ABLY_CLI_CONFIG_DIR || ""; + process.env.ABLY_CLI_CONFIG_DIR = testConfigDir; + + const configContent = `[current] +account = "default" + +[accounts.default] +accessToken = "${mockAccessToken}" +accountId = "${mockAccountId}" +accountName = "Test Account" +userEmail = "test@example.com" +currentAppId = "${mockAppId}" + +[accounts.default.apps."${mockAppId}"] +appName = "Test App" +apiKey = "${mockApiKey}" +`; + writeFileSync(resolve(testConfigDir, "config"), configContent); + }); + + afterEach(() => { + delete process.env.ABLY_ACCESS_TOKEN; + + if (originalConfigDir) { + process.env.ABLY_CLI_CONFIG_DIR = originalConfigDir; + } else { + delete process.env.ABLY_CLI_CONFIG_DIR; + } + + if (existsSync(testConfigDir)) { + rmSync(testConfigDir, { recursive: true, force: true }); + } + + globalThis.__TEST_MOCKS__ = undefined; + }); + + describe("command arguments and flags", () => { + it("should require space argument", async () => { + const { error } = await runCommand( + ["spaces:locks:subscribe"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/Missing .* required arg/); + }); + + it("should reject unknown flags", async () => { + const { error } = await runCommand( + ["spaces:locks:subscribe", "test-space", "--unknown-flag"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/unknown|Nonexistent flag/i); + }); + + it("should accept --json flag", async () => { + const mockLocks = { + subscribe: vi.fn().mockResolvedValue(), + unsubscribe: vi.fn().mockResolvedValue(), + getAll: vi.fn().mockResolvedValue([]), + }; + + const mockMembers = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + getAll: vi.fn().mockResolvedValue([]), + }; + + const mockCursors = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + }; + + const mockLocations = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + }; + + const mockSpace = { + locks: mockLocks, + members: mockMembers, + cursors: mockCursors, + locations: mockLocations, + enter: vi.fn().mockResolvedValue(), + leave: vi.fn().mockResolvedValue(), + }; + + const mockConnection = { + on: vi.fn(), + once: vi.fn(), + state: "connected", + id: "test-connection-id", + }; + + const mockAuth = { + clientId: "test-client-id", + }; + + const mockRealtimeClient = { + connection: mockConnection, + auth: mockAuth, + close: vi.fn(), + }; + + const mockSpacesClient = { + get: vi.fn().mockReturnValue(mockSpace), + }; + + globalThis.__TEST_MOCKS__ = { + ablyRealtimeMock: mockRealtimeClient, + ablySpacesMock: mockSpacesClient, + }; + + // Emit SIGINT to exit the command + setTimeout(() => process.emit("SIGINT", "SIGINT"), 100); + + const { error } = await runCommand( + ["spaces:locks:subscribe", "test-space", "--json"], + import.meta.url, + ); + + // Should not have unknown flag error + expect(error?.message || "").not.toMatch(/unknown|Nonexistent flag/i); + }); + + it("should accept --pretty-json flag", async () => { + const { error } = await runCommand( + ["spaces:locks:subscribe", "test-space", "--pretty-json"], + import.meta.url, + ); + + // Should not have unknown flag error + expect(error?.message || "").not.toMatch(/unknown|Nonexistent flag/i); + }); + + it("should accept --duration flag", async () => { + const { error } = await runCommand( + ["spaces:locks:subscribe", "test-space", "--duration", "1"], + import.meta.url, + ); + + // Should not have unknown flag error (command may fail for other reasons without mocks) + expect(error?.message || "").not.toMatch(/unknown|Nonexistent flag/i); + }); + }); + + describe("subscription behavior", () => { + it("should subscribe to lock events in a space", async () => { + const mockLocks = { + subscribe: vi.fn().mockResolvedValue(), + unsubscribe: vi.fn().mockResolvedValue(), + getAll: vi.fn().mockResolvedValue([]), + }; + + const mockMembers = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + getAll: vi.fn().mockResolvedValue([]), + }; + + const mockCursors = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + }; + + const mockLocations = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + }; + + const mockSpace = { + locks: mockLocks, + members: mockMembers, + cursors: mockCursors, + locations: mockLocations, + enter: vi.fn().mockResolvedValue(), + leave: vi.fn().mockResolvedValue(), + }; + + const mockConnection = { + on: vi.fn(), + once: vi.fn(), + state: "connected", + id: "test-connection-id", + }; + + const mockAuth = { + clientId: "test-client-id", + }; + + const mockRealtimeClient = { + connection: mockConnection, + auth: mockAuth, + close: vi.fn(), + }; + + const mockSpacesClient = { + get: vi.fn().mockReturnValue(mockSpace), + }; + + globalThis.__TEST_MOCKS__ = { + ablyRealtimeMock: mockRealtimeClient, + ablySpacesMock: mockSpacesClient, + }; + + setTimeout(() => process.emit("SIGINT", "SIGINT"), 100); + + await runCommand( + ["spaces:locks:subscribe", "test-space"], + import.meta.url, + ); + + expect(mockSpace.enter).toHaveBeenCalled(); + expect(mockLocks.subscribe).toHaveBeenCalledWith(expect.any(Function)); + }); + + it("should display initial subscription message", async () => { + const mockLocks = { + subscribe: vi.fn().mockResolvedValue(), + unsubscribe: vi.fn().mockResolvedValue(), + getAll: vi.fn().mockResolvedValue([]), + }; + + const mockMembers = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + getAll: vi.fn().mockResolvedValue([]), + }; + + const mockCursors = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + }; + + const mockLocations = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + }; + + const mockSpace = { + locks: mockLocks, + members: mockMembers, + cursors: mockCursors, + locations: mockLocations, + enter: vi.fn().mockResolvedValue(), + leave: vi.fn().mockResolvedValue(), + }; + + const mockConnection = { + on: vi.fn(), + once: vi.fn(), + state: "connected", + id: "test-connection-id", + }; + + const mockAuth = { + clientId: "test-client-id", + }; + + const mockRealtimeClient = { + connection: mockConnection, + auth: mockAuth, + close: vi.fn(), + }; + + const mockSpacesClient = { + get: vi.fn().mockReturnValue(mockSpace), + }; + + globalThis.__TEST_MOCKS__ = { + ablyRealtimeMock: mockRealtimeClient, + ablySpacesMock: mockSpacesClient, + }; + + setTimeout(() => process.emit("SIGINT", "SIGINT"), 100); + + const { stdout } = await runCommand( + ["spaces:locks:subscribe", "test-space"], + import.meta.url, + ); + + expect(stdout).toContain("Subscribing to lock events"); + expect(stdout).toContain("test-space"); + }); + + it("should fetch and display current locks", async () => { + const mockLocksData = [ + { + id: "lock-1", + status: "locked", + member: { clientId: "user-1", connectionId: "conn-1" }, + }, + { + id: "lock-2", + status: "pending", + member: { clientId: "user-2", connectionId: "conn-2" }, + }, + ]; + + const mockLocks = { + subscribe: vi.fn().mockResolvedValue(), + unsubscribe: vi.fn().mockResolvedValue(), + getAll: vi.fn().mockResolvedValue(mockLocksData), + }; + + const mockMembers = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + getAll: vi.fn().mockResolvedValue([]), + }; + + const mockCursors = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + }; + + const mockLocations = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + }; + + const mockSpace = { + locks: mockLocks, + members: mockMembers, + cursors: mockCursors, + locations: mockLocations, + enter: vi.fn().mockResolvedValue(), + leave: vi.fn().mockResolvedValue(), + }; + + const mockConnection = { + on: vi.fn(), + once: vi.fn(), + state: "connected", + id: "test-connection-id", + }; + + const mockAuth = { + clientId: "test-client-id", + }; + + const mockRealtimeClient = { + connection: mockConnection, + auth: mockAuth, + close: vi.fn(), + }; + + const mockSpacesClient = { + get: vi.fn().mockReturnValue(mockSpace), + }; + + globalThis.__TEST_MOCKS__ = { + ablyRealtimeMock: mockRealtimeClient, + ablySpacesMock: mockSpacesClient, + }; + + setTimeout(() => process.emit("SIGINT", "SIGINT"), 100); + + const { stdout } = await runCommand( + ["spaces:locks:subscribe", "test-space"], + import.meta.url, + ); + + expect(mockLocks.getAll).toHaveBeenCalled(); + expect(stdout).toContain("Current locks"); + expect(stdout).toContain("lock-1"); + }); + + it("should show message when no locks exist", async () => { + const mockLocks = { + subscribe: vi.fn().mockResolvedValue(), + unsubscribe: vi.fn().mockResolvedValue(), + getAll: vi.fn().mockResolvedValue([]), + }; + + const mockMembers = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + getAll: vi.fn().mockResolvedValue([]), + }; + + const mockCursors = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + }; + + const mockLocations = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + }; + + const mockSpace = { + locks: mockLocks, + members: mockMembers, + cursors: mockCursors, + locations: mockLocations, + enter: vi.fn().mockResolvedValue(), + leave: vi.fn().mockResolvedValue(), + }; + + const mockConnection = { + on: vi.fn(), + once: vi.fn(), + state: "connected", + id: "test-connection-id", + }; + + const mockAuth = { + clientId: "test-client-id", + }; + + const mockRealtimeClient = { + connection: mockConnection, + auth: mockAuth, + close: vi.fn(), + }; + + const mockSpacesClient = { + get: vi.fn().mockReturnValue(mockSpace), + }; + + globalThis.__TEST_MOCKS__ = { + ablyRealtimeMock: mockRealtimeClient, + ablySpacesMock: mockSpacesClient, + }; + + setTimeout(() => process.emit("SIGINT", "SIGINT"), 100); + + const { stdout } = await runCommand( + ["spaces:locks:subscribe", "test-space"], + import.meta.url, + ); + + expect(stdout).toContain("No locks"); + }); + }); + + describe("cleanup behavior", () => { + it("should close client on completion", async () => { + const mockLocks = { + subscribe: vi.fn().mockResolvedValue(), + unsubscribe: vi.fn().mockResolvedValue(), + getAll: vi.fn().mockResolvedValue([]), + }; + + const mockMembers = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + getAll: vi.fn().mockResolvedValue([]), + }; + + const mockCursors = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + }; + + const mockLocations = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + }; + + const mockSpace = { + locks: mockLocks, + members: mockMembers, + cursors: mockCursors, + locations: mockLocations, + enter: vi.fn().mockResolvedValue(), + leave: vi.fn().mockResolvedValue(), + }; + + const mockConnection = { + on: vi.fn(), + once: vi.fn(), + state: "connected", + id: "test-connection-id", + }; + + const mockAuth = { + clientId: "test-client-id", + }; + + const mockClose = vi.fn(); + const mockRealtimeClient = { + connection: mockConnection, + auth: mockAuth, + close: mockClose, + }; + + const mockSpacesClient = { + get: vi.fn().mockReturnValue(mockSpace), + }; + + globalThis.__TEST_MOCKS__ = { + ablyRealtimeMock: mockRealtimeClient, + ablySpacesMock: mockSpacesClient, + }; + + // Use SIGINT to exit + setTimeout(() => process.emit("SIGINT", "SIGINT"), 100); + + await runCommand( + ["spaces:locks:subscribe", "test-space"], + import.meta.url, + ); + + // Verify close was called during cleanup + expect(mockClose).toHaveBeenCalled(); + }); + }); + + describe("error handling", () => { + it("should handle getAll rejection gracefully", async () => { + const mockLocks = { + subscribe: vi.fn().mockResolvedValue(), + unsubscribe: vi.fn().mockResolvedValue(), + getAll: vi.fn().mockRejectedValue(new Error("Failed to get locks")), + }; + + const mockMembers = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + getAll: vi.fn().mockResolvedValue([]), + }; + + const mockCursors = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + }; + + const mockLocations = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + }; + + const mockSpace = { + locks: mockLocks, + members: mockMembers, + cursors: mockCursors, + locations: mockLocations, + enter: vi.fn().mockResolvedValue(), + leave: vi.fn().mockResolvedValue(), + }; + + const mockConnection = { + on: vi.fn(), + once: vi.fn(), + state: "connected", + id: "test-connection-id", + }; + + const mockAuth = { + clientId: "test-client-id", + }; + + const mockRealtimeClient = { + connection: mockConnection, + auth: mockAuth, + close: vi.fn(), + }; + + const mockSpacesClient = { + get: vi.fn().mockReturnValue(mockSpace), + }; + + globalThis.__TEST_MOCKS__ = { + ablyRealtimeMock: mockRealtimeClient, + ablySpacesMock: mockSpacesClient, + }; + + setTimeout(() => process.emit("SIGINT", "SIGINT"), 100); + + // The command catches errors and continues + const { stdout } = await runCommand( + ["spaces:locks:subscribe", "test-space"], + import.meta.url, + ); + + // Command should have run (output should be present) + expect(stdout).toBeDefined(); + }); + }); +}); From 4bd7556b6546c03574bb6639a6595e2417a94c4f Mon Sep 17 00:00:00 2001 From: Andy Ford Date: Thu, 11 Dec 2025 22:35:58 +0000 Subject: [PATCH 42/44] test: run in sequence temporarily due to config bug --- vitest.config.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/vitest.config.ts b/vitest.config.ts index 8f441612..4dbad228 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -38,6 +38,9 @@ export default defineConfig({ ABLY_CLI_TEST_MODE: "true", ABLY_API_KEY: undefined, }, + // This is a temporary workaround whilst a bug / race with test config setup is fixed + // fixed as it causes races + fileParallelism: false, }, }, { From b588726f7c2aef84000473d2255399176bc55cea Mon Sep 17 00:00:00 2001 From: Andy Ford Date: Fri, 12 Dec 2025 00:07:09 +0000 Subject: [PATCH 43/44] test: tighten assertions in auth/revoke-token test --- test/unit/commands/auth/revoke-token.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/unit/commands/auth/revoke-token.test.ts b/test/unit/commands/auth/revoke-token.test.ts index d5193546..f2267b46 100644 --- a/test/unit/commands/auth/revoke-token.test.ts +++ b/test/unit/commands/auth/revoke-token.test.ts @@ -269,8 +269,7 @@ apiKey = "${mockApiKey}" import.meta.url, ); - expect(stdout).toContain("Debug:"); - expect(stdout).toContain("Using API key:"); + expect(stdout).toContain("Debug: Using API key:"); }); it("should mask the API key secret in debug output", async () => { From e12bea1a3e8330e24f25b3d23df165a27e6800ca Mon Sep 17 00:00:00 2001 From: Andy Ford Date: Fri, 12 Dec 2025 00:07:14 +0000 Subject: [PATCH 44/44] test: tighten assertions in channels/list test --- test/unit/commands/channels/list.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/unit/commands/channels/list.test.ts b/test/unit/commands/channels/list.test.ts index 0b641481..10feb99d 100644 --- a/test/unit/commands/channels/list.test.ts +++ b/test/unit/commands/channels/list.test.ts @@ -109,9 +109,9 @@ describe("channels:list command", () => { import.meta.url, ); - expect(stdout).toContain("Connections:"); - expect(stdout).toContain("Publishers:"); - expect(stdout).toContain("Subscribers:"); + expect(stdout).toContain("Connections: 5"); + expect(stdout).toContain("Publishers: 2"); + expect(stdout).toContain("Subscribers: 3"); }); it("should handle empty channels response", async () => {