Files
botino/src/modules/3-turn-engine/fsm.test.js
Lucas Tettamanti e85afab3e6 vitest
2026-01-26 00:13:03 -03:00

566 lines
22 KiB
JavaScript

/**
* 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);
});
});