Quelltext des aktuellen Standes
<?php
declare(strict_types=1);
/**
* Sammelstörung – die Hierarchie und was von ihr abhängt.
*
* Eine Sammelstörung sammelt Meldungen, eine Meldung sammelt keine weiteren.
* Diese eine Ebene trägt alles Übrige: Wäre sie tiefer, könnte eine Antwort im
* Kreis laufen, und „alle mitschließen" wüsste nicht, wo es aufhören soll.
*
* Geprüft wird deshalb, dass jeder Weg in eine zweite Ebene abgewiesen wird,
* dass eine einzelne scheiternde Meldung die übrigen nicht aufhält und dass
* die Aktionen nur mit Recht und im eigenen Standort erreichbar sind.
*/
use OpenNIT\Core\Database;
use OpenNIT\Tickets\TicketManager;
return static function (): void {
$wurzel = dirname(__DIR__);
$quelle = static fn (string $pfad): string => (string)file_get_contents($wurzel . '/' . $pfad);
/**
* Ein TicketManager auf einer gedachten Ticket-Tabelle.
*
* @param array<int,array<string,mixed>> $tickets Kennung => Zeile
*/
$managerMit = static function (array $tickets) {
$db = new class ($tickets) extends Database {
/** @var array<int,array<string,mixed>> */
public array $tickets;
/** @var list<array{0:string,1:array<string,mixed>,2:array<int,mixed>}> */
public array $writes = [];
public function __construct(array $tickets)
{
$this->tickets = $tickets;
}
public function fetchOne(string $sql, array $params = []): ?array
{
if (str_contains($sql, 'JOIN tickets p')) {
$kind = $this->tickets[(int)$params[0]] ?? null;
$pid = $kind['parent_ticket_id'] ?? null;
if ($pid === null || !isset($this->tickets[(int)$pid])) {
return null;
}
return ['id' => (int)$pid] + $this->tickets[(int)$pid];
}
return $this->tickets[(int)$params[0]] ?? null;
}
public function fetchAll(string $sql, array $params = []): array
{
$treffer = [];
foreach ($this->tickets as $id => $zeile) {
if ((int)($zeile['parent_ticket_id'] ?? 0) === (int)$params[0]) {
$treffer[] = ['id' => $id] + $zeile;
}
}
return $treffer;
}
public function fetchColumn(string $sql, array $params = [], int $column = 0): mixed
{
$zahl = 0;
foreach ($this->tickets as $zeile) {
if ((int)($zeile['parent_ticket_id'] ?? 0) === (int)$params[0]) {
$zahl++;
}
}
return $zahl;
}
public function update(string $table, array $data, string $where, array $params = []): int
{
$this->writes[] = [$table, $data, $params];
if (array_key_exists('parent_ticket_id', $data) && isset($this->tickets[(int)$params[0]])) {
$this->tickets[(int)$params[0]]['parent_ticket_id'] = $data['parent_ticket_id'];
}
return 1;
}
};
// Ein TicketManager, der statt zu schließen nur mitschreibt, wen er
// angefasst hat – der Abschluss selbst hängt an Mail und SLA.
$manager = new class extends TicketManager {
/** @var list<array{0:int,1:string}> */
public array $behandelt = [];
public function __construct()
{
}
public function solveTicket(int $id): void
{
$this->behandelt[] = [$id, 'solved'];
}
public function closeTicket(int $id, array $opts = []): void
{
$this->behandelt[] = [$id, 'closed'];
}
};
// `db` ist im TicketManager privat und über die Unterklasse nicht
// erreichbar – deshalb direkt an der Basisklasse ansetzen.
$feld = (new ReflectionClass(TicketManager::class))->getProperty('db');
$feld->setAccessible(true);
$feld->setValue($manager, $db);
return [$manager, $db];
};
// ----------------------------------------------------------------
// Die Hierarchie bleibt eine Ebene tief
// ----------------------------------------------------------------
test('Eine Meldung hängt sich an eine Sammelstörung', function () use ($managerMit) {
[$manager, $db] = $managerMit([
1 => ['parent_ticket_id' => null, 'subject' => 'Sammelstörung', 'status' => 'open'],
2 => ['parent_ticket_id' => null, 'subject' => 'Meldung', 'status' => 'new'],
]);
assertSame('', $manager->attachToParent(2, 1));
assertSame(1, $db->tickets[2]['parent_ticket_id']);
});
test('Ein Ticket kann nicht sich selbst untergeordnet werden', function () use ($managerMit) {
[$manager, $db] = $managerMit([
1 => ['parent_ticket_id' => null, 'subject' => 'Eins', 'status' => 'open'],
]);
assertTrue($manager->attachToParent(1, 1) !== '', 'Das Selbstanhängen wurde zugelassen');
assertSame([], $db->writes, 'Trotz Ablehnung wurde geschrieben');
});
test('Eine Meldung wird nicht zur Sammelstörung einer weiteren', function () use ($managerMit) {
// Zweite Ebene: 2 hängt schon an 1, also darf 3 nicht an 2.
[$manager, $db] = $managerMit([
1 => ['parent_ticket_id' => null, 'subject' => 'Sammelstörung', 'status' => 'open'],
2 => ['parent_ticket_id' => 1, 'subject' => 'Meldung', 'status' => 'open'],
3 => ['parent_ticket_id' => null, 'subject' => 'Weitere', 'status' => 'new'],
]);
assertTrue($manager->attachToParent(3, 2) !== '', 'Eine zweite Ebene wurde zugelassen');
assertSame([], $db->writes, 'Trotz Ablehnung wurde geschrieben');
});
test('Eine Sammelstörung hängt sich nicht selbst an eine andere', function () use ($managerMit) {
// Andersherum: 1 sammelt bereits, darf also nicht unter 9 rutschen.
[$manager, $db] = $managerMit([
1 => ['parent_ticket_id' => null, 'subject' => 'Sammelstörung', 'status' => 'open'],
2 => ['parent_ticket_id' => 1, 'subject' => 'Meldung', 'status' => 'open'],
9 => ['parent_ticket_id' => null, 'subject' => 'Andere', 'status' => 'open'],
]);
assertTrue($manager->attachToParent(1, 9) !== '', 'Eine zweite Ebene wurde zugelassen');
assertSame([], $db->writes, 'Trotz Ablehnung wurde geschrieben');
});
test('Ein Ticket, das es nicht gibt, wird abgewiesen', function () use ($managerMit) {
[$manager, $db] = $managerMit([
1 => ['parent_ticket_id' => null, 'subject' => 'Sammelstörung', 'status' => 'open'],
]);
assertTrue($manager->attachToParent(2, 1) !== '', 'Eine unbekannte Meldung wurde angehängt');
assertTrue($manager->attachToParent(1, 7) !== '', 'Eine unbekannte Sammelstörung wurde akzeptiert');
assertSame([], $db->writes, 'Trotz Ablehnung wurde geschrieben');
});
test('Das Lösen setzt die Zuordnung zurück', function () use ($managerMit) {
[$manager, $db] = $managerMit([
1 => ['parent_ticket_id' => null, 'subject' => 'Sammelstörung', 'status' => 'open'],
2 => ['parent_ticket_id' => 1, 'subject' => 'Meldung', 'status' => 'open'],
]);
$manager->detachFromParent(2);
assertSame(null, $db->tickets[2]['parent_ticket_id']);
});
test('Jede Ablehnung nennt einen Grund', function () use ($managerMit) {
// Der Grund landet als Meldung in der Oberfläche – ein leerer Text
// sähe dort wie Erfolg aus.
[$manager] = $managerMit([
1 => ['parent_ticket_id' => null, 'subject' => 'Sammelstörung', 'status' => 'open'],
2 => ['parent_ticket_id' => 1, 'subject' => 'Meldung', 'status' => 'open'],
]);
foreach ([[1, 1], [3, 1], [1, 3], [9, 2]] as [$kind, $eltern]) {
$grund = $manager->attachToParent($kind, $eltern);
assertTrue($grund !== '' && trim($grund) === $grund && str_ends_with($grund, '.'),
'Kein brauchbarer Grund für ' . $kind . '→' . $eltern . ': ' . var_export($grund, true));
}
});
// ----------------------------------------------------------------
// Gemeinsam schließen
// ----------------------------------------------------------------
test('Was schon abgeschlossen ist, bleibt unangetastet', function () use ($managerMit) {
// Ein erneutes Schließen würde den Melder ein zweites Mal
// benachrichtigen und den Lösungszeitpunkt überschreiben.
[$manager] = $managerMit([
1 => ['parent_ticket_id' => null, 'subject' => 'Sammelstörung', 'status' => 'open'],
2 => ['parent_ticket_id' => 1, 'subject' => 'A', 'status' => 'open'],
3 => ['parent_ticket_id' => 1, 'subject' => 'B', 'status' => 'closed'],
4 => ['parent_ticket_id' => 1, 'subject' => 'C', 'status' => 'solved'],
5 => ['parent_ticket_id' => 1, 'subject' => 'D', 'status' => 'waiting'],
6 => ['parent_ticket_id' => 9, 'subject' => 'Fremd', 'status' => 'open'],
]);
assertSame(2, $manager->closeChildren(1, 'solved'));
assertSame([[2, 'solved'], [5, 'solved']], $manager->behandelt);
});
test('Der Zielstatus geht an jede Meldung durch', function () use ($managerMit) {
[$manager] = $managerMit([
1 => ['parent_ticket_id' => null, 'subject' => 'Sammelstörung', 'status' => 'open'],
2 => ['parent_ticket_id' => 1, 'subject' => 'A', 'status' => 'new'],
3 => ['parent_ticket_id' => 1, 'subject' => 'B', 'status' => 'open'],
]);
assertSame(2, $manager->closeChildren(1, 'closed'));
assertSame([[2, 'closed'], [3, 'closed']], $manager->behandelt);
});
test('Eine Sammelstörung ohne Meldungen schließt nichts mit', function () use ($managerMit) {
[$manager] = $managerMit([
1 => ['parent_ticket_id' => null, 'subject' => 'Allein', 'status' => 'open'],
]);
assertSame(0, $manager->closeChildren(1, 'solved'));
assertSame([], $manager->behandelt);
});
test('Ein unbekannter Zielstatus bleibt wirkungslos', function () use ($managerMit) {
[$manager, $db] = $managerMit([
1 => ['parent_ticket_id' => null, 'subject' => 'Sammelstörung', 'status' => 'open'],
2 => ['parent_ticket_id' => 1, 'subject' => 'A', 'status' => 'open'],
]);
assertSame(0, $manager->closeChildren(1, 'open'));
assertSame(0, $manager->closeChildren(1, ''));
assertSame([], $manager->behandelt, 'Ein unbekannter Status hat Meldungen angefasst');
assertSame([], $db->writes, 'Ein unbekannter Status hat geschrieben');
});
test('Eine scheiternde Meldung hält die übrigen nicht auf', function () use ($quelle) {
// Ein einzelner Fehlschlag darf den Durchlauf nicht abbrechen – sonst
// bliebe der Rest der Sammelstörung unbehandelt zurück.
$manager = $quelle('src/Tickets/TicketManager.php');
$abschnitt = substr(
$manager,
(int)strpos($manager, 'public function replyToChildren'),
(int)strpos($manager, 'public function addLink') - (int)strpos($manager, 'public function replyToChildren')
);
assertSame(2, substr_count($abschnitt, 'catch (\Throwable)'),
'Nicht jeder Durchlauf fängt den Fehlschlag einer einzelnen Meldung ab');
});
// ----------------------------------------------------------------
// Schema
// ----------------------------------------------------------------
test('Die Zuordnung steht als Spalte im Schema', function () use ($wurzel) {
$migration = null;
foreach ((array)glob($wurzel . '/database/migrations/*.sql') as $datei) {
$inhalt = (string)file_get_contents((string)$datei);
if (str_contains($inhalt, 'parent_ticket_id')) {
$migration = $inhalt;
break;
}
}
assertTrue($migration !== null, 'Keine Migration legt die Spalte an');
assertTrue(str_contains($migration, 'ADD COLUMN `parent_ticket_id`'), 'Die Spalte fehlt');
assertTrue(str_contains($migration, 'ADD INDEX `idx_tickets_parent`'), 'Ohne Index wird je Ticket voll gelesen');
// Verschwindet die Sammelstörung, müssen die Meldungen bestehen bleiben.
assertTrue(str_contains($migration, 'ON DELETE SET NULL'), 'Das Löschen der Sammelstörung nähme die Meldungen mit');
assertFalse(str_contains($migration, 'ON DELETE CASCADE'), 'Das Löschen der Sammelstörung nähme die Meldungen mit');
});
// ----------------------------------------------------------------
// Erreichbarkeit
// ----------------------------------------------------------------
test('Die Aktionen sind nur mit Recht und bei aktivem Modul erreichbar', function () use ($quelle) {
$app = $quelle('src/Core/Application.php');
foreach (['parentAttach', 'parentDetach', 'childrenClose'] as $aktion) {
assertTrue(
(bool)preg_match(
"/TicketController@" . $aktion . "'[^)]*'capability'\s*=>\s*'manage_tickets'/",
$app
),
'Die Aktion ' . $aktion . ' ist ohne Rechteprüfung erreichbar'
);
}
// Die Routen müssen im Modul-Block liegen; dort steht auch die
// bestehende Verknüpfungs-Route.
$links = (int)strpos($app, "'/tickets/{id}/links'");
$eltern = (int)strpos($app, "'/tickets/{id}/parent'");
assertTrue($eltern > $links && $eltern - $links < 4000,
'Die Sammelstörung-Routen stehen nicht beim übrigen Ticket-Modul');
});
test('Jede schreibende Aktion prüft den Standort', function () use ($quelle) {
// Ohne Standortprüfung könnte ein beschränkter Bearbeiter fremde
// Tickets an seine Sammelstörung hängen.
$controller = $quelle('src/Controllers/TicketController.php');
foreach ([
'parentAttach' => 'guardTicketAction',
'parentDetach' => 'guardTicketAction',
'childrenClose' => 'guardTicketLocationRedirect',
] as $aktion => $wache) {
$von = (int)strpos($controller, 'public function ' . $aktion . '(');
assertTrue($von > 0, 'Die Aktion ' . $aktion . ' fehlt');
$abschnitt = substr($controller, $von, 1200);
assertTrue(str_contains($abschnitt, $wache),
'Die Aktion ' . $aktion . ' prüft den Standort nicht');
assertTrue(str_contains($abschnitt, "authorize('manage_tickets')"),
'Die Aktion ' . $aktion . ' prüft die Berechtigung nicht');
}
// Beim Anhängen zählt zusätzlich der Standort des Ziels – sonst ließe
// sich über die Fehlermeldung die Existenz fremder Tickets abfragen.
$von = (int)strpos($controller, 'public function parentAttach(');
$abschnitt = substr($controller, $von, 1600);
assertTrue(str_contains($abschnitt, 'ticketLocationAllowed'),
'Der Standort der Sammelstörung wird nicht geprüft');
});
test('Die Antwort an alle läuft nur auf ausdrücklichen Wunsch', function () use ($quelle) {
$controller = $quelle('src/Controllers/TicketController.php');
assertTrue(str_contains($controller, "\$request->post('reply_to_children')"),
'Die Antwort an alle hängt an keinem Schalter');
assertTrue(str_contains($controller, '$this->tickets->replyToChildren('),
'Die Antwort erreicht die angehängten Meldungen nicht');
});
test('Die Oberfläche bietet beide Seiten der Zuordnung an', function () use ($quelle) {
$sidebar = $quelle('templates/tickets/_sidebar.php');
foreach ([
'/parent/delete' => 'Das Lösen fehlt',
'/children/close' => 'Das gemeinsame Schließen fehlt',
'ticket-parent-form' => 'Das Anhängen fehlt',
] as $stelle => $fehler) {
assertTrue(str_contains($sidebar, $stelle), $fehler);
}
// Jedes schreibende Formular braucht den Token unter dem erwarteten Namen.
$formulare = preg_match_all('/<form method="POST" action="\/tickets\/[^"]*(?:parent|children)[^"]*"/i', $sidebar);
$token = substr_count($sidebar, 'name="_csrf"');
assertTrue($formulare > 0 && $token >= $formulare,
'Ein Formular der Sammelstörung sendet keinen Token');
});
};