taskExecutionListGroupSync.test.tsx8.5 KBView on GitHub
/**
 * The execution rail must not fight itself over `?task=` and `?group=`.
 *
 * Opening a task writes both params. Two effects in TaskExecutionList then read them: A opens the
 * named group's first task, B moves the group to the open task's own group. When the pair arrives
 * DISAGREEING — which is what a conversation's task rows produced while `crm.getConversation` was
 * dropping `taskGroupId`, so every one of them claimed the virtual Misc lane — the two used to
 * write over each other forever: A opened Misc's first task, B moved the group back, A opened the
 * other group's first task, B moved it back… Each write flipped the OTHER's one-shot ref, so the
 * alternating values defeated both guards. Nothing throws — every write goes through the store and
 * the URL rather than setState, so React's update-depth guard never fires — the tab simply spins
 * until it dies.
 *
 * A newly-opened task therefore OWNS the group: A stands down until B has filed it. A task B has
 * already filed is a pill click, where opening the lane's first task is the whole point.
 */
import React from 'react';
import { render, act } from '@testing-library/react';

// ── nuqs: one shared, reactive param store ────────────────────────────────────
// The fight is between two effects reading the same two params, so the mock has to be shared and
// it has to re-render on write — a per-hook stub would hide the bug.
const mockParams: Record<string, string | null> = {};
const mockListeners = new Set<() => void>();
const mockWrites: string[] = [];

const mockSetParam = (key=[redacted], value: string | null) => {
  if (mockParams[key] === value) return;
  mockParams[key] = value;
  mockWrites.push(`${key}=${value}`);
  mockListeners.forEach((notify) => notify());
};

jest.mock('nuqs', () => ({
  useQueryState: (key=[redacted] => {
    const R = require('react') as typeof React;
    const [, force] = R.useReducer((n: number) => n + 1, 0);
    R.useEffect(() => {
      mockListeners.add(force);
      return () => void mockListeners.delete(force);
    }, []);
    return [mockParams[key] ?? null, (value: string | null) => mockSetParam(key, value)];
  },
}));

// ── Tasks ─────────────────────────────────────────────────────────────────────
const mockGroupId = '11111111-1111-1111-1111-111111111111';
const mockYesterday = new Date(Date.now() - 86_400_000).toISOString();

const mockGroupedTask = {
  id: 'task-grouped',
  status: 'todo',
  description: 'Follow up with Acme',
  conversationId: 'conv-1',
  taskGroupId: mockGroupId,
  chatThreadId: null,
  dueDate: mockYesterday,
  taskActionData: { channel: 'email', threadId: 'thread-1', draftId: 'draft-1' },
};
const mockMiscTask = {
  id: 'task-misc',
  status: 'todo',
  description: 'Something ungrouped',
  conversationId: 'conv-2',
  taskGroupId: null,
  chatThreadId: null,
  dueDate: mockYesterday,
  taskActionData: null,
};
const mockTodoTasks = [mockGroupedTask, mockMiscTask];

jest.mock('@/modules/store', () => ({
  useTodoTasks: () => mockTodoTasks,
}));

jest.mock('@tanstack/react-query', () => ({
  useQuery: (opts: { __kind?: string }) =>
    opts.__kind === 'groups'
      ? {
          data: {
            groups: [{ id: mockGroupId, name: 'Top deals', isMisc: false, position: 0 }],
          },
        }
      : { data: { tasks: mockTodoTasks } },
  // The view-options hook writes the board's remembered switches back to user settings. Nothing
  // here turns one, so the write end is a stub.
  useMutation: () => ({ mutate: () => {} }),
  useQueryClient: () => ({ invalidateQueries: () => {} }),
}));

jest.mock('@/modules/userSettings/hooks/use-settings', () => ({
  useSettings: () => ({ data: undefined }),
}));

jest.mock('@/providers/query-provider', () => ({
  useTRPC: () => ({
    taskGroups: { listGroups: { queryOptions: () => ({ __kind: 'groups' }) } },
    userTasks: { listUserTasks: { queryOptions: () => ({ __kind: 'tasks' }) } },
    settings: { save: { mutationOptions: () => ({}) }, get: { queryKey: () => ['settings'] } },
  }),
}));

// ── The click path, verbatim: opening a task writes both params ───────────────
// (`useOpenTaskInExecutionMode` also switches the chat thread and opens the artifact; only the two
// params matter here, and the rest needs the whole store.)
const mockOpened: string[] = [];
jest.mock('@/modules/userTasks/hooks/use-open-task-in-execution-mode', () => ({
  useOpenTaskInExecutionMode: () => (task: { id: string; taskGroupId?: string | null }) => {
    mockOpened.push(task.id);
    mockSetParam('group', task.taskGroupId ?? 'misc');
    mockSetParam('task', task.id);
  },
}));

// ── Everything the rail renders around the two effects ────────────────────────
jest.mock('@/modules/userTasks/hooks/use-hydrate-tasks-slice', () => ({
  useHydrateTasksSlice: () => {},
}));
jest.mock('@/modules/userTasks/hooks/use-task-execution-nav', () => ({
  useTaskExecutionNav: () => {},
}));
jest.mock('@/modules/userTasks/hooks/use-execute-task-now', () => ({
  useExecuteTaskNow: () => async () => {},
}));
jest.mock('@/modules/userTasks/hooks/use-optimistic-task-actions', () => ({
  useOptimisticTaskActions: () => ({
    optimisticCompleteTask: () => {},
    optimisticSnoozeTask: () => {},
    optimisticDeleteTask: () => {},
  }),
}));
jest.mock('@/modules/agentCanvas/utils/open-conversation', () => ({
  openConversationFromAgenda: () => {},
}));
jest.mock('@/modules/userTasks/components/TaskKanbanCard', () => ({
  TaskKanbanCard: ({ task }: { task: { id: string } }) => <div>{task.id}</div>,
}));
jest.mock('@/components/ui/date-picker-dialog', () => ({
  DatePickerDialog: () => null,
}));

import { TaskExecutionList } from '@/modules/userTasks/components/TaskExecutionList';

describe('TaskExecutionList — ?task= and ?group= settle instead of fighting', () => {
  // jsdom has no layout, so the open row's scroll-into-view is a no-op here.
  beforeAll(() => {
    Element.prototype.scrollIntoView = () => {};
  });

  beforeEach(() => {
    for (const key of Object.keys(mockParams)) delete mockParams[key];
    mockListeners.clear();
    mockWrites.length = 0;
    mockOpened.length = 0;
  });

  it('lets a task opened under the WRONG group pull the group to it, once', () => {
    // The pair a conversation task row produced: the right task, the Misc lane it never belonged to.
    mockParams.task = mockGroupedTask.id;
    mockParams.group = 'misc';

    render(<TaskExecutionList />);
    act(() => {}); // let the effects settle

    expect(mockParams.group).toBe(mockGroupId);
    expect(mockParams.task).toBe(mockGroupedTask.id); // the task you clicked is still the open one
    expect(mockOpened).toEqual([]); // …and no other task was opened over it
    // The loop rewrote both params on every pass; settled, `group` is written exactly once.
    expect(mockWrites).toEqual([`group=${mockGroupId}`]);
  });

  it('leaves a task this rail cannot see alone, rather than opening one it can', () => {
    // A teammate's task on a shared deal, a task the toolbar filter excludes, a task still
    // hydrating — all reach the rail as a `?task=` that is in none of its buckets, and B can't file
    // any of them either. Reading that as "no task is open" and opening the lane's first task over
    // it is the hijack: you asked for one task and got another (and, when that other one has no
    // draft, its CONVERSATION — which closes the thread the click just opened).
    mockParams.task = 'task-belongs-to-a-teammate';
    mockParams.group = 'misc'; // a lane that does have work in it, so there is something to hijack to

    render(<TaskExecutionList />);
    act(() => {});

    expect(mockOpened).toEqual([]);
    expect(mockParams.task).toBe('task-belongs-to-a-teammate');
    expect(mockWrites).toEqual([]);
  });

  it('still opens a lane’s first task when the lane itself is selected', () => {
    // A pill click: the open task is already filed in its group, and `?group=` moves to another
    // lane. That lane's first task should open — the behaviour the guard must not swallow.
    mockParams.task = mockGroupedTask.id;
    mockParams.group = mockGroupId;

    render(<TaskExecutionList />);
    act(() => {});
    act(() => mockSetParam('group', 'misc'));

    expect(mockOpened).toEqual([mockMiscTask.id]);
    expect(mockParams.group).toBe('misc');
    expect(mockParams.task).toBe(mockMiscTask.id);
  });
});