openChannelItem.test.tsx7.1 KBView on GitHub
import { act, renderHook } from '@testing-library/react';

import { useCedarStore } from '@/modules/store';
import {
  findSlackItem,
  slackContainerKey,
  useOpenChannelItem,
} from '@/modules/inbox/hooks/use-open-channel-item';
import type { InboxItem } from '@/modules/inbox/types';
import { settledChannelFeeds } from '../../lib/inboxFeed';

/**
 * The SOURCE side of `?slack=` (slack-parity Phase 5). LayoutUrlSync can only project an
 * artifact somebody sets, and opening a Slack channel in the unibox used to set nothing at
 * all — the open chat was `useState` in mail.tsx. This hook is the mirror: Slack rows write
 * the display artifact (which becomes the URL) and read it back when the URL changes.
 *
 * LinkedIn/WhatsApp deliberately stay local — they have no `ContextKind`, so a param for
 * them would deep-link to a screen nothing can restore.
 */

const slackItem = (channelId: string): InboxItem =>
  ({
    id: `slack:W1:${channelId}`,
    channel: 'slack',
    ref: { kind: 'slack', slackChannelId: channelId, workspaceId: 'W1', conversationId: 'conv_1' },
    sortedAt: '2026-08-12T00:00:00.000Z',
    snippet: '',
    counterpart: { name: 'Slack' },
  }) as unknown as InboxItem;

const linkedinItem = (chatId: string): InboxItem =>
  ({
    id: `li:${chatId}`,
    channel: 'linkedin',
    ref: { kind: 'linkedin', chatId, unipileAccountId: 'A1' },
    sortedAt: '2026-08-12T00:00:00.000Z',
    snippet: '',
    counterpart: { name: 'Someone' },
  }) as unknown as InboxItem;

const seedFeed = (items: InboxItem[]) =>
  act(() => {
    useCedarStore.getState().setChannelFeeds(settledChannelFeeds(items));
  });

beforeEach(() => {
  useCedarStore.setState({
    threadMap: {},
    mainThreadId: '',
    activeThreadId: '',
    channelFeeds: {},
  });
});

describe('slackContainerKey', () => {
  it('is the Slack channel id, and nothing for the other channels', () => {
    // The channel id (`C…`/`D…`) — the same key `channels.*` and the reconciler take.
    expect(slackContainerKey(slackItem('C123'))).toBe('C123');
    expect(slackContainerKey(linkedinItem('chat_1'))).toBeNull();
  });
});

describe('findSlackItem', () => {
  it('finds the row for a container key and ignores same-id rows on other channels', () => {
    const items = [slackItem('C123'), linkedinItem('C123')];
    expect(findSlackItem(items, 'C123')).toBe(items[0]);
    expect(findSlackItem(items, 'C999')).toBeUndefined();
  });
});

describe('useOpenChannelItem', () => {
  it('opening a Slack row sets the display artifact the URL is projected from', () => {
    const { result } = renderHook(() => useOpenChannelItem());

    act(() => result.current.openChannel(slackItem('C123')));

    expect(result.current.openChannelItem?.id).toBe('slack:W1:C123');
    expect(useCedarStore.getState().getDisplayArtifact()).toEqual({
      kind: 'slack_thread',
      id: 'C123',
    });
  });

  // Was "opening a LinkedIn row touches no artifact". It does now, and deliberately: the artifact
  // is not only the address `?linkedin=` is projected from, it is what `artifactToContext` reads
  // to pick the surface for the chat column. Leaving the slot empty meant a LinkedIn chat opened
  // with the *mail* surface beside it, so the counterpart profile card — declared on the
  // `channelChat` surface — could never render.
  it('opening a LinkedIn row sets its artifact, exactly as a Slack row does', () => {
    const { result } = renderHook(() => useOpenChannelItem());

    act(() => result.current.openChannel(linkedinItem('chat_1')));

    expect(result.current.openChannelItem?.id).toBe('li:chat_1');
    expect(useCedarStore.getState().getDisplayArtifact()).toEqual({
      kind: 'linkedin_chat',
      id: 'chat_1',
    });
  });

  it('closing clears the artifact, which is what strips the param', () => {
    const { result } = renderHook(() => useOpenChannelItem());
    act(() => result.current.openChannel(slackItem('C123')));

    act(() => result.current.closeChannel());

    expect(result.current.openChannelItem).toBeNull();
    expect(useCedarStore.getState().getDisplayArtifact()).toBeNull();
  });

  it('closing leaves a non-channel artifact alone', () => {
    // The kind guard still matters, but the reachable case has moved. Opening a LinkedIn row now
    // TAKES the slot, so the conversation has to arrive afterwards — someone opening a deal from
    // the chat panel while a chat is up. That conversation is not this close button's to clear.
    const { result } = renderHook(() => useOpenChannelItem());
    act(() => result.current.openChannel(linkedinItem('chat_1')));
    act(() => {
      useCedarStore.getState().setSelectedArtifact({ kind: 'conversation', id: 'conv_9' });
    });

    act(() => result.current.closeChannel());

    expect(useCedarStore.getState().getDisplayArtifact()).toEqual({
      kind: 'conversation',
      id: 'conv_9',
    });
  });

  it('opens the channel the URL restored, once the feed carries it', () => {
    // The deep-link order: LayoutUrlSync sets the artifact from `?slack=` long before the
    // first feed page lands, so the hook must re-resolve when the feed arrives rather than
    // give up on the one pass where the row did not exist yet.
    const { result } = renderHook(() => useOpenChannelItem());
    act(() => {
      useCedarStore.getState().setSelectedArtifact({ kind: 'slack_thread', id: 'C123' });
    });
    expect(result.current.openChannelItem).toBeNull();

    seedFeed([slackItem('C123'), linkedinItem('chat_1')]);

    expect(result.current.openChannelItem?.id).toBe('slack:W1:C123');
  });

  it('closes the open Slack chat when the artifact goes (the back button)', () => {
    const { result } = renderHook(() => useOpenChannelItem());
    seedFeed([slackItem('C123')]);
    act(() => result.current.openChannel(slackItem('C123')));

    act(() => {
      useCedarStore.getState().setSelectedArtifact(null);
    });

    expect(result.current.openChannelItem).toBeNull();
  });

  // Was "does not close a LinkedIn chat when an unrelated artifact goes". Now that a LinkedIn chat
  // IS the artifact, the back button closes it the same way it closes a Slack channel — which is
  // the point of giving it an address at all.
  it('closes the open LinkedIn chat when the artifact goes (the back button)', () => {
    const { result } = renderHook(() => useOpenChannelItem());
    seedFeed([linkedinItem('chat_1')]);
    act(() => result.current.openChannel(linkedinItem('chat_1')));

    act(() => {
      useCedarStore.getState().setSelectedArtifact(null);
    });

    expect(result.current.openChannelItem).toBeNull();
  });

  it('opens the LinkedIn chat the URL restored, once the feed carries it', () => {
    // Deep-link parity with Slack: `?linkedin=<chatId>` sets the artifact long before the first
    // feed page lands, so the hook must re-resolve when the feed arrives.
    const { result } = renderHook(() => useOpenChannelItem());
    act(() => {
      useCedarStore.getState().setSelectedArtifact({ kind: 'linkedin_chat', id: 'chat_1' });
    });
    expect(result.current.openChannelItem).toBeNull();

    seedFeed([slackItem('C123'), linkedinItem('chat_1')]);

    expect(result.current.openChannelItem?.id).toBe('li:chat_1');
  });
});