taskTicketProperties.test.tsx8.7 KBView on GitHub
/**
 * TaskTicketProperties — the ticket's rail.
 *
 * Two things are pinned:
 *
 *   - each control fires the RIGHT mutation with the right arguments. All four are separate
 *     server procedures with different input shapes (`status` vs `enabled` vs `groupId` vs
 *     `dueDate`), and a control wired to the wrong one, or passing the wrong key, fails as a
 *     silent no-op rather than an error the user would report.
 *
 *   - a teammate's task is entirely read-only. Every one of those mutations is owner-scoped
 *     server side, so rendering the controls would offer an action that can only fail.
 */
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';

const mockMutations = {
  updateTaskStatus: jest.fn(),
  updateTaskDueDate: jest.fn(),
  moveTaskToGroup: jest.fn(),
  toggleAgentExecution: jest.fn(),
};

/** Which procedure a given useMutation call belongs to, tagged through mutationOptions. */
jest.mock('@/providers/query-provider', () => ({
  useTRPC: () => ({
    userTasks: {
      getTaskById: { queryKey: () => ['task'] },
      listUserTasks: { queryKey: () => ['tasks'] },
      updateTaskStatus: { mutationOptions: () => ({ __name: 'updateTaskStatus' }) },
      updateTaskDueDate: { mutationOptions: () => ({ __name: 'updateTaskDueDate' }) },
      toggleAgentExecution: { mutationOptions: () => ({ __name: 'toggleAgentExecution' }) },
    },
    taskGroups: {
      listGroups: { queryOptions: () => ({ queryKey: ['groups'] }) },
      moveTaskToGroup: { mutationOptions: () => ({ __name: 'moveTaskToGroup' }) },
    },
  }),
}));

jest.mock('@tanstack/react-query', () => ({
  useQuery: () => ({
    data: {
      groups: [
        { id: 'grp-1', name: 'Followups' },
        { id: 'grp-2', name: 'CRM' },
        // listGroups appends the virtual Misc lane itself, with a null id.
        { id: null, name: 'Misc' },
      ],
    },
  }),
  useQueryClient: () => ({ invalidateQueries: jest.fn() }),
  useMutation: (opts: { __name?: keyof typeof mockMutations }) => ({
    mutate: (vars: unknown) => opts.__name && mockMutations[opts.__name](vars),
  }),
}));

jest.mock('sonner', () => ({ toast: { error: jest.fn() } }));

// The due date renders through the same helpers RelativeDateBadge uses, minus the pill. Pinned
// to fixed output so this file asserts the WORDS and the colour reach the row, without pinning
// how any particular date formats — that belongs to the time utils' own tests.
jest.mock('@/modules/crm/utils/time', () => ({
  ...jest.requireActual('@/modules/crm/utils/time'),
  formatRelativeDate: () => 'in 3 days',
  getScheduledTextColor: () => 'text-green-700',
}));

// The shared date dialog is a command palette with its own tests; here it only matters that the
// due row opens one rather than embedding a second date idiom of its own.
jest.mock('@/components/ui/date-picker-dialog', () => ({
  DatePickerDialog: ({ open, title }: { open: boolean; title?: string }) =>
    open ? <div data-testid="date-picker">{title}</div> : null,
}));

// The company avatar fetches its own conversation and reads the store; it has its own tests.
// Here it only needs to occupy the badge's place in the Conversation row.
jest.mock('@/modules/conversationsPage/components/ConversationCompanyAvatar', () => ({
  ConversationCompanyAvatar: ({ fallback }: { fallback?: string }) => (
    <span data-testid="company-avatar">{fallback}</span>
  ),
}));

import {
  TaskTicketProperties,
  type TaskTicketPropertiesTask,
} from '@/modules/userTasks/components/TaskTicketProperties';

/**
 * Typed as the component's own prop shape rather than a hand-written restatement of it — the
 * restatement is what let a new field be added to this value but not to its type, so the
 * fixture and the component drifted with nothing to catch it.
 */
const TASK: TaskTicketPropertiesTask = {
  id: 'task-1',
  status: 'todo',
  dueDate: '2030-05-07T09:00:00Z',
  taskGroup: { id: 'grp-1', name: 'Followups' },
  taskOutput: null,
  taskCreatedBy: 'agent',
  createdAt: '2026-08-24T00:52:11Z',
  conversationId: 'conv-1',
  executions: [{ createdAt: '2026-08-24T01:00:00Z' }],
  triggeringEvent: null,
};

function renderRail(
  overrides: Partial<TaskTicketPropertiesTask> = {},
  editable = true,
  onOpenTriggeringEvent?: () => void,
) {
  return render(
    <TaskTicketProperties
      task={{ ...TASK, ...overrides }}
      editable={editable}
      onOpenTriggeringEvent={onOpenTriggeringEvent}
    />,
  );
}

beforeEach(() => jest.clearAllMocks());

describe('TaskTicketProperties — editing', () => {
  it('has no agent-execution toggle', () => {
    // Removed outright: it told the user Cedar might run the task without saying enough about
    // when for anyone to decide anything with it.
    renderRail();

    expect(screen.queryByRole('switch')).not.toBeInTheDocument();
  });

  it('renders the lane picker seeded with the task’s current lane', () => {
    renderRail();

    // The trigger shows the current value, so the user can see what they are changing FROM.
    expect(screen.getByText('Followups')).toBeInTheDocument();
  });

  it('shows the due date as relative text only — the badge words, not the pill', () => {
    // The relative form is the readable one; printing the absolute date beside it says the same
    // thing twice in a 256px column.
    renderRail();

    const due = screen.getByText('in 3 days');
    expect(due).toBeInTheDocument();
    // Same colour treatment the task card's badge gets, so the two read alike.
    expect(due).toHaveClass('text-green-700');
    expect(screen.queryByText('May 7, 2030')).not.toBeInTheDocument();
  });

  it('opens the shared date dialog to change the due date', () => {
    renderRail();

    fireEvent.click(screen.getByText('in 3 days'));

    expect(screen.getByTestId('date-picker')).toHaveTextContent('Due date');
  });

  it('renders the read-only metadata the old panel never showed', () => {
    renderRail();

    expect(screen.getByText('Undecided')).toBeInTheDocument(); // taskOutput null
    // Created reads "by Cedar agent" with its own date on the line beneath — the createdAt,
    // not the due date, which is why this pins Aug 2026 rather than the 2030 due date above.
    expect(screen.getByText('by Cedar agent')).toBeInTheDocument();
    // Matched loosely on purpose: the fixture's 00:52 UTC lands on either Aug 23 or Aug 24
    // depending on the runner's timezone, and the point is that the CREATED date renders on its
    // own line — not which side of midnight the machine is on.
    expect(screen.getByText(/^Aug \d{1,2}, 2026$/)).toBeInTheDocument();
    // The conversation is NOT here — it is the badge under the title (see taskTicketView), where
    // the task card and the list row also put it.
    expect(screen.queryByText('Daniel @ edexia.ai')).not.toBeInTheDocument();
  });
});

describe('TaskTicketProperties — the triggering event', () => {
  const MEETING = {
    id: 'evt-1',
    title: 'Cedar <> Payroll Integrations',
    eventType: 'meeting',
    threadId: null,
  };

  it('names the event the task came from, and opens it', () => {
    const open = jest.fn();
    renderRail({ triggeringEvent: MEETING }, true, open);

    fireEvent.click(screen.getByText('Cedar <> Payroll Integrations'));

    expect(open).toHaveBeenCalled();
  });

  it('renders no row at all when the task has no triggering event', () => {
    // Most tasks have none — a cron sweep, a task typed by hand — so a permanent "None" row
    // would be noise on the majority of tickets.
    renderRail();

    expect(screen.queryByText(/Payroll Integrations/)).not.toBeInTheDocument();
  });

  it('still names the event when there is nothing to open it with', () => {
    // No thread and no conversation: the title is the only thing that survives, and knowing
    // WHAT set the task off is most of its value even when you cannot click through.
    renderRail({ triggeringEvent: MEETING, conversationId: null }, true, undefined);

    const row = screen.getByText('Cedar <> Payroll Integrations').closest('button');
    expect(row).toBeDisabled();
  });
});

describe('TaskTicketProperties — a teammate’s task', () => {
  it('renders every property read-only, with no controls at all', () => {
    renderRail({}, false);

    expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
    // The values are still readable — read-only, not hidden. Status shows its label, not the
    // raw column value.
    expect(screen.getByText('Todo')).toBeInTheDocument();
    expect(screen.getByText('Followups')).toBeInTheDocument();
  });

  it('names the Misc lane rather than leaving it blank', () => {
    // A null group IS a lane, not missing data.
    renderRail({ taskGroup: null }, false);

    expect(screen.getByText('Misc')).toBeInTheDocument();
  });
});