/** * Tests para fsm.js */ import { describe, it, expect } from 'vitest'; import { ConversationState, ALL_STATES, INTENTS_BY_STATE, shouldReturnToCart, hasCartItems, hasPendingItems, hasReadyPendingItems, hasShippingInfo, hasPaymentInfo, isPaid, deriveNextState, validateTransition, safeNextState, } from './fsm.js'; // ───────────────────────────────────────────────────────────── // Constants // ───────────────────────────────────────────────────────────── describe('ConversationState', () => { it('tiene todos los estados definidos', () => { expect(ConversationState.IDLE).toBe('IDLE'); expect(ConversationState.CART).toBe('CART'); expect(ConversationState.SHIPPING).toBe('SHIPPING'); expect(ConversationState.PAYMENT).toBe('PAYMENT'); expect(ConversationState.WAITING_WEBHOOKS).toBe('WAITING_WEBHOOKS'); expect(ConversationState.AWAITING_HUMAN).toBe('AWAITING_HUMAN'); }); it('ALL_STATES contiene todos', () => { expect(ALL_STATES).toContain('IDLE'); expect(ALL_STATES).toContain('CART'); expect(ALL_STATES).toContain('SHIPPING'); expect(ALL_STATES).toContain('PAYMENT'); expect(ALL_STATES).toContain('WAITING_WEBHOOKS'); expect(ALL_STATES).toContain('AWAITING_HUMAN'); expect(ALL_STATES).toHaveLength(6); }); it('INTENTS_BY_STATE define intents para cada estado', () => { expect(INTENTS_BY_STATE[ConversationState.IDLE]).toContain('greeting'); expect(INTENTS_BY_STATE[ConversationState.CART]).toContain('add_to_cart'); expect(INTENTS_BY_STATE[ConversationState.SHIPPING]).toContain('provide_address'); expect(INTENTS_BY_STATE[ConversationState.PAYMENT]).toContain('select_payment'); }); }); // ───────────────────────────────────────────────────────────── // hasCartItems // ───────────────────────────────────────────────────────────── describe('hasCartItems', () => { it('retorna true si cart tiene items', () => { const order = { cart: [{ woo_id: 1, qty: 1 }] }; expect(hasCartItems(order)).toBe(true); }); it('retorna false si cart está vacío', () => { const order = { cart: [] }; expect(hasCartItems(order)).toBe(false); }); it('retorna false si cart es undefined', () => { const order = {}; expect(hasCartItems(order)).toBe(false); }); it('retorna false si order es null', () => { expect(hasCartItems(null)).toBe(false); }); it('retorna false si order es undefined', () => { expect(hasCartItems(undefined)).toBe(false); }); }); // ───────────────────────────────────────────────────────────── // hasPendingItems // ───────────────────────────────────────────────────────────── describe('hasPendingItems', () => { it('retorna true si hay NEEDS_TYPE', () => { const order = { pending: [{ status: 'NEEDS_TYPE' }] }; expect(hasPendingItems(order)).toBe(true); }); it('retorna true si hay NEEDS_QUANTITY', () => { const order = { pending: [{ status: 'NEEDS_QUANTITY' }] }; expect(hasPendingItems(order)).toBe(true); }); it('retorna false si solo hay READY', () => { const order = { pending: [{ status: 'READY' }] }; expect(hasPendingItems(order)).toBe(false); }); it('retorna false si pending está vacío', () => { const order = { pending: [] }; expect(hasPendingItems(order)).toBe(false); }); it('retorna false si order es null', () => { expect(hasPendingItems(null)).toBe(false); }); it('detecta entre múltiples items', () => { const order = { pending: [ { status: 'READY' }, { status: 'NEEDS_TYPE' }, ] }; expect(hasPendingItems(order)).toBe(true); }); }); // ───────────────────────────────────────────────────────────── // hasReadyPendingItems // ───────────────────────────────────────────────────────────── describe('hasReadyPendingItems', () => { it('retorna true si hay READY', () => { const order = { pending: [{ status: 'READY' }] }; expect(hasReadyPendingItems(order)).toBe(true); }); it('retorna false si no hay READY', () => { const order = { pending: [{ status: 'NEEDS_TYPE' }] }; expect(hasReadyPendingItems(order)).toBe(false); }); it('retorna false si pending vacío', () => { const order = { pending: [] }; expect(hasReadyPendingItems(order)).toBe(false); }); it('retorna false si order es null', () => { expect(hasReadyPendingItems(null)).toBe(false); }); }); // ───────────────────────────────────────────────────────────── // hasShippingInfo // ───────────────────────────────────────────────────────────── describe('hasShippingInfo', () => { it('retorna true para pickup (no necesita dirección)', () => { const order = { is_delivery: false }; expect(hasShippingInfo(order)).toBe(true); }); it('retorna true para delivery con dirección', () => { const order = { is_delivery: true, shipping_address: 'Calle Falsa 123' }; expect(hasShippingInfo(order)).toBe(true); }); it('retorna false para delivery sin dirección', () => { const order = { is_delivery: true, shipping_address: null }; expect(hasShippingInfo(order)).toBe(false); }); it('retorna false si is_delivery es null', () => { const order = { is_delivery: null }; expect(hasShippingInfo(order)).toBe(false); }); it('retorna false para order vacío', () => { expect(hasShippingInfo({})).toBe(false); }); }); // ───────────────────────────────────────────────────────────── // hasPaymentInfo // ───────────────────────────────────────────────────────────── describe('hasPaymentInfo', () => { it('retorna true para cash', () => { const order = { payment_type: 'cash' }; expect(hasPaymentInfo(order)).toBe(true); }); it('retorna true para link', () => { const order = { payment_type: 'link' }; expect(hasPaymentInfo(order)).toBe(true); }); it('retorna false para null', () => { const order = { payment_type: null }; expect(hasPaymentInfo(order)).toBe(false); }); it('retorna false para undefined', () => { const order = {}; expect(hasPaymentInfo(order)).toBe(false); }); it('retorna false para otros valores', () => { const order = { payment_type: 'bitcoin' }; expect(hasPaymentInfo(order)).toBe(false); }); }); // ───────────────────────────────────────────────────────────── // isPaid // ───────────────────────────────────────────────────────────── describe('isPaid', () => { it('retorna true si is_paid es true', () => { const order = { is_paid: true }; expect(isPaid(order)).toBe(true); }); it('retorna false si is_paid es false', () => { const order = { is_paid: false }; expect(isPaid(order)).toBe(false); }); it('retorna false si is_paid es undefined', () => { const order = {}; expect(isPaid(order)).toBe(false); }); it('retorna false si order es null', () => { expect(isPaid(null)).toBe(false); }); }); // ───────────────────────────────────────────────────────────── // shouldReturnToCart // ───────────────────────────────────────────────────────────── describe('shouldReturnToCart', () => { describe('no redirige si ya está en CART o IDLE', () => { it('retorna false en CART', () => { const nlu = { intent: 'add_to_cart', entities: { product_query: 'provoleta' } }; expect(shouldReturnToCart(ConversationState.CART, nlu)).toBe(false); }); it('retorna false en IDLE', () => { const nlu = { intent: 'add_to_cart', entities: { product_query: 'provoleta' } }; expect(shouldReturnToCart(ConversationState.IDLE, nlu)).toBe(false); }); }); describe('redirige desde otros estados', () => { it('redirige add_to_cart desde SHIPPING', () => { const nlu = { intent: 'add_to_cart', entities: { product_query: 'provoleta' } }; expect(shouldReturnToCart(ConversationState.SHIPPING, nlu)).toBe(true); }); it('redirige add_to_cart desde PAYMENT', () => { const nlu = { intent: 'add_to_cart', entities: { product_query: 'vacío' } }; expect(shouldReturnToCart(ConversationState.PAYMENT, nlu)).toBe(true); }); it('redirige browse desde SHIPPING', () => { const nlu = { intent: 'browse', entities: { product_query: 'carnes' } }; expect(shouldReturnToCart(ConversationState.SHIPPING, nlu)).toBe(true); }); }); describe('no redirige números solos en checkout', () => { it('no redirige "1" en SHIPPING', () => { const nlu = { intent: 'add_to_cart', entities: {} }; expect(shouldReturnToCart(ConversationState.SHIPPING, nlu, '1')).toBe(false); }); it('no redirige "2" en PAYMENT', () => { const nlu = { intent: 'other', entities: {} }; expect(shouldReturnToCart(ConversationState.PAYMENT, nlu, '2')).toBe(false); }); it('no redirige "1.5" en SHIPPING', () => { const nlu = { intent: 'add_to_cart', entities: {} }; expect(shouldReturnToCart(ConversationState.SHIPPING, nlu, '1.5')).toBe(false); }); }); describe('requiere producto real', () => { it('no redirige sin product_query', () => { const nlu = { intent: 'add_to_cart', entities: {} }; expect(shouldReturnToCart(ConversationState.SHIPPING, nlu, 'algo')).toBe(false); }); it('no redirige con product_query muy corto', () => { const nlu = { intent: 'add_to_cart', entities: { product_query: 'ab' } }; expect(shouldReturnToCart(ConversationState.SHIPPING, nlu)).toBe(false); }); it('redirige con items array', () => { const nlu = { intent: 'add_to_cart', entities: { items: [{ product_query: 'provoleta' }] } }; expect(shouldReturnToCart(ConversationState.SHIPPING, nlu)).toBe(true); }); }); }); // ───────────────────────────────────────────────────────────── // deriveNextState // ───────────────────────────────────────────────────────────── describe('deriveNextState', () => { describe('return_to_cart signal', () => { it('fuerza CART si return_to_cart', () => { const result = deriveNextState( ConversationState.PAYMENT, {}, { return_to_cart: true } ); expect(result).toBe(ConversationState.CART); }); }); describe('pagado', () => { it('va a IDLE si está pagado', () => { const order = { is_paid: true }; const result = deriveNextState(ConversationState.WAITING_WEBHOOKS, order, {}); expect(result).toBe(ConversationState.IDLE); }); }); describe('esperando pago', () => { it('va a WAITING_WEBHOOKS si tiene woo_order_id', () => { const order = { woo_order_id: 123, is_paid: false }; const result = deriveNextState(ConversationState.PAYMENT, order, {}); expect(result).toBe(ConversationState.WAITING_WEBHOOKS); }); }); describe('IDLE -> CART', () => { it('va a CART si hay cart items', () => { const order = { cart: [{ woo_id: 1 }], pending: [] }; const result = deriveNextState(ConversationState.IDLE, order, {}); expect(result).toBe(ConversationState.CART); }); it('va a CART si hay pending items', () => { const order = { cart: [], pending: [{ status: 'NEEDS_TYPE' }] }; const result = deriveNextState(ConversationState.IDLE, order, {}); expect(result).toBe(ConversationState.CART); }); it('queda en IDLE si vacío', () => { const order = { cart: [], pending: [] }; const result = deriveNextState(ConversationState.IDLE, order, {}); expect(result).toBe(ConversationState.IDLE); }); }); describe('CART -> SHIPPING', () => { it('queda en CART si hay pending items', () => { const order = { cart: [{ woo_id: 1 }], pending: [{ status: 'NEEDS_TYPE' }] }; const result = deriveNextState(ConversationState.CART, order, { confirm_order: true }); expect(result).toBe(ConversationState.CART); }); it('va a SHIPPING con confirm_order y cart items', () => { const order = { cart: [{ woo_id: 1 }], pending: [] }; const result = deriveNextState(ConversationState.CART, order, { confirm_order: true }); expect(result).toBe(ConversationState.SHIPPING); }); it('queda en CART sin confirm_order', () => { const order = { cart: [{ woo_id: 1 }], pending: [] }; const result = deriveNextState(ConversationState.CART, order, {}); expect(result).toBe(ConversationState.CART); }); }); describe('SHIPPING -> PAYMENT', () => { it('va a PAYMENT con shipping info (pickup)', () => { const order = { is_delivery: false }; const result = deriveNextState(ConversationState.SHIPPING, order, {}); expect(result).toBe(ConversationState.PAYMENT); }); it('va a PAYMENT con shipping info (delivery + address)', () => { const order = { is_delivery: true, shipping_address: 'Calle 123' }; const result = deriveNextState(ConversationState.SHIPPING, order, {}); expect(result).toBe(ConversationState.PAYMENT); }); it('queda en SHIPPING sin info completa', () => { const order = { is_delivery: true, shipping_address: null }; const result = deriveNextState(ConversationState.SHIPPING, order, {}); expect(result).toBe(ConversationState.SHIPPING); }); }); describe('PAYMENT -> WAITING_WEBHOOKS', () => { it('va a WAITING con payment_selected', () => { const order = {}; const result = deriveNextState( ConversationState.PAYMENT, order, { payment_selected: true } ); expect(result).toBe(ConversationState.WAITING_WEBHOOKS); }); it('va a WAITING si ya tiene payment_type', () => { const order = { payment_type: 'cash' }; const result = deriveNextState(ConversationState.PAYMENT, order, {}); expect(result).toBe(ConversationState.WAITING_WEBHOOKS); }); }); describe('WAITING_WEBHOOKS', () => { it('va a IDLE si está pagado', () => { const order = { is_paid: true }; const result = deriveNextState(ConversationState.WAITING_WEBHOOKS, order, {}); expect(result).toBe(ConversationState.IDLE); }); it('queda en WAITING si no está pagado', () => { const order = { is_paid: false }; const result = deriveNextState(ConversationState.WAITING_WEBHOOKS, order, {}); expect(result).toBe(ConversationState.WAITING_WEBHOOKS); }); }); describe('default', () => { it('retorna IDLE si no hay estado previo', () => { const result = deriveNextState(null, {}, {}); expect(result).toBe(ConversationState.IDLE); }); }); }); // ───────────────────────────────────────────────────────────── // validateTransition // ───────────────────────────────────────────────────────────── describe('validateTransition', () => { describe('transiciones válidas', () => { it('IDLE -> IDLE es válido', () => { const result = validateTransition(ConversationState.IDLE, ConversationState.IDLE); expect(result.ok).toBe(true); }); it('IDLE -> CART es válido', () => { const result = validateTransition(ConversationState.IDLE, ConversationState.CART); expect(result.ok).toBe(true); }); it('CART -> SHIPPING es válido', () => { const result = validateTransition(ConversationState.CART, ConversationState.SHIPPING); expect(result.ok).toBe(true); }); it('SHIPPING -> PAYMENT es válido', () => { const result = validateTransition(ConversationState.SHIPPING, ConversationState.PAYMENT); expect(result.ok).toBe(true); }); it('PAYMENT -> WAITING_WEBHOOKS es válido', () => { const result = validateTransition(ConversationState.PAYMENT, ConversationState.WAITING_WEBHOOKS); expect(result.ok).toBe(true); }); it('SHIPPING -> CART (volver) es válido', () => { const result = validateTransition(ConversationState.SHIPPING, ConversationState.CART); expect(result.ok).toBe(true); }); }); describe('transiciones inválidas', () => { it('IDLE -> PAYMENT es inválido', () => { const result = validateTransition(ConversationState.IDLE, ConversationState.PAYMENT); expect(result.ok).toBe(false); expect(result.reason).toBe('invalid_transition'); }); it('CART -> WAITING_WEBHOOKS es inválido', () => { const result = validateTransition(ConversationState.CART, ConversationState.WAITING_WEBHOOKS); expect(result.ok).toBe(false); }); }); describe('estados desconocidos', () => { it('estado previo desconocido', () => { const result = validateTransition('UNKNOWN', ConversationState.CART); expect(result.ok).toBe(false); expect(result.reason).toBe('unknown_prev_state'); }); it('estado siguiente desconocido', () => { const result = validateTransition(ConversationState.IDLE, 'UNKNOWN'); expect(result.ok).toBe(false); expect(result.reason).toBe('unknown_next_state'); }); }); describe('maneja null/undefined', () => { it('prevState null se trata como IDLE', () => { const result = validateTransition(null, ConversationState.CART); expect(result.ok).toBe(true); }); it('nextState null se trata como IDLE', () => { const result = validateTransition(ConversationState.IDLE, null); expect(result.ok).toBe(true); }); }); }); // ───────────────────────────────────────────────────────────── // safeNextState // ───────────────────────────────────────────────────────────── describe('safeNextState', () => { it('retorna estado derivado si transición válida', () => { const order = { cart: [{ woo_id: 1 }], pending: [] }; const result = safeNextState(ConversationState.CART, order, { confirm_order: true }); expect(result.next_state).toBe(ConversationState.SHIPPING); expect(result.validation.ok).toBe(true); }); it('fuerza CART si transición inválida', () => { // Forzar una situación donde deriveNextState retornaría un estado inválido // Esto es difícil de provocar porque deriveNextState ya es bastante seguro // Pero podemos verificar que la lógica de fallback existe const order = {}; const result = safeNextState(ConversationState.IDLE, order, {}); // Debería quedarse en IDLE (transición válida) expect(result.next_state).toBe(ConversationState.IDLE); expect(result.validation.ok).toBe(true); }); it('incluye validation en resultado', () => { const order = { is_delivery: false }; const result = safeNextState(ConversationState.SHIPPING, order, {}); expect(result).toHaveProperty('next_state'); expect(result).toHaveProperty('validation'); expect(result.validation).toHaveProperty('ok'); }); it('maneja transition IDLE -> CART -> SHIPPING flow', () => { // Paso 1: IDLE con cart items -> CART let result = safeNextState(ConversationState.IDLE, { cart: [{ woo_id: 1 }], pending: [] }, {}); expect(result.next_state).toBe(ConversationState.CART); // Paso 2: CART con confirm -> SHIPPING result = safeNextState(ConversationState.CART, { cart: [{ woo_id: 1 }], pending: [] }, { confirm_order: true }); expect(result.next_state).toBe(ConversationState.SHIPPING); // Paso 3: SHIPPING con pickup -> PAYMENT result = safeNextState(ConversationState.SHIPPING, { is_delivery: false }, {}); expect(result.next_state).toBe(ConversationState.PAYMENT); // Paso 4: PAYMENT con payment_selected -> WAITING result = safeNextState(ConversationState.PAYMENT, {}, { payment_selected: true }); expect(result.next_state).toBe(ConversationState.WAITING_WEBHOOKS); }); });