open-task.test.ts9.7 KBView on GitHub /**
* `openTask` — deciding what a task opens.
*
* This replaces handleNavigateToTask.test.ts, which replicated an EmbeddedCedarChat handler
* that no longer exists. That handler decided between "open the compose window" and "open the
* thread panel" by reading the local email-thread cache, and got it wrong whenever the cache
* hadn't loaded yet — a freshly-drafted thread isn't in `threadData`, so it fell through to
* opening an empty thread panel. Those tests documented the bug rather than a contract.
*
* `openTask` cannot reproduce it: it routes purely on `taskActionData`, and the thread case is
* URL-driven (`threadOpen`), so nothing depends on what happens to be cached. These tests pin
* that down.
*
* They also pin the routing change that made the task ticket reachable. Every case that is not an
* email draft used to open the task's CONVERSATION — the deal inbox, with the task the user just
* clicked named nowhere on it. That was a workaround for the ticket being near-empty; now that it
* holds the task's own content, the task is what opens. `openConversation` is gone from the deps
* entirely rather than left unused.
*/
import { openTask, type OpenTaskDeps } from '@/modules/userTasks/utils/open-task';
function makeDeps(): OpenTaskDeps & {
updateThreadConversationId: jest.Mock;
setThreadOpen: jest.Mock;
openSlackChannel: jest.Mock;
openTaskOutput: jest.Mock;
} {
return {
updateThreadConversationId: jest.fn(),
setThreadOpen: jest.fn(),
openSlackChannel: jest.fn(),
openTaskOutput: jest.fn(),
};
}
describe('openTask — email tasks with a draft thread', () => {
it('opens the thread by URL, regardless of whether it is cached locally', () => {
const deps = makeDeps();
openTask(
{
id: 'task-1',
conversationId: 'conv-1',
taskActionData: { channel: 'email', threadId: 'thread-1' },
},
deps,
);
expect(deps.setThreadOpen).toHaveBeenCalledWith('thread-1');
expect(deps.openTaskOutput).not.toHaveBeenCalled();
});
it('links the thread to its conversation before opening it', () => {
const deps = makeDeps();
openTask(
{
id: 'task-1',
conversationId: 'conv-1',
taskActionData: { channel: 'email', threadId: 'thread-1' },
},
deps,
);
expect(deps.updateThreadConversationId).toHaveBeenCalledWith('thread-1', 'conv-1');
});
it('still opens the thread when the task has no conversation', () => {
const deps = makeDeps();
openTask(
{
id: 'task-1',
conversationId: null,
taskActionData: { channel: 'email', threadId: 'thread-1' },
},
deps,
);
expect(deps.updateThreadConversationId).not.toHaveBeenCalled();
expect(deps.setThreadOpen).toHaveBeenCalledWith('thread-1');
});
});
describe('openTask — tasks with no draft thread', () => {
it('opens the ticket when the email task has no threadId yet', () => {
const deps = makeDeps();
openTask(
{ id: 'task-1', conversationId: 'conv-1', taskActionData: { channel: 'email' } },
deps,
);
expect(deps.openTaskOutput).toHaveBeenCalledWith('task-1');
expect(deps.setThreadOpen).not.toHaveBeenCalled();
});
it('never treats a non-email threadId as an email draft', () => {
// Only an EMAIL task's threadId is a draft to open; on any other channel it is not.
const deps = makeDeps();
openTask(
{
id: 'task-1',
conversationId: 'conv-1',
taskActionData: {
channel: 'slack',
threadId: 'thread-1',
channelId: 'C123',
workspaceId: 'T08',
message: 'Hi',
},
},
deps,
);
expect(deps.setThreadOpen).not.toHaveBeenCalled();
expect(deps.openSlackChannel).toHaveBeenCalled();
});
});
describe('openTask — slack tasks and the no-output cases', () => {
it('opens the CHANNEL, through the same artifact mechanism email uses for its thread', () => {
// The draft used to render as a card inside the task ticket: the same words, in a surface
// that cannot send them. This is the destination AgendaTaskNode and TaskBlock already use,
// so a Slack draft now opens identically from every surface.
const deps = makeDeps();
openTask(
{
id: 'task-1',
conversationId: 'conv-1',
taskActionData: {
channel: 'slack',
channelId: 'C123',
channelName: 'cedar-edexia',
workspaceId: 'T08',
message: 'Recap of our call…',
},
},
deps,
);
// The workspace rides along because the channel cannot be READ without it — the messages
// query is keyed by workspace + channel — and the name so a channel the feed has not loaded
// can still render its own header.
expect(deps.openSlackChannel).toHaveBeenCalledWith({
conversationId: 'conv-1',
channelId: 'C123',
channelName: 'cedar-edexia',
workspaceId: 'T08',
message: 'Recap of our call…',
alreadySent: false,
});
expect(deps.openTaskOutput).not.toHaveBeenCalled();
});
it('opens the channel even before the task has drafted anything', () => {
// There is nothing useful to say about a Slack task away from the channel it is about, so
// the channel is the destination whether or not a draft exists yet.
const deps = makeDeps();
openTask(
{
id: 'task-1',
conversationId: 'conv-1',
taskActionData: { channel: 'slack', channelId: 'C123', workspaceId: 'T08' },
},
deps,
);
expect(deps.openSlackChannel).toHaveBeenCalledWith({
conversationId: 'conv-1',
channelId: 'C123',
channelName: undefined,
workspaceId: 'T08',
message: undefined,
alreadySent: false,
});
});
it('falls back to the ticket when channelId holds a channel NAME, not an id', () => {
// 45 of 757 Slack tasks on prod carry "ar-meeting-follow-up" where a C…/D… id belongs.
// There is no way to resolve that client-side, and opening it rendered an empty channel —
// and, before the counterpart guard, crashed the route. The ticket still shows the draft.
const deps = makeDeps();
openTask(
{
id: 'task-1',
conversationId: 'conv-1',
taskActionData: {
channel: 'slack',
channelId: 'ar-meeting-follow-up',
workspaceId: 'T08',
message: 'Hi',
},
},
deps,
);
expect(deps.openSlackChannel).not.toHaveBeenCalled();
expect(deps.openTaskOutput).toHaveBeenCalledWith('task-1');
});
it('falls back to the ticket when the task carries no workspace', () => {
// 13 of 757 have none, and inbox.slackChannelMessages is keyed by workspace AND channel —
// so the channel would open and stay permanently empty.
const deps = makeDeps();
openTask(
{
id: 'task-1',
conversationId: 'conv-1',
taskActionData: { channel: 'slack', channelId: 'C123', message: 'Hi' },
},
deps,
);
expect(deps.openSlackChannel).not.toHaveBeenCalled();
expect(deps.openTaskOutput).toHaveBeenCalledWith('task-1');
});
it('falls back to the ticket for a slack task that names no channel', () => {
// Nothing to address, so the ticket's own draft card is the only place the message shows.
const deps = makeDeps();
openTask(
{ id: 'task-1', conversationId: 'conv-1', taskActionData: { channel: 'slack', message: 'Hi' } },
deps,
);
expect(deps.openSlackChannel).not.toHaveBeenCalled();
expect(deps.openTaskOutput).toHaveBeenCalledWith('task-1');
});
it('opens the channel even with no conversation behind the task', () => {
// The channel stands on its own; only the composer seeding needs a conversation.
const deps = makeDeps();
openTask(
{
id: 'task-1',
conversationId: null,
taskActionData: { channel: 'slack', channelId: 'C123', workspaceId: 'T08', message: 'Hi' },
},
deps,
);
expect(deps.openSlackChannel).toHaveBeenCalledWith({
conversationId: null,
channelId: 'C123',
channelName: undefined,
workspaceId: 'T08',
message: 'Hi',
alreadySent: false,
});
});
it('marks an already-sent draft, so it is not seeded back into the composer', () => {
// Sending posts the message and completes the task, but leaves the text on `taskActionData`
// — there is no `sentAt` on the output. So completion IS the sent signal, and without it
// re-opening a done Slack task drops an already-posted message into the box, one Enter from
// a duplicate.
const deps = makeDeps();
openTask(
{
id: 'task-1',
conversationId: 'conv-1',
status: 'done',
taskActionData: { channel: 'slack', channelId: 'C123', workspaceId: 'T08', message: 'Hi' },
},
deps,
);
expect(deps.openSlackChannel).toHaveBeenCalledWith(
expect.objectContaining({ channelId: 'C123', message: 'Hi', alreadySent: true }),
);
});
it('opens the ticket for a reminder — no taskActionData at all', () => {
// The task the ticket exists for. This is the case that used to land on the deal inbox.
const deps = makeDeps();
openTask({ id: 'task-1', conversationId: 'conv-1', taskActionData: null }, deps);
expect(deps.openTaskOutput).toHaveBeenCalledWith('task-1');
expect(deps.setThreadOpen).not.toHaveBeenCalled();
});
it('opens the ticket when the task has no conversation either', () => {
// Having a conversation no longer changes where a task goes — the ticket links to the deal.
const deps = makeDeps();
openTask({ id: 'task-1', conversationId: null, taskActionData: null }, deps);
expect(deps.openTaskOutput).toHaveBeenCalledWith('task-1');
});
});