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