Quelltext des aktuellen Standes
<?php
declare(strict_types=1);
/**
* Quittieren und Verschieben einzelner Fristen.
*
* Fristen haben keine eigene Zeile – sie entstehen abgeleitet aus anderen
* Modulen. Eine Entscheidung haftet deshalb am Tripel aus Quelle, Objekt und
* Fälligkeitsdatum; verschiebt sich das Datum am Ursprung, ist es eine neue
* Frist. Genau das prüfen diese Fälle.
*/
use OpenNIT\Core\Database;
use OpenNIT\Deadlines\DeadlineActions;
return function (): void {
$wurzel = dirname(__DIR__);
/** Datenbank-Doppel, das Schreibvorgänge mitschreibt. */
$db = static function (array $zeilen = []): Database {
return new class($zeilen) extends Database {
/** @var list<array{sql:string, params:array}> */
public array $schreib = [];
public function __construct(private array $zeilen)
{
parent::__construct([]);
}
public function fetchAll(string $sql, array $params = []): array
{
return $this->zeilen;
}
public function fetchOne(string $sql, array $params = []): ?array
{
return $this->zeilen[0] ?? null;
}
public function execute(string $sql, array $params = []): \PDOStatement
{
$this->schreib[] = ['sql' => $sql, 'params' => $params];
return new class extends \PDOStatement {
public function rowCount(): int { return 1; }
};
}
};
};
$audit = static function (): OpenNIT\Audit\AuditLogger {
return new class extends OpenNIT\Audit\AuditLogger {
/** @var list<string> */
public array $eintraege = [];
public function __construct() {}
public function logWithUser(
int $userId, string $actionCategory, string $actionType, ?string $objectType = null,
?int $objectId = null, ?string $objectName = null, ?array $details = null,
string $result = 'success', string $riskLevel = 'low',
?array $oldValue = null, ?array $newValue = null
): int {
$this->eintraege[] = $actionType;
return 1;
}
};
};
// ----------------------------------------------------------------
// Verschieben
// ----------------------------------------------------------------
test('Ohne Begründung wird nicht verschoben', function () use ($db, $audit) {
$doppel = $db();
$a = new DeadlineActions($doppel, $audit());
$e = $a->verschieben('contract', 5, '2026-10-01', '2026-10-15', ' ', 1);
assertFalse($e['ok'], 'Eine Verschiebung ohne Grund wird angenommen');
assertTrue(str_contains($e['message'], 'Begründung'), 'Der Grund wird nicht benannt');
assertSame(0, count($doppel->schreib), 'Es wurde trotz Ablehnung geschrieben');
});
test('Ein Datum in der Vergangenheit wird abgewiesen', function () use ($db, $audit) {
$a = new DeadlineActions($db(), $audit());
$e = $a->verschieben('contract', 5, '2026-10-01', date('Y-m-d', strtotime('-1 day')), 'Grund', 1);
assertFalse($e['ok'], 'Ein vergangenes Datum wird angenommen');
assertTrue(str_contains($e['message'], 'Vergangenheit'), 'Die Ursache wird nicht benannt');
});
test('Beliebig weit lässt sich eine Frist nicht schieben', function () use ($db, $audit) {
$a = new DeadlineActions($db(), $audit());
$zuWeit = date('Y-m-d', strtotime('+' . (DeadlineActions::MAX_POSTPONE_DAYS + 30) . ' days'));
$e = $a->verschieben('contract', 5, '2026-10-01', $zuWeit, 'Grund', 1);
assertFalse($e['ok'], 'Eine Frist lässt sich unbegrenzt schieben');
assertTrue(str_contains($e['message'], (string)DeadlineActions::MAX_POSTPONE_DAYS), 'Die Grenze wird nicht genannt');
});
test('Eine gültige Verschiebung wird festgehalten und protokolliert', function () use ($db, $audit) {
$doppel = $db();
$prot = $audit();
$a = new DeadlineActions($doppel, $prot);
$ziel = date('Y-m-d', strtotime('+14 days'));
$e = $a->verschieben('contract', 5, '2026-10-01', $ziel, 'Verhandlung läuft noch', 1);
assertTrue($e['ok'], 'Die Verschiebung wird abgelehnt: ' . $e['message']);
assertSame(1, count($doppel->schreib), 'Es wurde nicht genau einmal geschrieben');
assertTrue(str_contains($doppel->schreib[0]['sql'], 'ON DUPLICATE KEY UPDATE'),
'Eine zweite Entscheidung zur selben Frist legt eine weitere Zeile an');
assertTrue(in_array('deadline_postponed', $prot->eintraege, true), 'Die Verschiebung steht nicht im Protokoll');
});
// ----------------------------------------------------------------
// Quittieren
// ----------------------------------------------------------------
test('Quittieren geht ohne Begründung', function () use ($db, $audit) {
$prot = $audit();
$a = new DeadlineActions($db(), $prot);
$e = $a->quittieren('ssl', 3, '2026-09-30', '', 1);
assertTrue($e['ok'], 'Das Quittieren wird abgelehnt: ' . $e['message']);
assertTrue(in_array('deadline_acknowledged', $prot->eintraege, true), 'Das Quittieren steht nicht im Protokoll');
});
test('Eine unbekannte Quelle oder ein krummes Datum wird abgewiesen', function () use ($db, $audit) {
$a = new DeadlineActions($db(), $audit());
assertFalse($a->quittieren('erfunden', 3, '2026-09-30', '', 1)['ok'], 'Eine erfundene Quelle wird angenommen');
assertFalse($a->quittieren('ssl', 3, '30.09.2026', '', 1)['ok'], 'Ein krummes Datum wird angenommen');
assertFalse($a->quittieren('ssl', 0, '2026-09-30', '', 1)['ok'], 'Objekt 0 wird angenommen');
});
// ----------------------------------------------------------------
// Anreichern
// ----------------------------------------------------------------
test('Eine verschobene Frist wird nach dem neuen Datum beurteilt', function () use ($audit) {
$inDreiTagen = date('Y-m-d', strtotime('+3 days'));
$doppel = new class($inDreiTagen) extends Database {
public function __construct(private string $ziel) { parent::__construct([]); }
public function fetchAll(string $sql, array $params = []): array
{
return [[
'source' => 'contract', 'object_id' => 5, 'due_date' => '2026-01-01',
'action' => 'postponed', 'postponed_to' => $this->ziel,
'note' => 'Grund', 'created_at' => '2026-01-01 10:00:00', 'entschieden_von' => 'A. Person',
]];
}
};
$a = new DeadlineActions($doppel, $audit());
$fristen = $a->anreichern([[
'source' => 'contract', 'object_id' => 5, 'due_date' => '2026-01-01',
'days_left' => -300, 'severity' => 'overdue',
]]);
assertSame('postponed', $fristen[0]['action'], 'Die Entscheidung wird nicht angehängt');
assertSame(3, $fristen[0]['days_left'], 'Die Restlaufzeit folgt nicht dem neuen Datum');
assertSame('critical', $fristen[0]['severity'], 'Die Einstufung folgt nicht dem neuen Datum');
assertSame('A. Person', $fristen[0]['action_by'], 'Wer entschieden hat, fehlt');
});
test('Quittiertes verschwindet nicht, sondern rückt ans Ende', function () use ($audit) {
$doppel = new class extends Database {
public function __construct() { parent::__construct([]); }
public function fetchAll(string $sql, array $params = []): array
{
return [[
'source' => 'ssl', 'object_id' => 1, 'due_date' => '2026-05-05',
'action' => 'acknowledged', 'postponed_to' => null,
'note' => '', 'created_at' => '2026-01-01 10:00:00', 'entschieden_von' => 'B',
]];
}
};
$a = new DeadlineActions($doppel, $audit());
$fristen = $a->anreichern([
['source' => 'ssl', 'object_id' => 1, 'due_date' => '2026-05-05', 'days_left' => 1, 'severity' => 'critical'],
['source' => 'contract', 'object_id' => 9, 'due_date' => '2026-06-06', 'days_left' => 40, 'severity' => 'info'],
]);
assertSame(2, count($fristen), 'Eine quittierte Frist verschwindet');
assertSame('contract', $fristen[0]['source'], 'Die offene Frist steht nicht vorn');
assertSame('acknowledged', $fristen[1]['action'], 'Die quittierte Frist steht nicht hinten');
});
// ----------------------------------------------------------------
// Anbindung
// ----------------------------------------------------------------
test('Ohne die Tabelle zeigt das Cockpit die Fristen trotzdem', function () use ($audit) {
// Zwischen dem Einspielen des Updates und dem Migrationslauf liegt ein
// Moment; die Seite darf in dieser Zeit nicht ausfallen.
$doppel = new class extends Database {
public function __construct() { parent::__construct([]); }
public function fetchAll(string $sql, array $params = []): array
{
throw new \RuntimeException("Table 'deadline_actions' doesn't exist");
}
};
$a = new DeadlineActions($doppel, $audit());
$fristen = $a->anreichern([
['source' => 'ssl', 'object_id' => 1, 'due_date' => '2026-05-05', 'days_left' => 5, 'severity' => 'critical'],
]);
assertSame(1, count($fristen), 'Die Frist verschwindet');
assertSame('', $fristen[0]['action'], 'Es wird eine Entscheidung erfunden');
});
test('Der Fremdschlüssel passt zum Typ der Benutzer-Kennung', function () use ($wurzel) {
// Ein abweichender Typ laesst die Migration mit errno 150 scheitern.
$migration = (string)file_get_contents($wurzel . '/database/migrations/226_deadline_actions.sql');
assertTrue(
(bool)preg_match('/`created_by`\s+BIGINT UNSIGNED/', $migration),
'created_by passt nicht zu users.id'
);
$baseline = (string)file_get_contents($wurzel . '/database/migrations/001_initial_schema.sql');
$stelle = strpos($baseline, 'CREATE TABLE IF NOT EXISTS `users`');
assertTrue($stelle !== false, 'Die Benutzertabelle ist nicht auffindbar');
assertTrue(
str_contains(substr($baseline, $stelle, 200), '`id` BIGINT UNSIGNED'),
'Der Typ von users.id hat sich geaendert - der Fremdschluessel will nachgezogen werden'
);
});
test('Der Aggregator liefert die Kennung des Ursprungs mit', function () use ($wurzel) {
// Ohne sie liesse sich keine Entscheidung zuordnen.
$agg = (string)file_get_contents($wurzel . '/src/Deadlines/DeadlineAggregator.php');
assertTrue(str_contains($agg, "'object_id' => (int)(\$r['id'] ?? 0)"), 'Die Kennung fehlt am Eintrag');
});
test('Die drei Wege sind an eine Berechtigung gebunden', function () use ($wurzel) {
$routen = (string)file_get_contents($wurzel . '/src/Core/Application.php');
foreach (['/deadlines/acknowledge', '/deadlines/postpone', '/deadlines/revoke'] as $route) {
$stelle = strpos($routen, "post('" . $route . "'");
assertTrue($stelle !== false, "Die Route {$route} fehlt");
assertTrue(
str_contains(substr($routen, $stelle, 200), "'capability' => 'view_deadlines'"),
"Die Route {$route} ist nicht an eine Berechtigung gebunden"
);
}
});
test('Jedes Formular der Fristenseite trägt den Token', function () use ($wurzel) {
// Die Attributreihenfolge ist nicht einheitlich; geprüft wird jedes
// Formular für sich, nicht die Summe.
$seite = (string)file_get_contents($wurzel . '/templates/deadlines/index.php');
preg_match_all('/<form\b(.*?)<\/form>/s', $seite, $treffer);
assertTrue(count($treffer[0]) >= 4, 'Es fehlen Formulare auf der Seite');
$geprueft = 0;
foreach ($treffer[0] as $formular) {
$kopf = substr($formular, 0, (int)strpos($formular, '>'));
if (stripos($kopf, 'method="POST"') === false) {
continue;
}
$geprueft++;
assertTrue(
str_contains($formular, 'name="_csrf"'),
'Ein POST-Formular ohne Token: ' . substr($kopf, 0, 80)
);
}
assertTrue($geprueft >= 3, 'Die Entscheidungs-Formulare fehlen');
});
};