diff --git a/README.md b/README.md index f2a933a..5ff6b22 100644 --- a/README.md +++ b/README.md @@ -402,6 +402,29 @@ slack-cli users presence --name @alice --format json slack-cli users list --profile work ``` +### User Groups + +```bash +# List user groups in the workspace +slack-cli usergroups list + +# Include disabled user groups +slack-cli usergroups list --include-disabled + +# Output in different formats +slack-cli usergroups list --format json +slack-cli usergroups list --format simple + +# List members of a user group by ID +slack-cli usergroups members --id S01ABCDEF + +# List members of a user group by handle +slack-cli usergroups members --handle @engineers + +# Use specific profile +slack-cli usergroups list --profile work +``` + ### Scheduled Messages ```bash @@ -639,6 +662,25 @@ Subcommands: `list`, `info`, `lookup` | --email | | Email address to look up (required) | | --format | | Output format: table, simple, json (default: table) | +### usergroups command + +Subcommands: `list`, `members` + +#### usergroups list + +| Option | Short | Description | +| ------------------ | ----- | --------------------------------------------------- | +| --include-disabled | | Include disabled user groups | +| --format | | Output format: table, simple, json (default: table) | + +#### usergroups members + +| Option | Short | Description | +| -------- | ----- | ---------------------------------------------------- | +| --id | | User group ID (either --id or --handle is required) | +| --handle | | User group handle (e.g. @engineers) | +| --format | | Output format: table, simple, json (default: table) | + ### scheduled command Subcommands: `list`, `cancel` @@ -799,6 +841,7 @@ Your Slack API token needs the following scopes: - `im:write` - Open DM channels for --user/--email DM sending - `users:read` - Access user information for unread counts and user listing - `users:read.email` - Look up users by email address +- `usergroups:read` - List user groups and their members - `search:read` - Search messages (user token only, not supported with bot tokens) - `reactions:write` - Add and remove reactions - `pins:read` - List pinned items in a channel diff --git a/package-lock.json b/package-lock.json index a68a022..6b2c830 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@mimo-3/slack-cli", - "version": "0.23.0", + "version": "0.24.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@mimo-3/slack-cli", - "version": "0.23.0", + "version": "0.24.0", "license": "MIT", "dependencies": { "@slack/web-api": "7.15.0", diff --git a/package.json b/package.json index 62a2928..8f32fff 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@mimo-3/slack-cli", - "version": "0.23.0", + "version": "0.24.0", "description": "A command-line tool for sending messages to Slack", "main": "dist/index.js", "bin": { diff --git a/src/commands/usergroups.ts b/src/commands/usergroups.ts new file mode 100644 index 0000000..225ffe0 --- /dev/null +++ b/src/commands/usergroups.ts @@ -0,0 +1,118 @@ +import { Command } from 'commander'; +import { UsergroupsListOptions, UsergroupsMembersOptions } from '../types/commands'; +import { SlackUsergroup } from '../types/slack'; +import { renderByFormat, withSlackClient } from '../utils/command-support'; +import { wrapCommand } from '../utils/command-wrapper'; +import { createMembersFormatter, MemberInfo } from '../utils/formatters/members-formatters'; +import { parseFormat } from '../utils/option-parsers'; +import { sanitizeTerminalData, sanitizeTerminalText } from '../utils/terminal-sanitizer'; +import { createValidationHook, optionValidators } from '../utils/validators'; + +function renderUsergroupTable(usergroups: SlackUsergroup[]) { + const rows = usergroups.map((usergroup) => ({ + id: sanitizeTerminalText(usergroup.id || ''), + handle: sanitizeTerminalText(usergroup.handle || ''), + name: sanitizeTerminalText(usergroup.name || ''), + description: sanitizeTerminalText(usergroup.description || ''), + user_count: usergroup.user_count ?? '', + })); + + console.table(sanitizeTerminalData(rows)); +} + +function renderUsergroupSimple(usergroups: SlackUsergroup[]) { + for (const usergroup of usergroups) { + console.log( + `${sanitizeTerminalText(usergroup.id || '')}\t@${sanitizeTerminalText( + usergroup.handle || '' + )}\t${sanitizeTerminalText(usergroup.name || '')}` + ); + } +} + +export function setupUsergroupsCommand(): Command { + const usergroupsCommand = new Command('usergroups').description( + 'List user groups and their members' + ); + + const listCommand = new Command('list') + .description('List user groups in the workspace') + .option('--include-disabled', 'Include disabled user groups') + .option('--format ', 'Output format: table, simple, json', 'table') + .option('--profile ', 'Use specific workspace profile') + .hook('preAction', createValidationHook([optionValidators.format])) + .action( + wrapCommand(async (options: UsergroupsListOptions) => { + await withSlackClient(options, async (client) => { + const usergroups = await client.listUsergroups(options.includeDisabled ?? false); + + if (usergroups.length === 0) { + console.log('No usergroups found'); + return; + } + + renderByFormat(options, usergroups, { + table: renderUsergroupTable, + simple: renderUsergroupSimple, + }); + }); + }) + ); + + const membersCommand = new Command('members') + .description('List members of a user group') + .option('--id ', 'User group ID') + .option('--handle ', 'User group handle (e.g. @engineers)') + .option('--format ', 'Output format: table, simple, json', 'table') + .option('--profile ', 'Use specific workspace profile') + .hook('preAction', createValidationHook([optionValidators.format])) + .action( + wrapCommand(async (options: UsergroupsMembersOptions) => { + if (!options.id && !options.handle) { + throw new Error('You must specify either --id or --handle'); + } + if (options.id && options.handle) { + throw new Error('Cannot use both --id and --handle'); + } + + await withSlackClient(options, async (client) => { + let usergroupId: string; + if (options.handle) { + usergroupId = await client.resolveUsergroupIdByHandle(options.handle); + } else { + usergroupId = options.id!; + } + + const memberIds = await client.listUsergroupMembers(usergroupId); + + if (memberIds.length === 0) { + console.log('No members found'); + return; + } + + const members: MemberInfo[] = await Promise.all( + memberIds.map(async (userId) => { + try { + const user = await client.getUserInfo(userId); + return { + id: userId, + name: user.name, + realName: user.real_name, + }; + } catch { + return { id: userId }; + } + }) + ); + + const format = parseFormat(options.format); + createMembersFormatter(format).format({ members }); + }); + }) + ); + + usergroupsCommand.addCommand(listCommand); + usergroupsCommand.addCommand(membersCommand); + + return usergroupsCommand; +} diff --git a/src/index.ts b/src/index.ts index 3069d8e..7e5802a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,6 +25,7 @@ import { setupSendCommand } from './commands/send'; import { setupSendEphemeralCommand } from './commands/send-ephemeral'; import { setupUnreadCommand } from './commands/unread'; import { setupUploadCommand } from './commands/upload'; +import { setupUsergroupsCommand } from './commands/usergroups'; import { setupUsersCommand } from './commands/users'; import { checkForUpdates } from './utils/update-notifier'; @@ -62,6 +63,7 @@ export function createProgram(): Command { program.addCommand(setupReactionCommand()); program.addCommand(setupPinCommand()); program.addCommand(setupUsersCommand()); + program.addCommand(setupUsergroupsCommand()); program.addCommand(setupChannelCommand()); program.addCommand(setupMembersCommand()); program.addCommand(setupSendEphemeralCommand()); diff --git a/src/types/commands.ts b/src/types/commands.ts index 077f595..8ddeadb 100644 --- a/src/types/commands.ts +++ b/src/types/commands.ts @@ -142,6 +142,19 @@ export interface UsersPresenceOptions { profile?: string; } +export interface UsergroupsListOptions { + includeDisabled?: boolean; + format?: 'table' | 'simple' | 'json'; + profile?: string; +} + +export interface UsergroupsMembersOptions { + id?: string; + handle?: string; + format?: 'table' | 'simple' | 'json'; + profile?: string; +} + export interface ChannelInfoOptions { channel: string; format?: 'table' | 'simple' | 'json'; diff --git a/src/types/slack.ts b/src/types/slack.ts index c6c0e50..78cf6d4 100644 --- a/src/types/slack.ts +++ b/src/types/slack.ts @@ -90,6 +90,18 @@ export interface UserPresence { presence: string; } +export interface SlackUsergroup { + id?: string; + team_id?: string; + name?: string; + handle?: string; + description?: string; + is_external?: boolean; + date_create?: number; + date_delete?: number; + user_count?: number; +} + export interface SlackUser { id?: string; name?: string; diff --git a/src/utils/slack-client-service.ts b/src/utils/slack-client-service.ts index 8f09e76..6a62dc1 100644 --- a/src/utils/slack-client-service.ts +++ b/src/utils/slack-client-service.ts @@ -22,6 +22,7 @@ import type { SearchMessagesOptions, SearchResult, SlackUser, + SlackUsergroup, StarListResult, UserPresence, } from '../types/slack'; @@ -37,6 +38,7 @@ import { ReminderOperations } from './slack-operations/reminder-operations'; import { SearchOperations } from './slack-operations/search-operations'; import { StarOperations } from './slack-operations/star-operations'; import { UserOperations } from './slack-operations/user-operations'; +import { UsergroupOperations } from './slack-operations/usergroup-operations'; export class SlackApiClient { private channelOps: ChannelOperations; @@ -45,6 +47,7 @@ export class SlackApiClient { private reactionOps: ReactionOperations; private pinOps: PinOperations; private userOps: UserOperations; + private usergroupOps: UsergroupOperations; private searchOps: SearchOperations; private reminderOps: ReminderOperations; private starOps: StarOperations; @@ -58,6 +61,7 @@ export class SlackApiClient { this.reactionOps = new ReactionOperations(sharedContext, this.channelOps); this.pinOps = new PinOperations(sharedContext, this.channelOps); this.userOps = new UserOperations(sharedContext); + this.usergroupOps = new UsergroupOperations(sharedContext); this.searchOps = new SearchOperations(sharedContext); this.reminderOps = new ReminderOperations(sharedContext); this.starOps = new StarOperations(sharedContext); @@ -215,6 +219,18 @@ export class SlackApiClient { return this.userOps.resolveUserIdByName(username); } + async listUsergroups(includeDisabled?: boolean): Promise { + return this.usergroupOps.listUsergroups(includeDisabled); + } + + async listUsergroupMembers(usergroupId: string): Promise { + return this.usergroupOps.listUsergroupMembers(usergroupId); + } + + async resolveUsergroupIdByHandle(handle: string): Promise { + return this.usergroupOps.resolveUsergroupIdByHandle(handle); + } + async searchMessages(query: string, options?: SearchMessagesOptions): Promise { return this.searchOps.searchMessages(query, options); } diff --git a/src/utils/slack-operations/usergroup-operations.ts b/src/utils/slack-operations/usergroup-operations.ts new file mode 100644 index 0000000..72e9fe6 --- /dev/null +++ b/src/utils/slack-operations/usergroup-operations.ts @@ -0,0 +1,39 @@ +import type { SlackUsergroup } from '../../types/slack'; +import { ApiError } from '../errors'; +import { BaseSlackClient, SlackClientDependency } from './base-client'; + +export class UsergroupOperations extends BaseSlackClient { + constructor(dependency: SlackClientDependency) { + super(dependency); + } + + async listUsergroups(includeDisabled = false): Promise { + const response = await this.client.usergroups.list({ + include_count: true, + ...(includeDisabled ? { include_disabled: true } : {}), + }); + + return (response.usergroups || []) as SlackUsergroup[]; + } + + async listUsergroupMembers(usergroupId: string): Promise { + const response = await this.client.usergroups.users.list({ + usergroup: usergroupId, + }); + + return (response.users || []) as string[]; + } + + async resolveUsergroupIdByHandle(handle: string): Promise { + const normalized = handle.replace(/^@/, '').toLowerCase(); + + const usergroups = await this.listUsergroups(true); + for (const usergroup of usergroups) { + if (usergroup.handle?.toLowerCase() === normalized) { + return usergroup.id!; + } + } + + throw new ApiError(`Usergroup '@${handle.replace(/^@/, '')}' not found`); + } +} diff --git a/tests/commands/usergroups.test.ts b/tests/commands/usergroups.test.ts new file mode 100644 index 0000000..04806a8 --- /dev/null +++ b/tests/commands/usergroups.test.ts @@ -0,0 +1,336 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { setupUsergroupsCommand } from '../../src/commands/usergroups'; +import { ProfileConfigManager } from '../../src/utils/profile-config'; +import { SlackApiClient } from '../../src/utils/slack-api-client'; +import { createTestProgram, restoreMocks, setupMockConsole } from '../test-utils'; + +vi.mock('../../src/utils/slack-api-client'); +vi.mock('../../src/utils/profile-config'); + +describe('usergroups command', () => { + let program: ReturnType; + let mockSlackClient: SlackApiClient; + let mockConfigManager: ProfileConfigManager; + let mockConsole: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + + mockConfigManager = new ProfileConfigManager(); + vi.mocked(ProfileConfigManager).mockImplementation(function () { + return mockConfigManager; + }); + + mockSlackClient = new SlackApiClient('test-token'); + vi.mocked(SlackApiClient).mockImplementation(function () { + return mockSlackClient; + }); + + mockConsole = setupMockConsole(); + program = createTestProgram(); + program.addCommand(setupUsergroupsCommand()); + }); + + afterEach(() => { + restoreMocks(); + }); + + describe('list subcommand', () => { + it('should list usergroups in table format', async () => { + vi.mocked(mockConfigManager.getConfig).mockResolvedValue({ + token: 'test-token', + updatedAt: new Date().toISOString(), + }); + vi.mocked(mockSlackClient.listUsergroups).mockResolvedValue([ + { + id: 'S123', + name: 'Engineering', + handle: 'engineers', + description: 'Engineering team', + user_count: 10, + }, + ]); + + await program.parseAsync(['node', 'slack-cli', 'usergroups', 'list']); + + expect(mockSlackClient.listUsergroups).toHaveBeenCalledWith(false); + expect(mockConsole.logSpy).toHaveBeenCalled(); + }); + + it('should include disabled usergroups with --include-disabled', async () => { + vi.mocked(mockConfigManager.getConfig).mockResolvedValue({ + token: 'test-token', + updatedAt: new Date().toISOString(), + }); + vi.mocked(mockSlackClient.listUsergroups).mockResolvedValue([]); + + await program.parseAsync(['node', 'slack-cli', 'usergroups', 'list', '--include-disabled']); + + expect(mockSlackClient.listUsergroups).toHaveBeenCalledWith(true); + }); + + it('should list usergroups in json format', async () => { + vi.mocked(mockConfigManager.getConfig).mockResolvedValue({ + token: 'test-token', + updatedAt: new Date().toISOString(), + }); + const usergroups = [ + { + id: 'S123', + name: 'Engineering', + handle: 'engineers', + description: 'Engineering team', + user_count: 10, + }, + ]; + vi.mocked(mockSlackClient.listUsergroups).mockResolvedValue(usergroups); + + await program.parseAsync(['node', 'slack-cli', 'usergroups', 'list', '--format', 'json']); + + expect(mockConsole.logSpy).toHaveBeenCalledWith(JSON.stringify(usergroups, null, 2)); + }); + + it('should list usergroups in simple format', async () => { + vi.mocked(mockConfigManager.getConfig).mockResolvedValue({ + token: 'test-token', + updatedAt: new Date().toISOString(), + }); + vi.mocked(mockSlackClient.listUsergroups).mockResolvedValue([ + { + id: 'S123', + name: 'Engineering', + handle: 'engineers', + description: 'Engineering team', + user_count: 10, + }, + ]); + + await program.parseAsync(['node', 'slack-cli', 'usergroups', 'list', '--format', 'simple']); + + expect(mockConsole.logSpy).toHaveBeenCalledWith(expect.stringContaining('S123')); + expect(mockConsole.logSpy).toHaveBeenCalledWith(expect.stringContaining('engineers')); + }); + + it('should show message when no usergroups found', async () => { + vi.mocked(mockConfigManager.getConfig).mockResolvedValue({ + token: 'test-token', + updatedAt: new Date().toISOString(), + }); + vi.mocked(mockSlackClient.listUsergroups).mockResolvedValue([]); + + await program.parseAsync(['node', 'slack-cli', 'usergroups', 'list']); + + expect(mockConsole.logSpy).toHaveBeenCalledWith('No usergroups found'); + }); + + it('should use specified profile', async () => { + vi.mocked(mockConfigManager.getConfig).mockResolvedValue({ + token: 'work-token', + updatedAt: new Date().toISOString(), + }); + vi.mocked(mockSlackClient.listUsergroups).mockResolvedValue([]); + + await program.parseAsync(['node', 'slack-cli', 'usergroups', 'list', '--profile', 'work']); + + expect(mockConfigManager.getConfig).toHaveBeenCalledWith('work'); + expect(SlackApiClient).toHaveBeenCalledWith('work-token'); + }); + + it('should handle missing scope error', async () => { + vi.mocked(mockConfigManager.getConfig).mockResolvedValue({ + token: 'test-token', + updatedAt: new Date().toISOString(), + }); + vi.mocked(mockSlackClient.listUsergroups).mockRejectedValue(new Error('missing_scope')); + + await program.parseAsync(['node', 'slack-cli', 'usergroups', 'list']); + + expect(mockConsole.errorSpy).toHaveBeenCalledWith( + expect.stringContaining('Error:'), + expect.any(String) + ); + expect(mockConsole.exitSpy).toHaveBeenCalledWith(1); + }); + }); + + describe('members subcommand', () => { + it('should list usergroup members by id', async () => { + vi.mocked(mockConfigManager.getConfig).mockResolvedValue({ + token: 'test-token', + updatedAt: new Date().toISOString(), + }); + vi.mocked(mockSlackClient.listUsergroupMembers).mockResolvedValue(['U123', 'U456']); + vi.mocked(mockSlackClient.getUserInfo).mockImplementation(async (userId: string) => ({ + id: userId, + name: userId === 'U123' ? 'alice' : 'bob', + real_name: userId === 'U123' ? 'Alice Smith' : 'Bob Jones', + })); + + await program.parseAsync(['node', 'slack-cli', 'usergroups', 'members', '--id', 'S123']); + + expect(mockSlackClient.listUsergroupMembers).toHaveBeenCalledWith('S123'); + expect(mockSlackClient.getUserInfo).toHaveBeenCalledWith('U123'); + expect(mockSlackClient.getUserInfo).toHaveBeenCalledWith('U456'); + expect(mockConsole.logSpy).toHaveBeenCalled(); + }); + + it('should list usergroup members by handle', async () => { + vi.mocked(mockConfigManager.getConfig).mockResolvedValue({ + token: 'test-token', + updatedAt: new Date().toISOString(), + }); + vi.mocked(mockSlackClient.resolveUsergroupIdByHandle).mockResolvedValue('S123'); + vi.mocked(mockSlackClient.listUsergroupMembers).mockResolvedValue(['U123']); + vi.mocked(mockSlackClient.getUserInfo).mockResolvedValue({ + id: 'U123', + name: 'alice', + real_name: 'Alice Smith', + }); + + await program.parseAsync([ + 'node', + 'slack-cli', + 'usergroups', + 'members', + '--handle', + '@engineers', + ]); + + expect(mockSlackClient.resolveUsergroupIdByHandle).toHaveBeenCalledWith('@engineers'); + expect(mockSlackClient.listUsergroupMembers).toHaveBeenCalledWith('S123'); + }); + + it('should list members in simple format', async () => { + vi.mocked(mockConfigManager.getConfig).mockResolvedValue({ + token: 'test-token', + updatedAt: new Date().toISOString(), + }); + vi.mocked(mockSlackClient.listUsergroupMembers).mockResolvedValue(['U123']); + vi.mocked(mockSlackClient.getUserInfo).mockResolvedValue({ + id: 'U123', + name: 'alice', + real_name: 'Alice Smith', + }); + + await program.parseAsync([ + 'node', + 'slack-cli', + 'usergroups', + 'members', + '--id', + 'S123', + '--format', + 'simple', + ]); + + expect(mockConsole.logSpy).toHaveBeenCalledWith(expect.stringContaining('U123')); + expect(mockConsole.logSpy).toHaveBeenCalledWith(expect.stringContaining('alice')); + }); + + it('should fall back to user ID when user info lookup fails', async () => { + vi.mocked(mockConfigManager.getConfig).mockResolvedValue({ + token: 'test-token', + updatedAt: new Date().toISOString(), + }); + vi.mocked(mockSlackClient.listUsergroupMembers).mockResolvedValue(['U123']); + vi.mocked(mockSlackClient.getUserInfo).mockRejectedValue(new Error('user_not_found')); + + await program.parseAsync([ + 'node', + 'slack-cli', + 'usergroups', + 'members', + '--id', + 'S123', + '--format', + 'simple', + ]); + + expect(mockConsole.logSpy).toHaveBeenCalledWith(expect.stringContaining('U123')); + expect(mockConsole.exitSpy).not.toHaveBeenCalled(); + }); + + it('should show message when usergroup has no members', async () => { + vi.mocked(mockConfigManager.getConfig).mockResolvedValue({ + token: 'test-token', + updatedAt: new Date().toISOString(), + }); + vi.mocked(mockSlackClient.listUsergroupMembers).mockResolvedValue([]); + + await program.parseAsync(['node', 'slack-cli', 'usergroups', 'members', '--id', 'S123']); + + expect(mockConsole.logSpy).toHaveBeenCalledWith('No members found'); + }); + + it('should error when neither --id nor --handle is specified', async () => { + vi.mocked(mockConfigManager.getConfig).mockResolvedValue({ + token: 'test-token', + updatedAt: new Date().toISOString(), + }); + + await program.parseAsync(['node', 'slack-cli', 'usergroups', 'members']); + + expect(mockConsole.errorSpy).toHaveBeenCalledWith( + expect.stringContaining('Error:'), + expect.any(String) + ); + expect(mockConsole.exitSpy).toHaveBeenCalledWith(1); + }); + + it('should error when both --id and --handle are specified', async () => { + vi.mocked(mockConfigManager.getConfig).mockResolvedValue({ + token: 'test-token', + updatedAt: new Date().toISOString(), + }); + + await program.parseAsync([ + 'node', + 'slack-cli', + 'usergroups', + 'members', + '--id', + 'S123', + '--handle', + 'engineers', + ]); + + expect(mockConsole.errorSpy).toHaveBeenCalledWith( + expect.stringContaining('Error:'), + expect.any(String) + ); + expect(mockConsole.exitSpy).toHaveBeenCalledWith(1); + }); + + it('should handle usergroup not found error', async () => { + vi.mocked(mockConfigManager.getConfig).mockResolvedValue({ + token: 'test-token', + updatedAt: new Date().toISOString(), + }); + vi.mocked(mockSlackClient.listUsergroupMembers).mockRejectedValue( + new Error('no_such_subteam') + ); + + await program.parseAsync(['node', 'slack-cli', 'usergroups', 'members', '--id', 'SINVALID']); + + expect(mockConsole.errorSpy).toHaveBeenCalledWith( + expect.stringContaining('Error:'), + expect.any(String) + ); + expect(mockConsole.exitSpy).toHaveBeenCalledWith(1); + }); + }); + + describe('error handling', () => { + it('should handle missing configuration', async () => { + vi.mocked(mockConfigManager.getConfig).mockResolvedValue(null); + + await program.parseAsync(['node', 'slack-cli', 'usergroups', 'list']); + + expect(mockConsole.errorSpy).toHaveBeenCalledWith( + expect.stringContaining('Error:'), + expect.any(String) + ); + expect(mockConsole.exitSpy).toHaveBeenCalledWith(1); + }); + }); +}); diff --git a/tests/utils/slack-operations/usergroup-operations.test.ts b/tests/utils/slack-operations/usergroup-operations.test.ts new file mode 100644 index 0000000..ae08b5e --- /dev/null +++ b/tests/utils/slack-operations/usergroup-operations.test.ts @@ -0,0 +1,199 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { UsergroupOperations } from '../../../src/utils/slack-operations/usergroup-operations'; + +vi.mock('@slack/web-api', () => ({ + WebClient: vi.fn().mockImplementation(function () { + return { + usergroups: { + list: vi.fn(), + users: { + list: vi.fn(), + }, + }, + }; + }), + LogLevel: { + ERROR: 'error', + }, +})); + +describe('UsergroupOperations', () => { + type MockClient = { + usergroups: { + list: ReturnType; + users: { + list: ReturnType; + }; + }; + }; + + let usergroupOps: UsergroupOperations; + let mockClient: MockClient; + + beforeEach(() => { + vi.clearAllMocks(); + usergroupOps = new UsergroupOperations('test-token'); + mockClient = (usergroupOps as unknown as { client: MockClient }).client; + }); + + describe('listUsergroups', () => { + it('should list usergroups with member counts', async () => { + mockClient.usergroups.list.mockResolvedValue({ + ok: true, + usergroups: [ + { + id: 'S123', + name: 'Engineering', + handle: 'engineers', + description: 'Engineering team', + user_count: 10, + date_delete: 0, + }, + { + id: 'S456', + name: 'Design', + handle: 'designers', + description: 'Design team', + user_count: 5, + date_delete: 0, + }, + ], + }); + + const result = await usergroupOps.listUsergroups(); + + expect(mockClient.usergroups.list).toHaveBeenCalledWith({ + include_count: true, + }); + expect(result).toHaveLength(2); + expect(result[0].id).toBe('S123'); + expect(result[0].handle).toBe('engineers'); + expect(result[1].id).toBe('S456'); + }); + + it('should include disabled usergroups when requested', async () => { + mockClient.usergroups.list.mockResolvedValue({ + ok: true, + usergroups: [], + }); + + await usergroupOps.listUsergroups(true); + + expect(mockClient.usergroups.list).toHaveBeenCalledWith({ + include_count: true, + include_disabled: true, + }); + }); + + it('should return empty array when no usergroups exist', async () => { + mockClient.usergroups.list.mockResolvedValue({ + ok: true, + usergroups: [], + }); + + const result = await usergroupOps.listUsergroups(); + + expect(result).toEqual([]); + }); + + it('should return empty array when usergroups field is missing', async () => { + mockClient.usergroups.list.mockResolvedValue({ + ok: true, + }); + + const result = await usergroupOps.listUsergroups(); + + expect(result).toEqual([]); + }); + + it('should throw when API call fails', async () => { + mockClient.usergroups.list.mockRejectedValue(new Error('missing_scope')); + + await expect(usergroupOps.listUsergroups()).rejects.toThrow('missing_scope'); + }); + }); + + describe('listUsergroupMembers', () => { + it('should list user IDs in a usergroup', async () => { + mockClient.usergroups.users.list.mockResolvedValue({ + ok: true, + users: ['U123', 'U456'], + }); + + const result = await usergroupOps.listUsergroupMembers('S123'); + + expect(mockClient.usergroups.users.list).toHaveBeenCalledWith({ + usergroup: 'S123', + }); + expect(result).toEqual(['U123', 'U456']); + }); + + it('should return empty array when usergroup has no users', async () => { + mockClient.usergroups.users.list.mockResolvedValue({ + ok: true, + users: [], + }); + + const result = await usergroupOps.listUsergroupMembers('S123'); + + expect(result).toEqual([]); + }); + + it('should throw when usergroup not found', async () => { + mockClient.usergroups.users.list.mockRejectedValue(new Error('no_such_subteam')); + + await expect(usergroupOps.listUsergroupMembers('SINVALID')).rejects.toThrow( + 'no_such_subteam' + ); + }); + }); + + describe('resolveUsergroupIdByHandle', () => { + it('should resolve usergroup ID by handle', async () => { + mockClient.usergroups.list.mockResolvedValue({ + ok: true, + usergroups: [ + { id: 'S123', name: 'Engineering', handle: 'engineers' }, + { id: 'S456', name: 'Design', handle: 'designers' }, + ], + }); + + const result = await usergroupOps.resolveUsergroupIdByHandle('designers'); + + expect(result).toBe('S456'); + }); + + it('should resolve handle with @ prefix', async () => { + mockClient.usergroups.list.mockResolvedValue({ + ok: true, + usergroups: [{ id: 'S123', name: 'Engineering', handle: 'engineers' }], + }); + + const result = await usergroupOps.resolveUsergroupIdByHandle('@engineers'); + + expect(result).toBe('S123'); + }); + + it('should resolve handle case-insensitively', async () => { + mockClient.usergroups.list.mockResolvedValue({ + ok: true, + usergroups: [{ id: 'S123', name: 'Engineering', handle: 'Engineers' }], + }); + + const result = await usergroupOps.resolveUsergroupIdByHandle('engineers'); + + expect(result).toBe('S123'); + }); + + it('should throw when handle not found', async () => { + mockClient.usergroups.list.mockResolvedValue({ + ok: true, + usergroups: [{ id: 'S123', name: 'Engineering', handle: 'engineers' }], + }); + + await expect(usergroupOps.resolveUsergroupIdByHandle('unknown')).rejects.toThrow( + "Usergroup '@unknown' not found" + ); + }); + }); +});