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"; } }