Files
bot_palmas/setup/tests/bootstrap.php
T
Lizandro GuarnizoandClaude Sonnet 4.6 1f7d33b40d test: flujos ejecutados de punta a punta contra un ERP falso
setup/tests/ corre NormalBot de verdad —no un espejo—: SQLite en memoria con
los endpoints del seed reescritos hacia fixtures_api.php servido con php -S,
asi el curl real se ejecuta y los POST capturados se comparan contra el
contrato del procesador. ConversationContext, WhatsAppSender y AiBot son
falsos en memoria; el resto es el codigo de produccion.

51 casos: finca restringida que entra sola, ausentismo completo con rango
invertido rechazado, perfiles trabajador/supervisor, ciclos con multi-select
paginado, mantenimiento con requires/resolver, labores con cuadrilla, y NLU
que resuelve "plateo" por entity hasta el informe.

Destaparon dos errores reales:
- registrar_mantenimiento posteaba a labores_up sin novedad_id ni empleados,
  que el procesador exige: el ERP lo habria rechazado siempre. Ahora pide la
  labor del grupo elegido (endpoint nuevo novedades_mant_dn) y la cuadrilla.
- resolverPorCampos reventaba con warning al sustituir arreglos en la URL.

Uso: bash setup/tests/run.sh

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-19 22:27:05 -05:00

200 lines
7.4 KiB
PHP

<?php
declare(strict_types=1);
/**
* Arnés para ejecutar NormalBot de verdad, sin MySQL ni WhatsApp:
*
* db() -> SQLite en memoria, con los endpoints del seed
* reescritos hacia el servidor de fixtures
* ConversationContext -> estado en memoria, un contexto por teléfono
* WhatsAppSender -> captura los envíos directos en ::$enviados
* AiBot -> respuestas guionadas en ::$respuestas
*
* El curl es real: pega contra fixtures_api.php servido con `php -S`.
*/
const FIXTURE_BASE = 'http://127.0.0.1:8973/fixtures_api.php';
// ── db(): SQLite con los endpoints reales del seed ───────────────────────────
function db(): PDO
{
static $pdo = null;
if ($pdo !== null) return $pdo;
$pdo = new PDO('sqlite::memory:', null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
$pdo->exec("CREATE TABLE company_endpoints (
company_id INT, endpoint_key TEXT, direction TEXT, url TEXT,
method TEXT DEFAULT 'GET', params TEXT DEFAULT NULL, is_active INT DEFAULT 1)");
$pdo->exec("CREATE TABLE company_phones (
company_id INT, wa_number TEXT, label TEXT, permission_type INT,
tercero_id INT NULL, modulos_json TEXT NULL, es_supervisor INT DEFAULT 0, is_active INT DEFAULT 1)");
// Los endpoints salen del seed real: mismas claves y placeholders, otra base.
$seed = file_get_contents(dirname(__DIR__) . '/seed_palmas.php');
preg_match_all("/\['key' => '([^']+)',\s*'dir' => '([^']+)',\s*'url' => \\\$BASE \. '([^']*)'/", $seed, $m);
$ins = $pdo->prepare("INSERT INTO company_endpoints (company_id, endpoint_key, direction, url) VALUES (1, ?, ?, ?)");
foreach ($m[1] as $i => $key) {
$ins->execute([$key, $m[2][$i], FIXTURE_BASE . $m[3][$i]]);
}
return $pdo;
}
// ── ConversationContext en memoria ───────────────────────────────────────────
class ConversationContext
{
public static array $store = [];
private static function &ctx(int $id): array
{
if (!isset(self::$store[$id])) {
self::$store[$id] = ['current_node' => null, 'metadata' => []];
}
return self::$store[$id];
}
public static function getOrCreate(int $companyId, string $phone, string $botType = 'normal'): array
{
// Un id estable por teléfono para aislar escenarios
$id = crc32($companyId . '|' . $phone) % 100000;
$c = self::ctx($id);
return ['id' => $id, 'current_node' => $c['current_node'], 'metadata' => json_encode($c['metadata'])];
}
public static function updateNode(int $id, ?string $node): void
{
self::ctx($id)['current_node'] = $node;
}
public static function getMetadata(int $id): array
{
return self::ctx($id)['metadata'];
}
public static function updateMetadata(int $id, array $meta): void
{
self::ctx($id)['metadata'] = $meta;
}
public static function reset(int $id): void
{
self::$store[$id] = ['current_node' => null, 'metadata' => []];
}
}
// ── WhatsAppSender: captura ──────────────────────────────────────────────────
class WhatsAppSender
{
public static array $enviados = [];
public static function sendText(string $to, string $text, string $phoneNumberId): array
{
self::$enviados[] = ['to' => $to, 'text' => $text];
return ['success' => true];
}
public static function uploadMedia(string $file, string $mime, string $phoneNumberId): array
{
return ['success' => true, 'media_id' => 'test-media'];
}
public static function sendDocument(string $to, string $mediaId, string $phoneNumberId, string $caption, string $name): array
{
self::$enviados[] = ['to' => $to, 'document' => $name];
return ['success' => true];
}
}
// ── AiBot guionado ───────────────────────────────────────────────────────────
class AiBot
{
/** Cola de respuestas para routeOrChat; vacía = chat vacío (cae al fallback). */
public static array $respuestas = [];
public static function routeOrChat(array $company, array $context, string $input): array
{
return array_shift(self::$respuestas) ?? ['action' => 'chat', 'text' => ''];
}
}
require_once dirname(__DIR__, 2) . '/services/NormalBot.php';
// ── Utilidades del test ──────────────────────────────────────────────────────
function empresa(): array
{
static $config = null;
if ($config === null) {
$seed = file_get_contents(dirname(__DIR__) . '/seed_palmas.php');
$open = strpos($seed, '[', strpos($seed, '$configJson = ['));
$d = 0; $end = $open;
for ($i = $open, $n = strlen($seed); $i < $n; $i++) {
if ($seed[$i] === '[') $d++;
elseif ($seed[$i] === ']') { $d--; if (!$d) { $end = $i; break; } }
}
$config = eval('return ' . substr($seed, $open, $end - $open + 1) . ';');
// Sin saludo diario: mete un envío extra en cada primer mensaje
$config['welcome']['enabled'] = false;
}
return [
'id' => 1,
'name' => 'test',
'display_name' => 'Empresa Test',
'api_key' => 'k',
'config_json' => json_encode($config),
'_permission_type' => 3,
];
}
function contexto(string $phone): array
{
return ['from' => $phone, 'name' => 'Tester', 'phone_number_id' => 'pn', 'permission_type' => 3];
}
/** Texto plano de la respuesta del bot, venga como texto o interactivo. */
function textoDe(?array $resp): string
{
if (!$resp) return '';
$p = json_decode($resp['payload'] ?? '{}', true);
if (isset($p['text'])) return $p['text'];
$i = $p['interactive'] ?? [];
return ($i['header']['text'] ?? '') . ' ' . ($i['body']['text'] ?? '');
}
/** Filas/botones de una respuesta interactiva, como id => title. */
function filasDe(?array $resp): array
{
$p = json_decode($resp['payload'] ?? '{}', true);
$i = $p['interactive'] ?? [];
$out = [];
foreach ($i['action']['sections'] ?? [] as $sec) {
foreach ($sec['rows'] ?? [] as $r) $out[$r['id']] = $r['title'];
}
foreach ($i['action']['buttons'] ?? [] as $b) {
$out[$b['reply']['id']] = $b['reply']['title'];
}
return $out;
}
/** Las capturas del servidor de fixtures desde la última llamada. */
function capturas(): array
{
$log = getenv('CAPTURE_LOG');
if (!is_file($log)) return [];
$out = [];
foreach (file($log, FILE_IGNORE_NEW_LINES) as $l) $out[] = json_decode($l, true);
file_put_contents($log, '');
return $out;
}
$GLOBALS['fallas'] = 0;
function check(string $nombre, $obtenido, $esperado = true): void
{
$ok = $obtenido === $esperado;
if (!$ok) $GLOBALS['fallas']++;
printf("%s %s\n", $ok ? ' ok ' : ' FALLA', $nombre);
if (!$ok) {
echo " esperaba: " . json_encode($esperado, JSON_UNESCAPED_UNICODE) . "\n";
echo " obtuvo: " . json_encode($obtenido, JSON_UNESCAPED_UNICODE) . "\n";
}
}