Quelltext des aktuellen Standes
<?php
declare(strict_types=1);
use OpenNIT\Core\Database;
use OpenNIT\Tickets\TicketManager;
/**
* Deckt die Zustandslogik der Tickets ab.
*
* Zwei Zusagen hängen daran: Die Liste sortiert nach Bearbeitungsstand, und die
* SLA-Uhr steht genau so lange, wie das Ticket in der Warteschlange liegt. Beide
* laufen über `recordPauseTransition`, den gemeinsamen Punkt aller
* Statuswechsel-Pfade.
*/
return static function (): void {
/**
* Datenbank-Attrappe, die jeden Schreibvorgang mitschreibt.
*
* @param string|null $status Status, den `SELECT status FROM tickets` liefert
*/
$db = static function (?string $status = null): Database {
return new class($status) extends Database {
/** @var array<int,string> Ausgeführte Anweisungen in Reihenfolge */
public array $statements = [];
/** @var array<int,array<string,mixed>> Daten der update()-Aufrufe */
public array $updates = [];
public function __construct(private ?string $status = null)
{
parent::__construct([]);
}
public function insert(string $table, array $data): int
{
$this->statements[] = 'INSERT INTO ' . $table;
return 42;
}
public function execute(string $sql, array $params = []): \PDOStatement
{
$this->statements[] = preg_replace('/\s+/', ' ', trim($sql));
// Der Aufrufer wertet den Rückgabewert nicht aus.
return new class extends \PDOStatement {};
}
public function fetchOne(string $sql, array $params = []): ?array
{
if ($this->status !== null && str_contains($sql, 'FROM tickets')) {
return ['status' => $this->status];
}
return null;
}
public function fetchAll(string $sql, array $params = []): array
{
return [];
}
public function fetchColumn(string $sql, array $params = [], int $column = 0): mixed
{
return false;
}
public function update(string $table, array $data, string $where = '', array $params = []): int
{
$this->statements[] = 'UPDATE ' . $table;
$this->updates[] = $data;
return 1;
}
};
};
/** Erzeugt den Manager ohne Konstruktor (der baut Mail-Konfiguration auf). */
$manager = static function (Database $datenbank): TicketManager {
$klasse = new ReflectionClass(TicketManager::class);
$mgr = $klasse->newInstanceWithoutConstructor();
$feld = $klasse->getProperty('db');
$feld->setAccessible(true);
$feld->setValue($mgr, $datenbank);
return $mgr;
};
/** Enthält eine der ausgeführten Anweisungen den Ausschnitt? */
$enthaelt = static function (array $statements, string $teil): bool {
foreach ($statements as $s) {
if (str_contains($s, $teil)) {
return true;
}
}
return false;
};
// ----------------------------------------------------------------
// Sortierung der Liste
// ----------------------------------------------------------------
test('Der Bearbeitungsstand bestimmt die Reihenfolge in der Liste', function () use ($db, $manager) {
$mgr = $manager($db());
// Offene Vorgänge zuerst, abgeschlossene zuletzt.
assertSame(1, $mgr->getSortOrder('new'));
assertSame(2, $mgr->getSortOrder('open'));
assertSame(3, $mgr->getSortOrder('waiting'));
assertSame(4, $mgr->getSortOrder('solved'));
assertSame(5, $mgr->getSortOrder('closed'));
});
test('Ein unbekannter Status wird wie „offen" einsortiert', function () use ($db, $manager) {
// Sonst rutschte ein Ticket mit unerwartetem Status ans Listenende und
// fiele der Bearbeitung aus dem Blick.
assertSame($manager($db())->getSortOrder('open'), $manager($db())->getSortOrder('irgendwas'));
});
// ----------------------------------------------------------------
// SLA-Uhr
// ----------------------------------------------------------------
test('Ein Wechsel auf denselben Status bleibt folgenlos', function () use ($db, $manager) {
$datenbank = $db();
$manager($datenbank)->recordPauseTransition(1, 'open', 'open');
assertSame(0, count($datenbank->statements), 'Ein Nicht-Wechsel wurde protokolliert');
});
test('Der Eintritt in die Warteschlange hält die SLA-Uhr an', function () use ($db, $manager, $enthaelt) {
$datenbank = $db();
$manager($datenbank)->recordPauseTransition(1, 'open', 'waiting');
assertTrue($enthaelt($datenbank->statements, 'sla_paused_at = NOW()'), 'Die Uhr läuft weiter');
// Nur setzen, wenn noch keine Pause läuft – sonst ginge die bereits
// angelaufene Pausenzeit beim erneuten Setzen verloren.
assertTrue($enthaelt($datenbank->statements, 'sla_paused_at IS NULL'), 'Eine laufende Pause wird überschrieben');
});
test('Der Austritt schreibt die Pausenzeit fort und lässt die Uhr weiterlaufen', function () use ($db, $manager, $enthaelt) {
$datenbank = $db();
$manager($datenbank)->recordPauseTransition(1, 'waiting', 'open');
assertTrue(
$enthaelt($datenbank->statements, 'sla_paused_total_seconds + GREATEST(0,'),
'Die Pausenzeit wird nicht aufaddiert'
);
assertTrue($enthaelt($datenbank->statements, 'sla_paused_at = NULL'), 'Die Pause wird nicht beendet');
});
test('Die Pausenzeit wächst nie um einen negativen Betrag', function () use ($db, $manager, $enthaelt) {
// GREATEST(0, …) fängt eine rückwärts laufende Uhr ab: Eine verstellte
// Server-Zeit darf die aufgelaufene Pausenzeit nicht verkleinern.
$datenbank = $db();
$manager($datenbank)->recordPauseTransition(1, 'waiting', 'solved');
assertTrue($enthaelt($datenbank->statements, 'GREATEST(0,'), 'Kein Schutz gegen negative Differenz');
});
test('Ein Wechsel ohne Warteschlange rührt die SLA-Uhr nicht an', function () use ($db, $manager, $enthaelt) {
$datenbank = $db();
$manager($datenbank)->recordPauseTransition(1, 'open', 'solved');
assertFalse($enthaelt($datenbank->statements, 'sla_paused_at'), 'Die SLA-Uhr wurde angefasst');
});
test('Jeder Wechsel wird protokolliert', function () use ($db, $manager, $enthaelt) {
// Grundlage der Auswertung „Verweildauer je Status".
foreach ([['open', 'waiting'], ['waiting', 'open'], ['open', 'solved'], [null, 'new']] as [$alt, $neu]) {
$datenbank = $db();
$manager($datenbank)->recordPauseTransition(1, $alt, $neu);
assertTrue(
$enthaelt($datenbank->statements, 'INSERT INTO ticket_status_log'),
'Nicht protokolliert: ' . ($alt ?? 'null') . ' → ' . $neu
);
}
});
// ----------------------------------------------------------------
// Wiedereröffnung durch eine Antwort
// ----------------------------------------------------------------
/** Setzt eine Antwort ab und liefert die Daten des Ticket-Updates. */
$antwort = static function (Database $datenbank, TicketManager $mgr): array {
$mgr->addWatcherReply(1, 'Antworttext', 'Anna', 'anna@example.org', true);
return $datenbank->updates[0] ?? [];
};
test('Ein gelöstes oder wartendes Ticket kommt durch eine Antwort zurück', function () use ($db, $manager, $antwort) {
foreach (['solved', 'waiting'] as $status) {
$datenbank = $db($status);
$daten = $antwort($datenbank, $manager($datenbank));
assertSame('open', $daten['status'] ?? null, 'Nicht wiedereröffnet aus ' . $status);
// Der Lösungszeitpunkt wird ausdrücklich auf NULL gesetzt – sonst
// gälte das Ticket in den Kennzahlen weiterhin als gelöst.
assertTrue(array_key_exists('solved_at', $daten), 'Der Lösungszeitpunkt wird nicht zurückgesetzt');
assertSame(null, $daten['solved_at'], 'Der Lösungszeitpunkt bleibt stehen');
}
});
test('Ein geschlossenes Ticket wird durch eine Antwort nicht wiedereröffnet', function () use ($db, $manager, $antwort) {
// Ein abgeschlossener Vorgang bleibt abgeschlossen; eine späte Antwort
// darf ihn nicht in die Bearbeitung zurückholen.
$datenbank = $db('closed');
$daten = $antwort($datenbank, $manager($datenbank));
assertFalse(isset($daten['status']), 'Der Status wurde verändert');
});
test('Ein offenes Ticket rutscht in der Liste nach oben', function () use ($db, $manager, $antwort) {
// Ohne aufgefrischtes updated_at bliebe das Ticket unten stehen und der
// Hinweis auf Ungelesenes löste nicht aus.
$datenbank = $db('open');
$daten = $antwort($datenbank, $manager($datenbank));
assertFalse(isset($daten['status']), 'Ein offenes Ticket wird umgestellt');
assertTrue(isset($daten['updated_at']), 'Die Liste sortiert das Ticket nicht nach oben');
assertSame(42, $daten['last_reply_id'] ?? null);
});
};