Files
deskhub/DeskHub-Webseite/tests/ticket_features.php
T
2026-11-04 07:55:43 +00:00

277 lines
25 KiB
PHP

<?php
declare(strict_types=1);
/**
* Prüft die Logik der neuen Ticket-Funktionen: verknüpfte Tickets, zeitversetzt gesendete Antworten, Berichte per E-Mail nach Zeitplan
* und die Datenschutz-Werkzeuge (Auskunft und Löschung), sowie die Syntax der neuen Skripte.
*/
putenv('DESKHUB_TESTING=1');
putenv('DESKHUB_SQLITE_PATH=:memory:');
require_once dirname(__DIR__) . '/includes/bootstrap.php';
$failures = [];
$total = 0;
$check = function (bool $ok, string $what) use (&$failures, &$total): void {
$total++;
if (!$ok) $failures[] = $what;
};
$pdo = get_db();
$now = date('Y-m-d H:i:s');
$dept = (int) $pdo->query('SELECT id FROM departments ORDER BY id LIMIT 1')->fetchColumn();
$prio = (int) $pdo->query('SELECT id FROM priorities ORDER BY id LIMIT 1')->fetchColumn();
$pdo->exec('DELETE FROM staff');
$insStaff = $pdo->prepare('INSERT INTO staff (name, email, password_hash, role, department_id, is_active, created_at) VALUES (?,?,?,?,?,1,?)');
$insStaff->execute(['Chef', 'chef@test.de', 'x', 'admin', $dept, $now]);
$adminId = (int) $pdo->lastInsertId();
$insStaff->execute(['Agent', 'agent@test.de', 'x', 'agent', $dept, $now]);
$agentId = (int) $pdo->lastInsertId();
$admin = $pdo->query("SELECT * FROM staff WHERE id = {$adminId}")->fetch(PDO::FETCH_ASSOC);
$agent = $pdo->query("SELECT * FROM staff WHERE id = {$agentId}")->fetch(PDO::FETCH_ASSOC);
$mk = function (string $number, string $email = 'kunde@example.org', string $status = 'open', ?int $userId = null) use ($pdo, $now, $dept, $prio): array {
$pdo->prepare('INSERT INTO tickets (ticket_number, subject, user_id, requester_name, requester_email, department_id, priority_id, status, source, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)')
->execute([$number, 'Betreff ' . $number, $userId, 'Kunde Eins', $email, $dept, $prio, $status, 'web', $now, $now]);
$id = (int) $pdo->lastInsertId();
return $pdo->query("SELECT * FROM tickets WHERE id = {$id}")->fetch(PDO::FETCH_ASSOC);
};
// ── Verknüpfte Tickets ──────────────────────────────────────────────────────
$a = $mk('T-A'); $b = $mk('T-B'); $c = $mk('T-C', 'x@example.org', 'closed');
$check(ticket_link_add($pdo, (int) $a['id'], 'T-B', 'related', $adminId) === null, 'Verknüpfen funktioniert');
$check(ticket_link_add($pdo, (int) $a['id'], '#T-B', 'duplicate', $adminId) !== null, 'zweite Verknüpfung desselben Paares wird abgelehnt');
$check(ticket_link_add($pdo, (int) $b['id'], 'T-A', 'related', $adminId) !== null, 'auch in Gegenrichtung ist das Paar schon verknüpft');
$check(ticket_link_add($pdo, (int) $a['id'], 'T-A', 'related', $adminId) !== null, 'Ticket nicht mit sich selbst');
$check(ticket_link_add($pdo, (int) $a['id'], 'GIBTSNICHT', 'related', $adminId) !== null, 'unbekannte Nummer wird abgelehnt');
$check(ticket_link_add($pdo, (int) $a['id'], 'T-C', 'unsinn', $adminId) !== null, 'ungültige Art wird abgelehnt');
$check(ticket_link_add($pdo, (int) $a['id'], 'T-C', 'blocks', $adminId, [$dept + 99]) !== null, 'Ticket einer fremden Abteilung darf nicht verknüpft werden');
$check(ticket_link_add($pdo, (int) $c['id'], 'T-A', 'blocks', $adminId) === null, 'blockiert-Verknüpfung (T-C blockiert T-A)');
$la = ticket_links_for($pdo, (int) $a['id']);
$byNum = array_column($la, null, 'number');
$check(count($la) === 2 && ($byNum['T-B']['label'] ?? '') === 'Verwandt mit' && ($byNum['T-C']['label'] ?? '') === 'Blockiert durch', 'Ansicht von T-A: Bezeichnungen: ' . json_encode(array_column($la, 'label')));
$lc = ticket_links_for($pdo, (int) $c['id']);
$check(count($lc) === 1 && $lc[0]['label'] === 'Blockiert' && $lc[0]['number'] === 'T-A', 'Gegenseite: T-C blockiert T-A');
$check(ticket_links_for($pdo, (int) $a['id'], [$dept + 99]) === [], 'ohne Zugriff auf die Abteilung bleibt die Liste leer');
$check(ticket_open_blockers($pdo, (int) $a['id']) === [], 'geschlossener Blocker zählt nicht als offen');
$pdo->exec("UPDATE tickets SET status = 'open' WHERE id = {$c['id']}");
$check(count(ticket_open_blockers($pdo, (int) $a['id'])) === 1, 'offener Blocker wird gemeldet');
$check(!ticket_link_remove($pdo, (int) $la[0]['id'], (int) $c['id'] + 1000), 'fremde Verknüpfung lässt sich nicht über ein anderes Ticket entfernen');
$check(ticket_link_remove($pdo, (int) $la[0]['id'], (int) $a['id']), 'Verknüpfung entfernen');
delete_ticket($pdo, $c, $adminId);
$check(ticket_links_for($pdo, (int) $a['id']) === [] || count(ticket_links_for($pdo, (int) $a['id'])) <= 1, 'Löschen eines Tickets entfernt dessen Verknüpfungen');
$check((int) $pdo->query("SELECT COUNT(*) FROM ticket_links WHERE ticket_id = {$c['id']} OR linked_ticket_id = {$c['id']}")->fetchColumn() === 0, 'keine Verknüpfungs-Reste nach dem Löschen');
// ── Zeitversetzte Antworten ─────────────────────────────────────────────────
$t = $mk('T-S1');
$check(scheduled_reply_parse_time('2030-05-01T14:30') === '2030-05-01 14:30:00', 'Zeit aus datetime-local');
$check(scheduled_reply_parse_time('morgen') === null && scheduled_reply_parse_time('') === null, 'ungültige Zeit');
$future = date('Y-m-d H:i:s', time() + 3600);
$check(scheduled_reply_create($pdo, $t, $agentId, '<p>Hallo</p>', date('Y-m-d H:i:s', time() - 60)) !== null, 'Zeitpunkt in der Vergangenheit wird abgelehnt');
$check(scheduled_reply_create($pdo, $t, $agentId, '<p>Hallo</p>', date('Y-m-d H:i:s', time() + 200 * 86400)) !== null, 'Zeitpunkt zu weit in der Zukunft wird abgelehnt');
$check(scheduled_reply_create($pdo, $t, $agentId, '<p> </p>', $future) !== null, 'leere Nachricht wird abgelehnt');
$check(scheduled_reply_create($pdo, $t, $agentId, '<p>x</p>', $future, ['set_status' => 'closed']) !== null, 'Schließen ohne Abschlussgrund wird abgelehnt');
$check(scheduled_reply_create($pdo, $t, $agentId, '<p>Später</p>', $future, ['set_status' => 'pending']) === null, 'Antwort einplanen');
$check(scheduled_reply_create($pdo, $t, $agentId, '<p>Heute noch</p>', date('Y-m-d H:i:s', time() + 40), ['set_status' => 'resolved', 'resolution_code' => 'fixed']) === null, 'Antwort mit Abschlussgrund einplanen');
$pend = scheduled_replies_pending($pdo, (int) $t['id']);
$check(count($pend) === 2, 'zwei ausstehende Antworten');
$check(scheduled_replies_run_due($pdo) === ['sent' => 0, 'failed' => 0], 'noch nichts fällig');
// zweite Antwort fällig machen
$pdo->exec("UPDATE scheduled_replies SET send_at = '" . date('Y-m-d H:i:s', time() - 5) . "' WHERE message LIKE '%Heute noch%'");
$r = scheduled_replies_run_due($pdo);
$check($r === ['sent' => 1, 'failed' => 0], 'fällige Antwort wird gesendet: ' . json_encode($r));
$after = $pdo->query("SELECT * FROM tickets WHERE id = {$t['id']}")->fetch(PDO::FETCH_ASSOC);
$check($after['status'] === 'resolved' && $after['resolution_code'] === 'fixed' && $after['first_responded_at'] !== null && (int) $after['assigned_staff_id'] === $agentId, 'Status, Abschlussgrund, Erstreaktion und Zuweisung gesetzt');
$thread = $pdo->query("SELECT * FROM ticket_threads WHERE ticket_id = {$t['id']}")->fetchAll(PDO::FETCH_ASSOC);
$check(count($thread) === 1 && $thread[0]['author_type'] === 'staff' && (int) $thread[0]['is_internal_note'] === 0 && str_contains($thread[0]['message'], 'Heute noch'), 'Nachricht steht im Ticket');
$check((int) $pdo->query("SELECT COUNT(*) FROM scheduled_replies WHERE state = 'sent'")->fetchColumn() === 1, 'Zeile ist als gesendet markiert');
$check(scheduled_replies_run_due($pdo)['sent'] === 0, 'zweiter Lauf sendet nicht doppelt');
$other = $pend[0]['message'] === '<p>Später</p>' ? $pend[0] : $pend[1];
$check(!scheduled_reply_cancel($pdo, (int) $other['id'], (int) $t['id'], ['id' => $agentId + 50, 'role' => 'agent']), 'Fremder Mitarbeiter darf nicht abbrechen');
$check(scheduled_reply_cancel($pdo, (int) $other['id'], (int) $t['id'], $agent), 'Verfasser bricht ab');
$check(scheduled_replies_pending($pdo, (int) $t['id']) === [], 'nichts mehr ausstehend');
// Ticket verschwindet vor dem Senden
$t2 = $mk('T-S2');
scheduled_reply_create($pdo, $t2, $adminId, '<p>Weg</p>', $future);
$pdo->exec("UPDATE scheduled_replies SET send_at = '" . date('Y-m-d H:i:s', time() - 5) . "' WHERE state = 'pending'");
$pdo->exec("DELETE FROM tickets WHERE id = {$t2['id']}");
$r = scheduled_replies_run_due($pdo);
$row = $pdo->query("SELECT state, error FROM scheduled_replies WHERE message LIKE '%Weg%'")->fetch(PDO::FETCH_ASSOC);
$check($r['failed'] === 1 && $row['state'] === 'failed' && $row['error'] !== '', 'Ticket gelöscht: Antwort schlägt sauber fehl');
// Verfasser deaktiviert
$t3 = $mk('T-S3');
scheduled_reply_create($pdo, $t3, $agentId, '<p>Inaktiv</p>', $future);
$pdo->exec("UPDATE staff SET is_active = 0 WHERE id = {$agentId}");
$pdo->exec("UPDATE scheduled_replies SET send_at = '" . date('Y-m-d H:i:s', time() - 5) . "' WHERE state = 'pending'");
$check(scheduled_replies_run_due($pdo)['failed'] === 1, 'deaktivierter Verfasser: Antwort wird nicht gesendet');
$pdo->exec("UPDATE staff SET is_active = 1 WHERE id = {$agentId}");
// ── Berichte per E-Mail ─────────────────────────────────────────────────────
$ref = strtotime('2026-11-04 12:00:00'); // Mittwoch
$check(report_period_range('yesterday', $ref) === ['2026-11-03', '2026-11-03'], 'Zeitraum gestern');
$check(report_period_range('last_7', $ref) === ['2026-10-28', '2026-11-03'], 'Zeitraum letzte 7 Tage');
$check(report_period_range('last_week', $ref) === ['2026-10-26', '2026-11-01'], 'Zeitraum letzte Kalenderwoche: ' . json_encode(report_period_range('last_week', $ref)));
$check(report_period_range('last_month', $ref) === ['2026-10-01', '2026-10-31'], 'Zeitraum letzter Monat');
$check(report_period_range('last_30', $ref) === ['2026-10-05', '2026-11-03'], 'Zeitraum letzte 30 Tage');
$s = ['frequency' => 'weekly', 'weekday' => 1, 'send_time' => '08:00', 'monthday' => 1];
$check(report_schedule_next($s, $ref) === '2026-11-09 08:00:00', 'nächster Montag: ' . report_schedule_next($s, $ref));
$check(report_schedule_next(['frequency' => 'weekly', 'weekday' => 3, 'send_time' => '18:00'] , $ref) === '2026-11-04 18:00:00', 'heute später am Tag');
$check(report_schedule_next(['frequency' => 'weekly', 'weekday' => 3, 'send_time' => '08:00'], $ref) === '2026-11-11 08:00:00', 'heute schon vorbei: nächste Woche');
$check(report_schedule_next(['frequency' => 'daily', 'send_time' => '07:30'], $ref) === '2026-11-05 07:30:00', 'täglich');
$check(report_schedule_next(['frequency' => 'monthly', 'monthday' => 1, 'send_time' => '06:00'], $ref) === '2026-12-01 06:00:00', 'monatlich');
$fri = strtotime('2026-11-06 12:00:00'); // Freitag
$check(report_schedule_next(['frequency' => 'daily', 'send_time' => '08:00', 'weekdays_only' => 1], $fri) === '2026-11-09 08:00:00', 'täglich nur Werktage: nach Freitag kommt Montag');
$check(report_schedule_next(['frequency' => 'daily', 'send_time' => '08:00', 'weekdays_only' => 0], $fri) === '2026-11-07 08:00:00', 'täglich ohne Einschränkung: auch am Samstag');
$check(report_schedule_next(['frequency' => 'weekly', 'weekday' => 1, 'send_time' => 'quatsch'], $ref) === '2026-11-09 08:00:00', 'ungültige Uhrzeit fällt auf 08:00 zurück');
[$list, $err] = report_schedule_recipients($pdo, "chef@test.de; AGENT@test.de\nchef@test.de", $agent);
$check($err === null && $list === ['chef@test.de', 'agent@test.de'], 'Empfänger: doppelte entfernt, Kleinschreibung');
[, $err] = report_schedule_recipients($pdo, 'extern@example.org', $agent);
$check($err !== null, 'Mitarbeiter ohne Adminrechte: externer Empfänger abgelehnt');
[$list, $err] = report_schedule_recipients($pdo, 'extern@example.org', $admin);
$check($err === null && $list === ['extern@example.org'], 'Administrator darf externe Empfänger eintragen');
[, $err] = report_schedule_recipients($pdo, 'keine-adresse', $admin);
$check($err !== null, 'ungültige Adresse abgelehnt');
[, $err] = report_schedule_recipients($pdo, implode(',', array_map(fn($i) => "u{$i}@example.org", range(1, 11))), $admin);
$check($err !== null, 'mehr als 10 Empfänger abgelehnt');
$cfg = report_clean_config($pdo, ['dimension' => 'department', 'metrics' => ['total', 'open']]);
$pdo->prepare('INSERT INTO saved_reports (staff_id, name, config, is_shared, created_at, updated_at) VALUES (?,?,?,?,?,?)')
->execute([$adminId, 'Wochenbericht <b>', json_encode($cfg), 0, $now, $now]);
$reportId = (int) $pdo->lastInsertId();
$check(report_schedule_create($pdo, $agent, ['report_id' => $reportId, 'recipients' => 'agent@test.de']) !== null, 'fremden, nicht geteilten Bericht darf der Mitarbeiter nicht einplanen');
$check(report_schedule_create($pdo, $admin, ['report_id' => $reportId, 'recipients' => 'chef@test.de', 'frequency' => 'daily', 'send_time' => '07:00', 'period' => 'yesterday']) === null, 'Zeitplan anlegen');
$sch = $pdo->query('SELECT * FROM report_schedules')->fetch(PDO::FETCH_ASSOC);
$check($sch && $sch['frequency'] === 'daily' && $sch['period'] === 'yesterday' && $sch['next_run_at'] > $now, 'Zeitplan gespeichert, nächster Termin liegt in der Zukunft');
$check((int) $sch['only_if_data'] === 1 && (int) $sch['skip_unchanged'] === 1 && (int) $sch['weekdays_only'] === 0, 'Standard: nur bei Daten, nur bei Änderung (Werktage ohne Angabe aus)');
$rendered = report_schedule_render($pdo, $sch);
$check($rendered !== null && str_contains($rendered[1], 'Wochenbericht &lt;b&gt;') && !str_contains($rendered[1], 'Wochenbericht <b>') && str_contains($rendered[0], 'DeskHub-Bericht'), 'Bericht wird gerendert, Name ist maskiert');
$check(str_contains($rendered[1], '<table') && str_contains($rendered[1], 'Gesamt') || str_contains($rendered[1], 'Keine Tickets'), 'Tabelle im Text');
$check(report_schedules_run_due($pdo) === ['sent' => 0, 'failed' => 0], 'noch nicht fällig');
$pdo->exec("UPDATE report_schedules SET next_run_at = '" . date('Y-m-d H:i:s', time() - 10) . "'");
$r = report_schedules_run_due($pdo);
$sch2 = $pdo->query('SELECT * FROM report_schedules')->fetch(PDO::FETCH_ASSOC);
$check($sch2['next_run_at'] > $now, 'nach dem Lauf ist der nächste Termin gesetzt (kein Doppelversand)');
$check($r === ['sent' => 0, 'failed' => 0] && str_contains((string) $sch2['last_note'], 'keine Daten'), 'leerer Bericht wird nicht gesendet, die Notiz steht im Zeitplan: ' . json_encode([$r, $sch2['last_note']]));
$check(report_schedule_send($pdo, $sch2)['skipped'] === null, '„Test senden“ ignoriert die Schalter');
// Mit Daten: wird versucht (ohne eingerichteten Mailweg scheitert der Versand, das ist hier erwartet)
$mk('R-1');
$pdo->exec("UPDATE tickets SET created_at = '" . date('Y-m-d H:i:s', strtotime('-1 day')) . "' WHERE ticket_number = 'R-1'");
$pdo->exec("UPDATE report_schedules SET next_run_at = '" . date('Y-m-d H:i:s', time() - 10) . "'");
$r = report_schedules_run_due($pdo);
$check($r['sent'] + $r['failed'] === 1, 'Bericht mit Daten wird bearbeitet: ' . json_encode($r));
// Gleiche Zahlen wie beim letzten Versand: übersprungen
$hash = report_schedule_render($pdo, $pdo->query('SELECT * FROM report_schedules')->fetch(PDO::FETCH_ASSOC))[3];
$pdo->exec("UPDATE report_schedules SET last_hash = '{$hash}', next_run_at = '" . date('Y-m-d H:i:s', time() - 10) . "'");
$r = report_schedules_run_due($pdo);
$sch3 = $pdo->query('SELECT * FROM report_schedules')->fetch(PDO::FETCH_ASSOC);
$check($r === ['sent' => 0, 'failed' => 0] && str_contains((string) $sch3['last_note'], 'keine Änderung'), 'unveränderter Bericht wird nicht erneut gesendet: ' . json_encode([$r, $sch3['last_note']]));
$pdo->exec("UPDATE report_schedules SET skip_unchanged = 0, next_run_at = '" . date('Y-m-d H:i:s', time() - 10) . "'");
$r = report_schedules_run_due($pdo);
$check($r['sent'] + $r['failed'] === 1, 'ohne „nur bei Änderung“ wird wieder gesendet');
$check(report_schedules_run_due($pdo) === ['sent' => 0, 'failed' => 0], 'zweiter Lauf: nichts mehr fällig');
$pdo->exec("DELETE FROM saved_reports");
$check(report_schedule_render($pdo, $sch2) === null, 'gelöschter Bericht: kein Versand');
// ── Datenschutz ─────────────────────────────────────────────────────────────
$check(!gdpr_tools_enabled(), 'Werkzeuge sind standardmäßig ausgeschaltet');
set_setting('gdpr_tools_enabled', '1');
$check(gdpr_tools_enabled(), 'Werkzeuge einschaltbar');
$pdo->exec("INSERT INTO users (name, email, password_hash, phone, created_at) VALUES ('Erika Muster','erika@example.org','x','0123','{$now}')");
$uid = (int) $pdo->lastInsertId();
$g1 = $mk('G-1', 'erika@example.org', 'closed', $uid);
$g2 = $mk('G-2', 'erika@example.org', 'open', $uid);
$other = $mk('G-OTHER', 'andere@example.org', 'closed');
$pdo->prepare("INSERT INTO ticket_threads (ticket_id, author_type, user_id, message, is_internal_note, created_at) VALUES (?,?,?,?,0,?)")->execute([$g1['id'], 'customer', $uid, '<p>Meine Adresse ist Musterweg 1</p>', $now]);
$cThread = (int) $pdo->lastInsertId();
$pdo->prepare("INSERT INTO ticket_threads (ticket_id, author_type, staff_id, message, is_internal_note, created_at) VALUES (?,?,?,?,1,?)")->execute([$g1['id'], 'staff', $adminId, 'Interne Notiz', $now]);
$pdo->prepare("INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES (?,?,?,0,?)")->execute([$other['id'], 'customer', 'Fremde Nachricht', $now]);
$uploadDir = realpath((string) config()['uploads']['path']) ?: sys_get_temp_dir();
$stored = 'gdpr-test-' . bin2hex(random_bytes(4)) . '.txt';
file_put_contents($uploadDir . DIRECTORY_SEPARATOR . $stored, 'geheim');
$pdo->prepare('INSERT INTO ticket_attachments (thread_id, original_name, stored_name, filesize, created_at) VALUES (?,?,?,?,?)')->execute([$cThread, 'ausweis.txt', $stored, 6, $now]);
$pdo->prepare('INSERT INTO customer_notes (user_id, staff_id, note, created_at) VALUES (?,?,?,?)')->execute([$uid, $adminId, 'Ruft oft an', $now]);
$pdo->prepare('INSERT INTO ticket_collaborators (ticket_id, email, name, created_at) VALUES (?,?,?,?)')->execute([$g1['id'], 'erika@example.org', 'Erika', $now]);
$exp = gdpr_customer_export($pdo, ['user_id' => $uid]);
$check($exp !== null && $exp['kunde']['email'] === 'erika@example.org' && $exp['kunde']['telefon'] === '0123' && $exp['kunde']['hat_konto'] === true, 'Export: Stammdaten');
$check(count($exp['tickets']) === 2 && !in_array('G-OTHER', array_column($exp['tickets'], 'nummer'), true), 'Export: nur Tickets dieses Kunden');
$msgs = $exp['tickets'][0]['nachrichten'];
$check(count($msgs) === 2 && $msgs[0]['text'] === 'Meine Adresse ist Musterweg 1' && $msgs[1]['intern'] === true, 'Export: Nachrichten als Klartext, interne gekennzeichnet');
$check($exp['tickets'][0]['anhaenge'][0]['name'] === 'ausweis.txt' && $exp['notizen_zum_kunden'][0]['text'] === 'Ruft oft an', 'Export: Anhangsnamen und Notizen');
$check(json_encode($exp, JSON_UNESCAPED_UNICODE) !== false, 'Export lässt sich als JSON kodieren');
$guest = gdpr_customer_export($pdo, ['email' => 'andere@example.org']);
$check($guest !== null && $guest['kunde']['hat_konto'] === false && count($guest['tickets']) === 1, 'Export für Gast über E-Mail');
$check(gdpr_customer_export($pdo, ['email' => 'niemand@example.org']) === null && gdpr_customer_export($pdo, ['user_id' => 99999]) === null, 'unbekannter Kunde: kein Export');
$res = gdpr_customer_erase($pdo, ['user_id' => $uid]);
$check($res['error'] !== null && (int) $pdo->query("SELECT COUNT(*) FROM users WHERE id = {$uid}")->fetchColumn() === 1, 'offenes Ticket verhindert die Löschung');
$res = gdpr_customer_erase($pdo, ['user_id' => $uid], true);
$check($res['error'] === null && $res['tickets'] === 2 && $res['messages'] === 1 && $res['files'] === 1, 'Löschung erzwungen: ' . json_encode($res));
$check((int) $pdo->query("SELECT COUNT(*) FROM users WHERE id = {$uid}")->fetchColumn() === 0, 'Konto entfernt');
$tk = $pdo->query("SELECT requester_name, requester_email, requester_ip, user_id FROM tickets WHERE id = {$g1['id']}")->fetch(PDO::FETCH_ASSOC);
$check($tk['requester_name'] === 'Gelöscht' && str_ends_with($tk['requester_email'], '@deleted.local') && $tk['user_id'] === null, 'Ticket anonymisiert');
$check($pdo->query("SELECT message FROM ticket_threads WHERE id = {$cThread}")->fetchColumn() === '[Auf Anfrage des Betroffenen gelöscht]', 'Kundennachricht ersetzt');
$check((int) $pdo->query("SELECT COUNT(*) FROM ticket_threads WHERE ticket_id = {$g1['id']} AND is_internal_note = 1")->fetchColumn() === 1, 'Mitarbeiter-Notiz bleibt');
$check((int) $pdo->query('SELECT COUNT(*) FROM ticket_attachments')->fetchColumn() === 0 && !file_exists($uploadDir . DIRECTORY_SEPARATOR . $stored), 'Anhang: Zeile und Datei entfernt');
$check((int) $pdo->query('SELECT COUNT(*) FROM customer_notes')->fetchColumn() === 0 && (int) $pdo->query('SELECT COUNT(*) FROM ticket_collaborators')->fetchColumn() === 0, 'Notizen und Mitleser entfernt');
$check($pdo->query("SELECT requester_email FROM tickets WHERE id = {$other['id']}")->fetchColumn() === 'andere@example.org', 'Daten anderer Kunden bleiben unberührt');
$check(gdpr_customer_erase($pdo, ['email' => 'niemand@example.org'])['error'] !== null, 'unbekannter Kunde: Fehler');
$res = gdpr_customer_erase($pdo, ['email' => 'andere@example.org']);
$check($res['error'] === null && $res['tickets'] === 1, 'Gast löschen (geschlossenes Ticket, ohne Erzwingen)');
@unlink($uploadDir . DIRECTORY_SEPARATOR . $stored);
// ── Zähler „braucht Aufmerksamkeit“ ──────────────────────────────────────────
$pdo->exec('DELETE FROM ticket_threads'); $pdo->exec('DELETE FROM tickets'); $pdo->exec('DELETE FROM ticket_acks');
$mkA = function (string $n, string $status, ?int $assigned, array $extra = []) use ($pdo, $now, $dept, $prio): int {
$pdo->prepare('INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, department_id, priority_id, status, source, assigned_staff_id, is_spam, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)')
->execute([$n, 'S ' . $n, 'K', 'k@example.org', $dept, $prio, $status, $extra['source'] ?? 'web', $assigned, $extra['spam'] ?? 0, $extra['created'] ?? $now, $now]);
return (int) $pdo->lastInsertId();
};
$att = fn() => staff_attention_tickets($pdo, $agent);
$tNew = $mkA('A-NEW', 'open', null);
$tMine = $mkA('A-MINE', 'open', $agentId);
$tOther = $mkA('A-OTHER', 'open', $adminId);
$tAnswered = $mkA('A-ANS', 'answered', $agentId);
$tSpam = $mkA('A-SPAM', 'open', null, ['spam' => 1]);
$tStaff = $mkA('A-STAFF', 'open', null, ['source' => 'staff']);
$r = $att();
$check($r['unassigned'] === [$tNew] && $r['mine'] === [$tMine], 'Zähler: nur offene, nicht zugewiesene und eigene Tickets: ' . json_encode($r));
ticket_ack($pdo, $tNew, $agentId);
$check($att()['unassigned'] === [], 'Zähler: geöffnetes (gesehenes) Ticket verschwindet');
ticket_ack($pdo, $tNew, $agentId); // zweites Mal: kein Fehler, keine Doppelzeile
$check((int) $pdo->query("SELECT COUNT(*) FROM ticket_acks WHERE ticket_id = {$tNew}")->fetchColumn() === 1, 'Zähler: Merker wird nicht doppelt angelegt');
$pdo->exec("UPDATE ticket_acks SET acked_at = '2000-01-01 00:00:00' WHERE ticket_id = {$tNew}");
$check($att()['unassigned'] === [$tNew], 'Zähler: eine ältere Merker-Zeit zählt nicht als gesehen');
ticket_ack($pdo, $tNew, $agentId);
$pdo->prepare("INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES (?,?,?,0,?)")->execute([$tNew, 'customer', 'Neue Frage', date('Y-m-d H:i:s', time() + 60)]);
$check($att()['unassigned'] === [$tNew], 'Zähler: neue Kundennachricht nach dem Öffnen zählt wieder');
$pdo->exec("UPDATE tickets SET status = 'answered' WHERE id = {$tMine}");
$check($att()['mine'] === [], 'Zähler: beantwortetes Ticket verschwindet von selbst');
$pdo->exec("UPDATE tickets SET assigned_staff_id = {$adminId} WHERE id = {$tNew}");
$check($att()['unassigned'] === [], 'Zähler: jemand anderes hat das Ticket übernommen: weg');
$pdo->exec("UPDATE tickets SET assigned_staff_id = NULL, snoozed_until = '" . date('Y-m-d H:i:s', time() + 3600) . "' WHERE id = {$tNew}");
$check($att()['unassigned'] === [], 'Zähler: zurückgestelltes Ticket zählt nicht');
$pdo->exec("UPDATE tickets SET snoozed_until = NULL WHERE id = {$tNew}");
$check($att()['unassigned'] === [$tNew], 'Zähler: nach der Wiedervorlage zählt es wieder');
$check(staff_attention_tickets($pdo, $admin)['mine'] === [$tOther], 'Zähler: jeder Mitarbeiter sieht seinen eigenen Stand');
// ── Skripte ─────────────────────────────────────────────────────────────────
$node = trim((string) @shell_exec(PHP_OS_FAMILY === 'Windows' ? 'where node 2>NUL' : 'command -v node 2>/dev/null'));
if ($node !== '') {
foreach (['staff-shortcuts.js', 'ticket-lightbox.js'] as $js) {
$out = [];
exec('node --check ' . escapeshellarg(dirname(__DIR__) . '/assets/js/' . $js) . ' 2>&1', $out, $code);
$check($code === 0, $js . ' hat gültige Syntax: ' . implode(' ', $out));
}
}
$check(str_contains((string) file_get_contents(dirname(__DIR__) . '/staff/_footer.php'), 'staff-shortcuts.js'), 'Tastenkürzel sind im Fuß der Mitarbeiterseiten eingebunden');
foreach (['send_scheduled_replies', 'send_report_schedules'] as $job) {
$check(isset(pseudo_cron_job_definitions()[$job]), 'Hintergrundjob ' . $job . ' ist im eingebauten Cron eingetragen');
}
if ($failures) {
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
exit(1);
}
echo "Ticket features checks passed ({$total} Prüfungen).\n";