chatMessageOrdering.test.ts3.2 KBView on GitHub
import {
  isOutbound,
  occurredAtOf,
  sortKeyOf,
  type LooseMessage,
} from '@/modules/inbox/components/ChannelThreadView';

/**
 * How a LinkedIn / WhatsApp thread decides ORDER and AUTHORSHIP.
 *
 * The ordering bug these pin was invisible in both the types and a screenshot. LinkedIn's rows
 * come off a drizzle `timestamp` column, and tRPC's superjson transformer revives them as `Date`
 * on the client — while `LooseMessage` declared `string`. The sort compared
 * `String(occurredAt) < String(occurredAt)`, and `String(new Date())` is
 * `"Sun Aug 16 2026 13:31:21 GMT-0700"`, so the thread was ordered BY WEEKDAY NAME: Fri, Mon,
 * Sat, Sun, Thu, Tue, Wed. Most days that still looks plausible, which is why it survived — it
 * surfaced as a day pill sitting above a message from a different day.
 */

const at = (when: string | Date, over: Partial<LooseMessage> = {}): LooseMessage => ({
  id: String(when),
  text: 'x',
  occurredAt: when,
  ...over,
});

describe('sortKeyOf', () => {
  it('orders Dates chronologically, not by their weekday name', () => {
    // Thu Aug 13 vs Sun Aug 16. Lexically "Sun…" < "Thu…", so the old comparison put the LATER
    // message first — the exact inversion that moved the "Today" pill above yesterday's message.
    const thu = at(new Date('2026-08-13T11:19:47.961Z'));
    const sun = at(new Date('2026-08-16T20:31:21.529Z'));
    expect([sun, thu].sort((a, b) => sortKeyOf(a) - sortKeyOf(b))).toEqual([thu, sun]);
  });

  it('orders ISO strings and Dates against each other', () => {
    // The two sources disagree on type: LinkedIn's mirror sends Dates, WhatsApp's live read
    // sends ISO strings. A thread never mixes them today, but the comparison must not care.
    const older = at('2026-08-13T11:19:47.961Z');
    const newer = at(new Date('2026-08-16T20:31:21.529Z'));
    expect([newer, older].sort((a, b) => sortKeyOf(a) - sortKeyOf(b))).toEqual([older, newer]);
  });

  it('sorts a message with no usable timestamp to the END', () => {
    // Almost always one we just sent and have not heard back about — it belongs at the bottom,
    // not at the epoch above every real message.
    const real = at('2026-08-16T20:31:21.529Z');
    const undated: LooseMessage = { id: 'pending', text: 'just sent' };
    expect([undated, real].sort((a, b) => sortKeyOf(a) - sortKeyOf(b))).toEqual([real, undated]);
  });
});

describe('occurredAtOf', () => {
  it('normalizes either spelling and either type to an ISO string', () => {
    expect(occurredAtOf(at(new Date('2026-08-16T20:31:21.529Z')))).toBe('2026-08-16T20:31:21.529Z');
    expect(occurredAtOf({ at: '2026-08-16T20:31:21.529Z' })).toBe('2026-08-16T20:31:21.529Z');
    expect(occurredAtOf({ createdAt: '2026-08-16T20:31:21.529Z' })).toBe('2026-08-16T20:31:21.529Z');
    expect(occurredAtOf({})).toBeUndefined();
  });
});

describe('isOutbound', () => {
  it('trusts WhatsApp’s boolean and LinkedIn’s direction alike', () => {
    expect(isOutbound({ fromSelf: true })).toBe(true);
    expect(isOutbound({ fromSelf: false, direction: 'outbound' })).toBe(false);
    expect(isOutbound({ direction: 'outbound' })).toBe(true);
    expect(isOutbound({ direction: 'inbound' })).toBe(false);
    expect(isOutbound({})).toBe(false);
  });
});