diff --git a/README.md b/README.md index b4e1a102..22f37a3c 100644 --- a/README.md +++ b/README.md @@ -201,10 +201,12 @@ $ ably-interactive * [`ably rooms typing keystroke ROOM`](#ably-rooms-typing-keystroke-room) * [`ably rooms typing subscribe ROOM`](#ably-rooms-typing-subscribe-room) * [`ably spaces`](#ably-spaces) +* [`ably spaces create SPACE_NAME`](#ably-spaces-create-space_name) * [`ably spaces cursors`](#ably-spaces-cursors) * [`ably spaces cursors get-all SPACE_NAME`](#ably-spaces-cursors-get-all-space_name) * [`ably spaces cursors set SPACE_NAME`](#ably-spaces-cursors-set-space_name) * [`ably spaces cursors subscribe SPACE_NAME`](#ably-spaces-cursors-subscribe-space_name) +* [`ably spaces get SPACE_NAME`](#ably-spaces-get-space_name) * [`ably spaces list`](#ably-spaces-list) * [`ably spaces locations`](#ably-spaces-locations) * [`ably spaces locations get-all SPACE_NAME`](#ably-spaces-locations-get-all-space_name) @@ -217,10 +219,12 @@ $ ably-interactive * [`ably spaces locks subscribe SPACE_NAME`](#ably-spaces-locks-subscribe-space_name) * [`ably spaces members`](#ably-spaces-members) * [`ably spaces members enter SPACE_NAME`](#ably-spaces-members-enter-space_name) +* [`ably spaces members get-all SPACE_NAME`](#ably-spaces-members-get-all-space_name) * [`ably spaces members subscribe SPACE_NAME`](#ably-spaces-members-subscribe-space_name) * [`ably spaces occupancy`](#ably-spaces-occupancy) * [`ably spaces occupancy get SPACE_NAME`](#ably-spaces-occupancy-get-space_name) * [`ably spaces occupancy subscribe SPACE_NAME`](#ably-spaces-occupancy-subscribe-space_name) +* [`ably spaces subscribe SPACE_NAME`](#ably-spaces-subscribe-space_name) * [`ably stats`](#ably-stats) * [`ably stats account`](#ably-stats-account) * [`ably stats app [ID]`](#ably-stats-app-id) @@ -4463,21 +4467,61 @@ DESCRIPTION EXAMPLES $ ably spaces list + $ ably spaces get my-space + + $ ably spaces create my-space + + $ ably spaces subscribe my-space + $ ably spaces members enter my-space $ ably spaces locations set my-space COMMANDS + ably spaces create Initialize a space without entering it ably spaces cursors Commands for interacting with Cursors in Ably Spaces + ably spaces get Get the current state of a space ably spaces list List active spaces ably spaces locations Commands for location management in Ably Spaces ably spaces locks Commands for component locking in Ably Spaces ably spaces members Commands for managing members in Ably Spaces ably spaces occupancy Commands for working with occupancy in Ably Spaces + ably spaces subscribe Subscribe to both spaces members and location update events ``` _See code: [src/commands/spaces/index.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/spaces/index.ts)_ +## `ably spaces create SPACE_NAME` + +Initialize a space without entering it + +``` +USAGE + $ ably spaces create SPACE_NAME [-v] [--json | --pretty-json] [--client-id ] + +ARGUMENTS + SPACE_NAME Name of the space to initialize + +FLAGS + -v, --verbose Output verbose logs + --client-id= Overrides any default client ID when using API authentication. Use "none" to explicitly set + no client ID. Not applicable when using token authentication. + --json Output in JSON format + --pretty-json Output in colorized JSON format + +DESCRIPTION + Initialize a space without entering it + +EXAMPLES + $ ably spaces create my-space + + $ ably spaces create my-space --json + + $ ably spaces create my-space --client-id my-client +``` + +_See code: [src/commands/spaces/create.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/spaces/create.ts)_ + ## `ably spaces cursors` Commands for interacting with Cursors in Ably Spaces @@ -4613,6 +4657,35 @@ EXAMPLES _See code: [src/commands/spaces/cursors/subscribe.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/spaces/cursors/subscribe.ts)_ +## `ably spaces get SPACE_NAME` + +Get the current state of a space + +``` +USAGE + $ ably spaces get SPACE_NAME [-v] [--json | --pretty-json] + +ARGUMENTS + SPACE_NAME Name of the space to get + +FLAGS + -v, --verbose Output verbose logs + --json Output in JSON format + --pretty-json Output in colorized JSON format + +DESCRIPTION + Get the current state of a space + +EXAMPLES + $ ably spaces get my-space + + $ ably spaces get my-space --json + + $ ably spaces get my-space --pretty-json +``` + +_See code: [src/commands/spaces/get.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/spaces/get.ts)_ + ## `ably spaces list` List active spaces @@ -4977,6 +5050,35 @@ EXAMPLES _See code: [src/commands/spaces/members/enter.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/spaces/members/enter.ts)_ +## `ably spaces members get-all SPACE_NAME` + +Get all members in a space + +``` +USAGE + $ ably spaces members get-all SPACE_NAME [-v] [--json | --pretty-json] + +ARGUMENTS + SPACE_NAME Name of the space to get members from + +FLAGS + -v, --verbose Output verbose logs + --json Output in JSON format + --pretty-json Output in colorized JSON format + +DESCRIPTION + Get all members in a space + +EXAMPLES + $ ably spaces members get-all my-space + + $ ably spaces members get-all my-space --json + + $ ably spaces members get-all my-space --pretty-json +``` + +_See code: [src/commands/spaces/members/get-all.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/spaces/members/get-all.ts)_ + ## `ably spaces members subscribe SPACE_NAME` Subscribe to member presence events in a space @@ -5091,6 +5193,40 @@ EXAMPLES _See code: [src/commands/spaces/occupancy/subscribe.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/spaces/occupancy/subscribe.ts)_ +## `ably spaces subscribe SPACE_NAME` + +Subscribe to both spaces members and location update events + +``` +USAGE + $ ably spaces subscribe SPACE_NAME [-v] [--json | --pretty-json] [--client-id ] [-D ] + +ARGUMENTS + SPACE_NAME Name of the space to subscribe to + +FLAGS + -D, --duration= Automatically exit after N seconds + -v, --verbose Output verbose logs + --client-id= Overrides any default client ID when using API authentication. Use "none" to explicitly set + no client ID. Not applicable when using token authentication. + --json Output in JSON format + --pretty-json Output in colorized JSON format + +DESCRIPTION + Subscribe to both spaces members and location update events + +EXAMPLES + $ ably spaces subscribe my-space + + $ ably spaces subscribe my-space --json + + $ ably spaces subscribe my-space --pretty-json + + $ ably spaces subscribe my-space --duration 30 +``` + +_See code: [src/commands/spaces/subscribe.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/spaces/subscribe.ts)_ + ## `ably stats` View statistics for your Ably account or apps diff --git a/src/commands/spaces/create.ts b/src/commands/spaces/create.ts new file mode 100644 index 00000000..9d4af4ac --- /dev/null +++ b/src/commands/spaces/create.ts @@ -0,0 +1,61 @@ +import { Args } from "@oclif/core"; + +import { productApiFlags, clientIdFlag } from "../../flags.js"; +import { SpacesBaseCommand } from "../../spaces-base-command.js"; +import { + formatProgress, + formatResource, + formatSuccess, +} from "../../utils/output.js"; + +export default class SpacesCreate extends SpacesBaseCommand { + static override args = { + space_name: Args.string({ + description: "Name of the space to initialize", + required: true, + }), + }; + + static override description = "Initialize a space without entering it"; + + static override examples = [ + "$ ably spaces create my-space", + "$ ably spaces create my-space --json", + "$ ably spaces create my-space --client-id my-client", + ]; + + static override flags = { + ...productApiFlags, + ...clientIdFlag, + }; + + async run(): Promise { + const { args, flags } = await this.parse(SpacesCreate); + const spaceName = args.space_name; + + try { + if (!this.shouldOutputJson(flags)) { + this.log( + formatProgress(`Initializing space ${formatResource(spaceName)}`), + ); + } + + await this.initializeSpace(flags, spaceName, { + enterSpace: false, + setupConnectionLogging: false, + }); + + if (this.shouldOutputJson(flags)) { + this.logJsonResult({ space: { name: spaceName } }, flags); + } else { + this.log( + formatSuccess( + `Space ${formatResource(spaceName)} initialized. Use "ably spaces members enter" to activate it.`, + ), + ); + } + } catch (error) { + this.fail(error, flags, "spaceCreate"); + } + } +} diff --git a/src/commands/spaces/get.ts b/src/commands/spaces/get.ts new file mode 100644 index 00000000..b6647463 --- /dev/null +++ b/src/commands/spaces/get.ts @@ -0,0 +1,175 @@ +import { Args } from "@oclif/core"; + +import { productApiFlags } from "../../flags.js"; +import { SpacesBaseCommand } from "../../spaces-base-command.js"; +import { + formatClientId, + formatCountLabel, + formatEventType, + formatHeading, + formatIndex, + formatLabel, + formatMessageTimestamp, + formatProgress, + formatResource, +} from "../../utils/output.js"; +import type { MemberOutput } from "../../utils/spaces-output.js"; + +const SPACE_CHANNEL_TAG = "::$space"; + +const PRESENCE_ACTION_MAP: Record = { + 0: "absent", + 1: "present", + 2: "enter", + 3: "leave", + 4: "update", +}; + +function resolvePresenceAction(action: number | string): string { + if (typeof action === "string") { + return action; + } + + return PRESENCE_ACTION_MAP[action] ?? String(action); +} + +interface PresenceItem { + clientId: string; + connectionId: string; + action: number | string; + timestamp: number; + data?: { + profileUpdate?: { current?: Record }; + locationUpdate?: { current?: unknown }; + }; +} + +export default class SpacesGet extends SpacesBaseCommand { + static override args = { + space_name: Args.string({ + description: "Name of the space to get", + required: true, + }), + }; + + static override description = "Get the current state of a space"; + + static override examples = [ + "$ ably spaces get my-space", + "$ ably spaces get my-space --json", + "$ ably spaces get my-space --pretty-json", + ]; + + static override flags = { + ...productApiFlags, + }; + + async run(): Promise { + const { args, flags } = await this.parse(SpacesGet); + const spaceName = args.space_name; + + try { + if (!this.shouldOutputJson(flags)) { + this.log( + formatProgress( + `Fetching state for space ${formatResource(spaceName)}`, + ), + ); + } + + const rest = await this.createAblyRestClient(flags); + if (!rest) return; + + const channelName = `${spaceName}${SPACE_CHANNEL_TAG}`; + const response = await rest.request( + "get", + `/channels/${encodeURIComponent(channelName)}/presence`, + 2, + {}, + null, + ); + + if (response.statusCode !== 200) { + this.fail( + `Failed to fetch space: ${response.statusCode}`, + flags, + "spaceGet", + ); + } + + const items: PresenceItem[] = response.items || []; + + if (items.length === 0) { + this.fail( + `Space ${spaceName} doesn't have any members currently present. Spaces only exist while members are present. Please enter at least one member using "ably spaces members enter".`, + flags, + "spaceGet", + { spaceName }, + ); + } + + const members: MemberOutput[] = items.map((item) => { + const action = resolvePresenceAction(item.action); + return { + clientId: item.clientId, + connectionId: item.connectionId, + isConnected: action !== "leave" && action !== "absent", + profileData: item.data?.profileUpdate?.current ?? null, + location: item.data?.locationUpdate?.current ?? null, + lastEvent: { name: action, timestamp: item.timestamp }, + }; + }); + + if (this.shouldOutputJson(flags)) { + this.logJsonResult( + { + space: { + name: spaceName, + members, + }, + }, + flags, + ); + } else { + this.log(`\n${formatHeading("Space")} ${formatResource(spaceName)}\n`); + this.log( + `${formatHeading("Members")} (${formatCountLabel(members.length, "member")}):\n`, + ); + + for (let i = 0; i < members.length; i++) { + const member = members[i]; + this.log(`${formatIndex(i + 1)}`); + this.log( + ` ${formatLabel("Client ID")} ${formatClientId(member.clientId)}`, + ); + this.log(` ${formatLabel("Connection ID")} ${member.connectionId}`); + this.log(` ${formatLabel("Connected")} ${member.isConnected}`); + if ( + member.profileData && + Object.keys(member.profileData).length > 0 + ) { + this.log( + ` ${formatLabel("Profile")} ${JSON.stringify(member.profileData)}`, + ); + } + + if (member.location != null) { + this.log( + ` ${formatLabel("Location")} ${JSON.stringify(member.location)}`, + ); + } + + this.log( + ` ${formatLabel("Last Event")} ${formatEventType(member.lastEvent.name)}`, + ); + this.log( + ` ${formatLabel("Event Timestamp")} ${formatMessageTimestamp(member.lastEvent.timestamp)}`, + ); + this.log(""); + } + } + } catch (error) { + this.fail(error, flags, "spaceGet", { spaceName }); + } + } +} diff --git a/src/commands/spaces/index.ts b/src/commands/spaces/index.ts index 5acb0807..b9b771dc 100644 --- a/src/commands/spaces/index.ts +++ b/src/commands/spaces/index.ts @@ -8,6 +8,9 @@ export default class SpacesIndex extends BaseTopicCommand { static override examples = [ "<%= config.bin %> <%= command.id %> list", + "<%= config.bin %> <%= command.id %> get my-space", + "<%= config.bin %> <%= command.id %> create my-space", + "<%= config.bin %> <%= command.id %> subscribe my-space", "<%= config.bin %> <%= command.id %> members enter my-space", "<%= config.bin %> <%= command.id %> locations set my-space", ]; diff --git a/src/commands/spaces/members/get-all.ts b/src/commands/spaces/members/get-all.ts new file mode 100644 index 00000000..4d7f45e3 --- /dev/null +++ b/src/commands/spaces/members/get-all.ts @@ -0,0 +1,83 @@ +import type { SpaceMember } from "@ably/spaces"; +import { Args } from "@oclif/core"; + +import { productApiFlags } from "../../../flags.js"; +import { SpacesBaseCommand } from "../../../spaces-base-command.js"; +import { + formatCountLabel, + formatHeading, + formatIndex, + formatProgress, + formatResource, + formatWarning, +} from "../../../utils/output.js"; +import { + formatMemberBlock, + formatMemberOutput, +} from "../../../utils/spaces-output.js"; + +export default class SpacesMembersGetAll extends SpacesBaseCommand { + static override args = { + space_name: Args.string({ + description: "Name of the space to get members from", + required: true, + }), + }; + + static override description = "Get all members in a space"; + + static override examples = [ + "$ ably spaces members get-all my-space", + "$ ably spaces members get-all my-space --json", + "$ ably spaces members get-all my-space --pretty-json", + ]; + + static override flags = { + ...productApiFlags, + }; + + async run(): Promise { + const { args, flags } = await this.parse(SpacesMembersGetAll); + const { space_name: spaceName } = args; + + try { + await this.initializeSpace(flags, spaceName, { + enterSpace: false, + setupConnectionLogging: false, + }); + + if (!this.shouldOutputJson(flags)) { + this.log( + formatProgress( + `Fetching members for space ${formatResource(spaceName)}`, + ), + ); + } + + const members: SpaceMember[] = await this.space!.members.getAll(); + + if (this.shouldOutputJson(flags)) { + this.logJsonResult( + { + members: members.map((member) => formatMemberOutput(member)), + }, + flags, + ); + } else if (members.length === 0) { + this.logToStderr(formatWarning("No members currently in this space.")); + } else { + this.log( + `\n${formatHeading("Current members")} (${formatCountLabel(members.length, "member")}):\n`, + ); + + for (let i = 0; i < members.length; i++) { + this.log(`${formatIndex(i + 1)}`); + this.log(formatMemberBlock(members[i], { indent: " " })); + this.log(""); + } + } + } catch (error) { + this.fail(error, flags, "memberGetAll", { spaceName }); + } + } +} diff --git a/src/commands/spaces/subscribe.ts b/src/commands/spaces/subscribe.ts new file mode 100644 index 00000000..32f471fa --- /dev/null +++ b/src/commands/spaces/subscribe.ts @@ -0,0 +1,122 @@ +import type { SpaceMember } from "@ably/spaces"; +import { Args } from "@oclif/core"; + +import { productApiFlags, clientIdFlag, durationFlag } from "../../flags.js"; +import { SpacesBaseCommand } from "../../spaces-base-command.js"; +import { + formatCountLabel, + formatListening, + formatProgress, + formatResource, + formatSuccess, + formatTimestamp, +} from "../../utils/output.js"; +import { + formatMemberBlock, + formatMemberOutput, +} from "../../utils/spaces-output.js"; + +export default class SpacesSubscribe extends SpacesBaseCommand { + static override args = { + space_name: Args.string({ + description: "Name of the space to subscribe to", + required: true, + }), + }; + + static override description = + "Subscribe to both spaces members and location update events"; + + static override examples = [ + "$ ably spaces subscribe my-space", + "$ ably spaces subscribe my-space --json", + "$ ably spaces subscribe my-space --pretty-json", + "$ ably spaces subscribe my-space --duration 30", + ]; + + static override flags = { + ...productApiFlags, + ...clientIdFlag, + ...durationFlag, + }; + + private listener: ((spaceState: { members: SpaceMember[] }) => void) | null = + null; + + async finally(error: Error | undefined): Promise { + if (this.space && this.listener) { + try { + this.space.unsubscribe("update", this.listener); + } catch (error_) { + this.debug(`Failed to unsubscribe from space update: ${error_}`); + } + } + + await super.finally(error); + } + + async run(): Promise { + const { args, flags } = await this.parse(SpacesSubscribe); + const { space_name: spaceName } = args; + + try { + if (!this.shouldOutputJson(flags)) { + this.log(formatProgress("Subscribing to space updates")); + } + + await this.initializeSpace(flags, spaceName, { enterSpace: false }); + + this.logCliEvent( + flags, + "space", + "subscribing", + "Subscribing to space updates", + ); + + this.listener = (spaceState: { members: SpaceMember[] }) => { + const { members } = spaceState; + + if (this.shouldOutputJson(flags)) { + this.logJsonEvent( + { + space: { + members: members.map((m) => formatMemberOutput(m)), + }, + }, + flags, + ); + } else { + this.log( + `${formatTimestamp(new Date().toISOString())} Found ${formatCountLabel(members.length, "member")} on space: ${formatResource(spaceName)}`, + ); + + for (const member of members) { + this.log(formatMemberBlock(member)); + this.log(""); + } + } + }; + + // space.subscribe() is synchronous (calls super.on()), no await needed + this.space!.subscribe("update", this.listener); + + if (!this.shouldOutputJson(flags)) { + this.log( + formatSuccess(`Subscribed to space: ${formatResource(spaceName)}.`), + ); + this.log(formatListening("Listening for space updates.")); + } + + this.logCliEvent( + flags, + "space", + "subscribed", + "Subscribed to space updates", + ); + + await this.waitAndTrackCleanup(flags, "space", flags.duration); + } catch (error) { + this.fail(error, flags, "spaceSubscribe"); + } + } +} diff --git a/src/utils/spaces-output.ts b/src/utils/spaces-output.ts index c54a698d..46029afa 100644 --- a/src/utils/spaces-output.ts +++ b/src/utils/spaces-output.ts @@ -112,7 +112,8 @@ export function formatMemberBlock( } lines.push( - `${indent}${formatLabel("Last Event")} ${member.lastEvent.name} at ${formatMessageTimestamp(member.lastEvent.timestamp)}`, + `${indent}${formatLabel("Last Event")} ${formatEventType(member.lastEvent.name)}`, + `${indent}${formatLabel("Event Timestamp")} ${formatMessageTimestamp(member.lastEvent.timestamp)}`, ); return lines.join("\n"); diff --git a/test/helpers/mock-ably-spaces.ts b/test/helpers/mock-ably-spaces.ts index 661d0a3a..13fe1782 100644 --- a/test/helpers/mock-ably-spaces.ts +++ b/test/helpers/mock-ably-spaces.ts @@ -110,10 +110,15 @@ export interface MockSpace { enter: Mock; leave: Mock; updateProfileData: Mock; + subscribe: Mock; + unsubscribe: Mock; + getState: Mock; members: MockSpaceMembers; locations: MockSpaceLocations; locks: MockSpaceLocks; cursors: MockSpaceCursors; + // Internal emitter for simulating space-level events + _emitter: AblyEventEmitter; } /** @@ -284,15 +289,33 @@ function createMockSpaceCursors(): MockSpaceCursors { * Create a mock space object. */ function createMockSpace(name: string): MockSpace { + const emitter = new EventEmitter(); + return { name, enter: vi.fn().mockImplementation(async () => {}), leave: vi.fn().mockImplementation(async () => {}), updateProfileData: vi.fn().mockImplementation(async () => {}), + subscribe: vi.fn((eventOrCallback, callback?) => { + const cb = callback ?? eventOrCallback; + const event = callback ? eventOrCallback : null; + emitter.on(event, cb); + }), + unsubscribe: vi.fn((eventOrCallback?, callback?) => { + if (!eventOrCallback) { + emitter.off(); + } else if (typeof eventOrCallback === "function") { + emitter.off(null, eventOrCallback); + } else if (callback) { + emitter.off(eventOrCallback, callback); + } + }), + getState: vi.fn().mockResolvedValue({ members: [] }), members: createMockSpaceMembers(), locations: createMockSpaceLocations(), locks: createMockSpaceLocks(), cursors: createMockSpaceCursors(), + _emitter: emitter, }; } diff --git a/test/unit/commands/spaces/create.test.ts b/test/unit/commands/spaces/create.test.ts new file mode 100644 index 00000000..7d59458f --- /dev/null +++ b/test/unit/commands/spaces/create.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { runCommand } from "@oclif/test"; +import { getMockAblySpaces } from "../../../helpers/mock-ably-spaces.js"; +import { getMockAblyRealtime } from "../../../helpers/mock-ably-realtime.js"; +import { parseNdjsonLines } from "../../../helpers/ndjson.js"; +import { + standardHelpTests, + standardArgValidationTests, + standardFlagTests, +} from "../../../helpers/standard-tests.js"; + +describe("spaces:create command", () => { + beforeEach(() => { + getMockAblyRealtime(); + getMockAblySpaces(); + }); + + standardHelpTests("spaces:create", import.meta.url); + standardArgValidationTests("spaces:create", import.meta.url, { + requiredArgs: ["test-space"], + }); + standardFlagTests("spaces:create", import.meta.url, ["--json"]); + + describe("functionality", () => { + it("should create space and display success", async () => { + const spacesMock = getMockAblySpaces(); + const space = spacesMock._getSpace("test-space"); + + const { stdout } = await runCommand( + ["spaces:create", "test-space"], + import.meta.url, + ); + + expect(space.enter).not.toHaveBeenCalled(); + expect(stdout).toContain("initialized"); + expect(stdout).toContain("test-space"); + }); + + it("should output JSON envelope with space name", async () => { + const { stdout } = await runCommand( + ["spaces:create", "test-space", "--json"], + import.meta.url, + ); + + const records = parseNdjsonLines(stdout); + const result = records.find((r) => r.type === "result"); + expect(result).toBeDefined(); + expect(result).toHaveProperty("type", "result"); + expect(result).toHaveProperty("command", "spaces:create"); + expect(result).toHaveProperty("success", true); + expect(result).toHaveProperty("space"); + expect(result!.space).toHaveProperty("name", "test-space"); + }); + + it("should not enter the space", async () => { + const spacesMock = getMockAblySpaces(); + const space = spacesMock._getSpace("test-space"); + + await runCommand(["spaces:create", "test-space"], import.meta.url); + + expect(space.enter).not.toHaveBeenCalled(); + }); + }); + + describe("error handling", () => { + it("should handle connection errors gracefully", async () => { + const spacesMock = getMockAblySpaces(); + spacesMock.get.mockRejectedValue(new Error("Connection failed")); + + const { error } = await runCommand( + ["spaces:create", "test-space"], + import.meta.url, + ); + expect(error).toBeDefined(); + }); + }); +}); diff --git a/test/unit/commands/spaces/get.test.ts b/test/unit/commands/spaces/get.test.ts new file mode 100644 index 00000000..f07b293f --- /dev/null +++ b/test/unit/commands/spaces/get.test.ts @@ -0,0 +1,182 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { runCommand } from "@oclif/test"; +import { getMockAblyRest } from "../../../helpers/mock-ably-rest.js"; +import { parseNdjsonLines } from "../../../helpers/ndjson.js"; +import { + standardHelpTests, + standardArgValidationTests, + standardFlagTests, +} from "../../../helpers/standard-tests.js"; + +describe("spaces:get command", () => { + beforeEach(() => { + const mock = getMockAblyRest(); + mock.request.mockClear(); + mock.request.mockResolvedValue({ + items: [ + { + clientId: "user-1", + connectionId: "conn-1", + action: "present", + timestamp: 1710000000000, + data: { + profileUpdate: { current: { name: "Alice" } }, + locationUpdate: { current: { slide: 1 } }, + }, + }, + ], + statusCode: 200, + }); + }); + + standardHelpTests("spaces:get", import.meta.url); + standardArgValidationTests("spaces:get", import.meta.url, { + requiredArgs: ["test-space"], + }); + standardFlagTests("spaces:get", import.meta.url, ["--json"]); + + describe("functionality", () => { + it("should fetch space state and display members", async () => { + const mock = getMockAblyRest(); + + const { stdout } = await runCommand( + ["spaces:get", "test-space"], + import.meta.url, + ); + + expect(mock.request).toHaveBeenCalledOnce(); + const [method, path, version, , body] = mock.request.mock.calls[0]; + expect(method).toBe("get"); + expect(path).toBe( + `/channels/${encodeURIComponent("test-space::$space")}/presence`, + ); + expect(version).toBe(2); + expect(body).toBeNull(); + + expect(stdout).toContain("test-space"); + expect(stdout).toContain("user-1"); + expect(stdout).toContain("Client ID:"); + expect(stdout).toContain("Connection ID:"); + }); + + it("should output JSON envelope with space and members", async () => { + const { stdout } = await runCommand( + ["spaces:get", "test-space", "--json"], + import.meta.url, + ); + + const records = parseNdjsonLines(stdout); + const result = records.find((r) => r.type === "result"); + expect(result).toBeDefined(); + expect(result).toHaveProperty("type", "result"); + expect(result).toHaveProperty("command", "spaces:get"); + expect(result).toHaveProperty("success", true); + expect(result).toHaveProperty("space"); + const space = result!.space as { name: string; members: unknown[] }; + expect(space).toHaveProperty("name", "test-space"); + expect(space.members).toBeInstanceOf(Array); + expect(space.members[0]).toHaveProperty("clientId", "user-1"); + expect(space.members[0]).toHaveProperty("profileData", { name: "Alice" }); + expect(space.members[0]).toHaveProperty("location", { slide: 1 }); + }); + + it("should correctly parse SDK internal data format", async () => { + const mock = getMockAblyRest(); + mock.request.mockResolvedValue({ + items: [ + { + clientId: "user-2", + connectionId: "conn-2", + action: "present", + timestamp: 1710000000000, + data: { + profileUpdate: { current: { role: "admin" } }, + }, + }, + ], + statusCode: 200, + }); + + const { stdout } = await runCommand( + ["spaces:get", "test-space", "--json"], + import.meta.url, + ); + + const records = parseNdjsonLines(stdout); + const result = records.find((r) => r.type === "result"); + const space = result!.space as { + members: Array<{ profileData: unknown; location: unknown }>; + }; + expect(space.members[0].profileData).toEqual({ role: "admin" }); + expect(space.members[0].location).toBeNull(); + }); + + it("should fail when space has no members (empty presence)", async () => { + const mock = getMockAblyRest(); + mock.request.mockResolvedValue({ items: [], statusCode: 200 }); + + const { error } = await runCommand( + ["spaces:get", "test-space"], + import.meta.url, + ); + expect(error).toBeDefined(); + expect(error?.message).toContain("doesn't have any members"); + }); + + it("should mark leave/absent members as not connected", async () => { + const mock = getMockAblyRest(); + mock.request.mockResolvedValue({ + items: [ + { + clientId: "user-1", + connectionId: "conn-1", + action: "leave", + timestamp: 1710000000000, + data: {}, + }, + ], + statusCode: 200, + }); + + const { stdout } = await runCommand( + ["spaces:get", "test-space", "--json"], + import.meta.url, + ); + + const records = parseNdjsonLines(stdout); + const result = records.find((r) => r.type === "result"); + const space = result!.space as { + members: Array<{ isConnected: boolean }>; + }; + expect(space.members[0].isConnected).toBe(false); + }); + }); + + describe("error handling", () => { + it("should handle API errors gracefully", async () => { + const mock = getMockAblyRest(); + mock.request.mockRejectedValue(new Error("API error")); + + const { error } = await runCommand( + ["spaces:get", "test-space"], + import.meta.url, + ); + expect(error).toBeDefined(); + }); + + it("should handle non-200 status codes", async () => { + const mock = getMockAblyRest(); + mock.request.mockResolvedValue({ + items: [], + statusCode: 500, + }); + + const { error } = await runCommand( + ["spaces:get", "test-space"], + import.meta.url, + ); + expect(error).toBeDefined(); + expect(error?.message).toContain("500"); + }); + }); +}); diff --git a/test/unit/commands/spaces/members/get-all.test.ts b/test/unit/commands/spaces/members/get-all.test.ts new file mode 100644 index 00000000..6abda8e2 --- /dev/null +++ b/test/unit/commands/spaces/members/get-all.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { runCommand } from "@oclif/test"; +import { getMockAblySpaces } from "../../../../helpers/mock-ably-spaces.js"; +import { getMockAblyRealtime } from "../../../../helpers/mock-ably-realtime.js"; +import { parseNdjsonLines } from "../../../../helpers/ndjson.js"; +import { + standardHelpTests, + standardArgValidationTests, + standardFlagTests, +} from "../../../../helpers/standard-tests.js"; + +describe("spaces:members:get-all command", () => { + beforeEach(() => { + getMockAblyRealtime(); + getMockAblySpaces(); + }); + + standardHelpTests("spaces:members:get-all", import.meta.url); + standardArgValidationTests("spaces:members:get-all", import.meta.url, { + requiredArgs: ["test-space"], + }); + standardFlagTests("spaces:members:get-all", import.meta.url, ["--json"]); + + describe("functionality", () => { + it("should get all members from a space", async () => { + const spacesMock = getMockAblySpaces(); + const space = spacesMock._getSpace("test-space"); + space.members.getAll.mockResolvedValue([ + { + clientId: "user-1", + connectionId: "conn-1", + isConnected: true, + profileData: { name: "Alice" }, + location: null, + lastEvent: { name: "enter", timestamp: Date.now() }, + }, + ]); + + const { stdout } = await runCommand( + ["spaces:members:get-all", "test-space", "--json"], + import.meta.url, + ); + + expect(space.enter).not.toHaveBeenCalled(); + expect(space.members.getAll).toHaveBeenCalled(); + expect(stdout).toContain("members"); + }); + + it("should output JSON envelope with type and command for member results", async () => { + const spacesMock = getMockAblySpaces(); + const space = spacesMock._getSpace("test-space"); + space.members.getAll.mockResolvedValue([ + { + clientId: "user-1", + connectionId: "conn-1", + isConnected: true, + profileData: { name: "Alice" }, + location: null, + lastEvent: { name: "enter", timestamp: Date.now() }, + }, + ]); + + const { stdout } = await runCommand( + ["spaces:members:get-all", "test-space", "--json"], + import.meta.url, + ); + + const records = parseNdjsonLines(stdout); + const resultRecord = records.find( + (r) => r.type === "result" && Array.isArray(r.members), + ); + expect(resultRecord).toBeDefined(); + expect(resultRecord).toHaveProperty("type", "result"); + expect(resultRecord).toHaveProperty("command"); + expect(resultRecord).toHaveProperty("success", true); + expect(resultRecord!.members).toBeInstanceOf(Array); + expect(resultRecord!.members[0]).toHaveProperty("clientId", "user-1"); + expect(resultRecord!.members[0]).toHaveProperty("connectionId", "conn-1"); + }); + + it("should handle no members found", async () => { + const spacesMock = getMockAblySpaces(); + const space = spacesMock._getSpace("test-space"); + space.members.getAll.mockResolvedValue([]); + + const { stdout } = await runCommand( + ["spaces:members:get-all", "test-space", "--json"], + import.meta.url, + ); + + expect(stdout).toContain("members"); + }); + + it("should display non-JSON output with member blocks", async () => { + const spacesMock = getMockAblySpaces(); + const space = spacesMock._getSpace("test-space"); + space.members.getAll.mockResolvedValue([ + { + clientId: "user-1", + connectionId: "conn-1", + isConnected: true, + profileData: { name: "Alice" }, + location: null, + lastEvent: { name: "enter", timestamp: Date.now() }, + }, + ]); + + const { stdout } = await runCommand( + ["spaces:members:get-all", "test-space"], + import.meta.url, + ); + + expect(stdout).toContain("Current members"); + expect(stdout).toContain("Client ID:"); + expect(stdout).toContain("user-1"); + expect(stdout).toContain("Connection ID:"); + expect(stdout).toContain("conn-1"); + }); + }); + + describe("error handling", () => { + it("should handle errors gracefully", async () => { + const spacesMock = getMockAblySpaces(); + const space = spacesMock._getSpace("test-space"); + space.members.getAll.mockRejectedValue( + new Error("Failed to get members"), + ); + + const { error } = await runCommand( + ["spaces:members:get-all", "test-space"], + import.meta.url, + ); + expect(error).toBeDefined(); + }); + }); +}); diff --git a/test/unit/commands/spaces/subscribe.test.ts b/test/unit/commands/spaces/subscribe.test.ts new file mode 100644 index 00000000..6aacc796 --- /dev/null +++ b/test/unit/commands/spaces/subscribe.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { runCommand } from "@oclif/test"; +import { getMockAblySpaces } from "../../../helpers/mock-ably-spaces.js"; +import { getMockAblyRealtime } from "../../../helpers/mock-ably-realtime.js"; +import { + standardHelpTests, + standardArgValidationTests, + standardFlagTests, +} from "../../../helpers/standard-tests.js"; + +describe("spaces:subscribe command", () => { + beforeEach(() => { + getMockAblyRealtime(); + getMockAblySpaces(); + }); + + standardHelpTests("spaces:subscribe", import.meta.url); + standardArgValidationTests("spaces:subscribe", import.meta.url, { + requiredArgs: ["test-space"], + }); + standardFlagTests("spaces:subscribe", import.meta.url, ["--json"]); + + describe("functionality", () => { + it("should subscribe to space updates and output events", async () => { + const spacesMock = getMockAblySpaces(); + const space = spacesMock._getSpace("test-space"); + + space.subscribe.mockImplementation( + (event: string, cb: (state: unknown) => void) => { + setTimeout(() => { + cb({ + members: [ + { + clientId: "user-1", + connectionId: "other-conn-1", + isConnected: true, + profileData: { name: "Alice" }, + location: null, + lastEvent: { name: "enter", timestamp: Date.now() }, + }, + ], + }); + }, 10); + }, + ); + + const { stdout } = await runCommand( + ["spaces:subscribe", "test-space"], + import.meta.url, + ); + + expect(stdout).toContain("Client ID:"); + expect(stdout).toContain("user-1"); + expect(stdout).toContain("Connection ID:"); + expect(stdout).toContain("other-conn-1"); + }); + + it("should subscribe to space 'update' event and not enter", async () => { + const spacesMock = getMockAblySpaces(); + const space = spacesMock._getSpace("test-space"); + + await runCommand(["spaces:subscribe", "test-space"], import.meta.url); + + expect(space.enter).not.toHaveBeenCalled(); + expect(space.subscribe).toHaveBeenCalledWith( + "update", + expect.any(Function), + ); + }); + + it("should output JSON event with members array", async () => { + const spacesMock = getMockAblySpaces(); + const space = spacesMock._getSpace("test-space"); + + space.subscribe.mockImplementation( + (event: string, cb: (state: unknown) => void) => { + setTimeout(() => { + cb({ + members: [ + { + clientId: "user-1", + connectionId: "other-conn-1", + isConnected: true, + profileData: { name: "Alice" }, + location: null, + lastEvent: { name: "enter", timestamp: Date.now() }, + }, + ], + }); + }, 10); + }, + ); + + const { stdout } = await runCommand( + ["spaces:subscribe", "test-space", "--json"], + import.meta.url, + ); + + const result = JSON.parse(stdout); + expect(result.type).toBe("event"); + expect(result.space).toBeDefined(); + expect(result.space.members).toBeDefined(); + expect(result.space.members).toBeInstanceOf(Array); + expect(result.space.members[0].clientId).toBe("user-1"); + }); + }); + + describe("error handling", () => { + it("should handle errors gracefully", async () => { + const spacesMock = getMockAblySpaces(); + const space = spacesMock._getSpace("test-space"); + space.subscribe.mockImplementation(() => { + throw new Error("Subscribe failed"); + }); + + const { error } = await runCommand( + ["spaces:subscribe", "test-space"], + import.meta.url, + ); + expect(error).toBeDefined(); + expect(error?.message).toContain("Subscribe failed"); + }); + }); +});