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