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