From b331d68572c48a541b5693134b2c41f4a32ca9c6 Mon Sep 17 00:00:00 2001 From: Leonardo Date: Tue, 28 Apr 2026 21:28:47 -0300 Subject: [PATCH] Test: classifySendError cobre 22 casos de erro de envio WhatsApp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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_ - 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) --- .../__tests__/conversationDrawerStore.spec.js | 149 ++++++++++++++++++ src/stores/conversationDrawerStore.js | 5 +- 2 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 src/stores/__tests__/conversationDrawerStore.spec.js diff --git a/src/stores/__tests__/conversationDrawerStore.spec.js b/src/stores/__tests__/conversationDrawerStore.spec.js new file mode 100644 index 0000000..d3b6dd9 --- /dev/null +++ b/src/stores/__tests__/conversationDrawerStore.spec.js @@ -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(); + }); + }); +}); diff --git a/src/stores/conversationDrawerStore.js b/src/stores/conversationDrawerStore.js index 6e31e8a..6207dfc 100644 --- a/src/stores/conversationDrawerStore.js +++ b/src/stores/conversationDrawerStore.js @@ -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: '', message: '' }. -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;