Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 68 additions & 6 deletions src/lib/refs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -266,6 +277,9 @@ describe('resolveChannelRef', () => {
getChannel: mockGetChannel,
getChannels: mockGetChannels,
},
workspaces: {
getPublicChannels: mockGetPublicChannels,
},
})
})

Expand Down Expand Up @@ -306,27 +320,29 @@ 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)

expect(result).toEqual(ch)
})

it('throws AMBIGUOUS_CHANNEL on multiple substring matches', async () => {
mockGetChannels.mockResolvedValue([
mockChannelLists([
createChannel('CHENG', 'Engineering'),
createChannel('CHEOP', 'Engineering-Ops'),
])
Expand All @@ -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', () => {
Expand Down
20 changes: 19 additions & 1 deletion src/lib/refs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading