errorCode() === $code, "Fehlercode {$code}, erhalten {$e->errorCode()}"); } }; $pdo = get_db(); $now = db_now(); $dept1 = (int) $pdo->query('SELECT id FROM departments ORDER BY id LIMIT 1')->fetchColumn(); $prio = (int) $pdo->query('SELECT id FROM priorities ORDER BY sort_order LIMIT 1')->fetchColumn(); $pdo->prepare("INSERT INTO staff (name, email, password_hash, role, department_id, is_active, created_at) VALUES ('Agent','agent@example.test','x','agent',?,1,?)")->execute([$dept1, $now]); $agentId = (int) $pdo->lastInsertId(); // ── Abteilungen ───────────────────────────────────────────────────────────── $newDept = department_save($pdo, 0, ['name' => 'Vertrieb', 'email' => '', 'is_default' => false]); $check($newDept > 0, 'Abteilung wird angelegt'); $check((int) $pdo->query("SELECT is_active FROM departments WHERE id = {$newDept}")->fetchColumn() === 1, 'neue Abteilung ist aktiv'); $throwsAm(fn() => department_save($pdo, 0, ['name' => '']), 'NAME_REQUIRED'); $throwsAm(fn() => department_save($pdo, $newDept, ['name' => 'Vertrieb', 'is_default' => true, 'is_active' => false]), 'DEFAULT_INACTIVE'); $throwsAm(fn() => department_save($pdo, $newDept, ['name' => 'Vertrieb', 'manager_id' => 99999]), 'INVALID_MANAGER'); $throwsAm(fn() => department_save($pdo, $newDept, ['name' => 'Vertrieb', 'sla_plan_id' => 99999]), 'INVALID_SLA_PLAN'); department_save($pdo, $newDept, ['name' => 'Vertrieb Nord', 'is_active' => false]); department_save($pdo, $newDept, ['name' => 'Vertrieb Nord']); $check((int) $pdo->query("SELECT is_active FROM departments WHERE id = {$newDept}")->fetchColumn() === 0, 'fehlendes is_active lässt den Abteilungsstatus unverändert'); $pdo->prepare('INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, department_id, priority_id) VALUES (?,?,?,?,?,?)') ->execute(['AM-1', 'Test', 'X', 'x@example.test', $newDept, $prio]); $throwsAm(fn() => department_delete($pdo, $newDept), 'DEPARTMENT_IN_USE'); // ── Hilfethemen ───────────────────────────────────────────────────────────── $tParent = help_topic_save($pdo, 0, ['name' => 'A', 'department_id' => $dept1, 'priority_id' => $prio]); $tChild = help_topic_save($pdo, 0, ['name' => 'B', 'department_id' => $dept1, 'priority_id' => $prio, 'parent_id' => $tParent]); $throwsAm(fn() => help_topic_save($pdo, $tParent, ['name' => 'A', 'department_id' => $dept1, 'priority_id' => $prio, 'parent_id' => $tChild]), 'INVALID_PARENT'); $throwsAm(fn() => help_topic_save($pdo, $tParent, ['name' => 'A', 'department_id' => $dept1, 'priority_id' => $prio, 'parent_id' => $tParent]), 'INVALID_PARENT'); $throwsAm(fn() => help_topic_save($pdo, 0, ['name' => 'C', 'department_id' => 99999, 'priority_id' => $prio]), 'INVALID_DEPARTMENT'); help_topic_delete($pdo, $tParent); $check((int) $pdo->query("SELECT COALESCE(parent_id, 0) FROM help_topics WHERE id = {$tChild}")->fetchColumn() === 0, 'Unterthema rückt beim Löschen des Elternthemas nach oben'); // ── Teams ─────────────────────────────────────────────────────────────────── $teamA = team_save($pdo, 0, ['name' => 'Alpha', 'members' => [$agentId, $agentId, 0]]); $check((int) $pdo->query("SELECT COUNT(*) FROM team_members WHERE team_id = {$teamA}")->fetchColumn() === 1, 'Teammitglieder werden ohne Duplikate gespeichert'); $throwsAm(fn() => team_save($pdo, 0, ['name' => 'alpha']), 'NAME_TAKEN'); $throwsAm(fn() => team_save($pdo, 0, ['name' => 'Beta', 'lead_id' => 99999]), 'INVALID_LEAD'); team_save($pdo, $teamA, ['name' => 'Alpha', 'is_active' => false, 'no_alerts' => true]); team_save($pdo, $teamA, ['name' => 'Alpha 2']); $row = $pdo->query("SELECT is_active, no_alerts FROM teams WHERE id = {$teamA}")->fetch(); $check((int) $row['is_active'] === 0 && (int) $row['no_alerts'] === 1, 'fehlende Team-Schalter bleiben unverändert'); // ── SLA-Pläne ─────────────────────────────────────────────────────────────── $throwsAm(fn() => sla_plan_save($pdo, 0, ['name' => 'Ungültig', 'response_hours' => 10, 'resolution_hours' => 5]), 'INVALID_HOURS'); $planDept = sla_plan_save($pdo, 0, ['name' => 'Abteilungsplan', 'response_hours' => 2, 'resolution_hours' => 8]); $planTopic = sla_plan_save($pdo, 0, ['name' => 'Themenplan', 'response_hours' => 1, 'resolution_hours' => 3]); $throwsAm(fn() => sla_plan_save($pdo, 0, ['name' => 'abteilungsplan', 'response_hours' => 1, 'resolution_hours' => 2]), 'NAME_TAKEN'); $pdo->prepare('UPDATE departments SET sla_plan_id = ? WHERE id = ?')->execute([$planDept, $dept1]); $topicSla = help_topic_save($pdo, 0, ['name' => 'Mit Plan', 'department_id' => $dept1, 'priority_id' => $prio, 'sla_plan_id' => $planTopic]); $prioRow = $pdo->query("SELECT * FROM priorities WHERE id = {$prio}")->fetch(); $from = '2026-01-05 08:00:00'; $none = calc_sla_dates($prioRow, $from); $viaDept = calc_sla_dates($prioRow, $from, $dept1, null); $viaTopic = calc_sla_dates($prioRow, $from, $dept1, $topicSla); $check(strtotime($viaDept['resolution_due_at']) - strtotime($from) === 8 * 3600, 'Abteilungs-SLA-Plan ersetzt die Lösungszeit der Priorität'); $check(strtotime($viaTopic['resolution_due_at']) - strtotime($from) === 3 * 3600 && strtotime($viaTopic['first_response_due_at']) - strtotime($from) === 3600, 'Themen-SLA-Plan hat Vorrang vor dem der Abteilung'); $check($none['resolution_due_at'] !== $viaDept['resolution_due_at'], 'ohne Plan gelten die Zeiten der Priorität'); $pdo->prepare('UPDATE sla_plans SET is_active = 0 WHERE id = ?')->execute([$planTopic]); $viaInactive = calc_sla_dates($prioRow, $from, $dept1, $topicSla); $check(strtotime($viaInactive['resolution_due_at']) - strtotime($from) === 8 * 3600, 'deaktivierter Themenplan fällt auf den Abteilungsplan zurück'); sla_plan_delete($pdo, $planDept); $check(calc_sla_dates($prioRow, $from, $dept1, null)['resolution_due_at'] === $none['resolution_due_at'], 'nach dem Löschen gelten wieder die Zeiten der Priorität'); // ── Überwachung des Mail-Abrufs ───────────────────────────────────────────── $pdo->prepare("INSERT INTO imap_accounts (label, host, port, encryption, username, password_enc, folder, after_fetch, archive_folder, spam_folder, is_active, created_at) VALUES ('Test','imap.example.test',993,'ssl','u','x','INBOX','seen','Processed','Junk',1,?)")->execute([$now]); $accId = (int) $pdo->lastInsertId(); $acc = fn() => $pdo->query("SELECT * FROM imap_accounts WHERE id = {$accId}")->fetch(); $hasCode = fn(string $code) => in_array($code, array_column(system_health($pdo), 'code'), true); health_record_mail_result($pdo, $acc(), 'Verbindung abgelehnt'); $check($hasCode('mailbox_flaky') && !$hasCode('mailbox_down'), 'ein einzelner Fehlversuch ist nur ein Hinweis'); health_record_mail_result($pdo, $acc(), 'Verbindung abgelehnt'); health_record_mail_result($pdo, $acc(), 'Verbindung abgelehnt'); $check($hasCode('mailbox_down') && !empty($acc()['alert_sent_at']), 'nach drei Fehlversuchen: Störung gemeldet und Alarm vermerkt'); $alertAt = $acc()['alert_sent_at']; health_record_mail_result($pdo, $acc(), 'Verbindung abgelehnt'); $check($acc()['alert_sent_at'] === $alertAt, 'der Alarm wird nicht bei jedem Fehlversuch wiederholt'); health_record_mail_result($pdo, $acc(), null); $check(!$hasCode('mailbox_down') && !$hasCode('mailbox_flaky') && (int) $acc()['fail_count'] === 0 && empty($acc()['alert_sent_at']), 'nach Erfolg ist die Störung behoben'); set_setting('cron_fetch_emails_last_run', date('Y-m-d H:i:s', time() - 3600)); $check($hasCode('fetch_stale'), 'veralteter Abruf-Job wird gemeldet'); set_setting('cron_fetch_emails_last_run', db_now()); $check(!$hasCode('fetch_stale'), 'frischer Abruf-Job ist unauffällig'); // ── Bericht-Baukasten ─────────────────────────────────────────────────────── $cfg = report_clean_config($pdo, ['dimension' => "x'; DROP TABLE tickets;--", 'metrics' => ['total', 'bogus', 'x) FROM tickets--'], 'filters' => ['departmentId' => '1', 'evil_column' => '1', 'status' => 'open'], 'sort' => 'evil', 'from' => 'kein-datum', 'to' => '2026-02-01']); $check($cfg['dimension'] === 'department', 'unbekannte Gruppierung fällt auf den Standard zurück'); $check($cfg['metrics'] === ['total'], 'unbekannte Kennzahlen werden verworfen'); $check(array_keys($cfg['filters']) === ['departmentId', 'status'], 'unbekannte Filter werden verworfen'); $check($cfg['sort'] === 'label', 'unbekannte Sortierung fällt auf "Gruppe" zurück'); $check(preg_match('/^\d{4}-\d{2}-\d{2}$/', $cfg['from']) === 1, 'ungültiges Datum wird durch einen Standard ersetzt'); $swapped = report_clean_config($pdo, ['from' => '2026-03-10', 'to' => '2026-03-01']); $check($swapped['from'] === '2026-03-01' && $swapped['to'] === '2026-03-10', 'vertauschter Zeitraum wird korrigiert'); $pdo->prepare('INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, department_id, priority_id, status, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)') ->execute(['RB-1', 'Bericht', 'X', 'x@example.test', $dept1, $prio, 'resolved', '2026-03-05 10:00:00', '2026-03-05 10:00:00']); $run = report_run($pdo, ['dimension' => 'status', 'metrics' => ['total', 'resolved'], 'from' => '2026-03-01', 'to' => '2026-03-31'], null); $check(count($run['rows']) === 1 && $run['rows'][0]['label'] === 'resolved' && $run['rows'][0]['total'] === 1 && $run['totals']['resolved'] === 1, 'Bericht zählt Tickets im Zeitraum'); $none = report_run($pdo, ['dimension' => 'status', 'from' => '2025-01-01', 'to' => '2025-01-31'], null); $check($none['rows'] === [], 'ohne Tickets im Zeitraum bleibt der Bericht leer'); $scoped = report_run($pdo, ['dimension' => 'status', 'from' => '2026-03-01', 'to' => '2026-03-31'], [999999]); $check($scoped['rows'] === [], 'der Abteilungsbereich des Mitarbeiters schränkt den Bericht ein'); $monthRun = report_run($pdo, ['dimension' => 'month', 'metrics' => ['total'], 'from' => '2026-03-01', 'to' => '2026-03-31'], null); $check(($monthRun['rows'][0]['label'] ?? '') === '2026-03', 'Gruppierung nach Monat liefert "JJJJ-MM"'); $pdo->prepare('INSERT INTO departments (name, email, created_at) VALUES (?,?,?)')->execute(['=HYPERLINK("x")', '', $now]); $csvDept = (int) $pdo->lastInsertId(); $pdo->prepare('INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, department_id, priority_id, status, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)') ->execute(['RB-2', 'Bericht', 'X', 'x@example.test', $csvDept, $prio, 'open', '2026-03-06 10:00:00', '2026-03-06 10:00:00']); $csv = report_to_csv(report_run($pdo, ['dimension' => 'department', 'metrics' => ['total'], 'from' => '2026-03-01', 'to' => '2026-03-31'], null)); $check(str_contains($csv, "\"'=HYPERLINK") || str_contains($csv, "'=HYPERLINK"), 'CSV schützt vor Formel-Injektion in Gruppennamen'); $check(str_starts_with($csv, "\xEF\xBB\xBF"), 'CSV beginnt mit UTF-8-BOM für Excel'); // ── Datenbank-Update (Erkennung ausstehender Migrationen) ──────────────────── require_once dirname(__DIR__) . '/database/migrate.php'; $check(count(migrations_known()) >= 30, 'bekannte Migrationen werden erkannt'); $check(migrations_pending($pdo) === [], 'frische Datenbank hat keine ausstehenden Migrationen'); $pdo->exec("DELETE FROM schema_migrations WHERE migration = 'php_0030_saved_reports'"); $check(migrations_pending($pdo) === ['php_0030_saved_reports'], 'fehlende Migration wird als ausstehend gemeldet'); $pdo->exec("INSERT INTO schema_migrations (migration) VALUES ('php_0030_saved_reports')"); $check(migrations_pending($pdo) === [], 'nach dem Einspielen ist nichts mehr ausstehend'); if ($failures) { fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n"); exit(1); } echo "Admin shared logic checks passed.\n";