conversation-update-error.test.ts5.2 KBView on GitHub
/**
 * Toast copy for failed conversation updates.
 *
 * The bug these pin: changing a conversation's AOP held the request open past
 * CloudFront's 60s origin timeout, so the browser threw a raw fetch error and the UI
 * piped `error.message` straight into `toast.error(...)` — producing a toast that named
 * `api.mail.cedarcopilot.com`, said nothing about the AOP, and implied the change was
 * lost when it had usually already committed.
 * See apps/server/docs/bug-reports/aop-change-60s-cloudfront-timeout.md.
 */
import {
  describeConversationUpdateError,
  describeUpdatedFields,
} from '@/modules/crm/lib/conversation-update-error';

describe('describeUpdatedFields', () => {
  it('uses the label the UI shows, not the payload key', () => {
    expect(describeUpdatedFields(['aopId'])).toBe('AOP');
    expect(describeUpdatedFields(['nextStepDate'])).toBe('next step date');
    expect(describeUpdatedFields(['dealValue'])).toBe('deal value');
  });

  it('joins multiple fields readably', () => {
    expect(describeUpdatedFields(['status', 'priority'])).toBe('status and priority');
    expect(describeUpdatedFields(['status', 'priority', 'risk'])).toBe(
      'status, priority, and risk',
    );
  });

  it('falls back to the raw key for anything unmapped, and to a generic noun for none', () => {
    expect(describeUpdatedFields(['somethingNew'])).toBe('somethingNew');
    expect(describeUpdatedFields([])).toBe('conversation');
  });
});

describe('describeConversationUpdateError — dropped connections', () => {
  // The exact shapes each browser throws when CloudFront severs the request at 60s.
  const connectionFailures: Array<[string, unknown]> = [
    ['Chrome', new TypeError('Failed to fetch')],
    ['Safari', new Error('Load failed')],
    ['Firefox', new Error('NetworkError when attempting to fetch resource.')],
    ['undici', new Error('fetch failed')],
    ['gateway timeout', new Error('504 Gateway Timeout')],
    ['abort', new Error('The operation was aborted')],
  ];

  it.each(connectionFailures)('names the AOP and not the API host (%s)', (_label, error) => {
    const message = describeConversationUpdateError(error, ['aopId']);

    expect(message).toContain('AOP');
    expect(message).not.toMatch(/cedarcopilot|api\.mail|https?:\/\//i);
  });

  it('does not claim the change was lost — it usually committed server-side', () => {
    const message = describeConversationUpdateError(new TypeError('Failed to fetch'), ['aopId']);

    expect(message).toMatch(/may have saved/i);
    expect(message).not.toMatch(/\bnot saved\b|\bwas lost\b|\bdiscarded\b/i);
  });

  it('tells the user what to do next', () => {
    const message = describeConversationUpdateError(new TypeError('Failed to fetch'), ['aopId']);
    expect(message).toMatch(/refresh/i);
  });

  it('recognises a 5xx only when it is presented as a status', () => {
    for (const raw of ['HTTP 502', 'status: 503', 'code 504', '502 Bad Gateway']) {
      expect(describeConversationUpdateError(new Error(raw), ['aopId'])).toMatch(/may have saved/i);
    }
  });

  it('does not treat digits that merely contain 50x as a dropped connection', () => {
    // A real server error must not be softened into "it may have saved" just because a
    // count or an id happened to contain those digits.
    for (const raw of ['Rejected: 1502 events exceed the batch limit', 'Deal value 5040 invalid']) {
      const message = describeConversationUpdateError(new Error(raw), ['aopId']);
      expect(message).not.toMatch(/may have saved/i);
      expect(message).toContain(raw);
    }
  });
});

describe('describeConversationUpdateError — real server errors', () => {
  it('keeps an actionable server message, prefixed with the field', () => {
    const message = describeConversationUpdateError(
      new Error('Conversation update conflicted with a concurrent write — please retry'),
      ['aopId'],
    );

    expect(message).toBe(
      "Couldn't update the AOP: Conversation update conflicted with a concurrent write — please retry",
    );
  });

  it('strips a hostname the server happened to include', () => {
    const message = describeConversationUpdateError(
      new Error('POST https://api.mail.cedarcopilot.com/api/trpc/crm.updateConversation returned 500'),
      ['status'],
    );

    expect(message).not.toMatch(/cedarcopilot|https?:\/\//i);
    expect(message).toContain('status');
  });

  it('keeps a dotted version number — only hostnames get stripped', () => {
    const message = describeConversationUpdateError(
      new Error('Schema mismatch at migration 1.2.3 — redeploy required'),
      ['aopId'],
    );

    expect(message).toContain('1.2.3');
  });

  it('falls back to generic copy when the message was nothing but a URL', () => {
    const message = describeConversationUpdateError(
      new Error('https://api.mail.cedarcopilot.com'),
      ['aopId'],
    );

    expect(message).toBe("Couldn't update the AOP. Please try again.");
  });

  it('handles non-Error rejections', () => {
    expect(describeConversationUpdateError(undefined, ['priority'])).toBe(
      "Couldn't update the priority. Please try again.",
    );
    expect(describeConversationUpdateError('Something broke', ['priority'])).toBe(
      "Couldn't update the priority: Something broke",
    );
  });
});