Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
68 changes: 62 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,26 @@ describe('resolveChannelRef', () => {
expect(mockGetChannel).not.toHaveBeenCalled()
})

it('resolves exact case-insensitive name match', async () => {
it('resolves exact case-insensitive name match against joined channels', 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)
})

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 +351,56 @@ 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 would appear in both. 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 joinedPublic = createChannel('CHJP', 'Engineering', { public: true })
mockChannelLists([joinedPublic], [joinedPublic])
Comment thread
scottlovegrove marked this conversation as resolved.
Outdated

const result = await resolveChannelRef('eng', 1)

expect(result).toEqual(joinedPublic)
})

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
16 changes: 15 additions & 1 deletion src/lib/refs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,21 @@ export async function resolveChannelRef(ref: string, workspaceId: number): Promi
}

if (parsed.type === 'name') {
const channels = await client.channels.getChannels({ workspaceId })
// getChannels is membership-scoped — it returns only channels the current user has
// joined (across active + archived). Public channels the user hasn't joined are not
// included, so name-resolving e.g. `tdc channel archive "Old Public Channel"` would
// fail with CHANNEL_NOT_FOUND even though the channel is discoverable. Merge with
// getPublicChannels (workspace-scoped, returns all public channels regardless of
// membership) and dedupe by id so a joined-and-public channel doesn't match twice.
const [joined, publicChannels] = await Promise.all([
Comment thread
scottlovegrove marked this conversation as resolved.
Outdated
client.channels.getChannels({ workspaceId }),
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