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, '

Hallo

', date('Y-m-d H:i:s', time() - 60)) !== null, 'Zeitpunkt in der Vergangenheit wird abgelehnt'); $check(scheduled_reply_create($pdo, $t, $agentId, '

Hallo

', 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, '

', $future) !== null, 'leere Nachricht wird abgelehnt'); $check(scheduled_reply_create($pdo, $t, $agentId, '

x

', $future, ['set_status' => 'closed']) !== null, 'Schließen ohne Abschlussgrund wird abgelehnt'); $check(scheduled_reply_create($pdo, $t, $agentId, '

Später

', $future, ['set_status' => 'pending']) === null, 'Antwort einplanen'); $check(scheduled_reply_create($pdo, $t, $agentId, '

Heute noch

', 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'] === '

Später

' ? $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, '

Weg

', $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, '

Inaktiv

', $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 ', 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 <b>') && !str_contains($rendered[1], 'Wochenbericht ') && str_contains($rendered[0], 'DeskHub-Bericht'), 'Bericht wird gerendert, Name ist maskiert'); $check(str_contains($rendered[1], ' 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, '

Meine Adresse ist Musterweg 1

', $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";