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",
);
});
});