Files
2026-10-23 17:22:24 +00:00

348 lines
29 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
/**
* Prüft den digitalen Assistenten: wann er aktiv ist (Live-Chat aus / niemand erreichbar), Antworten aus Wissensdatenbank und zusätzlichen
* Antworten, Ticket-Erstellung mit Abteilung, Priorität, Zuweisung und Schutz vor Missbrauch, Verwaltungsseite mit eigenem Recht.
*/
if (!extension_loaded('pdo_sqlite') || !function_exists('curl_init')) { echo "Aktivitäts-Test übersprungen: pdo_sqlite oder curl fehlt.\n"; exit(0); }
date_default_timezone_set('Europe/Berlin'); // wie in der Test-Installation
$root = dirname(__DIR__);
$work = sys_get_temp_dir() . '/deskhub-assistant-' . bin2hex(random_bytes(4));
$tmp = $work . '/inst';
mkdir($tmp, 0755, true);
$webPort = random_int(35000, 35499);
$skip = '#^(config/config\.php|database/[^/]+\.sqlite[^/]*|backups|uploads|tests|plugins/[^/]+|\.git)(/|$)#';
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST) as $f) {
$rel = str_replace(chr(92), '/', substr($f->getPathname(), strlen($root) + 1));
if (preg_match($skip, $rel) && !preg_match('#^(backups|uploads|plugins)/(\.htaccess|README\.md)$#', $rel)) continue;
if ($f->isDir()) { @mkdir($tmp . '/' . $rel, 0755, true); continue; }
@mkdir(dirname($tmp . '/' . $rel), 0755, true);
copy($f->getPathname(), $tmp . '/' . $rel);
}
@mkdir($tmp . '/uploads');
@mkdir($tmp . '/config');
file_put_contents($tmp . '/config/config.php', '<?php return ' . var_export([
'app_key' => bin2hex(random_bytes(32)),
'db' => ['driver' => 'sqlite', 'sqlite_path' => $tmp . '/database/deskhub.sqlite'],
'site' => ['name' => 'Test', 'url' => 'http://127.0.0.1', 'admin_email' => 'a@test.de', 'timezone' => 'Europe/Berlin'],
'uploads' => ['max_size_bytes' => 10485760, 'allowed_extensions' => ['png'], 'path' => $tmp . '/uploads'],
], true) . ';');
putenv('DESKHUB_CONFIG_JSON');
putenv('DESKHUB_SQLITE_PATH');
putenv('DESKHUB_TESTING');
putenv('DESKHUB_API_ALLOW_INSECURE=1');
require_once $root . '/database/sqlite_schema.php';
require_once $root . '/database/migrate.php';
$db = new PDO('sqlite:' . $tmp . '/database/deskhub.sqlite');
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
$db->exec('PRAGMA foreign_keys = ON');
$db->exec(convert_schema_to_sqlite((string) file_get_contents($root . '/database/schema.sql')));
$mig = run_migrations($db, true);
$now = date('Y-m-d H:i:s');
$dept = (int) $db->query('SELECT id FROM departments ORDER BY id LIMIT 1')->fetchColumn();
$db->exec('DELETE FROM staff');
$ins = $db->prepare('INSERT INTO staff (name, email, password_hash, role, department_id, is_active, created_at) VALUES (?,?,?,?,?,1,?)');
$ids = [];
foreach ([['Chef', 'chef@test.de', 'admin'], ['Agent', 'agent@test.de', 'agent'], ['Andere', 'andere@test.de', 'agent']] as [$n, $e, $r]) {
$ins->execute([$n, $e, password_hash('Sehr-Sicher-1234', PASSWORD_DEFAULT), $r, $dept, $now]);
$ids[$n] = (int) $db->lastInsertId();
}
$db = null;
$failures = [];
$total = 0;
$check = function (bool $ok, string $what) use (&$failures, &$total): void { $total++; if (!$ok) $failures[] = $what; };
$webProc = proc_open([PHP_BINARY, '-S', "127.0.0.1:$webPort", '-t', $tmp], [['pipe', 'r'], ['file', $work . '/web.out', 'w'], ['file', $work . '/web.out', 'a']], $wp, $tmp);
try {
for ($i = 0; $i < 50; $i++) { if (@fsockopen('127.0.0.1', $webPort)) break; usleep(100000); }
$pdo = function () use ($tmp): PDO {
$p = new PDO('sqlite:' . $tmp . '/database/deskhub.sqlite');
$p->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$p->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
return $p;
};
$set = function (string $k, string $v) use ($pdo): void {
$pdo()->prepare('INSERT OR REPLACE INTO settings (setting_key, setting_value) VALUES (?, ?)')->execute([$k, $v]);
};
$call = function (string $method, string $path, string $jar, array $form = [], array $headers = []) use ($webPort): array {
$ch = curl_init("http://127.0.0.1:$webPort$path");
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => false, CURLOPT_TIMEOUT => 60, CURLOPT_COOKIEJAR => $jar, CURLOPT_COOKIEFILE => $jar, CURLOPT_HTTPHEADER => array_merge(['Sec-Fetch-Mode: navigate', 'Sec-Fetch-User: ?1'], $headers)]);
if ($method === 'POST') { curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($form)); }
$b = (string) curl_exec($ch);
$s = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
return [$s, $b];
};
// Kunden-Sitzung mit CSRF-Token
$cust = tempnam(sys_get_temp_dir(), 'ast');
[, $lp] = $call('GET', '/staff/login.php', $cust);
preg_match('/name="csrf_token" value="([a-f0-9]+)"/', $lp, $m);
$csrf = $m[1] ?? '';
$as = function (string $action, array $f = [], ?string $jar = null, ?string $token = null) use ($call, $cust, $csrf): array {
[$s, $b] = $call('POST', '/assistant.php', $jar ?? $cust, array_merge(['action' => $action, 'csrf_token' => $token ?? $csrf], $f));
return [$s, json_decode($b, true) ?? []];
};
// Wissensdatenbank und zusätzliche Antworten
$db = $pdo();
$db->exec("INSERT INTO kb_categories (name, slug, sort_order) VALUES ('Konto', 'konto', 1)");
$cat = (int) $db->lastInsertId();
$now = date('Y-m-d H:i:s');
$art = $db->prepare('INSERT INTO kb_articles (category_id, title, slug, body, is_published, created_at, updated_at) VALUES (?,?,?,?,?,?,?)');
$art->execute([$cat, 'Passwort zurücksetzen', 'passwort-zuruecksetzen', '<p>Klicken Sie auf der Anmeldeseite auf <b>Passwort vergessen</b>. Wir senden Ihnen einen Link per E-Mail.</p><style>.x{color:red}</style>', 1, $now, $now]);
$art->execute([$cat, 'Rechnungen herunterladen', 'rechnungen', '<p>Ihre Rechnungen finden Sie im Kundenbereich unter Rechnungen.</p>', 1, $now, $now]);
$art->execute([$cat, 'Interner Entwurf', 'entwurf', '<p>Geheimer Entwurf zum Thema Garantie.</p>', 0, $now, $now]);
$db->prepare('INSERT INTO assistant_answers (title, keywords, answer, is_active, created_at, updated_at) VALUES (?,?,?,?,?,?)')
->execute(['Wie lange dauert der Versand?', 'Lieferzeit, Paket, Lieferung', "Der Versand dauert 2 bis 3 Werktage.\nDetails: https://example.org/versand", 1, $now, $now]);
$db->prepare('INSERT INTO assistant_answers (title, keywords, answer, is_active, created_at, updated_at) VALUES (?,?,?,?,?,?)')
->execute(['Alte Antwort zur Garantie', 'Garantie', 'Diese Antwort ist abgeschaltet.', 0, $now, $now]);
// ── Schalter ───────────────────────────────────────────────────────────
[$s, $r] = $as('status');
$check($s === 200 && $r['active'] === false, 'Ausgeschaltet: der Assistent ist nicht aktiv');
[$s] = $as('ask', ['message' => 'Passwort']);
$check($s === 409, 'Ausgeschaltet: Fragen werden abgelehnt (HTTP ' . $s . ')');
[$s] = $call('GET', '/home.php', $cust);
[, $home] = $call('GET', '/home.php', $cust);
$check(!str_contains($home, 'id="as-bubble"'), 'Ausgeschaltet: kein Fenster auf der Seite');
$set('assistant_enabled', '1');
[$s, $r] = $as('status');
$check($r['active'] === true && $r['name'] === 'Digitaler Assistent' && str_contains($r['greeting'], 'Hallo'), 'Eingeschaltet und Live-Chat aus: der Assistent ist aktiv, mit Vorgabe-Texten');
[, $home] = $call('GET', '/home.php', $cust);
$check(str_contains($home, 'id="as-bubble"') && str_contains($home, '/assistant.php'), 'Das Fenster des Assistenten erscheint auf der Kundenseite');
$set('live_chat_enabled', '1');
$db = $pdo();
$db->prepare("INSERT OR REPLACE INTO staff_availability (staff_id, status, updated_at) VALUES (?, 'available', ?)")->execute([$ids['Agent'], $now]);
[$s, $r] = $as('status');
$check($r['active'] === false, 'Live-Chat an und ein Mitarbeiter erreichbar: der Assistent ist nicht aktiv');
[, $home] = $call('GET', '/home.php', $cust);
$check(!str_contains($home, 'id="as-bubble"') && str_contains($home, 'lc-bubble'), 'dann erscheint stattdessen das Live-Chat-Fenster');
$set('assistant_mode', 'always');
[$s, $r] = $as('status');
$check($r['active'] === true, 'Einstellung „Immer“: aktiv, auch wenn jemand erreichbar ist');
$set('assistant_mode', 'offline_only');
$db->prepare("UPDATE staff_availability SET status = 'offline' WHERE staff_id = ?")->execute([$ids['Agent']]);
[$s, $r] = $as('status');
$check($r['active'] === true, 'Live-Chat an, aber niemand erreichbar: der Assistent übernimmt');
[, $home] = $call('GET', '/home.php', $cust);
$check(str_contains($home, 'id="as-bubble"') && !str_contains($home, 'id="lc-bubble"'), 'und ersetzt das Live-Chat-Fenster (nur eine Sprechblase)');
$set('helpdesk_status', 'offline');
[$s, $r] = $as('status');
$check($r['active'] === false, 'Im Wartungsmodus ist der Assistent nicht aktiv');
$set('helpdesk_status', 'online');
// ── Antworten ──────────────────────────────────────────────────────────
[$s, $r] = $as('ask', ['message' => 'Wie kann ich mein Passwort zurücksetzen?']);
$check($s === 200 && $r['type'] === 'answer' && $r['source'] === 'kb' && str_contains($r['text'], 'Passwort vergessen') && !str_contains($r['text'], 'color') && ($r['link'] ?? '') === '/kb_article.php?slug=passwort-zuruecksetzen', 'Antwort aus der Wissensdatenbank (Text ohne CSS, mit Link) ' . json_encode($r, JSON_UNESCAPED_UNICODE));
[$s, $r] = $as('ask', ['message' => 'Wo finde ich meine Rechnung?']);
$check($r['type'] === 'answer' && $r['title'] === 'Rechnungen herunterladen', 'Einzahl und Mehrzahl passen zusammen („Rechnung“ findet „Rechnungen“)');
[$s, $r] = $as('ask', ['message' => 'Wie lange dauert die Lieferung meines Pakets?']);
$check($r['type'] === 'answer' && $r['source'] === 'custom' && str_contains($r['text'], '2 bis 3 Werktage') && str_contains($r['text'], 'https://example.org/versand'), 'Zusätzliche Antwort aus dem Backend, gefunden über Stichwörter');
$hits = (int) $pdo()->query("SELECT hits FROM assistant_answers WHERE title LIKE 'Wie lange%'")->fetchColumn();
$check($hits === 1, 'Die Nutzung wird gezählt');
[$s, $r] = $as('ask', ['message' => 'Wie lange dauert der Versand?']);
$check($r['source'] === 'custom', 'Eigene Antworten haben Vorrang');
[$s, $r] = $as('ask', ['message' => 'Habe ich Garantie?']);
$check($r['type'] === 'none', 'Abgeschaltete Antworten und unveröffentlichte Artikel werden nicht verwendet');
$pdo()->prepare('INSERT INTO assistant_answers (title, keywords, answer, is_active, created_at, updated_at) VALUES (?,?,?,?,?,?)')->execute(['Discord beitreten', 'Discord, Einladungslink', 'Hier ist der Link: https://discord.gg/beispiel', 1, $now, $now]);
[$s, $r] = $as('ask', ['message' => 'Wie bekomme ich Zugang zum Discord?']);
$check($r['type'] === 'answer' && $r['source'] === 'custom' && str_contains($r['text'], 'discord.gg/beispiel'), 'Ein hinterlegtes Stichwort genügt (Frage mit mehreren anderen Wörtern findet die Discord-Antwort) ' . json_encode($r, JSON_UNESCAPED_UNICODE));
// Wissensdatenbank: Kategorie und Synonyme (Frage „Zugang zum Discord“ findet „Wie trete ich dem Server bei?“ in der Kategorie Discord)
$db = $pdo();
$db->exec("UPDATE assistant_answers SET is_active = 0 WHERE title = 'Discord beitreten'");
$db->exec("INSERT INTO kb_categories (name, slug, sort_order) VALUES ('Discord', 'discord', 2)");
$dcat = (int) $db->lastInsertId();
$art->execute([$dcat, 'Wie trete ich dem Server bei?', 'server-beitreten', '<p><a href="https://discord.gg/Qtsna6ZHer">https://discord.gg/Qtsna6ZHer</a> – Der Server hat 400 Mitglieder. Du brauchst einen Discord-Account.</p><ol><li>Beitreten.</li></ol>', 1, $now, $now]);
$art->execute([$cat, 'Wie bekomme ich Zugang zur Auto-Veröffentlichung?', 'auto-veroeffentlichung', '<p>Melde dich per Ticket, Discord oder Telegram. Wir senden dir die Zugangsdaten.</p>', 1, $now, $now]);
[$s, $r] = $as('ask', ['message' => 'Wie bekomme ich zugang zum Discord?']);
$check($r['type'] === 'answer' && $r['title'] === 'Wie trete ich dem Server bei?' && str_contains($r['text'], 'discord.gg/Qtsna6ZHer'), 'Wissensdatenbank: Kategorie „Discord“ und Synonym „Zugang = beitreten“ führen zum richtigen Artikel ' . json_encode($r, JSON_UNESCAPED_UNICODE));
$db->exec("UPDATE assistant_answers SET is_active = 1 WHERE title = 'Discord beitreten'");
[$s, $r] = $as('ask', ['message' => 'Wie wird das Wetter in Berlin morgen?']);
$check($r['type'] === 'none' && str_contains($r['text'], 'Ticket'), 'Ohne Treffer: Angebot, ein Ticket zu erstellen');
[$s, $r] = $as('ask', ['message' => 'Ich möchte ein Ticket erstellen']);
$check($r['type'] === 'ticket', 'Wunsch nach einem Ticket startet die Ticket-Erfassung');
[$s, $r] = $as('ask', ['message' => 'Hallo']);
$check($r['type'] === 'smalltalk', 'Begrüßung wird freundlich beantwortet');
[$s, $r] = $as('ask', ['message' => 'Danke']);
$check($r['type'] === 'smalltalk', 'Dank wird beantwortet');
[$s] = $as('ask', ['message' => ' ']);
$check($s === 422, 'Leere Frage wird abgelehnt');
[$s] = $as('ask', ['message' => 'Passwort'], null, 'falscher-token');
$check($s === 403, 'Falscher Token wird abgelehnt (HTTP ' . $s . ')');
$set('kb_enabled', '0');
[$s, $r] = $as('ask', ['message' => 'Wie kann ich mein Passwort zurücksetzen?']);
$check($r['type'] === 'none', 'Bei ausgeschalteter Wissensdatenbank im Portal werden ihre Artikel nicht ausgegeben');
$set('kb_enabled', '1');
// ── Ticket ─────────────────────────────────────────────────────────────
$tk = tempnam(sys_get_temp_dir(), 'ast');
[, $lp] = $call('GET', '/staff/login.php', $tk);
preg_match('/name="csrf_token" value="([a-f0-9]+)"/', $lp, $m);
$tcsrf = $m[1] ?? '';
$prioId = (int) $pdo()->query('SELECT id FROM priorities ORDER BY sort_order DESC LIMIT 1')->fetchColumn();
$db = $pdo();
$db->prepare("INSERT INTO departments (name, email, is_active, auto_assign_mode, auto_assign_staff_id, created_at) VALUES ('First Support', '', 1, 'staff', ?, ?)")->execute([$ids['Chef'], $now]);
$firstDept = (int) $db->lastInsertId();
$set('assistant_department_id', (string) $firstDept);
$set('assistant_priority_id', (string) $prioId);
$good = ['name' => 'Erika Muster', 'email' => 'erika@example.org', 'subject' => 'Meine Frage zur Rechnung', 'message' => 'Ich finde meine Rechnung nicht.'];
[$s, $r] = $as('ticket', ['name' => '', 'email' => 'x'] + $good, $tk, $tcsrf);
$check($s === 422 && !empty($r['error']), 'Ticket ohne Namen wird abgelehnt (HTTP ' . $s . ')');
[$s, $r] = $as('ticket', ['email' => 'keine-mail'] + $good, $tk, $tcsrf);
$check($s === 422, 'Ungültige E-Mail-Adresse wird abgelehnt');
[$s] = $as('ticket', ['hp_website' => 'http://spam'] + $good, $tk, $tcsrf);
$check($s === 400, 'Die Spam-Falle (verstecktes Feld) lehnt Bots ab');
[$s] = $as('ticket', ['helpTopicId' => '99999'] + $good, $tk, $tcsrf);
$check($s === 422, 'Unbekanntes Hilfethema wird abgelehnt');
[$s, $r] = $as('ticket', $good, $tk, $tcsrf);
$check($s === 201 && !empty($r['number']), 'Ticket wird erstellt (HTTP ' . $s . ' ' . json_encode($r, JSON_UNESCAPED_UNICODE) . ')');
$t = $pdo()->prepare('SELECT * FROM tickets WHERE ticket_number = ?');
$t->execute([$r['number'] ?? '']);
$row = $t->fetch() ?: [];
$check(($row['subject'] ?? '') === $good['subject'] && ($row['requester_email'] ?? '') === 'erika@example.org' && (int) ($row['department_id'] ?? 0) === $firstDept && (int) ($row['priority_id'] ?? 0) === $prioId, 'Betreff, Absender, eingestellte Abteilung und Priorität stimmen');
$check((int) ($row['assigned_staff_id'] ?? 0) === $ids['Chef'], 'Die automatische Zuweisung der Abteilung greift');
$tag = $pdo()->prepare('SELECT COUNT(*) FROM ticket_tags tt JOIN tags g ON g.id = tt.tag_id WHERE tt.ticket_id = ? AND g.name = ?');
$tag->execute([$row['id'] ?? 0, 'Assistent']);
$log = $pdo()->prepare("SELECT COUNT(*) FROM activity_log WHERE ticket_id = ? AND action = 'Ticket über den digitalen Assistenten erstellt'");
$log->execute([$row['id'] ?? 0]);
$check((int) $tag->fetchColumn() === 1 && (int) $log->fetchColumn() === 1, 'Das Ticket trägt den Tag „Assistent“ und der Verlauf nennt den Assistenten');
$msg = $pdo()->prepare('SELECT message FROM ticket_threads WHERE ticket_id = ?');
$msg->execute([$row['id'] ?? 0]);
$check(str_contains((string) $msg->fetchColumn(), 'Rechnung nicht'), 'Die Beschreibung steht im Ticket');
$as('ticket', ['subject' => 'Zweites'] + $good, $tk, $tcsrf);
$as('ticket', ['subject' => 'Drittes'] + $good, $tk, $tcsrf);
[$s, $r] = $as('ticket', ['subject' => 'Viertes'] + $good, $tk, $tcsrf);
$check($s === 429, 'Höchstens 3 Tickets pro Stunde und Adresse (HTTP ' . $s . ')');
// ── Verwaltung ─────────────────────────────────────────────────────────
$login = function (string $email) use ($call): string {
$jar = tempnam(sys_get_temp_dir(), 'ast');
[, $lp] = $call('GET', '/staff/login.php', $jar);
preg_match('/name="csrf_token" value="([a-f0-9]+)"/', $lp, $m);
$call('POST', '/staff/login.php', $jar, ['csrf_token' => $m[1] ?? '', 'email' => $email, 'password' => 'Sehr-Sicher-1234']);
return $jar;
};
$tokenOf = function (string $jar, string $path) use ($call): string {
[, $p] = $call('GET', $path, $jar);
preg_match('/name="csrf_token" value="([a-f0-9]+)"/', $p, $mm);
return $mm[1] ?? '';
};
$jarA = $login('chef@test.de');
[$s, $page] = $call('GET', '/staff/assistant.php?tab=answers', $jarA);
$check($s === 200 && str_contains($page, 'Digitaler Assistent') && str_contains($page, 'Zusätzliche Antworten') && str_contains($page, 'Wie lange dauert der Versand?'), 'Admin sieht die Verwaltungsseite mit den Antworten (HTTP ' . $s . ')');
[, $menu] = $call('GET', '/staff/availability.php', $jarA);
$check(str_contains($menu, 'href="/staff/assistant.php"'), 'Der Menüpunkt „Digitaler Assistent“ steht im Adminportal');
$tok = $tokenOf($jarA, '/staff/assistant.php');
[$s] = $call('POST', '/staff/assistant.php', $jarA, ['csrf_token' => $tok, 'action' => 'save_answer', 'id' => '0', 'title' => 'Öffnungszeiten', 'keywords' => 'geöffnet, Uhrzeit', 'answer' => 'Wir sind Mo-Fr von 9 bis 17 Uhr erreichbar.', 'is_active' => '1']);
$newAns = (int) $pdo()->query("SELECT COUNT(*) FROM assistant_answers WHERE title = 'Öffnungszeiten'")->fetchColumn();
$check($s === 302 && $newAns === 1, 'Antwort im Backend anlegen');
[$s, $r] = $as('ask', ['message' => 'Wann seid ihr geöffnet?']);
$check($r['type'] === 'answer' && $r['source'] === 'custom' && str_contains($r['text'], '9 bis 17'), 'Die neue Antwort wird sofort verwendet (Stichwort „geöffnet“)');
[$s, $page] = $call('POST', '/staff/assistant.php', $jarA, ['action' => 'save_answer', 'id' => '0', 'title' => 'x', 'answer' => 'y']);
$check($s === 403, 'Ohne CSRF-Token wird nichts gespeichert (HTTP ' . $s . ')');
$tok = $tokenOf($jarA, '/staff/assistant.php');
$call('POST', '/staff/assistant.php', $jarA, ['csrf_token' => $tok, 'action' => 'save_settings', 'assistant_enabled' => '1', 'assistant_mode' => 'always', 'assistant_name' => 'Hilfe-Bot <b>', 'assistant_greeting' => 'Moin!', 'assistant_no_answer' => '', 'assistant_department_id' => '0', 'assistant_priority_id' => '0']);
[$s, $r] = $as('status');
$check($r['name'] === 'Hilfe-Bot' && $r['greeting'] === 'Moin!' && $r['active'] === true, 'Name und Begrüßung lassen sich einstellen (HTML wird entfernt)');
$tok = $tokenOf($jarA, '/staff/assistant.php');
[$s, $page] = $call('POST', '/staff/assistant.php', $jarA, ['csrf_token' => $tok, 'action' => 'test', 'question' => 'Wo finde ich meine Rechnung?']);
$check($s === 200 && str_contains($page, 'Rechnungen herunterladen'), 'Die Testfunktion zeigt die Antwort');
$id = (int) $pdo()->query("SELECT id FROM assistant_answers WHERE title = 'Öffnungszeiten'")->fetchColumn();
$tok = $tokenOf($jarA, '/staff/assistant.php');
$call('POST', '/staff/assistant.php', $jarA, ['csrf_token' => $tok, 'action' => 'delete_answer', 'id' => (string) $id]);
$check((int) $pdo()->query("SELECT COUNT(*) FROM assistant_answers WHERE title = 'Öffnungszeiten'")->fetchColumn() === 0, 'Antwort löschen');
// Recht für Mitarbeiter
$jarB = $login('agent@test.de');
[$s] = $call('GET', '/staff/availability.php', $jarB);
$check($s === 200, 'Regression: Mitarbeiter ohne Admin-Rolle kommen bei eingeschaltetem Live-Chat auf Seiten des Mitarbeiterbereichs (Menü-Zähler des Live-Chats, HTTP ' . $s . ')');
[$s] = $call('GET', '/staff/assistant.php', $jarB);
$check($s === 302 || $s === 403, 'Mitarbeiter ohne das Recht kommen nicht auf die Seite (HTTP ' . $s . ')');
$db = $pdo();
$db->prepare('INSERT INTO roles (name, permissions) VALUES (?,?)')->execute(['Assistent-Pfleger', json_encode(['reply_ticket' => true, 'manage_assistant' => true])]);
$roleId = (int) $db->lastInsertId();
$db->prepare('UPDATE staff SET role_id = ? WHERE id = ?')->execute([$roleId, $ids['Agent']]);
[$s, $page] = $call('GET', '/staff/assistant.php', $jarB);
$check($s === 200 && str_contains($page, 'Zusätzliche Antworten'), 'Mit dem Recht „Digitalen Assistenten verwalten“ darf ein Mitarbeiter die Seite nutzen (HTTP ' . $s . ') ' . ($s === 200 ? '' : substr((string) @file_get_contents($work . '/web.out'), -1200)));
// ── Fragen-Log ─────────────────────────────────────────────────────────
$lg = $pdo()->query('SELECT result, COUNT(*) n FROM assistant_log GROUP BY result')->fetchAll(PDO::FETCH_KEY_PAIR);
$check(($lg['kb'] ?? 0) >= 2 && ($lg['custom'] ?? 0) >= 2 && ($lg['none'] ?? 0) >= 2 && ($lg['ticket_request'] ?? 0) === 1 && ($lg['ticket_created'] ?? 0) === 3, 'Log: Fragen werden mit Ergebnis protokolliert ' . json_encode($lg));
$check((int) $pdo()->query("SELECT COUNT(*) FROM assistant_log WHERE question IN ('Hallo','Danke')")->fetchColumn() === 0, 'Log: Begrüßung und Dank werden nicht protokolliert');
$check((int) $pdo()->query("SELECT COUNT(*) FROM assistant_log WHERE matched_title = 'Wie lange dauert der Versand?' AND source = 'custom'")->fetchColumn() >= 1, 'Log: zeigt, welche eigene Antwort gefunden wurde');
$check((int) $pdo()->query("SELECT COUNT(*) FROM assistant_log WHERE result = 'ticket_created' AND ticket_number IS NOT NULL")->fetchColumn() === 3, 'Log: erstellte Tickets mit Nummer');
[$s, $page] = $call('GET', '/staff/assistant.php?lr=none&lp=all', $jarA);
$check($s === 200 && str_contains($page, 'Fragen-Log') && str_contains($page, 'Wie wird das Wetter in Berlin morgen?') && !str_contains($page, 'Wo finde ich meine Rechnung?</td>'), 'Log: Filter „Keine Antwort“ zeigt nur unbeantwortete Fragen (HTTP ' . $s . ')');
[$s, $page] = $call('GET', '/staff/assistant.php?lr=custom&lp=all', $jarA);
$check(str_contains($page, 'Wie lange dauert die Lieferung meines Pakets?') && !str_contains($page, 'Wie wird das Wetter in Berlin morgen?</td>'), 'Log: Filter „Meine Antwort gefunden“');
[$s, $page] = $call('GET', '/staff/assistant.php?lq=Wetter&lp=all', $jarA);
$check(str_contains($page, 'Wie wird das Wetter in Berlin morgen?') && !str_contains($page, 'Lieferung meines Pakets?</td>'), 'Log: Suche im Text');
$as('ask', ['message' => 'Wie wird das Wetter in Berlin morgen?']);
[, $page] = $call('GET', '/staff/assistant.php?lr=none&lp=all', $jarA);
$check(str_contains($page, 'Häufige Fragen ohne Antwort') && str_contains($page, '(2×)') && str_contains($page, 'Antwort dazu anlegen'), 'Log: häufige unbeantwortete Fragen mit Zähler und Link zum Anlegen');
[, $page] = $call('GET', '/staff/assistant.php?new=1&title=' . rawurlencode('Wetter in Berlin'), $jarA);
$check(str_contains($page, 'value="Wetter in Berlin"'), 'Log: „Antwort anlegen“ füllt die Frage vor');
$tok = $tokenOf($jarA, '/staff/assistant.php');
$call('POST', '/staff/assistant.php', $jarA, ['csrf_token' => $tok, 'action' => 'save_settings', 'assistant_enabled' => '1', 'assistant_mode' => 'always', 'assistant_log_days' => '0']);
$before = (int) $pdo()->query('SELECT COUNT(*) FROM assistant_log')->fetchColumn();
$as('ask', ['message' => 'Noch eine Frage zum Wetter']);
$check((int) $pdo()->query('SELECT COUNT(*) FROM assistant_log')->fetchColumn() === $before, 'Log: bei Aufbewahrung 0 wird nichts gespeichert');
$tok = $tokenOf($jarA, '/staff/assistant.php');
$call('POST', '/staff/assistant.php', $jarA, ['csrf_token' => $tok, 'action' => 'save_settings', 'assistant_enabled' => '1', 'assistant_mode' => 'always', 'assistant_log_days' => '90']);
$tok = $tokenOf($jarA, '/staff/assistant.php');
$call('POST', '/staff/assistant.php', $jarA, ['csrf_token' => $tok, 'action' => 'clear_log']);
$check((int) $pdo()->query('SELECT COUNT(*) FROM assistant_log')->fetchColumn() === 0, 'Log: Leeren löscht alle Einträge');
[$s, $page] = $call('GET', '/staff/assistant.php', $jarB);
$check($s === 200 && str_contains($page, 'Fragen-Log'), 'Log: auch für Mitarbeiter mit dem Recht sichtbar');
// ── Rückmeldung und eigene Synonyme ────────────────────────────────────
[$s, $r] = $as('ask', ['message' => 'Wie lange dauert die Lieferung meines Pakets?']);
$check(!empty($r['logId']), 'Rückmeldung: die Antwort nennt ihre Log-Nummer');
$lid = (int) ($r['logId'] ?? 0);
$as('feedback', ['logId' => (string) ($lid + 999), 'helpful' => '1']);
$check($pdo()->query('SELECT COUNT(*) FROM assistant_log WHERE helpful IS NOT NULL')->fetchColumn() == 0, 'Rückmeldung: fremde Log-Nummern werden ignoriert');
[$s, $x] = $as('feedback', ['logId' => (string) $lid, 'helpful' => '1']);
$check($s === 200 && (int) $pdo()->query("SELECT helpful FROM assistant_log WHERE id = $lid")->fetchColumn() === 1, 'Rückmeldung: „Das hat geholfen“ wird gespeichert');
[$s, $r2] = $as('ask', ['message' => 'Wo finde ich meine Rechnung?']);
$as('feedback', ['logId' => (string) ($r2['logId'] ?? 0), 'helpful' => '0']);
[, $page] = $call('GET', '/staff/assistant.php?lr=helpful&lp=all', $jarA);
$check(str_contains($page, 'Lieferung meines Pakets?') && !str_contains($page, 'Wo finde ich meine Rechnung?</td>'), 'Log: Filter „Hat geholfen“');
[, $page] = $call('GET', '/staff/assistant.php?lr=not_helpful&lp=all', $jarA);
$check(str_contains($page, 'Wo finde ich meine Rechnung?') && str_contains($page, '👎'), 'Log: Filter „Hat nicht geholfen“ mit Markierung');
$db = $pdo();
$db->prepare('INSERT INTO assistant_answers (title, keywords, answer, is_active, created_at, updated_at) VALUES (?,?,?,?,?,?)')->execute(['Erstattung beantragen', '', 'Schreiben Sie uns eine Nachricht mit Ihrer Bestellnummer.', 1, $now, $now]);
[$s, $r] = $as('ask', ['message' => 'Wie läuft die Rückerstattung ab?']);
$check($r['type'] === 'none', 'Synonyme: ohne eigene Gruppe passt „Rückerstattung“ nicht zu „Erstattung“');
$tok = $tokenOf($jarA, '/staff/assistant.php');
$call('POST', '/staff/assistant.php', $jarA, ['csrf_token' => $tok, 'action' => 'save_synonyms', 'assistant_synonyms' => "Erstattung, Rückerstattung, Geld zurück\nnur, einzeln"]);
[$s, $r] = $as('ask', ['message' => 'Wie läuft die Rückerstattung ab?']);
$check($r['type'] === 'answer' && $r['title'] === 'Erstattung beantragen', 'Synonyme: eigene Gruppe verbindet „Rückerstattung“ mit „Erstattung“ ' . json_encode($r, JSON_UNESCAPED_UNICODE));
[, $page] = $call('GET', '/staff/assistant.php', $jarA);
$check(str_contains($page, 'Gleichbedeutende Wörter') && str_contains($page, 'Rückerstattung'), 'Synonyme: Eingabefeld zeigt die gespeicherten Gruppen');
// Begrenzung der Fragen
$limited = false;
for ($i = 0; $i < 35; $i++) { [$s] = $as('ask', ['message' => 'Passwort']); if ($s === 429) { $limited = true; break; } }
$check($limited, 'Zu viele Fragen in kurzer Zeit werden gebremst (HTTP 429)');
@unlink($jarA); @unlink($jarB); @unlink($cust); @unlink($tk);
} finally {
if (isset($webProc) && is_resource($webProc)) { proc_terminate($webProc); proc_close($webProc); }
}
usleep(300000);
$rm = function (string $d) use (&$rm) {
foreach (scandir($d) ?: [] as $e) { if ($e === '.' || $e === '..') continue; is_dir("$d/$e") && !is_link("$d/$e") ? $rm("$d/$e") : @unlink("$d/$e"); }
@rmdir($d);
};
if (str_starts_with($work, sys_get_temp_dir() . '/deskhub-assistant-')) $rm($work);
if ($failures) {
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
exit(1);
}
echo "Assistant checks passed ({$total} Prüfungen).\n";