LinkedInCounterpartCard.test.tsx15.5 KBView on GitHub
/**
 * The LinkedIn counterpart card in the empty chat panel.
 *
 * Two things here are worth more than the render assertions. The first is the budget rule: the
 * server answers `skipped: 'budget'` when the day's profile views are down to the share reserved
 * for SENDING, and a card that auto-retried on every render would spend the reserve one render at
 * a time — so the budget arm must fire nothing and offer a button instead. The second is the
 * once-per-chat guard on the auto-refresh: a paid provider call behind a `useEffect` is exactly
 * the shape that fires twice when a dependency identity changes, and nothing about the panel
 * would look wrong when it does.
 *
 * Each test uses its OWN chat id: the "one auto-refresh per chat per session" Set lives at module
 * scope (deliberately — the panel unmounts on every chat switch), so it is shared across the
 * tests in this file just as it is across a session.
 */

import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { CONTEXT_KIND_COLORS } from '@/modules/cedar-os/src/cedar-os-components/chatComponents/contextKinds';

// ── Module mocks (must precede the imports they affect) ─────────────────────

type StubProfile = {
  chatId: string;
  urn: string;
  personId: string | null;
  publicIdentifier: string | null;
  name: string | null;
  headline: string | null;
  location: string | null;
  profilePictureUrl: string | null;
  current: StubEntry | null;
  history: StubEntry[];
  posts: StubPost[];
  profileFetchedAt: string | null;
  postsFetchedAt: string | null;
  stale: boolean;
  skipped: 'budget' | 'fresh' | 'unavailable' | null;
};
type StubEntry = {
  company: string | null;
  companyId: string | null;
  companyUrl: string | null;
  /** The card renders the company's logo off this — see LinkedInCounterpartCard.tsx:146. */
  companyLogoUrl: string | null;
  title: string | null;
  location: string | null;
  start: string | null;
  end: string | null;
  isCurrent: boolean;
};
type StubPost = {
  postId: string;
  text: string;
  postedAt: string | null;
  url: string;
  reactions: number;
  comments: number;
  isRepost: boolean;
};

// jest.mock factories may only close over names beginning with `mock`.
let mockProfile: StubProfile | null = null;
const mockRefresh = jest.fn<Promise<null>, unknown[]>(() => Promise.resolve(null));

jest.mock('@/providers/query-provider', () => {
  const messaging = {
    counterpartProfile: {
      queryOptions: (input: unknown, opts?: object) => ({
        queryKey: ['counterpartProfile', input],
        queryFn: () => Promise.resolve(mockProfile),
        ...opts,
      }),
    },
    refreshCounterpartProfile: {
      mutationOptions: (opts?: object) => ({ mutationFn: mockRefresh, ...opts }),
    },
  };
  // No `accounts` here on purpose: the refresh names no seat. The server takes it off the chat,
  // so a client that asked the org which seats existed would be asking a question it cannot use
  // the answer to — see `readChatSeat`.
  return {
    useTRPC: () => ({
      outbound: {
        linkedin: {
          messaging,
        },
      },
    }),
  };
});

// ── Imports (after mocks) ───────────────────────────────────────────────────

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import {
  LinkedInCounterpartCard,
  groupByCompany,
  pastRoles,
} from '@/modules/linkedin/components/LinkedInCounterpartCard';
import { useCedarStore } from '@/modules/store';

// ── Fixtures ────────────────────────────────────────────────────────────────

function entry(over: Partial<StubEntry> = {}): StubEntry {
  return {
    company: 'ClassPass',
    companyId: '1866484',
    companyUrl: null,
    companyLogoUrl: null,
    title: 'Senior Account Executive',
    location: null,
    start: '3/1/2022',
    end: null,
    isCurrent: false,
    ...over,
  };
}

function profile(over: Partial<StubProfile> = {}): StubProfile {
  return {
    chatId: 'chat',
    urn: 'ACoAACDNrG0B9JTDvxf_lfJiVpc08IW8yLpwB2o',
    personId: null,
    publicIdentifier: 'molly-pilch',
    name: 'Molly Pilch',
    headline: 'Senior Account Executive @ ClassPass',
    location: 'New York, NY',
    profilePictureUrl: null,
    current: null,
    history: [],
    posts: [],
    profileFetchedAt: '2026-08-01T00:00:00.000Z',
    postsFetchedAt: '2026-09-01T00:00:00.000Z',
    stale: false,
    skipped: null,
    ...over,
  };
}

function openLinkedinChat(chatId: string) {
  useCedarStore.getState().openArtifact({ kind: 'linkedin_chat', id: chatId });
}

function renderCard(mounted: Array<() => void>) {
  const queryClient = new QueryClient({
    defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
  });
  const view = render(
    <QueryClientProvider client={queryClient}>
      <LinkedInCounterpartCard />
    </QueryClientProvider>,
  );
  mounted.push(() => {
    queryClient.clear();
    view.unmount();
  });
  return view;
}

// ── Suite ───────────────────────────────────────────────────────────────────

describe('LinkedInCounterpartCard', () => {
  const mounted: Array<() => void> = [];

  beforeEach(() => {
    const s = useCedarStore.getState();
    if (!s.threadMap[s.mainThreadId]) {
      const id = s.createThread(undefined, 'Test');
      s.selectThread(id);
    }
    s.clearArtifact();
    mockRefresh.mockClear();
    mockProfile = null;
  });

  afterEach(() => {
    while (mounted.length) mounted.pop()!();
  });

  it('renders the header, the current role, the past roles and the posts', async () => {
    mockProfile = profile({
      chatId: 'chat_render',
      current: entry({ isCurrent: true }),
      history: [
        entry({ company: 'Yelp', companyId: '17876', title: 'Account Executive', start: '1/1/2019', end: '2/1/2022' }),
      ],
      posts: [
        {
          postId: 'urn:li:activity:73',
          text: 'We just shipped the thing',
          postedAt: '2026-08-29T14:02:00.000Z',
          url: 'https://www.linkedin.com/feed/update/urn:li:activity:73',
          reactions: 41,
          comments: 6,
          isRepost: false,
        },
      ],
    });
    openLinkedinChat('chat_render');
    renderCard(mounted);

    expect(await screen.findByText('Molly Pilch')).toBeInTheDocument();
    // The obfuscated urn resolves on linkedin.com, but a real slug is preferred when we have one.
    expect(screen.getByTitle('Open LinkedIn profile')).toHaveAttribute(
      'href',
      'https://www.linkedin.com/in/molly-pilch',
    );
    expect(screen.getByTestId('counterpart-role')).toHaveTextContent('Senior Account Executive');
    expect(screen.getByTestId('counterpart-role')).toHaveTextContent('ClassPass');

    // The career is always visible — it was a disclosure, and the collapse only put a click
    // between the reader and the shape of someone's career.
    // Asserted off the one timeline GROUP, not off the document: "Account Executive" is also a
    // substring of the current role, so a document-wide match would prove nothing about the
    // timeline. The group is a company heading with its roles nested under it.
    const company = await screen.findByText(/Yelp/);
    const group = company.closest('li');
    expect(group).toHaveTextContent('Account Executive');
    expect(group).toHaveTextContent('Jan 2019 – Feb 2022');

    const post = screen.getByText('We just shipped the thing').closest('a');
    expect(post).toHaveAttribute('href', 'https://www.linkedin.com/feed/update/urn:li:activity:73');
    expect(screen.getByText('41')).toBeInTheDocument();
    expect(screen.getByText('6')).toBeInTheDocument();
  });

  it('renders nothing when the open artifact is a Slack thread', async () => {
    // Slack, LinkedIn and WhatsApp share this panel; only LinkedIn has a profile behind it, and
    // the card — not the layout — is what knows the difference.
    mockProfile = profile({ chatId: 'chat_slack' });
    useCedarStore.getState().openArtifact({ kind: 'slack_thread', id: 'C123' });
    const { container } = renderCard(mounted);

    await waitFor(() => expect(mockRefresh).not.toHaveBeenCalled());
    expect(container).toBeEmptyDOMElement();
    expect(screen.queryByText('Molly Pilch')).not.toBeInTheDocument();
  });

  it('offers "Load profile" and auto-fires nothing when the read was budget-blocked', async () => {
    // `stale` is true as well: the read is out of date AND the budget says not now. Re-asking
    // cannot change that answer, so the only thing that may spend a view is the rep pressing.
    mockProfile = profile({ chatId: 'chat_budget', stale: true, skipped: 'budget' });
    openLinkedinChat('chat_budget');
    renderCard(mounted);

    const button = await screen.findByRole('button', { name: 'Load profile' });
    expect(mockRefresh).not.toHaveBeenCalled();

    fireEvent.click(button);
    await waitFor(() => expect(mockRefresh).toHaveBeenCalledTimes(1));
    expect(mockRefresh).toHaveBeenCalledWith(
      expect.objectContaining({ chatId: 'chat_budget', force: true }),
      expect.anything(),
    );
  });

  it('wears LinkedIn blue at rest, not only on hover', async () => {
    // The mark exists to advertise that there is a profile behind the name. A muted glyph that
    // only colours under the cursor reads as decoration until you happen to sweep over it, so the
    // brand colour is applied at REST — and taken from the same constant the context chips use,
    // rather than a second copy of the hex.
    mockProfile = profile({ chatId: 'chat_brand' });
    openLinkedinChat('chat_brand');
    renderCard(mounted);

    const link = await screen.findByTitle('Open LinkedIn profile');
    expect(link).toHaveStyle({ color: CONTEXT_KIND_COLORS.linkedin_chat });
  });

  it('refreshes on demand from the header, forcing past both the TTL and the reserve', async () => {
    // The manual control is the one path that may spend a view when nothing else would: the read
    // is FRESH here (not stale, not budget-blocked), so nothing auto-fires — and the button still
    // has to force, or pressing it on a cached profile would do nothing at all.
    mockProfile = profile({ chatId: 'chat_manual', stale: false, skipped: null });
    openLinkedinChat('chat_manual');
    renderCard(mounted);

    const button = await screen.findByRole('button', { name: 'Refresh profile' });
    expect(mockRefresh).not.toHaveBeenCalled();

    fireEvent.click(button);
    await waitFor(() => expect(mockRefresh).toHaveBeenCalledTimes(1));
    expect(mockRefresh).toHaveBeenCalledWith(
      expect.objectContaining({ chatId: 'chat_manual', force: true }),
      expect.anything(),
    );
  });

  it('auto-refreshes a stale read exactly once, and not again on re-render or remount', async () => {
    mockProfile = profile({ chatId: 'chat_stale', stale: true });
    openLinkedinChat('chat_stale');
    const { rerender } = renderCard(mounted);

    await waitFor(() => expect(mockRefresh).toHaveBeenCalledTimes(1));
    expect(mockRefresh).toHaveBeenCalledWith(
      expect.objectContaining({ chatId: 'chat_stale' }),
      expect.anything(),
    );
    // No `force`: an automatic fetch never overrides the reserve.
    expect(mockRefresh.mock.calls[0]?.[0]).not.toHaveProperty('force', true);

    rerender(
      <QueryClientProvider
        client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
      >
        <LinkedInCounterpartCard />
      </QueryClientProvider>,
    );
    await waitFor(() => expect(screen.getByText('Molly Pilch')).toBeInTheDocument());
    expect(mockRefresh).toHaveBeenCalledTimes(1);

    // A remount is the real case: the panel unmounts on every chat switch, so a browse-away-and-
    // back must not cost a second view.
    renderCard(mounted);
    await waitFor(() => expect(screen.getAllByText('Molly Pilch').length).toBeGreaterThan(0));
    expect(mockRefresh).toHaveBeenCalledTimes(1);
  });

  it('falls back to the ongoing history entry when `current` is null', async () => {
    // Verified on live data: Unipile flags no entry as current far more often than not, and an
    // entry with no end date is itself a hard signal that the role is ongoing.
    mockProfile = profile({
      chatId: 'chat_fallback',
      current: null,
      history: [
        entry({ company: 'Mintlify', title: 'VP Sales', start: '6/1/2023', end: null }),
        entry({ company: 'Yelp', title: 'Account Executive', start: '1/1/2019', end: '2/1/2022' }),
      ],
    });
    openLinkedinChat('chat_fallback');
    renderCard(mounted);

    const role = await screen.findByTestId('counterpart-role');
    expect(role).toHaveTextContent('VP Sales');
    expect(role).toHaveTextContent('Mintlify');
    // The ended role is history, not the answer — it stays behind the disclosure.
    expect(role).not.toHaveTextContent('Yelp');
  });

  it('falls back to the headline when nothing in the history is ongoing', async () => {
    mockProfile = profile({
      chatId: 'chat_headline',
      current: null,
      history: [entry({ company: 'Yelp', title: 'Account Executive', start: '1/1/2019', end: '2/1/2022' })],
    });
    openLinkedinChat('chat_headline');
    renderCard(mounted);

    const role = await screen.findByTestId('counterpart-role');
    expect(role).toHaveTextContent('Senior Account Executive @ ClassPass');
    // …and exactly once: the headline moves down into the role line rather than being repeated
    // under the name.
    expect(screen.getAllByText('Senior Account Executive @ ClassPass')).toHaveLength(1);
  });
});

describe('pastRoles', () => {
  it('drops the entry already shown as the current role, so a job never appears twice', () => {
    // resolveDisplayRole picks its answer out of `history` whenever the provider flags nothing as
    // current — which is the common case. Without this filter the same job rendered as "now" AND
    // as the first past role, which is what the live profile showed.
    const ongoing = entry({ company: 'ClassPass', title: 'Senior Field AE', end: null });
    const older = entry({ company: 'Dataiku', title: 'BD', start: '1/1/2022', end: '1/1/2023' });
    const built = profile({ current: null, history: [ongoing, older] });

    expect(pastRoles(built, ongoing)).toEqual([older]);
    expect(pastRoles(built, null)).toEqual([ongoing, older]);
  });
});

describe('groupByCompany', () => {
  it('merges a run of roles at one company, so a promotion is not three jobs', () => {
    const groups = groupByCompany([
      entry({ company: 'ClassPass', title: 'Senior Field AE' }),
      entry({ company: 'ClassPass', title: 'Senior SMB AE' }),
      entry({ company: 'BODYROK', title: 'Instructor' }),
    ]);

    expect(groups).toHaveLength(2);
    expect(groups[0]?.company).toBe('ClassPass');
    expect(groups[0]?.roles).toHaveLength(2);
    expect(groups[1]?.company).toBe('BODYROK');
  });

  it('keeps two separate stints at the same company apart', () => {
    // Only CONSECUTIVE runs merge — someone who left and came back has two real stints, and
    // collapsing them would invent a continuous tenure that never happened.
    const groups = groupByCompany([
      entry({ company: 'ClassPass', title: 'AE' }),
      entry({ company: 'Yelp', title: 'AE' }),
      entry({ company: 'ClassPass', title: 'SDR' }),
    ]);

    expect(groups.map((g) => g.company)).toEqual(['ClassPass', 'Yelp', 'ClassPass']);
  });
});