Files
2026-10-24 20:14:43 +00:00

178 lines
12 KiB
PHP

<?php
declare(strict_types=1);
/**
* Prüft das Plugin "Urlaub und Vertretung": Eingabeprüfung, Beginn (Tickets an die Vertretung, Urlaubsstatus), neue Tickets,
* Ketten, Ende (Rückgabe nur der noch offenen und noch bei der Vertretung liegenden Tickets), Abbruch und Rechte.
*/
$pluginDir = sys_get_temp_dir() . '/deskhub-vt-' . bin2hex(random_bytes(4));
mkdir($pluginDir, 0755, true);
putenv('DESKHUB_PLUGINS_DIR=' . $pluginDir);
putenv('DESKHUB_TESTING=1');
putenv('DESKHUB_CATALOG_DEFAULTS=' . __DIR__ . '/fixtures/no-catalog.php');
putenv('DESKHUB_SQLITE_PATH=:memory:');
require_once dirname(__DIR__) . '/includes/bootstrap.php';
$failures = [];
$check = function (bool $ok, string $what) use (&$failures): void {
if (!$ok) $failures[] = $what;
};
$pdo = get_db();
require_once __DIR__ . '/fixtures/paths.php';
$src = plugin_source_dir_or_skip('vertretung', 'plugin_vertretung');
$zipPath = sys_get_temp_dir() . '/vt-' . bin2hex(random_bytes(3)) . '.zip';
$zip = new ZipArchive();
$zip->open($zipPath, ZipArchive::CREATE);
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($src, FilesystemIterator::SKIP_DOTS)) as $f) {
$zip->addFile($f->getPathname(), str_replace('\\', '/', substr($f->getPathname(), strlen($src) + 1)));
}
$zip->close();
$r = plugin_install_zip($pdo, $zipPath);
$check($r['ok'] && $r['slug'] === 'vertretung', 'Plugin lässt sich installieren: ' . json_encode($r));
$act = plugin_set_active($pdo, 'vertretung', true, ['hooks.tickets', 'hooks.cron', 'ui.menu', 'db.migrations']);
$check($act['ok'], 'Plugin lässt sich aktivieren: ' . json_encode($act));
$state = &plugin_state();
$state['booted'] = false;
plugins_boot();
$check(count(plugin_menu_items(['id' => 1, 'role' => 'agent'])) === 1, 'Menüpunkt ist für jeden Mitarbeiter sichtbar');
$mkStaff = function (string $name, string $role = 'agent') use ($pdo): int {
$pdo->prepare('INSERT INTO staff (name, email, password_hash, role, is_active, created_at) VALUES (?,?,?,?,1,?)')
->execute([$name, strtolower($name) . '@team.example', 'x', $role, db_now()]);
return (int) $pdo->lastInsertId();
};
$anna = $mkStaff('Anna'); $ben = $mkStaff('Ben'); $cara = $mkStaff('Cara'); $adm = $mkStaff('Chef', 'admin');
$actor = fn(int $id, string $role = 'agent') => ['id' => $id, 'role' => $role];
$dept = (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();
$n = 0;
$mk = function (int $assigned, string $status = 'open') use ($pdo, $dept, $prio, &$n): int {
$n++;
$now = db_now();
$pdo->prepare('INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, department_id, priority_id, status, source, assigned_staff_id, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)')
->execute(['VT-' . $n, 'Anfrage ' . $n, 'Kunde', 'kunde@x.example', $dept, $prio, $status, 'web', $assigned ?: null, $now, $now]);
return (int) $pdo->lastInsertId();
};
$owner = fn(int $t) => (int) $pdo->query("SELECT assigned_staff_id FROM tickets WHERE id = {$t}")->fetchColumn();
$flag = fn(int $s) => $pdo->query("SELECT is_on_vacation, vacation_substitute_id FROM staff WHERE id = {$s}")->fetch();
$day = fn(int $offset) => date('Y-m-d', strtotime('2026-07-15 12:00:00') + $offset * 86400);
$GLOBALS['vertretung_today'] = $day(0);
// ── Eingabeprüfung und Rechte ──────────────────────────────────────────────
$bad = fn($res) => !$res['ok'];
$check($bad(vertretung_create($pdo, $actor($anna), $anna, $ben, $day(3), $day(1), '')), 'Ende vor Beginn wird abgelehnt');
$check($bad(vertretung_create($pdo, $actor($anna), $anna, $ben, $day(-5), $day(-2), '')), 'Zeitraum in der Vergangenheit wird abgelehnt');
$check($bad(vertretung_create($pdo, $actor($anna), $anna, $anna, $day(1), $day(2), '')), 'man kann sich nicht selbst vertreten');
$check($bad(vertretung_create($pdo, $actor($anna), $anna, 9999, $day(1), $day(2), '')), 'unbekannte Vertretung wird abgelehnt');
$check($bad(vertretung_create($pdo, $actor($anna), $anna, $ben, '2026-02-30', $day(2), '')), 'ungültiges Datum wird abgelehnt');
$check($bad(vertretung_create($pdo, $actor($anna), $ben, $cara, $day(1), $day(2), '')), 'Mitarbeiter tragen keine fremde Abwesenheit ein');
$pdo->prepare('UPDATE staff SET is_active = 0 WHERE id = ?')->execute([$cara]);
$check($bad(vertretung_create($pdo, $actor($anna), $anna, $cara, $day(1), $day(2), '')), 'inaktive Vertretung wird abgelehnt');
$pdo->prepare('UPDATE staff SET is_active = 1 WHERE id = ?')->execute([$cara]);
// ── Geplant, dann Beginn ───────────────────────────────────────────────────
$t1 = $mk($anna); $t2 = $mk($anna, 'pending'); $tDone = $mk($anna, 'resolved'); $tBen = $mk($ben); $tNobody = $mk(0);
$plan = vertretung_create($pdo, $actor($anna), $anna, $ben, $day(2), $day(6), 'Urlaub');
$check($plan['ok'], 'Anna trägt ihre eigene Abwesenheit ein');
$check($owner($t1) === $anna && (int) $flag($anna)['is_on_vacation'] === 0, 'vor dem Beginn ändert sich nichts');
$check($bad(vertretung_create($pdo, $actor($anna), $anna, $cara, $day(4), $day(8), '')), 'überschneidende Abwesenheit wird abgelehnt');
vertretung_tick($pdo);
$check($owner($t1) === $anna, 'Tick vor dem Beginn ändert nichts');
$GLOBALS['vertretung_today'] = $day(2);
vertretung_tick($pdo);
$check($owner($t1) === $ben && $owner($t2) === $ben, 'zum Beginn gehen die offenen Tickets an die Vertretung');
$check($owner($tDone) === $anna, 'gelöste Tickets bleiben bei Anna');
$check($owner($tBen) === $ben && $owner($tNobody) === 0, 'andere Tickets bleiben unberührt');
$f = $flag($anna);
$check((int) $f['is_on_vacation'] === 1 && (int) $f['vacation_substitute_id'] === $ben, 'Urlaubsstatus und Vertretung im Profil sind gesetzt');
$check((int) $pdo->query("SELECT COUNT(*) FROM activity_log WHERE ticket_id = {$t1} AND action LIKE '%Vertretung Ben%'")->fetchColumn() === 1, 'Aktivität im Ticket vermerkt');
vertretung_tick($pdo);
$check((int) $pdo->query('SELECT COUNT(*) FROM plugin_vertretung_moves')->fetchColumn() === 2, 'zweiter Tick verteilt nicht noch einmal');
// neue Tickets für Anna
$tNew = $mk($anna);
webhook_dispatch($pdo, 'ticket.created', $tNew);
$check($owner($tNew) === $ben, 'neues Ticket für die Abwesende geht an die Vertretung');
$tManual = $mk($anna);
webhook_dispatch($pdo, 'ticket.assigned', $tManual);
$check($owner($tManual) === $ben, 'von Hand an die Abwesende zugewiesen geht ebenfalls weiter');
$tAnyone = $mk($cara);
webhook_dispatch($pdo, 'ticket.created', $tAnyone);
$check($owner($tAnyone) === $cara, 'Tickets anderer Personen bleiben');
// automatische Zuweisung der Abteilung berücksichtigt den Urlaubsstatus bereits
$pdo->prepare("UPDATE departments SET auto_assign_mode = 'staff', auto_assign_staff_id = ? WHERE id = ?")->execute([$anna, $dept]);
$check(department_auto_assignee($pdo, $dept) === $ben, 'automatische Zuweisung der Abteilung nimmt die Vertretung');
$pdo->prepare("UPDATE departments SET auto_assign_mode = 'none' WHERE id = ?")->execute([$dept]);
// Kette: Ben ist selbst abwesend mit Vertretung Cara
$chain = vertretung_create($pdo, $actor($adm, 'admin'), $ben, $cara, $day(2), $day(4), 'Kette');
$check($chain['ok'], 'Administrator trägt die Abwesenheit eines anderen ein');
vertretung_tick($pdo);
$check($owner($t1) === $cara && $owner($tBen) === $cara, 'Bens Tickets (auch von Anna übernommene) gehen weiter an Cara');
$check(vertretung_target($pdo, $anna) === $cara, 'die Kette führt von Anna über Ben zu Cara');
$tChain = $mk($anna);
webhook_dispatch($pdo, 'ticket.created', $tChain);
$check($owner($tChain) === $cara, 'neues Ticket für Anna landet am Ende der Kette');
// ── Ende von Ben: seine Tickets kommen zurück (Cara hatte sie nur übernommen) ──
$GLOBALS['vertretung_today'] = $day(5);
vertretung_tick($pdo);
$check((int) $flag($ben)['is_on_vacation'] === 0, 'Bens Urlaubsstatus ist zurückgenommen');
$check($owner($tBen) === $ben, 'Bens eigenes Ticket ist wieder bei ihm');
$check($owner($t1) === $ben && $owner($tChain) === $ben, 'auch die von Anna übernommenen liegen wieder bei Ben (dort ist die Abwesenheit von Anna noch aktiv)');
$st = $pdo->query("SELECT status FROM plugin_vertretung_absences WHERE id = " . (int) $chain['id'])->fetchColumn();
$check($st === 'done', 'Abwesenheit steht auf „Beendet“');
// ── Ende von Anna: nur offene, noch bei der Vertretung liegende Tickets kommen zurück ──
$pdo->prepare("UPDATE tickets SET status = 'closed' WHERE id = ?")->execute([$t2]); // erledigt während der Abwesenheit
$pdo->prepare('UPDATE tickets SET assigned_staff_id = ? WHERE id = ?')->execute([$cara, $tManual]); // von Hand umverteilt
$GLOBALS['vertretung_today'] = $day(7);
vertretung_tick($pdo);
$check((int) $flag($anna)['is_on_vacation'] === 0 && $flag($anna)['vacation_substitute_id'] === null, 'Annas Urlaubsstatus ist zurückgenommen');
$check($owner($t1) === $anna && $owner($tNew) === $anna && $owner($tChain) === $anna, 'offene Tickets sind wieder bei Anna');
$check($owner($t2) === $ben, 'geschlossenes Ticket bleibt, wo es war');
$check($owner($tManual) === $cara, 'von Hand umverteiltes Ticket bleibt bei der neuen Person');
$check((int) $pdo->query('SELECT COUNT(*) FROM plugin_vertretung_moves WHERE returned_at IS NULL')->fetchColumn() === 0, 'keine offenen Rückgaben mehr');
$check($pdo->query("SELECT status FROM plugin_vertretung_absences WHERE id = " . (int) $plan['id'])->fetchColumn() === 'done', 'Annas Abwesenheit steht auf „Beendet“');
// ── Beginn am selben Tag, Abbruch, nicht angetreten, Rechte ────────────────
$tX = $mk($cara);
$now1 = vertretung_create($pdo, $actor($cara), $cara, $anna, $day(7), $day(20), '');
$check($now1['ok'] && $owner($tX) === $anna, 'Beginn heute: Tickets gehen sofort weiter');
$check(!vertretung_cancel($pdo, $actor($ben), (int) $now1['id']), 'fremde Abwesenheit kann nicht beendet werden');
$check(vertretung_cancel($pdo, $actor($cara), (int) $now1['id']) && $owner($tX) === $cara && (int) $flag($cara)['is_on_vacation'] === 0, 'Abbruch gibt die Tickets zurück und nimmt den Status zurück');
$later = vertretung_create($pdo, $actor($adm, 'admin'), $ben, $anna, $day(10), $day(12), '');
$check($later['ok'] && vertretung_cancel($pdo, $actor($adm, 'admin'), (int) $later['id']) && $pdo->query('SELECT status FROM plugin_vertretung_absences WHERE id = ' . (int) $later['id'])->fetchColumn() === 'cancelled', 'geplante Abwesenheit lässt sich löschen');
$ghost = vertretung_create($pdo, $actor($adm, 'admin'), $ben, $anna, $day(8), $day(9), '');
$GLOBALS['vertretung_today'] = $day(15);
vertretung_tick($pdo);
$check($pdo->query('SELECT status FROM plugin_vertretung_absences WHERE id = ' . (int) $ghost['id'])->fetchColumn() === 'missed' && (int) $flag($ben)['is_on_vacation'] === 0, 'versäumter Zeitraum wird nicht mehr gestartet');
// Profil wurde zwischendurch von Hand geändert: Plugin nimmt fremde Einstellungen nicht zurück
$GLOBALS['vertretung_today'] = $day(30);
$pdo->exec('UPDATE plugin_vertretung_absences SET status = \'cancelled\' WHERE status IN (\'planned\',\'active\')');
$c = vertretung_create($pdo, $actor($adm, 'admin'), $cara, $ben, $day(30), $day(31), '');
$pdo->prepare('UPDATE staff SET vacation_substitute_id = ? WHERE id = ?')->execute([$anna, $cara]);
$GLOBALS['vertretung_today'] = $day(32);
vertretung_tick($pdo);
$check((int) $flag($cara)['is_on_vacation'] === 1 && (int) $flag($cara)['vacation_substitute_id'] === $anna, 'von Hand geänderter Urlaubsstatus bleibt bestehen');
unset($GLOBALS['vertretung_today']);
foreach (scandir($pluginDir) ?: [] as $entry) {
if ($entry === '.' || $entry === '..') continue;
is_dir($pluginDir . '/' . $entry) ? plugin_rmdir($pluginDir . '/' . $entry) : @unlink($pluginDir . '/' . $entry);
}
@rmdir($pluginDir);
@unlink($zipPath);
if ($failures) {
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
exit(1);
}
echo 'Vertretung plugin checks passed.' . "\n";