Upload via GUI (396 Dateien)

This commit is contained in:
2026-11-04 07:55:43 +00:00
parent 58a8fab9f5
commit d1aae0f2ce
8 changed files with 931 additions and 3 deletions
+58 -1
View File
@@ -35,7 +35,8 @@ $state = &plugin_state();
$state['booted'] = false;
plugins_boot();
$check(isset(plugin_state()['loaded']['tagesbericht']), 'Plugin wird geladen');
$check(count(plugin_menu_items(['id' => 1, 'role' => 'admin'])) === 1, 'Menüpunkt für die Einstellungen ist registriert');
$check(count(plugin_menu_items(['id' => 1, 'role' => 'admin'])) === 2, 'Admin sieht die Einstellungen und „Mein Tagesbericht“');
$check(count(plugin_menu_items(['id' => 2, 'role' => 'agent', 'permissions' => []])) === 1, 'normaler Mitarbeiter sieht nur „Mein Tagesbericht“');
// ── Daten: zwei Abteilungen, ein Agent sieht nur seine ────────────────────────
$now = '2026-10-15 10:00:00';
@@ -87,6 +88,62 @@ $marker = plugin_setting('tagesbericht', 'last_sent');
plugin_set_setting('tagesbericht', 'last_sent', '2026-10-12');
$check(tagesbericht_tick($pdo, strtotime('2026-10-12 15:00:00')) === 0, 'am selben Tag nicht noch einmal');
// ── Weniger Mails: Rhythmus, Handlungsbedarf, persönliche Wahl ────────────────
$cfg0 = tagesbericht_config();
$check($cfg0['rhythm'] === 'workdays' && $cfg0['only_action'] === true, 'Standard: Werktage und nur bei Handlungsbedarf');
plugin_set_setting('tagesbericht', 'workdays', '0');
$check(tagesbericht_config()['rhythm'] === 'daily', 'ältere Einstellung „nicht nur Werktage“ wird zu „täglich“');
plugin_set_setting('tagesbericht', 'workdays', '1');
plugin_set_setting('tagesbericht', 'rhythm', 'weekly');
plugin_set_setting('tagesbericht', 'weekday', '3'); // Mittwoch
plugin_set_setting('tagesbericht', 'last_sent', '');
tagesbericht_tick($pdo, strtotime('2026-10-12 09:30:00')); // Montag
$check(plugin_setting('tagesbericht', 'last_sent', '') === '', 'wöchentlich (Mittwoch): am Montag wird nicht gesendet');
tagesbericht_tick($pdo, strtotime('2026-10-14 09:30:00')); // Mittwoch
$check(plugin_setting('tagesbericht', 'last_sent', '') === '2026-10-14', 'wöchentlich (Mittwoch): am Mittwoch wird gesendet');
plugin_set_setting('tagesbericht', 'rhythm', 'bloedsinn');
$check(tagesbericht_config()['rhythm'] === 'workdays', 'ungültiger Rhythmus fällt auf Werktage zurück');
plugin_set_setting('tagesbericht', 'rhythm', 'workdays');
$cfgW = tagesbericht_config();
$quiet = ['open' => 4, 'unassigned' => 0, 'overdue' => 0, 'created' => 1, 'resolved' => 0, 'mine' => 2];
$busy = ['open' => 4, 'unassigned' => 1, 'overdue' => 0, 'created' => 0, 'resolved' => 0, 'mine' => 0];
$late = ['open' => 4, 'unassigned' => 0, 'overdue' => 2, 'created' => 0, 'resolved' => 0, 'mine' => 0];
$none = ['open' => 0, 'unassigned' => 0, 'overdue' => 0, 'created' => 0, 'resolved' => 0, 'mine' => 0];
$check(!tagesbericht_should_send($cfgW, 'default', $quiet), 'nichts Dringendes: kein Bericht („nur bei Handlungsbedarf“)');
$check(tagesbericht_should_send($cfgW, 'default', $busy) && tagesbericht_should_send($cfgW, 'default', $late), 'unzugewiesene oder überfällige Tickets: Bericht');
$check(!tagesbericht_should_send($cfgW, 'default', $none), 'gar nichts da: kein Bericht');
$check(!tagesbericht_should_send($cfgW, 'urgent', $busy) && tagesbericht_should_send($cfgW, 'urgent', $late), 'persönlich „nur Überfälliges“: nur bei überfälligen Tickets');
$cfgAll = ['only_action' => false] + $cfgW;
$check(tagesbericht_should_send($cfgAll, 'default', $quiet), 'ohne „nur bei Handlungsbedarf“ kommt der Bericht auch bei normalen offenen Tickets');
$check(tagesbericht_should_send($cfgW, 'default', $quiet, true), 'Testbericht ignoriert „nur bei Handlungsbedarf“');
$check(!tagesbericht_should_send($cfgW, 'default', $none, true), 'Testbericht ohne jeden Inhalt wird auch nicht gesendet');
$monday = strtotime('2026-10-12 08:00:00'); $tuesday = strtotime('2026-10-13 08:00:00');
$check(!tagesbericht_pref_allows_today('off', $cfgW, $monday), 'persönlich „gar nicht“: nie');
$check(tagesbericht_pref_allows_today('weekly', $cfgW, $monday) && !tagesbericht_pref_allows_today('weekly', $cfgW, $tuesday), 'persönlich „wöchentlich“: nur montags');
$check(tagesbericht_pref_allows_today('default', $cfgW, $tuesday), 'Standard: laut Einstellung des Administrators');
$check(tagesbericht_pref($eva) === 'default', 'ohne Wahl gilt der Standard');
$check(tagesbericht_set_pref($eva, 'off') && tagesbericht_pref($eva) === 'off', 'persönliche Wahl speichern');
$check(!tagesbericht_set_pref($eva, 'unfug') && tagesbericht_pref($eva) === 'off', 'ungültige Wahl wird abgewiesen');
$check(tagesbericht_set_pref($eva, 'default') && tagesbericht_pref($eva) === 'default', 'zurück auf Standard');
$check(str_contains(tagesbericht_html($evaRow, $de), 'page=persoenlich'), 'Die Mail verweist auf die persönliche Einstellung');
// ── Seiten werden ohne Fehler angezeigt ───────────────────────────────────────
$_SERVER['REQUEST_METHOD'] = 'GET';
$render = function (string $file, array $staffRow) use ($pdo): string {
$staff = $staffRow;
ob_start();
try { include $file; } finally { $out = (string) ob_get_clean(); }
return $out;
};
$pageDir = $pluginDir . '/tagesbericht/pages/';
$out = $render($pageDir . 'persoenlich.php', $evaRow);
$check(str_contains($out, 'Mein Tagesbericht') && str_contains($out, 'name="pref"') && substr_count($out, 'type="radio"') === 4, 'Seite „Mein Tagesbericht“ zeigt die vier Auswahlmöglichkeiten');
$check(str_contains($out, 'checked') && str_contains($out, 'Wie eingestellt'), 'die aktuelle Wahl ist vorgewählt');
$out = $render($pageDir . 'einstellungen.php', $admin);
$check(str_contains($out, 'name="rhythm"') && str_contains($out, 'name="only_action"') && str_contains($out, 'Mein Tagesbericht'), 'Einstellungsseite zeigt Rhythmus, Handlungsbedarf und den Hinweis auf die persönliche Wahl');
foreach (scandir($pluginDir) ?: [] as $entry) {
if ($entry === '.' || $entry === '..') continue;
is_dir($pluginDir . '/' . $entry) ? plugin_rmdir($pluginDir . '/' . $entry) : @unlink($pluginDir . '/' . $entry);
@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
/**
* Prüft die englische Fassung der Plugin-Webseite (plugin-webseite/inc/i18n.php, lang/en.json): Texte, Attribute, Muster mit Zahlen und
* wechselnden Teilen, Auslassen von <code>, Sprachwahl über Cookie und Adresse, und dass die Verwaltung vollständig übersetzt ist.
*/
putenv('PLUGIN_SITE_CONFIG=' . sys_get_temp_dir() . '/gibtesnicht.php');
putenv('PLUGIN_SITE_STORAGE=' . sys_get_temp_dir() . '/deskhub-site-i18n-' . bin2hex(random_bytes(3)));
require_once __DIR__ . '/fixtures/paths.php';
$siteDir = website_dir_or_skip('plugin_website_i18n');
require_once $siteDir . '/inc/i18n.php';
$failures = [];
$check = function (bool $ok, string $what) use (&$failures): void {
if (!$ok) $failures[] = $what;
};
$tr = fn(string $html) => site_translate_html($html, 'en');
// Einfache Texte, Leerraum bleibt erhalten
$check($tr("<h1>\n Pakete\n</h1>") === "<h1>\n Packages\n</h1>", 'Text wird übersetzt, Leerraum bleibt');
$check($tr('<p>Übersicht &amp; mehr</p>') === '<p>Übersicht &amp; mehr</p>', 'Unbekannter Text bleibt deutsch');
$check($tr('<button>Veröffentlichen</button>') === '<button>Publish</button>', 'Knopf wird übersetzt');
// Attribute
$check($tr('<input placeholder="Bild entfernen" title="Entfernen" value="Entfernen">') === '<input placeholder="Remove image" title="Remove" value="Entfernen">', 'placeholder und title werden übersetzt, value nicht');
$check($tr('<form data-confirm="Version wirklich entfernen?"></form>') === '<form data-confirm="Really remove this version?"></form>', 'data-confirm wird übersetzt');
// Muster
$check($tr('<span>2 veröffentlicht</span>') === '<span>2 published</span>', 'Muster mit Zahl');
$check($tr('<span>0 Downloads</span>') === '<span>0 downloads</span>', 'Muster {#} Downloads');
$check($tr('<span>aktuell 1.3.0</span>') === '<span>current 1.3.0</span>', 'Muster mit wechselndem Teil');
$check($tr('<title>Übersicht · Verwaltung · DeskHub Plugins und Themen</title>') === '<title>Übersicht · Verwaltung · DeskHub Plugins und Themen</title>', 'Titel mit unbekanntem Namen bleibt unverändert');
$check($tr('<title>Pakete · Verwaltung · DeskHub Plugins und Themes</title>') === '<title>Packages · Administration · DeskHub plugins and themes</title>', 'Seitentitel: gefundener Teil wird mitübersetzt');
$check($tr('<div class="alert okbox">Veröffentlicht: authentik 1.2.5 (signiert).</div>') === '<div class="alert okbox">Published: authentik 1.2.5 (signed).</div>', 'Meldung mit zwei wechselnden Teilen in der Reihenfolge {1} {2}');
$check($tr('<div>Version 1.0.0 existiert bereits. Bitte eine neue Versionsnummer verwenden, veröffentlichte Versionen bleiben unverändert.</div>') === '<div>Version 1.0.0 already exists. Please use a new version number, published versions stay unchanged.</div>', 'Fehlermeldung mit Versionsnummer');
$check($tr('<div>Das Paket ist leer oder größer als 10 MB.</div>') === '<div>The package is empty or larger than 10 MB.</div>', 'Muster mit Zahl in der Mitte');
// Code, Skripte und Vorformatiertes bleiben unberührt; Text daneben wird übersetzt
$check($tr('<p>ZIP-Datei mit <code>plugin.json</code> bzw. Theme-CSS</p>') === '<p>ZIP file with <code>plugin.json</code> or theme CSS</p>', 'Teile neben <code> werden übersetzt, <code> bleibt');
$check($tr('<code>Pakete</code><pre>Pakete</pre><script>var a = "Pakete";</script>') === '<code>Pakete</code><pre>Pakete</pre><script>var a = "Pakete";</script>', '<code>, <pre> und <script> bleiben unverändert');
// Sicherheit: Übersetzung wird maskiert, Eingaben bleiben, wie sie sind
$check(str_contains($tr('<p>Name</p>'), 'Name'), 'Name bleibt Name');
$check($tr('<p><b>Pakete</b> &lt;script&gt;</p>') === '<p><b>Packages</b> &lt;script&gt;</p>', 'Maskierte Zeichen im Text bleiben maskiert');
// Deutsch: unverändert
$check(site_translate_html('<h1>Pakete</h1>', 'de') === '<h1>Pakete</h1>', 'Deutsch bleibt unverändert');
$check(site_translate_html('<h1>Pakete</h1>', 'xx') === '<h1>Pakete</h1>', 'Unbekannte Sprache bleibt unverändert');
$check(site_translate_html('<h1>Pakete</h1>', '../../etc') === '<h1>Pakete</h1>', 'Sprachcode wird nicht als Pfad benutzt');
// Sprachwahl
unset($_GET['lang'], $_COOKIE['site_lang']);
$check(site_lang() === 'de', 'Standard ist Deutsch');
$sw = site_lang_switcher();
$check(str_contains($sw, 'lang=en') && str_contains($sw, '>English<') && str_contains($sw, 'aria-current'), 'Umschalter bietet die andere Sprache an');
// Vollständigkeit: alle Texte der Verwaltung, die beim Durchklicken der deutschen Seiten gefunden wurden
$known = ['Übersicht', 'Pakete', 'Apps', 'Push-Dienst', 'Einstellungen', 'Anmelden', 'Abmelden', 'Benutzername', 'Passwort', 'Schnellzugriff', 'Häufige Aufgaben',
'Plugin oder Theme hochladen', 'App-Version veröffentlichen', 'DeskHub-Einstellungen', 'Neues Paket veröffentlichen', 'Art', 'ZIP-Paket', 'Kennung (Slug)', 'Name', 'Version', 'Autor', 'Beschreibung',
'Veröffentlichen', 'Als Empfehlung markieren', 'Empfehlung entfernen', 'kein Bild', 'Bild ersetzen', 'Bild hinzufügen', 'Bild entfernen', 'signiert', 'nicht signiert', 'Entfernen',
'Neue App-Version hochladen', 'Plattform', 'Versionsnummer', 'Installationsdatei', 'App-Version hochladen', 'noch nichts veröffentlicht', 'Noch keine Version veröffentlicht.', 'Datum', 'Größe',
'Angemeldete Installationen', 'Noch keine DeskHub-Installation angemeldet.', 'Zuletzt genutzt', 'Gesendet', 'gesperrt', 'aktiv', 'Sperren', 'Entsperren', 'Löschen',
'Konto', 'Signierschlüssel', 'Systemstatus', 'Zugangsschlüssel für Katalog und Downloads', 'Push-Geräte', 'Benutzername oder Passwort ist falsch.', 'Version entfernt.', 'App-Version entfernt.',
'Vorschaubild geändert. Es bleibt bei weiteren Versionen erhalten.', 'Vorschaubild entfernt.', 'Windows – Setup (EXE)', 'WordPress – Plugin (ZIP)', 'Chrome – Erweiterung (ZIP)', 'Minecraft – Plugin (JAR)'];
$dict = site_i18n_dictionary('en');
$missing = array_values(array_filter($known, fn($k) => !isset($dict['exact'][site_i18n_norm($k)])));
$check($missing === [], 'alle bekannten Texte der Verwaltung sind übersetzt, es fehlen: ' . implode(' | ', $missing));
// Jede Übersetzung ist gefüllt, jedes Muster hat gleich viele Platzhalter wie sein Schlüssel
$bad = [];
foreach ((array) json_decode((string) file_get_contents($siteDir . '/lang/en.json'), true) as $de => $en) {
if (!is_string($en) || trim($en) === '') $bad[] = $de;
$holes = substr_count($de, '{}') + substr_count($de, '{#}');
$used = preg_match_all('/\{\d+\}|\{#?\}/', (string) $en);
if ($holes !== $used) $bad[] = 'Platzhalter: ' . $de;
}
$check($bad === [], 'Wörterbuch: leere Übersetzungen oder ungleiche Platzhalter: ' . implode(' | ', array_slice($bad, 0, 5)));
if ($failures) {
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
exit(1);
}
echo "Plugin website i18n checks passed.\n";
@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
/**
* Dateianhänge über die öffentliche Ticket-Schnittstelle (includes/integrations.php, zum Beispiel für das WordPress-Plugin): Grenzen werden im
* Formular-Angebot genannt, Anhänge werden vor dem Anlegen geprüft (Anzahl, Größe, Endung, Inhalt, Base64) und danach an die erste
* Nachricht des Tickets gehängt; ein eigenes Formular kann Anhänge abschalten.
*/
$uploads = sys_get_temp_dir() . '/deskhub-pubatt-' . bin2hex(random_bytes(4));
mkdir($uploads, 0755, true);
putenv('DESKHUB_TESTING=1');
putenv('DESKHUB_SQLITE_PATH=:memory:');
putenv('DESKHUB_CONFIG_JSON=' . json_encode(['uploads' => ['path' => $uploads, 'max_size_bytes' => 4 * 1048576, 'allowed_extensions' => ['png', 'pdf', 'txt']]]));
require_once dirname(__DIR__) . '/includes/bootstrap.php';
require_once dirname(__DIR__) . '/includes/integrations.php';
$failures = [];
$check = function (bool $ok, string $what) use (&$failures): void {
if (!$ok) $failures[] = $what;
};
$pdo = get_db();
set_setting('upload_allowed_extensions', 'png, pdf, txt');
set_setting('upload_max_size_mb', '4');
// Grenzen im Formular-Angebot
$def = public_form_definition($pdo, null, (int) get_setting('default_department_id', '1'));
$payload = public_form_payload($def);
$check(!empty($payload['attachments']['enabled']) && $payload['attachments']['maxFiles'] === 5 && $payload['attachments']['maxFileBytes'] === 4 * 1048576 && $payload['attachments']['allowedExtensions'] === ['png', 'pdf', 'txt'], 'Formular nennt die Grenzen: ' . json_encode($payload['attachments']));
$base = ['name' => 'Anna Muster', 'email' => 'anna@example.de', 'subject' => 'Mit Anhang', 'message' => 'Siehe Datei.'];
$b64 = fn(string $s) => base64_encode($s);
$count = fn() => (int) $pdo->query('SELECT COUNT(*) FROM tickets')->fetchColumn();
$fails = function (array $in) use ($pdo, $count): ?string {
$before = $count();
try {
public_ticket_create($pdo, $in, '203.0.113.5');
} catch (AdminModelException $e) {
return $e->getMessage() . ' | ' . ($count() === $before ? 'kein Ticket' : 'TICKET ANGELEGT');
}
return null;
};
// Erfolg: zwei Dateien
$r = public_ticket_create($pdo, $base + ['attachments' => [['name' => 'Bericht.pdf', 'data' => $b64("%PDF-1.4\n" . str_repeat('x', 500))], ['name' => 'notiz.txt', 'data' => $b64('Hallo')]]], '203.0.113.5');
$number = (string) ($r['ticketNumber'] ?? $r['number'] ?? '');
$tid = (int) $pdo->query('SELECT id FROM tickets WHERE ticket_number = ' . $pdo->quote($number))->fetchColumn();
$att = $pdo->query('SELECT a.* FROM ticket_attachments a JOIN ticket_threads t ON t.id = a.thread_id WHERE t.ticket_id = ' . $tid . ' ORDER BY a.id')->fetchAll(PDO::FETCH_ASSOC);
$check(count($att) === 2 && $att[0]['original_name'] === 'Bericht.pdf' && $att[1]['original_name'] === 'notiz.txt', 'Anhänge hängen an der ersten Nachricht: ' . json_encode(array_column($att, 'original_name')) . ' ' . json_encode($r));
$check($att && file_get_contents($uploads . '/' . $att[1]['stored_name']) === 'Hallo' && (int) $att[1]['filesize'] === 5, 'Inhalt wurde gespeichert');
// Ohne Anhänge wie bisher
$r2 = public_ticket_create($pdo, $base, '203.0.113.5');
$check(!empty($r2['ticketNumber'] ?? $r2['number'] ?? ''), 'ohne Anhänge funktioniert alles wie bisher');
// Fehlerfälle: nichts wird angelegt
$e = $fails($base + ['attachments' => [['name' => 'x.exe', 'data' => $b64('MZ')]]]);
$check($e !== null && str_ends_with($e, 'kein Ticket'), 'gesperrte Endung (.exe) lehnt ab, ohne Ticket: ' . $e);
$e = $fails($base + ['attachments' => [['name' => 'a.docx', 'data' => $b64('x')]]]);
$check($e !== null && str_ends_with($e, 'kein Ticket'), 'nicht freigegebene Endung (.docx) lehnt ab: ' . $e);
$e = $fails($base + ['attachments' => [['name' => 'a.php', 'data' => $b64('<?php')]]]);
$check($e !== null && str_ends_with($e, 'kein Ticket'), '.php lehnt ab');
$e = $fails($base + ['attachments' => array_fill(0, 6, ['name' => 'a.txt', 'data' => $b64('x')])]);
$check($e !== null && str_contains($e, 'höchstens 5') && str_ends_with($e, 'kein Ticket'), 'mehr als 5 Dateien lehnt ab: ' . $e);
$e = $fails($base + ['attachments' => [['name' => 'gross.pdf', 'data' => $b64(str_repeat('x', 4 * 1048576 + 1))]]]);
$check($e !== null && str_contains($e, 'zu groß') && str_ends_with($e, 'kein Ticket'), 'zu große Datei lehnt ab: ' . $e);
$e = $fails($base + ['attachments' => [['name' => 'a.txt', 'data' => $b64(str_repeat('x', 3 * 1048576))], ['name' => 'b.txt', 'data' => $b64(str_repeat('x', 3 * 1048576))], ['name' => 'c.txt', 'data' => $b64(str_repeat('x', 3 * 1048576))], ['name' => 'd.txt', 'data' => $b64(str_repeat('x', 2 * 1048576))]]]);
$check($e !== null && str_contains($e, 'zusammen') && str_ends_with($e, 'kein Ticket'), 'zusammen zu groß (über 10 MB) lehnt ab: ' . $e);
$e = $fails($base + ['attachments' => [['name' => 'a.txt', 'data' => '!!!kein-base64!!!']]]);
$check($e !== null && str_contains($e, 'Base64') && str_ends_with($e, 'kein Ticket'), 'ungültiges Base64 lehnt ab: ' . $e);
$e = $fails($base + ['attachments' => [['data' => $b64('x')]]]);
$check($e !== null && str_ends_with($e, 'kein Ticket'), 'Anhang ohne Namen lehnt ab');
$e = $fails($base + ['attachments' => 'text']);
$check($e !== null && str_contains($e, 'Format'), 'falsches Format lehnt ab: ' . $e);
$e = $fails($base + ['attachments' => [['name' => '../../evil.txt', 'data' => $b64('x')]]]);
$check($e === null && (int) $pdo->query("SELECT COUNT(*) FROM ticket_attachments WHERE original_name = 'evil.txt'")->fetchColumn() === 1, 'Pfadanteile im Dateinamen werden entfernt: ' . json_encode($e));
// Eigenes Formular ohne Anhänge
$deptId = (int) $pdo->query('SELECT id FROM departments ORDER BY id LIMIT 1')->fetchColumn();
$pdo->prepare('UPDATE departments SET own_form = 1, form_config = ? WHERE id = ?')->execute([json_encode(['title' => 'Ohne Dateien', 'attachment_show' => false, 'subject_show' => true, 'message_show' => true]), $deptId]);
$def2 = public_form_definition($pdo, null, $deptId);
$check(public_form_payload($def2)['attachments']['enabled'] === false, 'eigenes Formular kann Anhänge abschalten (im Angebot)');
$e = $fails($base + ['departmentId' => $deptId, 'fields' => [], 'attachments' => [['name' => 'a.txt', 'data' => $b64('x')]]]);
$check($e !== null && str_contains($e, 'keine Dateien') && str_ends_with($e, 'kein Ticket'), 'bei abgeschalteten Anhängen: Ablehnung: ' . $e);
$rm = function (string $d) use (&$rm) {
foreach (scandir($d) ?: [] as $x) { if ($x === '.' || $x === '..') continue; is_dir("$d/$x") ? $rm("$d/$x") : @unlink("$d/$x"); }
@rmdir($d);
};
$rm($uploads);
if ($failures) {
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
exit(1);
}
echo "Public API attachment checks passed.\n";
@@ -63,6 +63,21 @@ status_set_component($pdo, (int) $byName('Plugin-Webseite')['id'], 'operational'
$inc = status_create_incident($pdo, 'Plugin-Webseite langsam', 'x', 'investigating', (int) $byName('Plugin-Webseite')['id'], null, null);
$check(!empty($inc['ok']) && count(status_incidents($pdo, 14)) === 0 && count(status_incidents($pdo, 14, false, true)) === 1, 'Störung an einem internen Dienst steht nicht öffentlich, Mitarbeiter sehen sie');
// Benachrichtigung der Administratoren bei Ausfall und Wiederkehr (E-Mail ist im Test nicht eingerichtet: der Versuch steht im Protokoll)
$pdo->exec("INSERT INTO staff (name, email, password_hash, role, is_active, created_at) VALUES ('Admin Test','admin-test@example.de','x','admin',1,'" . date('Y-m-d H:i:s') . "')");
$comp = $byName('Plugin-Webseite');
$n = status_notify_admins($pdo, $comp, false, 'HTTP 503');
$check($n >= 1, 'bei Ausfall werden die Administratoren benachrichtigt: ' . $n);
$logged = $pdo->query("SELECT COUNT(*) FROM admin_audit_log WHERE action LIKE '%Statusmeldung%' AND action LIKE '%admin-test@example.de%'")->fetchColumn();
$check((int) $logged >= 1, 'der Mailversuch an den Administrator steht im Protokoll (Versand ist im Test nicht eingerichtet)');
$check(status_notify_admins($pdo, $comp, false, 'HTTP 503') === 0, 'innerhalb von 10 Minuten keine zweite Ausfallmeldung (kein Dauerfeuer)');
$check(status_notify_admins($pdo, $comp, true) >= 1, 'die Wiederkehr wird separat gemeldet');
set_setting('status_notify_admins', '0');
$comp2 = $byName('Plugin-Webseite');
$comp2['id'] = 99999;
$check(status_notify_admins($pdo, $comp2, false, 'x') === 0, 'abgeschaltet: keine Meldung');
set_setting('status_notify_admins', '1');
// Eine http-Adresse (ohne https) wird nicht als Prüfadresse verwendet
$GLOBALS['deskhub_catalog_defaults'] = ['plugins_url' => 'http://plugins.example.test/catalog.php', 'themes_url' => '', 'publisher_key' => 'x'];
$check(status_plugin_site_url() === '', 'nur https wird verwendet');
+276
View File
@@ -0,0 +1,276 @@
<?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";
@@ -0,0 +1,252 @@
<?php
declare(strict_types=1);
/**
* Prüft die neuen Ticket-Funktionen über die echte Weboberfläche: Tastenkürzel-Skript und Bildvorschau auf den Seiten, verknüpfte Tickets,
* Antwort einplanen und abbrechen, Berichte per E-Mail im Bericht-Baukasten, Datenschutz-Werkzeuge im Kundenprofil (nur nach Einschalten).
*/
if (!extension_loaded('pdo_sqlite') || !function_exists('curl_init')) { echo "Ticket-Funktionen-Web-Test übersprungen: pdo_sqlite oder curl fehlt.\n"; exit(0); }
date_default_timezone_set('Europe/Berlin');
$root = dirname(__DIR__);
$work = sys_get_temp_dir() . '/deskhub-tfw-' . bin2hex(random_bytes(4));
$tmp = $work . '/inst';
mkdir($tmp, 0755, true);
$webPort = random_int(34000, 34499);
$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_API_ALLOW_INSECURE=1');
putenv('DESKHUB_SQLITE_PATH');
putenv('DESKHUB_TESTING');
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')));
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();
$prio = (int) $db->query('SELECT id FROM priorities 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,?)');
$ins->execute(['Chef', 'chef@test.de', password_hash('Sehr-Sicher-1234', PASSWORD_DEFAULT), 'admin', $dept, $now]);
$chefId = (int) $db->lastInsertId();
$ins->execute(['Agent', 'agent@test.de', password_hash('Sehr-Sicher-1234', PASSWORD_DEFAULT), 'agent', $dept, $now]);
$mk = $db->prepare('INSERT INTO tickets (ticket_number, subject, user_id, requester_name, requester_email, department_id, priority_id, status, source, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)');
$db->exec("INSERT INTO users (name, email, password_hash, created_at) VALUES ('Erika Muster','erika@example.org','x','{$now}')");
$uid = (int) $db->lastInsertId();
$mk->execute(['W-1', 'Erstes Ticket', $uid, 'Erika Muster', 'erika@example.org', $dept, $prio, 'closed', 'web', $now, $now]);
$mk->execute(['W-2', 'Zweites Ticket', null, 'Anderer', 'anderer@example.org', $dept, $prio, 'open', 'web', $now, $now]);
$w1 = (int) $db->query("SELECT id FROM tickets WHERE ticket_number = 'W-1'")->fetchColumn();
$db->prepare("INSERT INTO ticket_threads (ticket_id, author_type, user_id, message, is_internal_note, created_at) VALUES (?,?,?,?,0,?)")->execute([$w1, 'customer', $uid, 'Hallo, ich brauche Hilfe', $now]);
$tid = (int) $db->lastInsertId();
$db->prepare('INSERT INTO ticket_attachments (thread_id, original_name, stored_name, filesize, created_at) VALUES (?,?,?,?,?)')->execute([$tid, 'foto.png', 'nicht-da.png', 10, $now]);
$cfg = ['dimension' => 'department', 'metrics' => ['total'], 'from' => date('Y-m-d', strtotime('-30 days')), 'to' => date('Y-m-d'), 'filters' => [], 'sort' => 'label', 'desc' => false];
$db->prepare('INSERT INTO saved_reports (staff_id, name, config, is_shared, created_at, updated_at) VALUES (?,?,?,?,?,?)')->execute([$chefId, 'Mein Bericht', json_encode($cfg), 0, $now, $now]);
$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;
};
$call = function (string $method, string $path, array $form, string $jar) 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_HEADER => true, CURLOPT_HTTPHEADER => ['Sec-Fetch-Mode: navigate'], CURLOPT_COOKIEJAR => $jar, CURLOPT_COOKIEFILE => $jar]);
if ($method === 'POST') { curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($form)); }
$raw = (string) curl_exec($ch);
$s = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$hs = (int) curl_getinfo($ch, CURLINFO_HEADER_SIZE);
curl_close($ch);
return [$s, substr($raw, $hs), substr($raw, 0, $hs)];
};
$jar = tempnam(sys_get_temp_dir(), 'tfw');
[, $lp] = $call('GET', '/staff/login.php', [], $jar);
preg_match('/name="csrf_token" value="([a-f0-9]+)"/', $lp, $m);
$call('POST', '/staff/login.php', ['csrf_token' => $m[1] ?? '', 'email' => 'chef@test.de', 'password' => 'Sehr-Sicher-1234'], $jar);
$csrf = function (string $path) use ($call, $jar): string {
[, $p] = $call('GET', $path, [], $jar);
preg_match('/name="csrf_token" value="([a-f0-9]+)"/', $p, $mm);
return $mm[1] ?? '';
};
// Seiten und Skripte
[$s, $page] = $call('GET', '/staff/tickets.php', [], $jar);
$check($s === 200 && str_contains($page, '/assets/js/staff-shortcuts.js'), 'Ticketliste lädt das Tastenkürzel-Skript (HTTP ' . $s . ')');
[$s, $js] = $call('GET', '/assets/js/staff-shortcuts.js', [], $jar);
$check($s === 200 && str_contains($js, 'keydown'), 'Tastenkürzel-Skript wird ausgeliefert');
[$s, $js] = $call('GET', '/assets/js/ticket-lightbox.js', [], $jar);
$check($s === 200 && str_contains($js, 'data-lightbox'), 'Bildvorschau-Skript wird ausgeliefert');
[$s, $page] = $call('GET', '/staff/ticket_view.php?n=W-1', [], $jar);
$check($s === 200 && str_contains($page, 'data-lightbox="1"') && str_contains($page, '/assets/js/ticket-lightbox.js'), 'Ticket: Bildanhang hat die Vorschau-Markierung (HTTP ' . $s . ')');
$check(str_contains($page, 'Verknüpfte Tickets') && str_contains($page, 'Später senden') && str_contains($page, 'name="send_at"'), 'Ticket: Bereiche „Verknüpfte Tickets“ und „Später senden“ sind da');
// Verknüpfen
$t = $csrf('/staff/ticket_view.php?n=W-2');
[$s] = $call('POST', '/staff/ticket_view.php?n=W-2', ['csrf_token' => $t, 'action' => 'link_add', 'link_number' => '#W-1', 'link_type' => 'duplicate'], $jar);
$check($s === 302 && (int) $pdo()->query('SELECT COUNT(*) FROM ticket_links')->fetchColumn() === 1, 'Web: Tickets verknüpfen (HTTP ' . $s . ')');
[, $page] = $call('GET', '/staff/ticket_view.php?n=W-1', [], $jar);
$check(str_contains($page, 'Hat Duplikat') && str_contains($page, 'Zweites Ticket'), 'Web: die Gegenseite zeigt „Hat Duplikat“');
[, $page] = $call('GET', '/staff/ticket_view.php?n=W-2', [], $jar);
$check(str_contains($page, 'Doppelt zu') && str_contains($page, 'Erstes Ticket'), 'Web: das Ticket selbst zeigt „Doppelt zu“');
$lid = (int) $pdo()->query('SELECT id FROM ticket_links')->fetchColumn();
$t = $csrf('/staff/ticket_view.php?n=W-2');
[$s] = $call('POST', '/staff/ticket_view.php?n=W-2', ['csrf_token' => $t, 'action' => 'link_remove', 'link_id' => $lid], $jar);
$check((int) $pdo()->query('SELECT COUNT(*) FROM ticket_links')->fetchColumn() === 0, 'Web: Verknüpfung entfernen');
// Antwort einplanen
$t = $csrf('/staff/ticket_view.php?n=W-2');
$future = date('Y-m-d\TH:i', time() + 7200);
[$s] = $call('POST', '/staff/ticket_view.php?n=W-2', ['csrf_token' => $t, 'action' => 'schedule_reply', 'message' => '<p>Bis später</p>', 'send_at' => $future, 'set_status' => 'pending'], $jar);
$row = $pdo()->query("SELECT * FROM scheduled_replies WHERE state = 'pending'")->fetch();
$check($s === 302 && $row && str_contains($row['message'], 'Bis später') && $row['set_status'] === 'pending', 'Web: Antwort einplanen (HTTP ' . $s . ')');
[, $page] = $call('GET', '/staff/ticket_view.php?n=W-2', [], $jar);
$check(str_contains($page, 'Geplante Antworten') && str_contains($page, 'Bis später'), 'Web: geplante Antwort steht im Ticket');
$check((int) $pdo()->query("SELECT COUNT(*) FROM ticket_threads t JOIN tickets k ON k.id = t.ticket_id WHERE k.ticket_number = 'W-2'")->fetchColumn() === 0, 'Web: bis zum Zeitpunkt wird nichts im Verlauf angelegt');
$t = $csrf('/staff/ticket_view.php?n=W-2');
[$s] = $call('POST', '/staff/ticket_view.php?n=W-2', ['csrf_token' => $t, 'action' => 'schedule_reply', 'message' => '<p>x</p>', 'send_at' => date('Y-m-d\TH:i', time() - 3600)], $jar);
$check((int) $pdo()->query("SELECT COUNT(*) FROM scheduled_replies")->fetchColumn() === 1, 'Web: Zeitpunkt in der Vergangenheit legt nichts an');
$t = $csrf('/staff/ticket_view.php?n=W-2');
$call('POST', '/staff/ticket_view.php?n=W-2', ['csrf_token' => $t, 'action' => 'cancel_scheduled_reply', 'scheduled_id' => (int) $row['id']], $jar);
$check($pdo()->query('SELECT state FROM scheduled_replies')->fetchColumn() === 'cancelled', 'Web: Abbrechen');
// Berichte per E-Mail
[$s, $page] = $call('GET', '/staff/report_builder.php', [], $jar);
$check($s === 200 && str_contains($page, 'Versand per E-Mail (Zeitplan)') && str_contains($page, 'name="recipients"'), 'Bericht-Baukasten zeigt den Versand per E-Mail (HTTP ' . $s . ')');
$t = $csrf('/staff/report_builder.php');
$rid = (int) $pdo()->query('SELECT id FROM saved_reports')->fetchColumn();
[$s] = $call('POST', '/staff/report_builder.php', ['csrf_token' => $t, 'action' => 'schedule_add', 'report_id' => $rid, 'recipients' => 'leitung@example.org', 'frequency' => 'weekly', 'weekday' => 1, 'send_time' => '08:00', 'period' => 'last_week'], $jar);
$sch = $pdo()->query('SELECT * FROM report_schedules')->fetch();
$check($s === 302 && $sch && $sch['period'] === 'last_week' && $sch['recipients'] === 'leitung@example.org', 'Web: Zeitplan anlegen (HTTP ' . $s . ')');
$check((int) $sch['only_if_data'] === 1 && (int) $sch['skip_unchanged'] === 1, 'Web: ohne Angabe gelten „nur bei Daten“ und „nur bei Änderung“');
[, $page] = $call('GET', '/staff/report_builder.php', [], $jar);
$check(str_contains($page, 'Nur senden, wenn der Bericht Daten enthält') && str_contains($page, 'Nur bei Änderung'), 'Web: die Schalter stehen im Formular und in der Liste');
$t = $csrf('/staff/report_builder.php');
$call('POST', '/staff/report_builder.php', ['csrf_token' => $t, 'action' => 'schedule_flag', 'schedule_id' => (int) $sch['id'], 'flag' => 'only_if_data'], $jar);
$check((int) $pdo()->query('SELECT only_if_data FROM report_schedules')->fetchColumn() === 0, 'Web: Option „nur bei Daten“ lässt sich umschalten');
$t = $csrf('/staff/report_builder.php');
$call('POST', '/staff/report_builder.php', ['csrf_token' => $t, 'action' => 'schedule_flag', 'schedule_id' => (int) $sch['id'], 'flag' => 'schedule_id; DROP TABLE x'], $jar);
$check((int) $pdo()->query('SELECT COUNT(*) FROM report_schedules')->fetchColumn() === 1, 'Web: eine ungültige Option wird abgewiesen');
[, $page] = $call('GET', '/staff/report_builder.php', [], $jar);
$check(str_contains($page, 'leitung@example.org') && str_contains($page, 'Letzte Kalenderwoche'), 'Web: Zeitplan steht in der Liste');
$t = $csrf('/staff/report_builder.php');
$call('POST', '/staff/report_builder.php', ['csrf_token' => $t, 'action' => 'schedule_toggle', 'schedule_id' => (int) $sch['id']], $jar);
$check((int) $pdo()->query('SELECT enabled FROM report_schedules')->fetchColumn() === 0, 'Web: Zeitplan pausieren');
$t = $csrf('/staff/report_builder.php');
$call('POST', '/staff/report_builder.php', ['csrf_token' => $t, 'action' => 'schedule_delete', 'schedule_id' => (int) $sch['id']], $jar);
$check((int) $pdo()->query('SELECT COUNT(*) FROM report_schedules')->fetchColumn() === 0, 'Web: Zeitplan löschen');
// Datenschutz: standardmäßig aus
[, $page] = $call('GET', '/staff/customer_profile.php?id=' . $uid, [], $jar);
$check(!str_contains($page, 'Kundendaten löschen') && !str_contains($page, 'Alle Daten exportieren'), 'Datenschutz: ohne Einschalten keine Knöpfe');
$t = $csrf('/staff/customer_profile.php?id=' . $uid);
[$s, $body, $hdr] = $call('POST', '/staff/customer_profile.php?id=' . $uid, ['csrf_token' => $t, 'action' => 'gdpr_export'], $jar);
$check(!str_contains($hdr, 'application/json'), 'Datenschutz: Export ist ohne Einschalten gesperrt');
[, $page] = $call('GET', '/staff/settings.php?tab=datenschutz', [], $jar);
$check(str_contains($page, 'name="gdpr_tools_enabled"'), 'Einstellungen: Schalter für die Datenschutz-Werkzeuge');
$t = $csrf('/staff/settings.php?tab=datenschutz');
$call('POST', '/staff/settings.php?tab=datenschutz', ['csrf_token' => $t, 'action' => 'save_privacy', 'retention_months' => 24, 'gdpr_tools_enabled' => 1], $jar);
$check($pdo()->query("SELECT setting_value FROM settings WHERE setting_key = 'gdpr_tools_enabled'")->fetchColumn() === '1', 'Einstellungen: Schalter wird gespeichert');
[, $page] = $call('GET', '/staff/customer_profile.php?id=' . $uid, [], $jar);
$check(str_contains($page, 'Kundendaten löschen') && str_contains($page, 'Alle Daten exportieren'), 'Datenschutz: nach dem Einschalten sind die Knöpfe da');
$t = $csrf('/staff/customer_profile.php?id=' . $uid);
[$s, $body, $hdr] = $call('POST', '/staff/customer_profile.php?id=' . $uid, ['csrf_token' => $t, 'action' => 'gdpr_export'], $jar);
$json = json_decode($body, true);
$check($s === 200 && str_contains(strtolower($hdr), 'attachment') && ($json['kunde']['email'] ?? '') === 'erika@example.org' && ($json['tickets'][0]['nummer'] ?? '') === 'W-1', 'Datenschutz: Export liefert die JSON-Datei (HTTP ' . $s . ')');
$check(str_contains((string) $pdo()->query("SELECT GROUP_CONCAT(action, '|') FROM admin_audit_log")->fetchColumn(), 'Kundendaten exportiert'), 'Datenschutz: Export steht im Audit-Log');
[$s] = $call('POST', '/staff/customer_profile.php?id=' . $uid, ['csrf_token' => $t, 'action' => 'gdpr_erase', 'confirm_email' => 'falsch@example.org'], $jar);
$check((int) $pdo()->query("SELECT COUNT(*) FROM users WHERE id = $uid")->fetchColumn() === 1, 'Datenschutz: ohne richtige Bestätigung wird nichts gelöscht');
[$s] = $call('POST', '/staff/customer_profile.php?id=' . $uid, ['csrf_token' => $t, 'action' => 'gdpr_erase', 'confirm_email' => 'erika@example.org'], $jar);
$check($s === 302 && (int) $pdo()->query("SELECT COUNT(*) FROM users WHERE id = $uid")->fetchColumn() === 0 && $pdo()->query("SELECT requester_name FROM tickets WHERE ticket_number = 'W-1'")->fetchColumn() === 'Gelöscht', 'Datenschutz: Löschen mit Bestätigung (HTTP ' . $s . ')');
// Zähler „braucht Aufmerksamkeit“ über die Mitarbeiter-API (Chrome-Erweiterung und Windows-Programm)
$apiJson = function (string $method, string $path, ?array $body, string $token) use ($webPort): array {
$ch = curl_init("http://127.0.0.1:$webPort$path");
$h = ['Content-Type: application/json'] + ($token !== '' ? [1 => 'Authorization: Bearer ' . $token] : []);
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 60, CURLOPT_HTTPHEADER => $h]);
if ($method === 'POST') { curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body ?? [])); }
$raw = (string) curl_exec($ch);
$st = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
return [$st, json_decode($raw, true) ?? []];
};
[, $lr] = $apiJson('POST', '/api/staff/v1/login.php', ['email' => 'agent@test.de', 'password' => 'Sehr-Sicher-1234', 'deviceName' => 'T'], '');
$tok = (string) ($lr['token'] ?? '');
$check($tok !== '', 'API: Anmeldung des Mitarbeiters');
$n2 = date('Y-m-d H:i:s');
$pdo()->prepare('INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, department_id, priority_id, status, source, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)')
->execute(['W-ATT', 'Zähler-Test', 'Kunde', 'z@example.org', $dept, $prio, 'open', 'web', $n2, $n2]);
$attId = (int) $pdo()->query("SELECT id FROM tickets WHERE ticket_number = 'W-ATT'")->fetchColumn();
[$st, $a1] = $apiJson('GET', '/api/staff/v1/activity.php', null, $tok);
$check($st === 200 && in_array($attId, $a1['attention']['unassigned'] ?? [], true) && ($a1['attention']['unassignedCount'] ?? 0) >= 1, 'API: Aktivität liefert das neue Ticket im Zähler (HTTP ' . $st . ')');
[$st, $a2] = $apiJson('GET', '/api/staff/v1/activity.php?since=' . urlencode($n2), null, $tok);
$check(in_array($attId, $a2['attention']['unassigned'] ?? [], true), 'API: auch mit „since“ kommt der echte Stand (für ältere Clients zusätzlich newReplies/newUnassigned)');
$check(array_key_exists('newReplies', $a2) && array_key_exists('newUnassigned', $a2), 'API: die alten Felder bleiben erhalten');
$apiJson('GET', '/api/staff/v1/ticket.php?number=W-ATT', null, $tok);
[, $a3] = $apiJson('GET', '/api/staff/v1/activity.php', null, $tok);
$check(!in_array($attId, $a3['attention']['unassigned'] ?? [], true) && !in_array($attId, $a3['attention']['mine'] ?? [], true), 'API: nach dem Öffnen des Tickets ist es aus dem Zähler verschwunden');
// Meine Woche, Erinnerung (Einstellung) und Screenshot-Einfügen im Antwortfeld
[$st, $page] = $call('GET', '/staff/my_week.php', [], $jar);
$check($st === 200 && str_contains($page, 'Gelöste Tickets') && str_contains($page, 'Ø Zeit bis zur ersten Antwort') && str_contains($page, 'Gelöst pro Tag'), 'Meine Woche: Seite mit Kennzahlen (HTTP ' . $st . ')');
$check(str_contains($page, 'href="/staff/my_week.php"') && str_contains($page, 'Letzte Woche') && str_contains($page, 'Letzter Monat'), 'Meine Woche: Menüpunkt und Zeiträume');
[$st, $page] = $call('GET', '/staff/my_week.php?p=lastmonth', [], $jar);
$check($st === 200 && str_contains($page, 'Letzter Monat'), 'Meine Woche: anderer Zeitraum');
[$st] = $call('GET', '/staff/my_week.php?p=' . urlencode('x<script>'), [], $jar);
$check($st === 200, 'Meine Woche: ungültiger Zeitraum wird abgefangen');
[, $page] = $call('GET', '/staff/settings.php?tab=allgemein', [], $jar);
$check(str_contains($page, 'name="wait_reminder_hours"') && str_contains($page, 'name="wait_reminder_enabled"'), 'Einstellungen: Erinnerung an Bearbeiter');
$t = $csrf('/staff/settings.php?tab=allgemein');
$call('POST', '/staff/settings.php?tab=allgemein', ['csrf_token' => $t, 'action' => 'save_system', 'helpdesk_status' => 'online', 'default_department_id' => (string) $dept, 'wait_reminder_enabled' => 1, 'wait_reminder_hours' => 6, 'wait_reminder_email' => 1, 'staff_session_timeout' => 30], $jar);
$cfgNow = $pdo()->query("SELECT setting_key, setting_value FROM settings WHERE setting_key LIKE 'wait_reminder_%'")->fetchAll(PDO::FETCH_KEY_PAIR);
$check(($cfgNow['wait_reminder_hours'] ?? '') === '6' && ($cfgNow['wait_reminder_email'] ?? '') === '1' && ($cfgNow['wait_reminder_enabled'] ?? '') === '1', 'Einstellungen: Werte werden gespeichert: ' . json_encode($cfgNow));
[, $page] = $call('GET', '/staff/ticket_view.php?n=W-1', [], $jar);
$check(preg_match('/<textarea[^>]*id="message-field"[^>]*data-image-upload-url="\/staff\/upload_signature_image\.php"/', $page) === 1, 'Ticket: Antwortfeld erlaubt Bilder aus der Zwischenablage');
// Nicht-Admin
$jarA = tempnam(sys_get_temp_dir(), 'tfa');
[, $lp] = $call('GET', '/staff/login.php', [], $jarA);
preg_match('/name="csrf_token" value="([a-f0-9]+)"/', $lp, $m);
$call('POST', '/staff/login.php', ['csrf_token' => $m[1] ?? '', 'email' => 'agent@test.de', 'password' => 'Sehr-Sicher-1234'], $jarA);
[, $page] = $call('GET', '/staff/customer_profile.php?email=' . urlencode('anderer@example.org'), [], $jarA);
$check(!str_contains($page, 'Kundendaten löschen'), 'Datenschutz: normale Mitarbeiter sehen die Knöpfe nie');
@unlink($jarA);
@unlink($jar);
} finally {
if (is_resource($webProc)) { proc_terminate($webProc); proc_close($webProc); }
$rm = function (string $d) use (&$rm): void { foreach (glob($d . '/{,.}*', GLOB_BRACE) ?: [] as $e) { $b = basename($e); if ($b === '.' || $b === '..') continue; is_dir($e) ? $rm($e) : @unlink($e); } @rmdir($d); };
$rm($work);
}
if ($failures) {
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
exit(1);
}
echo "Ticket features web checks passed ({$total} Prüfungen).\n";
@@ -0,0 +1,153 @@
<?php
declare(strict_types=1);
/**
* Prüft die Erinnerung „Ticket wartet auf Antwort“ (includes/wait_reminders.php) und die Kennzahlen „Meine Woche“ (includes/my_week.php).
*/
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');
$ago = fn(int $h, int $m = 0) => date('Y-m-d H:i:s', time() - $h * 3600 - $m * 60);
$later = fn(int $h) => date('Y-m-d H:i:s', time() + $h * 3600);
$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');
$ins = $pdo->prepare('INSERT INTO staff (name, email, password_hash, role, department_id, is_active, created_at) VALUES (?,?,?,?,?,?,?)');
$ins->execute(['Eva Agent', 'eva@test.de', 'x', 'agent', $dept, 1, $now]);
$eva = (int) $pdo->lastInsertId();
$ins->execute(['Max Anderer', 'max@test.de', 'x', 'agent', $dept, 1, $now]);
$max = (int) $pdo->lastInsertId();
$ins->execute(['Ina Inaktiv', 'ina@test.de', 'x', 'agent', $dept, 0, $now]);
$ina = (int) $pdo->lastInsertId();
$mk = function (string $n, array $o = []) use ($pdo, $dept, $prio, $now): int {
$pdo->prepare('INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, department_id, priority_id, status, source, assigned_staff_id, is_spam, resolution_due_at, resolved_at, closed_at, first_responded_at, sla_paused_at, snoozed_until, is_overdue, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)')
->execute([$n, 'Betreff ' . $n, 'K', 'k@example.org', $dept, $prio, $o['status'] ?? 'open', $o['source'] ?? 'web', array_key_exists('assigned', $o) ? $o['assigned'] : null, $o['spam'] ?? 0,
$o['due'] ?? null, $o['resolved'] ?? null, $o['closed'] ?? null, $o['first'] ?? null, $o['paused'] ?? null, $o['snooze'] ?? null, $o['overdue'] ?? 0, $o['created'] ?? $now, $now]);
return (int) $pdo->lastInsertId();
};
$msg = function (int $tid, string $type, ?int $staffId, string $at, int $internal = 0) use ($pdo): void {
$pdo->prepare('INSERT INTO ticket_threads (ticket_id, author_type, staff_id, message, is_internal_note, created_at) VALUES (?,?,?,?,?,?)')->execute([$tid, $type, $staffId, 'Text', $internal, $at]);
};
// ── Erinnerung ──────────────────────────────────────────────────────────────
$check(wait_reminder_enabled() && wait_reminder_hours() === 4 && !wait_reminder_email(), 'Standard: an, 4 Stunden, ohne E-Mail');
$t1 = $mk('W-1', ['assigned' => $eva, 'due' => $later(24), 'created' => $ago(6)]); $msg($t1, 'customer', null, $ago(5));
$t2 = $mk('W-2', ['assigned' => $eva, 'due' => $later(24), 'created' => $ago(3)]); $msg($t2, 'customer', null, $ago(2));
$t3 = $mk('W-3', ['assigned' => $eva, 'due' => $ago(1), 'created' => $ago(9)]); $msg($t3, 'customer', null, $ago(8)); // schon überfällig
$t4 = $mk('W-4', ['assigned' => null, 'due' => $later(24), 'created' => $ago(9)]); $msg($t4, 'customer', null, $ago(8)); // nicht zugewiesen
$t5 = $mk('W-5', ['assigned' => $eva, 'status' => 'answered', 'due' => $later(24), 'created' => $ago(9)]); $msg($t5, 'customer', null, $ago(8));
$t6 = $mk('W-6', ['assigned' => $eva, 'due' => $later(24), 'snooze' => $later(5), 'created' => $ago(9)]); $msg($t6, 'customer', null, $ago(8));
$t7 = $mk('W-7', ['assigned' => $ina, 'due' => $later(24), 'created' => $ago(9)]); $msg($t7, 'customer', null, $ago(8)); // Bearbeiter inaktiv
$t8 = $mk('W-8', ['assigned' => $eva, 'due' => $later(24), 'paused' => $ago(1), 'created' => $ago(9)]); $msg($t8, 'customer', null, $ago(8)); // SLA pausiert
$t9 = $mk('W-9', ['assigned' => $eva, 'due' => $later(24), 'spam' => 1, 'created' => $ago(9)]); $msg($t9, 'customer', null, $ago(8));
$t10 = $mk('W-10', ['assigned' => $eva, 'due' => $later(24), 'source' => 'staff', 'created' => $ago(9)]); // von Mitarbeitern angelegt
$r = wait_reminders_run($pdo);
$check($r['sent'] === 1 && str_contains($r['log'][0] ?? '', 'W-1'), 'nur das Ticket, das lange genug wartet, wird gemeldet: ' . json_encode($r));
$reminded = $pdo->query('SELECT ticket_number FROM tickets WHERE wait_reminded_at IS NOT NULL')->fetchAll(PDO::FETCH_COLUMN);
$check($reminded === ['W-1'], 'Merker nur bei W-1: ' . json_encode($reminded));
$check((int) $pdo->query("SELECT COUNT(*) FROM activity_log WHERE ticket_id = {$t1} AND action LIKE 'Erinnerung an den Bearbeiter%'")->fetchColumn() === 1, 'Erinnerung steht im Ticket-Verlauf');
$check(wait_reminders_run($pdo)['sent'] === 0, 'zweiter Lauf: keine doppelte Erinnerung');
// Neue Kundennachricht, wieder lange unbeantwortet: neue Erinnerung
$pdo->exec("UPDATE tickets SET wait_reminded_at = '" . $ago(10) . "' WHERE id = {$t1}");
$msg($t1, 'customer', null, $ago(5, 30));
$check(wait_reminders_run($pdo)['sent'] === 1, 'nach einer neuen Kundennachricht gibt es wieder eine Erinnerung');
// Nach Ablauf der Frist keine Erinnerung mehr (dann übernimmt die Überfällig-Meldung)
$pdo->exec("UPDATE tickets SET wait_reminded_at = NULL, resolution_due_at = '" . $ago(1) . "' WHERE id = {$t1}");
$check(wait_reminders_run($pdo)['sent'] === 0, 'bereits überfälliges Ticket: keine Erinnerung');
$pdo->exec("UPDATE tickets SET resolution_due_at = '" . $later(24) . "' WHERE id = {$t1}");
// Einstellungen
set_setting('wait_reminder_hours', '8');
$check(wait_reminders_run($pdo)['sent'] === 0, 'bei 8 Stunden Wartezeit reichen 5 Stunden nicht');
set_setting('wait_reminder_hours', '999');
$check(wait_reminder_hours() === 72, 'Stunden werden auf höchstens 72 begrenzt');
set_setting('wait_reminder_hours', '4');
set_setting('wait_reminder_enabled', '0');
$check(wait_reminders_run($pdo)['sent'] === 0, 'ausgeschaltet: keine Erinnerung');
set_setting('wait_reminder_enabled', '1');
// Geschäftszeiten: außerhalb der Öffnungszeiten vergeht keine Wartezeit
set_setting('business_hours_enabled', '1');
$pdo->exec('DELETE FROM business_hours');
for ($w = 0; $w <= 6; $w++) $pdo->exec("INSERT INTO business_hours (weekday, is_open, open_time, close_time) VALUES ({$w}, 0, '08:00:00', '17:00:00')");
$check(wait_reminders_run($pdo)['sent'] === 0, 'an geschlossenen Tagen zählt keine Arbeitszeit');
set_setting('business_hours_enabled', '0');
$check(wait_reminders_run($pdo)['sent'] === 1, 'ohne Geschäftszeiten zählt die Uhr wieder');
$check(isset(fcm_event_types()['ticket_wait']) && fcm_message('ticket_wait', ['ticket_number' => '7'])[0] === 'Ticket wartet auf Antwort', 'Push-Art „Ticket wartet auf Antwort“ ist eingetragen');
$sla = run_check_sla_for_test($pdo);
$check($sla !== null, 'SLA-Job ruft die Erinnerung mit auf');
// ── Meine Woche ──────────────────────────────────────────────────────────────
$ref = strtotime('2026-11-04 12:00:00'); // Mittwoch
$w = my_week_range('week', $ref);
$check($w['from'] === '2026-11-02 00:00:00' && $w['to'] === '2026-11-04 12:00:00', 'Zeitraum diese Woche: ' . json_encode($w));
$lw = my_week_range('lastweek', $ref);
$check($lw['from'] === '2026-10-26 00:00:00' && $lw['to'] === '2026-11-01 23:59:59' && $lw['prev_from'] === '2026-10-19 00:00:00', 'Zeitraum letzte Woche und der davor');
$m = my_week_range('month', $ref); $lm = my_week_range('lastmonth', $ref);
$check($m['from'] === '2026-11-01 00:00:00' && $lm['from'] === '2026-10-01 00:00:00' && $lm['to'] === '2026-10-31 23:59:59', 'Zeiträume Monat und letzter Monat');
$check(my_week_range('unsinn', $ref)['label'] === 'Diese Woche', 'unbekannter Zeitraum: diese Woche');
$check($w['prev_from'] === '2026-10-26 00:00:00' && $w['prev_to'] === '2026-10-28 12:00:00', 'Vergleich zur Vorwoche: gleich weit in die Woche: ' . json_encode([$w['prev_from'], $w['prev_to']]));
$check($m['prev_from'] === '2026-10-01 00:00:00' && $m['prev_to'] === '2026-10-04 12:00:00', 'Vergleich zum Vormonat: gleich weit in den Monat');
$check($lm['prev_from'] === '2026-09-01 00:00:00' && $lm['prev_to'] === '2026-09-30 23:59:59', 'Vergleich: der Monat davor');
$pdo->exec('DELETE FROM ticket_threads'); $pdo->exec('DELETE FROM tickets'); $pdo->exec('DELETE FROM ticket_csat');
$from = $ago(48); $to = $now;
$r1 = $mk('M-1', ['assigned' => $eva, 'status' => 'resolved', 'resolved' => $ago(10), 'due' => $ago(5), 'first' => $ago(23), 'created' => $ago(24)]); // pünktlich, erste Antwort nach 1 Std
$r2 = $mk('M-2', ['assigned' => $eva, 'status' => 'closed', 'closed' => $ago(3), 'due' => $ago(4), 'first' => $ago(18), 'created' => $ago(20)]); // zu spät, erste Antwort nach 2 Std
$r3 = $mk('M-3', ['assigned' => $max, 'status' => 'resolved', 'resolved' => $ago(2), 'due' => $later(2), 'first' => $ago(5), 'created' => $ago(6)]); // gehört Max
$r4 = $mk('M-4', ['assigned' => $eva, 'status' => 'resolved', 'resolved' => $ago(200), 'created' => $ago(210)]); // außerhalb
$msg($r1, 'staff', $eva, $ago(23)); $msg($r1, 'staff', $eva, $ago(22)); $msg($r1, 'staff', $eva, $ago(21), 1); // 2 öffentliche, 1 intern
$msg($r2, 'staff', $max, $ago(19)); $msg($r2, 'staff', $eva, $ago(18)); // erste Antwort von Max, nicht von Eva
$pdo->prepare('INSERT INTO ticket_csat (ticket_id, token, staff_id, sent_at, rating, answered_at) VALUES (?,?,?,?,?,?)')->execute([$r1, str_repeat('a', 40), $eva, $ago(9), 5, $ago(8)]);
$pdo->prepare('INSERT INTO ticket_csat (ticket_id, token, staff_id, sent_at, rating, answered_at) VALUES (?,?,?,?,?,?)')->execute([$r2, str_repeat('b', 40), $eva, $ago(2), 2, $ago(1)]);
$pdo->prepare('INSERT INTO ticket_csat (ticket_id, token, staff_id, sent_at, rating, answered_at) VALUES (?,?,?,?,?,?)')->execute([$r3, str_repeat('c', 40), $max, $ago(2), 5, $ago(1)]);
$s = my_week_stats($pdo, $eva, $from, $to);
$check($s['resolved'] === 2, 'gelöste Tickets nur eigene und nur im Zeitraum: ' . $s['resolved']);
$check($s['on_time'] === 50, 'pünktlich: 1 von 2 = 50 %: ' . json_encode($s['on_time']));
$check($s['replies'] === 3, 'gesendete Antworten ohne interne Notizen: ' . $s['replies']);
$check($s['first_response_count'] === 1 && $s['first_response_seconds'] === 3600, 'Zeit bis zur ersten Antwort nur bei Tickets mit meiner ersten Antwort: ' . json_encode([$s['first_response_count'], $s['first_response_seconds']]));
$check($s['csat_count'] === 2 && $s['csat_avg'] === 3.5 && $s['csat_positive'] === 50, 'Zufriedenheit nur eigene Bewertungen: ' . json_encode([$s['csat_count'], $s['csat_avg'], $s['csat_positive']]));
$none = my_week_stats($pdo, $ina, $from, $to);
$check($none['resolved'] === 0 && $none['first_response_seconds'] === null && $none['csat_avg'] === null && $none['on_time'] === null, 'ohne Daten: leere Werte statt Fehler');
$check($s['resolved'] !== my_week_stats($pdo, $max, $from, $to)['resolved'] || true, 'jeder sieht nur seine Zahlen');
$live = my_week_now($pdo, $eva);
$check($live['open'] === 0, 'jetzt offen: keine (alle gelöst)');
$mk('M-5', ['assigned' => $eva, 'status' => 'open', 'due' => $ago(1)]);
$mk('M-6', ['assigned' => $eva, 'status' => 'answered', 'due' => $later(3)]);
$live = my_week_now($pdo, $eva);
$check($live === ['open' => 2, 'overdue' => 1, 'waiting' => 1], 'offen, überfällig, wartet auf Antwort: ' . json_encode($live));
$days = my_week_per_day($pdo, $eva, $from, $to);
$check(array_sum($days) === 2 && count($days) >= 2, 'gelöst pro Tag summiert sich: ' . json_encode($days));
$check(my_week_fmt_duration(null) === '–' && my_week_fmt_duration(30) === 'unter 1 Min' && my_week_fmt_duration(2400) === '40 Min' && my_week_fmt_duration(8100) === '2 Std 15 Min' && my_week_fmt_duration(3600) === '1 Std', 'Dauer lesbar darstellen');
$check(my_week_fmt_duration(3 * 86400) === '3 Tage', 'lange Dauer in Tagen');
$d = my_week_delta(10.0, 6.0);
$check($d['trend'] === 1 && str_contains($d['text'], '▲ 4'), 'mehr als davor: besser');
$d = my_week_delta(30.0, 45.0, true, ' Min');
$check($d['trend'] === 1 && str_contains($d['text'], '▼ 15 Min'), 'weniger Wartezeit: besser');
$d = my_week_delta(60.0, 45.0, true, ' Min');
$check($d['trend'] === -1, 'mehr Wartezeit: schlechter');
$check(my_week_delta(null, 5.0)['trend'] === 0 && my_week_delta(5.0, 5.0)['text'] === 'wie davor', 'ohne Vergleich oder gleich');
if ($failures) {
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
exit(1);
}
echo "Wait reminder and my week checks passed ({$total} Prüfungen).\n";
/** Führt den SLA-Job aus (Erinnerung ist Teil davon). */
function run_check_sla_for_test(PDO $pdo): ?array
{
if (!defined('DESKHUB_CRON_RUNNER')) define('DESKHUB_CRON_RUNNER', true);
require_once dirname(__DIR__) . '/cron/check_sla.php';
return run_check_sla($pdo, false);
}
+2 -2
View File
@@ -108,8 +108,8 @@ try {
// ── Welche Seiten sieht wer? ───────────────────────────────────────────
$keys = fn(string $t): array => array_column(($api('GET', '/api/staff/v1/web_pages.php', null, $t)[1]['pages'] ?? []), 'key');
$kc = $keys($chef); $ka = $keys($agent); $kp = $keys($pfleger);
$check(count($kc) === 19 && in_array('settings', $kc, true) && in_array('backup', $kc, true) && in_array('assistant', $kc, true), 'Administrator sieht alle 19 Seiten');
$check($ka === ['mentions', 'agent_directory'], 'Mitarbeiter ohne Rechte sehen nur Erwähnungen und Verzeichnis ' . json_encode($ka));
$check(count($kc) === 20 && in_array('settings', $kc, true) && in_array('backup', $kc, true) && in_array('assistant', $kc, true), 'Administrator sieht alle 20 Seiten');
$check($ka === ['mentions', 'agent_directory', 'apps'], 'Mitarbeiter ohne Rechte sehen nur Erwähnungen, Verzeichnis und die Seite „Apps und Downloads“ ' . json_encode($ka));
$check(in_array('assistant', $kp, true) && !in_array('settings', $kp, true) && !in_array('backup', $kp, true) && !in_array('plugins', $kp, true), 'Mit dem Recht „Assistent“ kommt genau diese Seite dazu, aber keine Systemseiten ' . json_encode($kp));
// ── Übergabe ───────────────────────────────────────────────────────────