chatReactions.test.ts3.4 KBView on GitHub
import { applyOwnChatReaction, matchesMessage } from '@/modules/inbox/hooks/use-chat-reactions';
import type { EventReaction } from '@/modules/crm/types';

/**
 * The optimistic write for LinkedIn / WhatsApp reactions.
 *
 * It is NOT Slack's `applyToggle` under another name: those providers allow ONE reaction per
 * person per message, so a click can move a reaction from one chip to another — a case Slack's
 * model has no equivalent of, and the case that would leave the row lying until the next read
 * if the optimistic write got it wrong.
 */

const mine = (key=[redacted], count = 1): EventReaction => ({
  key,
  emojiUnicode: key,
  count,
  reactedByMe: true,
});
const theirs = (key=[redacted], count = 1): EventReaction => ({
  key,
  emojiUnicode: key,
  count,
  reactedByMe: false,
});

describe('applyOwnChatReaction', () => {
  it('adds a first reaction', () => {
    expect(applyOwnChatReaction([], '👍', false)).toEqual([mine('👍')]);
  });

  it('moves my reaction off the old emoji and onto the new one', () => {
    expect(applyOwnChatReaction([mine('👍')], '🎉', false)).toEqual([mine('🎉')]);
  });

  it('joins a chip others already used, without leaving my old one behind', () => {
    expect(applyOwnChatReaction([mine('👍'), theirs('🎉', 2)], '🎉', false)).toEqual([
      { key: '🎉', emojiUnicode: '🎉', count: 3, reactedByMe: true },
    ]);
  });

  it('withdraws my reaction', () => {
    expect(applyOwnChatReaction([mine('👍')], '👍', true)).toEqual([]);
  });

  it('leaves a shared chip standing, one lower, when I withdraw', () => {
    // The chip only disappears when I was its last reactor — otherwise it stays, un-highlighted,
    // which is what "someone else still reacted" has to look like.
    expect(applyOwnChatReaction([{ ...mine('👍'), count: 3 }], '👍', true)).toEqual([
      { key: '👍', emojiUnicode: '👍', count: 2, reactedByMe: false },
    ]);
  });
});

/**
 * Which cached row the optimistic write lands on.
 *
 * The three chat reads do not agree on where the provider's message id lives, and the write path
 * addresses a message by that id (`ChannelThreadView` sends `providerMessageId ?? id`). Matching
 * the cache on `id` alone missed every LinkedIn message served from the mirror — the normal path
 * — so the chip stayed frozen on click AND the server's authoritative aggregate was dropped on
 * the way back. These three shapes are exactly what the three reads return.
 */
describe('matchesMessage', () => {
  it('matches a mirrored LinkedIn row, whose `id` is a Cedar uuid', () => {
    const row = { id: '0f8c1b6e-3a11-4d55-9e21-7b0c9a2f4d18', providerMessageId: 'unipile_msg_1' };
    expect(matchesMessage(row, 'unipile_msg_1')).toBe(true);
    // And never on the uuid, which is not an id any caller writes against.
    expect(matchesMessage(row, '0f8c1b6e-3a11-4d55-9e21-7b0c9a2f4d18')).toBe(false);
  });

  it('matches a live-read row, whose provider id IS its `id`', () => {
    expect(matchesMessage({ id: 'unipile_msg_1' }, 'unipile_msg_1')).toBe(true);
  });

  it('matches an optimistic pending row, which sets both to the same sentinel', () => {
    const pending = { id: 'pending:1755300000000', providerMessageId: 'pending:1755300000000' };
    expect(matchesMessage(pending, 'pending:1755300000000')).toBe(true);
  });

  it('does not match a different message', () => {
    expect(matchesMessage({ id: 'a', providerMessageId: 'unipile_msg_1' }, 'unipile_msg_2')).toBe(
      false,
    );
  });
});