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');
  });
});