falso en PHP puro (sin drivers), 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(): falso en PHP puro ────────────────────────────────────────────────── // Sin SQLite: los servidores no siempre traen pdo_sqlite. NormalBot solo hace // dos consultas —el endpoint por clave y el perfil por número— así que un par // de mapas en memoria alcanza y no dependemos de ningún driver. class FakeStmt { private $row = false; public function __construct(private string $sql) {} public function execute(array $p = []): bool { if (str_contains($this->sql, 'company_endpoints')) { $key = $p[1] ?? ''; $url = FakeDb::$endpoints[$key] ?? null; $this->row = $url === null ? false : ['url' => $url, 'method' => 'GET', 'params' => null]; } elseif (str_contains($this->sql, 'company_phones')) { $this->row = FakeDb::$phones[$p[1] ?? ''] ?? false; } else { $this->row = false; } return true; } public function fetch() { return $this->row; } public function fetchColumn() { return $this->row ? array_values($this->row)[0] : false; } public function fetchAll(): array { return $this->row ? [$this->row] : []; } } class FakeDb { /** endpoint_key => url absoluta hacia el servidor de fixtures */ public static array $endpoints = []; /** wa_number => fila de company_phones */ public static array $phones = []; public function prepare(string $sql): FakeStmt { return new FakeStmt($sql); } public function query(string $sql): FakeStmt { $s = new FakeStmt($sql); $s->execute(); return $s; } public function exec(string $sql): int { return 0; } } function db(): FakeDb { static $db = null; if ($db !== null) return $db; $db = new FakeDb(); // 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); foreach ($m[1] as $i => $key) { FakeDb::$endpoints[$key] = FIXTURE_BASE . $m[3][$i]; } return $db; } /** Registra un número con perfil para los escenarios de trabajador/supervisor. */ function fakePhone(string $wa, ?int $tercero, int $esSupervisor): void { FakeDb::$phones[$wa] = [ 'tercero_id' => $tercero, 'modulos_json' => null, 'es_supervisor' => $esSupervisor, ]; } // ── 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"; } }