Skip to content
Draft
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
87 changes: 78 additions & 9 deletions src/commands/inbox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,19 +24,22 @@ vi.mock('../lib/global-args.js', async (importOriginal) => ({
includePrivateChannels: vi.fn().mockReturnValue(true),
}))

vi.mock('../lib/public-channels.js', () => ({
getPublicChannelIds: vi.fn(),
}))

vi.mock('chalk')

import { includePrivateChannels } from '../lib/global-args.js'
import { clearChannelCache } from '../lib/public-channels.js'
import { registerInboxCommand } from './inbox.js'

const createProgram = () => createTestProgram(registerInboxCommand)

beforeEach(() => {
clearChannelCache()
})

function mockClient(overrides: {
inboxThreads?: unknown[]
unreadData?: unknown[]
channels?: unknown[]
getChannel?: ReturnType<typeof vi.fn>
getInbox?: ReturnType<typeof vi.fn>
getUnread?: ReturnType<typeof vi.fn>
Expand All @@ -45,13 +48,14 @@ function mockClient(overrides: {
const getUnread =
overrides.getUnread ??
vi.fn().mockResolvedValue({ data: overrides.unreadData ?? [], version: 1 })
const getChannels = vi.fn().mockResolvedValue(overrides.channels ?? [])
const getChannel = overrides.getChannel ?? vi.fn()
apiMocks.getCommsClient.mockResolvedValue({
inbox: { getInbox },
threads: { getUnread },
channels: { getChannel },
channels: { getChannels, getChannel },
})
return { getInbox, getUnread, getChannel }
return { getInbox, getUnread, getChannels, getChannel }
}

describe('inbox --workspace conflict', () => {
Expand Down Expand Up @@ -134,7 +138,7 @@ describe('inbox --ids-only', () => {
it('outputs thread IDs without fetching channel names when no channel filter is set', async () => {
vi.clearAllMocks()
apiMocks.getCurrentWorkspaceId.mockResolvedValue(1)
const { getChannel } = mockClient({
const { getChannels, getChannel } = mockClient({
inboxThreads: [
{
id: 'thread-1',
Expand All @@ -157,6 +161,7 @@ describe('inbox --ids-only', () => {
await createProgram().parseAsync(['node', 'tdc', 'inbox', '--ids-only'])

expect(consoleSpy).toHaveBeenCalledWith('thread-1\nthread-2')
expect(getChannels).not.toHaveBeenCalled()
expect(getChannel).not.toHaveBeenCalled()
})
})
Expand All @@ -176,7 +181,7 @@ describe('inbox empty output (channel filter)', () => {
}
mockClient({
inboxThreads: [thread],
getChannel: vi.fn().mockResolvedValue({ id: 'CH10', name: 'engineering' }),
channels: [{ id: 'CH10', name: 'engineering', public: true }],
})
logSpy = captureConsole('log')
})
Expand All @@ -190,6 +195,70 @@ describe('inbox empty output (channel filter)', () => {
})
})

describe('inbox channel names', () => {
const threadIn = (channelId: string) => ({
id: `thread-${channelId}`,
channelId,
title: channelId,
posted: '2026-05-01T00:00:00Z',
url: `https://example.test/thread-${channelId}`,
})
let logSpy: ReturnType<typeof vi.spyOn>

beforeEach(() => {
vi.clearAllMocks()
apiMocks.getCurrentWorkspaceId.mockResolvedValue(1)
logSpy = captureConsole('log')
})

function channelNamesByThread(): Record<string, unknown> {
const output = JSON.parse(logSpy.mock.calls[0]?.[0] as string) as Array<{
id: string
channelName: unknown
}>
return Object.fromEntries(output.map((t) => [t.id, t.channelName]))
}

it('reuses the public-channel list for names without per-channel lookups', async () => {
vi.mocked(includePrivateChannels).mockReturnValueOnce(false)
const { getChannels, getChannel } = mockClient({
inboxThreads: [threadIn('CH1'), threadIn('CH2'), threadIn('CH3')],
channels: [
{ id: 'CH1', name: 'engineering', public: true },
{ id: 'CH2', name: 'design', public: true },
{ id: 'CH3', name: 'secret', public: false },
],
})

await createProgram().parseAsync(['node', 'tdc', 'inbox', '--json', '--full'])

expect(channelNamesByThread()).toEqual({
'thread-CH1': 'engineering',
'thread-CH2': 'design',
})
expect(getChannels).toHaveBeenCalledTimes(1)
expect(getChannel).not.toHaveBeenCalled()
})

it('looks up only the channels missing from the list', async () => {
const { getChannels, getChannel } = mockClient({
inboxThreads: [threadIn('CH1'), threadIn('CH-left')],
channels: [{ id: 'CH1', name: 'engineering', public: true }],
getChannel: vi.fn().mockResolvedValue({ id: 'CH-left', name: 'old-team' }),
})

await createProgram().parseAsync(['node', 'tdc', 'inbox', '--json', '--full'])

expect(channelNamesByThread()).toEqual({
'thread-CH1': 'engineering',
'thread-CH-left': 'old-team',
})
expect(getChannels).toHaveBeenCalledTimes(1)
expect(getChannel).toHaveBeenCalledTimes(1)
expect(getChannel).toHaveBeenCalledWith('CH-left')
})
})

describe('inbox API errors', () => {
it('propagates SDK rejections through the Promise.all that replaced batch', async () => {
vi.clearAllMocks()
Expand Down Expand Up @@ -249,7 +318,7 @@ describe('inbox unread mentions', () => {
mockClient({
inboxThreads: threads,
unreadData,
getChannel: vi.fn().mockResolvedValue({ id: 'CH1', name: 'engineering' }),
channels: [{ id: 'CH1', name: 'engineering', public: true }],
})
logSpy = captureConsole('log')
})
Expand Down
16 changes: 11 additions & 5 deletions src/commands/inbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { CliError } from '../lib/errors.js'
import { includePrivateChannels, isAccessible } from '../lib/global-args.js'
import { toDate, type PaginatedViewOptions } from '../lib/options.js'
import { colors, formatJson, formatNdjson } from '../lib/output.js'
import { getPublicChannelIds } from '../lib/public-channels.js'
import { getPublicChannelIds, getWorkspaceChannels } from '../lib/public-channels.js'
import { resolveWorkspaceRef } from '../lib/refs.js'
import { fetchUnreadThreads, unreadFlags } from '../lib/threads.js'

Expand Down Expand Up @@ -80,11 +80,17 @@ async function showInbox(workspaceRef: string | undefined, options: InboxOptions

let channelMap = new Map<string, string>()
if (outputMode !== 'ids-only' || options.channel) {
const channelIds = [...new Set(inboxThreads.map((t) => t.channelId))]
const channelEntries = await Promise.all(
channelIds.map(async (id) => [id, await client.channels.getChannel(id)] as const),
const channels = await getWorkspaceChannels(workspaceId)
channelMap = new Map(channels.map((ch) => [ch.id, ch.name]))
// The list only has channels the user is a member of, but an inbox thread can sit
// in one they've left, so look those up one by one.
const unlistedIds = [...new Set(inboxThreads.map((t) => t.channelId))].filter(
(id) => !channelMap.has(id),
)
channelMap = new Map(channelEntries.map(([id, channel]) => [id, channel.name]))
const unlisted = await Promise.all(
unlistedIds.map(async (id) => [id, await client.channels.getChannel(id)] as const),
)
for (const [id, channel] of unlisted) channelMap.set(id, channel.name)
}

if (options.channel) {
Expand Down
10 changes: 3 additions & 7 deletions src/lib/public-channels.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,7 @@ vi.mock('./api.js', () => ({

import { getCommsClient } from './api.js'
import { includePrivateChannels, resetGlobalArgs } from './global-args.js'
import {
assertChannelIsPublic,
clearPublicChannelCache,
getPublicChannelIds,
} from './public-channels.js'
import { assertChannelIsPublic, clearChannelCache, getPublicChannelIds } from './public-channels.js'

const mockGetCommsClient = vi.mocked(getCommsClient)

Expand Down Expand Up @@ -78,7 +74,7 @@ describe('includePrivateChannels', () => {

describe('getPublicChannelIds', () => {
beforeEach(() => {
clearPublicChannelCache()
clearChannelCache()
})

it('returns only public channel IDs', async () => {
Expand Down Expand Up @@ -128,7 +124,7 @@ describe('assertChannelIsPublic', () => {
const originalEnv = process.env.COMMS_INCLUDE_PRIVATE_CHANNELS

beforeEach(() => {
clearPublicChannelCache()
clearChannelCache()
resetGlobalArgs()
process.argv = ['node', 'tdc']
delete process.env.COMMS_INCLUDE_PRIVATE_CHANNELS
Expand Down
31 changes: 20 additions & 11 deletions src/lib/public-channels.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,34 @@
import type { Channel } from '@doist/comms-sdk'
import { getCommsClient } from './api.js'
import { CliError } from './errors.js'
import { includePrivateChannels } from './global-args.js'

const publicChannelCache = new Map<number, Set<string>>()
type WorkspaceChannels = { channels: Channel[]; publicIds: Set<string> }

export async function getPublicChannelIds(workspaceId: number): Promise<Set<string>> {
const cached = publicChannelCache.get(workspaceId)
const channelCache = new Map<number, WorkspaceChannels>()

async function loadWorkspaceChannels(workspaceId: number): Promise<WorkspaceChannels> {
const cached = channelCache.get(workspaceId)
if (cached) return cached

const client = await getCommsClient()
const channels = await client.channels.getChannels({ workspaceId })
const publicIds = new Set<string>()
for (const ch of channels) {
if (ch.public) publicIds.add(ch.id)
}
publicChannelCache.set(workspaceId, publicIds)
return publicIds
const publicIds = new Set(channels.filter((ch) => ch.public).map((ch) => ch.id))
const loaded = { channels, publicIds }
channelCache.set(workspaceId, loaded)
return loaded
}

export async function getWorkspaceChannels(workspaceId: number): Promise<Channel[]> {
return (await loadWorkspaceChannels(workspaceId)).channels
}

export async function getPublicChannelIds(workspaceId: number): Promise<Set<string>> {
return (await loadWorkspaceChannels(workspaceId)).publicIds
}

export function clearPublicChannelCache(): void {
publicChannelCache.clear()
export function clearChannelCache(): void {
channelCache.clear()
}

export async function assertChannelIsPublic(channelId: string, workspaceId: number): Promise<void> {
Expand Down
Loading