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