diff --git a/src/lib/refs.test.ts b/src/lib/refs.test.ts index a321211..c7a077b 100644 --- a/src/lib/refs.test.ts +++ b/src/lib/refs.test.ts @@ -258,6 +258,17 @@ describe('resolveChannelRef', () => { const mockGetChannel = vi.fn() const mockGetChannels = vi.fn() + const mockGetPublicChannels = vi.fn() + + /** + * For name refs, resolveChannelRef merges joined channels (getChannels — membership-scoped, + * includes both active + archived) with public channels (getPublicChannels — workspace-scoped, + * finds unjoined-but-public channels). Tests default both to empty unless overridden. + */ + function mockChannelLists(joined: unknown[] = [], publicChannels: unknown[] = []) { + mockGetChannels.mockResolvedValue(joined) + mockGetPublicChannels.mockResolvedValue(publicChannels) + } beforeEach(() => { vi.clearAllMocks() @@ -266,6 +277,9 @@ describe('resolveChannelRef', () => { getChannel: mockGetChannel, getChannels: mockGetChannels, }, + workspaces: { + getPublicChannels: mockGetPublicChannels, + }, }) }) @@ -306,19 +320,21 @@ describe('resolveChannelRef', () => { expect(mockGetChannel).not.toHaveBeenCalled() }) - it('resolves exact case-insensitive name match', async () => { + it('resolves exact case-insensitive name match against joined channels without fetching public list', async () => { const ch = createChannel('CHGEN', 'General') - mockGetChannels.mockResolvedValue([ch, createChannel('CHLEAD', 'Leadership')]) + mockChannelLists([ch, createChannel('CHLEAD', 'Leadership')]) const result = await resolveChannelRef('general', 1) - expect(mockGetChannels).toHaveBeenCalledWith({ workspaceId: 1 }) expect(result).toEqual(ch) + // Common case: exact match in joined list short-circuits before the + // workspace-wide getPublicChannels call. + expect(mockGetPublicChannels).not.toHaveBeenCalled() }) it('resolves unique substring name match', async () => { const ch = createChannel('CHMKT', 'Marketing') - mockGetChannels.mockResolvedValue([createChannel('CHGEN', 'General'), ch]) + mockChannelLists([createChannel('CHGEN', 'General'), ch]) const result = await resolveChannelRef('market', 1) @@ -326,7 +342,7 @@ describe('resolveChannelRef', () => { }) it('throws AMBIGUOUS_CHANNEL on multiple substring matches', async () => { - mockGetChannels.mockResolvedValue([ + mockChannelLists([ createChannel('CHENG', 'Engineering'), createChannel('CHEOP', 'Engineering-Ops'), ]) @@ -338,13 +354,59 @@ describe('resolveChannelRef', () => { }) it('throws CHANNEL_NOT_FOUND when no match', async () => { - mockGetChannels.mockResolvedValue([createChannel('CHGEN', 'General')]) + mockChannelLists([createChannel('CHGEN', 'General')]) await expect(resolveChannelRef('nope', 1)).rejects.toHaveProperty( 'code', 'CHANNEL_NOT_FOUND', ) }) + + it('resolves unjoined-but-public channel by name', async () => { + const publicCh = createChannel('CHPUB1', 'Old Public Channel') + mockChannelLists([createChannel('CHGEN', 'General')], [publicCh]) + + const result = await resolveChannelRef('Old Public Channel', 1) + + expect(result).toEqual(publicCh) + }) + + it('resolves unjoined-but-public channel by substring', async () => { + const publicCh = createChannel('CHSMOKE', 'tw-cli-smoke-test-channel') + mockChannelLists([createChannel('CHGEN', 'General')], [publicCh]) + + const result = await resolveChannelRef('smoke-test', 1) + + expect(result).toEqual(publicCh) + }) + + it('deduplicates channels appearing in both joined and public lists', async () => { + // A public channel the user has joined appears in both responses. Use distinct + // object instances with the same id — that's what two API calls actually return, + // and it ensures dedupe is by id (not reference equality). A substring query + // exercises the dedupe step: without it, matchByName sees two partial matches + // for the same channel id and throws AMBIGUOUS_CHANNEL. An exact-match query + // wouldn't catch a regression because matchByName returns on the first .find. + const joinedCopy = createChannel('CHJP', 'Engineering', { public: true }) + const publicCopy = createChannel('CHJP', 'Engineering', { public: true }) + mockChannelLists([joinedCopy], [publicCopy]) + + const result = await resolveChannelRef('eng', 1) + + expect(result).toEqual(joinedCopy) + }) + + it('throws AMBIGUOUS_CHANNEL on substring matches spanning joined and public lists', async () => { + mockChannelLists( + [createChannel('CHENG', 'Engineering')], + [createChannel('CHEOP', 'Engineering-Ops')], + ) + + await expect(resolveChannelRef('eng', 1)).rejects.toHaveProperty( + 'code', + 'AMBIGUOUS_CHANNEL', + ) + }) }) describe('resolveConversationId', () => { diff --git a/src/lib/refs.ts b/src/lib/refs.ts index 985294b..c62e8b4 100644 --- a/src/lib/refs.ts +++ b/src/lib/refs.ts @@ -270,7 +270,25 @@ export async function resolveChannelRef(ref: string, workspaceId: number): Promi } if (parsed.type === 'name') { - const channels = await client.channels.getChannels({ workspaceId }) + // getChannels is membership-scoped — only channels the current user has joined + // (active + archived). Try an exact match against that list first; the common + // case (user types a channel they're in) returns without the workspace-wide + // getPublicChannels call. Fall through only when we need the unjoined-public + // set — for unjoined-but-public matches or cross-list substring resolution. + const joined = await client.channels.getChannels({ workspaceId }) + const lowerName = parsed.name.toLowerCase() + const exactJoined = joined.find((channel) => channel.name.toLowerCase() === lowerName) + if (exactJoined) return exactJoined + + // getPublicChannels is workspace-scoped (all public channels regardless of + // membership). Merge and dedupe by id so a joined-and-public channel doesn't + // match twice through matchByName's substring path. + const publicChannels = await client.workspaces.getPublicChannels(workspaceId) + const joinedIds = new Set(joined.map((channel) => channel.id)) + const channels = [ + ...joined, + ...publicChannels.filter((channel) => !joinedIds.has(channel.id)), + ] return matchByName(channels, parsed.name, { ambiguousCode: 'AMBIGUOUS_CHANNEL', notFoundCode: 'CHANNEL_NOT_FOUND',