Test: classifySendError cobre 22 casos de erro de envio WhatsApp

Exporta classifySendError (era privada do store) pra poder testar
isolada. A funcao e' deterministica e pura, entao spec direto vale
mais que stub do supabase.functions.invoke.

Cobertura:
- 5xx downstream (502/503/504) -> banner "fora do ar" com 2 CTAs
  (Configurar + Comprar creditos), incluindo o case sem code
- http_500 explicitamente NAO cai no ramo 5xx (e' catch geral, nao
  "downstream fora") — checagem de regressao
- insufficient_credits, canal nao configurado/inativo (3 variacoes
  de string), credenciais evolution/twilio incompletas
- evolution retornou X (com e sem status 5xx — confirma precedencia
  dos ramos), twilio_send_failed_<code>
- auth (sessao expirou), forbidden (sem permissao) — ambos sem CTA
- "Edge Function returned a non-2xx" wrapper do supabase-js
- Fallback generico: code desconhecido com message custom; code+message
  vazios -> mensagem padrao
- Robustez: case-insensitive (INSUFFICIENT_CREDITS -> reconhece),
  status nao-numerico -> null em vez de NaN, codes nao-string
  (undefined/number/object) nao quebram

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-04-28 21:28:47 -03:00
parent 76b58af9a1
commit b331d68572
2 changed files with 153 additions and 1 deletions
@@ -0,0 +1,149 @@
/**
* conversationDrawerStore.spec.js
*
* Cobre `classifySendError` — a logica que transforma a resposta de erro
* do edge function send-whatsapp-message em um banner persistente
* { code, status, message, hint, action, secondaryAction? }.
*
* O classify e' deterministico (puro, sem deps) entao o teste so valida
* o mapeamento codigo+status -> banner. Cobre todos os ramos do switch
* + o fallback generico.
*/
import { describe, it, expect } from 'vitest';
import { classifySendError } from '@/stores/conversationDrawerStore';
describe('classifySendError', () => {
describe('5xx (servidor downstream fora)', () => {
it.each([502, 503, 504])('http %i -> banner "fora do ar" + 2 CTAs', (status) => {
const r = classifySendError('whatever', null, status);
expect(r.status).toBe(status);
expect(r.message).toMatch(/fora do ar/i);
expect(r.action).toEqual({ label: 'Configurar WhatsApp', url: '/configuracoes/whatsapp' });
expect(r.secondaryAction).toEqual({ label: 'Comprar créditos', url: '/configuracoes/creditos-whatsapp' });
});
it('http 502 sem code retornado ainda eh classificado', () => {
const r = classifySendError(null, null, 502);
expect(r.code).toBe('http_502');
expect(r.message).toMatch(/fora do ar/i);
});
it('http 500 NAO cai no ramo 5xx (so 502/503/504)', () => {
// 500 e' o catch geral do edge function, nao o "downstream fora"
const r = classifySendError('TypeError: fetch failed', null, 500);
expect(r.message).not.toMatch(/fora do ar/i);
});
});
describe('insufficient_credits', () => {
it('mostra CTA "Comprar creditos" sem secondary', () => {
const r = classifySendError('insufficient_credits', null, 200);
expect(r.message).toMatch(/saldo de créditos insuficiente/i);
expect(r.action).toEqual({ label: 'Comprar créditos', url: '/configuracoes/creditos-whatsapp' });
expect(r.secondaryAction).toBeUndefined();
});
});
describe('canal nao configurado / inativo', () => {
it.each([
'canal whatsapp nao configurado',
'canal whatsapp inativo',
'erro: canal whatsapp nao configurado pra esse tenant'
])('codigo %s -> CTA Configurar agora', (code) => {
const r = classifySendError(code, null, 400);
expect(r.message).toMatch(/nenhum canal whatsapp ativo/i);
expect(r.action).toEqual({ label: 'Configurar agora', url: '/configuracoes/whatsapp' });
});
});
describe('credenciais incompletas', () => {
it('evolution -> CTA Configuracoes WhatsApp', () => {
const r = classifySendError('Credenciais Evolution incompletas', null, 400);
expect(r.message).toMatch(/credenciais incompletas/i);
expect(r.action.url).toBe('/configuracoes/whatsapp');
});
it('twilio -> sem CTA (suporte resolve)', () => {
const r = classifySendError('Twilio credenciais incompletas', null, 400);
expect(r.message).toMatch(/credenciais incompletas/i);
expect(r.action).toBeNull();
expect(r.hint).toMatch(/suporte/i);
});
});
describe('falhas de provedor', () => {
it('"evolution retornou 500" -> CTA Ver status', () => {
const r = classifySendError('Evolution retornou 500', null, 502);
// 502 entra no ramo 5xx primeiro (mais especifico) — comportamento ok
expect(r.message).toMatch(/fora do ar/i);
});
it('"evolution retornou 500" sem status 5xx -> CTA Ver status', () => {
const r = classifySendError('Evolution retornou 500', null, 200);
expect(r.message).toMatch(/whatsapp pessoal não respondeu/i);
expect(r.action).toEqual({ label: 'Ver status', url: '/configuracoes/whatsapp' });
});
it('twilio_send_failed_xxx -> CTA Configuracoes WhatsApp', () => {
const r = classifySendError('twilio_send_failed_21610', null, 400);
expect(r.message).toMatch(/whatsapp oficial recusou/i);
expect(r.action.url).toBe('/configuracoes/whatsapp');
});
});
describe('auth/permissao', () => {
it('auth -> sessao expirou, sem CTA', () => {
const r = classifySendError('auth', null, 401);
expect(r.message).toMatch(/sessão expirou/i);
expect(r.action).toBeNull();
});
it('forbidden -> sem permissao, sem CTA', () => {
const r = classifySendError('forbidden', null, 403);
expect(r.message).toMatch(/permissão/i);
expect(r.action).toBeNull();
});
});
describe('edge function generic 4xx/5xx wrapper', () => {
it('"Edge Function returned a non-2xx status code" -> CTA Configurar', () => {
const r = classifySendError('Edge Function returned a non-2xx status code', null, 400);
expect(r.message).toMatch(/recusou o envio/i);
expect(r.action.url).toBe('/configuracoes/whatsapp');
});
});
describe('fallback generico', () => {
it('codigo desconhecido com message preservada', () => {
const r = classifySendError('weird_code_42', 'Algo deu errado especifico', 400);
expect(r.code).toBe('weird_code_42');
expect(r.message).toBe('Algo deu errado especifico'); // usa providedMessage
expect(r.action.url).toBe('/configuracoes/whatsapp');
});
it('codigo vazio + message vazia -> mensagem padrao', () => {
const r = classifySendError(null, null, null);
expect(r.code).toBe('unknown');
expect(r.message).toMatch(/não foi possível enviar/i);
expect(r.hint).toBeTruthy();
});
});
describe('robustez', () => {
it('case-insensitive no codigo (DB pode ter case variavel)', () => {
const r = classifySendError('INSUFFICIENT_CREDITS', null, 200);
expect(r.message).toMatch(/saldo de créditos/i);
});
it('status nao-numerico vira null em vez de NaN', () => {
const r = classifySendError('insufficient_credits', null, 'not a number');
expect(r.status).toBeNull();
});
it('codigo nao-string nao quebra (defensivo)', () => {
expect(() => classifySendError(undefined, null, null)).not.toThrow();
expect(() => classifySendError(123, null, null)).not.toThrow();
expect(() => classifySendError({}, null, null)).not.toThrow();
});
});
});
+4 -1
View File
@@ -25,7 +25,10 @@ import { useTenantStore } from '@/stores/tenantStore';
// status — HTTP status (error.context?.status) — captura 502/503/504
//
// O edge function retorna { ok: false, error: '<code>', message: '<human>' }.
function classifySendError(code, providedMessage, status) {
//
// Exportada pra ser testada isoladamente — o resto do store nao precisa
// dela como API publica, mas eh deterministica e vale fixar o comportamento.
export function classifySendError(code, providedMessage, status) {
const c = String(code || '').toLowerCase();
const hasMsg = !!providedMessage;
const httpStatus = Number(status) || null;