groupSidebarConversations.test.ts11.4 KBView on GitHub import type { WorkingMemoryEntry } from '@/modules/crm/types';
import type { SidebarConversation } from '@/modules/conversationsPage/hooks/use-conversations-sidebar-conversations';
import {
groupSidebarConversations,
isAwaitingResponse,
} from '@/modules/conversationsPage/utils/groupSidebarConversations';
import type { SidebarGroupBy } from '@/modules/conversationsPage/slice/conversationsSidebarSlice';
const SENTINEL = (
value: 'starredAndResponse' | 'awaitingResponse' | 'none',
): SidebarGroupBy => ({ kind: 'sentinel', value });
const COL = (columnId: string): SidebarGroupBy => ({ kind: 'column', columnId });
/**
* `customFields` is the wire type now (WorkingMemoryEntry, 11 fields). The grouping code
* under test only reads `name` and `value`, so fill the rest with inert defaults rather
* than casting the fixture — a cast here would hide a real shape change later.
*/
function makeField(f: { name: string; value: unknown }): WorkingMemoryEntry {
return {
id: `wm_${f.name}`,
conversationId: 'c1',
name: f.name,
value: String(f.value),
agentExecutionId: null,
editedBy: 'test',
lastEdited: new Date(0),
createdAt: new Date(0),
updatedAt: new Date(0),
signal: null,
signalReasoning: null,
} as WorkingMemoryEntry;
}
function makeConv(overrides: {
id: string;
important?: boolean;
status?: string | null;
priority?: string | null;
userId?: string;
aopId?: string | null;
latestEventType?: string;
latestDirection?: string | null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
customFields?: Array<{ name: string; value: any }>;
}): SidebarConversation {
const occurredAt = new Date('2026-06-01T00:00:00.000Z');
const events = overrides.latestEventType
? [
{
id: `e-${overrides.id}`,
conversationId: overrides.id,
threadId: null,
eventType: overrides.latestEventType,
title: 't',
direction: overrides.latestDirection ?? null,
// A wire timestamp is an ISO string, not a `Date` — which is why the sorter wraps it
// in `new Date(...)` (groupSidebarConversations.ts).
occurredAt: occurredAt.toISOString(),
},
]
: [];
const customFields = (overrides.customFields ?? []).map(makeField);
/**
* The row as it comes off the wire, FLAT.
*
* `crm.listConversations` spreads `hydrated.conversation` and hangs the joins beside it
* (crm.ts `CrmListedConversationSchema`), so the conversation's own columns and the joined
* counts sit at one level. `toSidebarConversation` then stores that whole row as
* `conversation`, which is why this is built once and referenced twice rather than written
* out in both places — the second copy is what drifts.
*
* `lastReviewedAt`, `conversationScope` and `overviewItems` are deliberately absent: the list
* query never selects them, and `ListedConversation` omits them so that reading one off a
* list row is a compile error rather than a silent blank.
*/
const row = {
id: overrides.id,
userId: overrides.userId ?? 'u1',
organizationId: null,
primaryCompanyId: null,
aopId: overrides.aopId ?? null,
name: overrides.id,
status: overrides.status ?? null,
priority: overrides.priority ?? null,
risk: null,
statusOverview: null,
nextSteps: null,
nextStepDate: null,
dealValue: null,
integrationMetadata: [],
lastContactedAt: occurredAt,
lastEmailAt: occurredAt,
primaryCompanyDomainClean: true,
important: overrides.important ?? false,
lastUpdatedAt: occurredAt,
createdAt: occurredAt,
updatedAt: occurredAt,
events,
// Always `[]` on the wire: the list query hardcodes it and readers fall through to
// `primaryCompany` instead.
conversationCompanies: [],
// Last event of each kind over the whole history, not derived from the capped `events`.
lastMeetingAt: null,
lastInboundAt: null,
lastOutboundEmailAt: null,
customFields,
userTasks: [],
scheduledCalendarEvents: [],
primaryCompany: null,
ownerUser: null,
unreadEmailCount: 0,
openTaskCount: 0,
};
return {
conversation: row,
people: [],
company: null,
customFields,
userTasks: [],
latestEvent: events.at(0) ?? null,
scheduledCalendarEvents: [],
ownerUser: null,
unreadEmailCount: 0,
openTaskCount: 0,
};
}
describe('isAwaitingResponse', () => {
it('returns true when latest event is email_inbound', () => {
expect(isAwaitingResponse(makeConv({ id: 'a', latestEventType: 'email_inbound' }))).toBe(true);
});
it('returns true when latest event is slack_message_inbound', () => {
expect(
isAwaitingResponse(makeConv({ id: 'a', latestEventType: 'slack_message_inbound' })),
).toBe(true);
});
it('returns true when latest event direction is inbound', () => {
expect(
isAwaitingResponse(makeConv({ id: 'a', latestEventType: 'note', latestDirection: 'inbound' })),
).toBe(true);
});
it('returns false when latest event is email_outbound', () => {
expect(isAwaitingResponse(makeConv({ id: 'a', latestEventType: 'email_outbound' }))).toBe(false);
});
it('returns false when there are no events', () => {
expect(isAwaitingResponse(makeConv({ id: 'a' }))).toBe(false);
});
});
describe('groupSidebarConversations', () => {
it('returns empty array for empty input', () => {
expect(groupSidebarConversations([], SENTINEL('starredAndResponse'), { currentUserId: null })).toEqual([]);
});
it('default starredAndResponse splits into Starred + Response + Other', () => {
const convs = [
makeConv({ id: 'star1', important: true, latestEventType: 'email_inbound' }),
makeConv({ id: 'resp1', latestEventType: 'email_inbound' }),
makeConv({ id: 'other1', latestEventType: 'email_outbound' }),
];
const groups = groupSidebarConversations(convs, SENTINEL('starredAndResponse'), { currentUserId: null });
expect(groups.map((g) => g.key)).toEqual(['starred', 'response', 'other']);
expect(groups[0].conversations.map((c) => c.conversation.id)).toEqual(['star1']);
expect(groups[1].conversations.map((c) => c.conversation.id)).toEqual(['resp1']);
expect(groups[2].conversations.map((c) => c.conversation.id)).toEqual(['other1']);
expect(groups[2].defaultCollapsed).toBe(true);
});
it('starredAndResponse does not double-count starred + awaiting (starred wins)', () => {
const convs = [
makeConv({ id: 'both', important: true, latestEventType: 'email_inbound' }),
];
const groups = groupSidebarConversations(convs, SENTINEL('starredAndResponse'), { currentUserId: null });
expect(groups.find((g) => g.key === 'starred')?.conversations.map((c) => c.conversation.id)).toEqual(['both']);
expect(groups.find((g) => g.key === 'response')).toBeUndefined();
});
it('none → single All group', () => {
const convs = [makeConv({ id: 'a' }), makeConv({ id: 'b' })];
const groups = groupSidebarConversations(convs, SENTINEL('none'), { currentUserId: null });
expect(groups).toHaveLength(1);
expect(groups[0]).toEqual(
expect.objectContaining({ key: 'all', label: 'All' }),
);
expect(groups[0].conversations).toHaveLength(2);
});
it('status groups follow AOP options order with Unset last', () => {
const convs = [
makeConv({ id: 'a', status: 'open' }),
makeConv({ id: 'b', status: 'closed' }),
makeConv({ id: 'c', status: null }),
];
const groups = groupSidebarConversations(convs, COL('status'), {
currentUserId: null,
statusOptions: [
{ value: 'closed', label: 'Closed' },
{ value: 'open', label: 'Open' },
],
});
expect(groups.map((g) => g.key)).toEqual(['closed', 'open', 'unset']);
});
it('owner (primaryUser column) labels current user as Me and pins first', () => {
const convs = [
makeConv({ id: 'a', userId: 'me' }),
makeConv({ id: 'b', userId: 'you' }),
];
const groups = groupSidebarConversations(convs, COL('primaryUser'), {
currentUserId: 'me',
userNamesById: { you: 'You' },
});
expect(groups[0].label).toBe('Me');
expect(groups[0].conversations.map((c) => c.conversation.id)).toEqual(['a']);
expect(groups[1].label).toBe('You');
});
it('aop (aopId column) buckets "no aop" last', () => {
const convs = [
makeConv({ id: 'a', aopId: 'sales' }),
makeConv({ id: 'b', aopId: null }),
];
const groups = groupSidebarConversations(convs, COL('aopId'), {
currentUserId: null,
aopNamesById: { sales: 'Sales' },
});
expect(groups.map((g) => g.label)).toEqual(['Sales', 'No AOP']);
});
it('awaitingResponse splits across inbound vs outbound', () => {
const convs = [
makeConv({ id: 'in', latestEventType: 'email_inbound' }),
makeConv({ id: 'out', latestEventType: 'email_outbound' }),
makeConv({ id: 'slack-in', latestEventType: 'slack_message_inbound' }),
];
const groups = groupSidebarConversations(convs, SENTINEL('awaitingResponse'), { currentUserId: null });
expect(groups.find((g) => g.key === 'awaiting')?.conversations.map((c) => c.conversation.id)).toEqual(['in', 'slack-in']);
expect(groups.find((g) => g.key === 'upToDate')?.conversations.map((c) => c.conversation.id)).toEqual(['out']);
});
it('custom select field — buckets follow option order, unset last', () => {
const convs = [
makeConv({ id: 'a', customFields: [{ name: 'stage', value: 'demo' }] }),
makeConv({ id: 'b', customFields: [{ name: 'stage', value: 'negotiation' }] }),
makeConv({ id: 'c', customFields: [{ name: 'stage', value: 'demo' }] }),
makeConv({ id: 'd', customFields: [] }),
];
const groups = groupSidebarConversations(convs, COL('wm_stage'), {
currentUserId: null,
customFieldDefinitions: {
stage: {
id: 'stage',
type: 'select',
label: 'Stage',
displayOrder: 0,
options: [
{ value: 'negotiation', label: 'Negotiation', enumOrder: 0, color: 'blue' },
{ value: 'demo', label: 'Demo', enumOrder: 1, color: 'green' },
],
},
},
});
expect(groups.map((g) => g.key)).toEqual(['negotiation', 'demo', 'unset']);
expect(groups[0].conversations.map((c) => c.conversation.id)).toEqual(['b']);
expect(groups[1].conversations.map((c) => c.conversation.id)).toEqual(['a', 'c']);
expect(groups[2].conversations.map((c) => c.conversation.id)).toEqual(['d']);
});
it('custom boolean field → Yes / No / Unset (unset last when present)', () => {
const convs = [
makeConv({ id: 'y1', customFields: [{ name: 'qualified', value: true }] }),
makeConv({ id: 'n1', customFields: [{ name: 'qualified', value: false }] }),
makeConv({ id: 'u1', customFields: [] }),
makeConv({ id: 'y2', customFields: [{ name: 'qualified', value: 'true' }] }),
];
const groups = groupSidebarConversations(convs, COL('wm_qualified'), {
currentUserId: null,
customFieldDefinitions: {
qualified: { id: 'qualified', type: 'boolean', label: 'Qualified', displayOrder: 0 },
},
});
expect(groups.map((g) => g.key)).toEqual(['yes', 'no', 'unset']);
expect(groups[0].conversations.map((c) => c.conversation.id)).toEqual(['y1', 'y2']);
expect(groups[1].conversations.map((c) => c.conversation.id)).toEqual(['n1']);
expect(groups[2].conversations.map((c) => c.conversation.id)).toEqual(['u1']);
});
});