Quelltext des aktuellen Standes
<?php
declare(strict_types=1);
use OpenNIT\Audit\AuditLogger;
use OpenNIT\Content\LoanManager;
use OpenNIT\Core\Database;
/**
* Deckt die Unterscheidung zwischen Reservierung und Ausgabe ab.
*
* Eine Ausgabe hält fest, dass ein Gerät den Besitzer gewechselt hat, und
* benennt die ausgebende Person. Wer aus der Ferne für eine Kollegin bucht,
* gibt nichts aus – deshalb entsteht eine Ausgabe nur auf ausdrücklichen
* Wunsch, und ein Ausgabedatum in der Zukunft nie.
*/
return static function (): void {
// ----------------------------------------------------------------
// Die Entscheidung selbst
// ----------------------------------------------------------------
test('Ohne ausdrücklichen Wunsch entsteht keine Ausgabe', function () {
// Kern der Unterscheidung: Das Datum allein begründet keine Ausgabe.
foreach (['2026-09-03', '2026-09-01', '2026-12-24'] as $start) {
assertFalse(
LoanManager::issuesImmediately(false, $start, '2026-09-03'),
'Ausgabe ohne Wunsch bei ' . $start
);
}
});
test('Mit Wunsch entsteht heute und rückwirkend eine Ausgabe', function () {
// Heute am Ausgabetisch, und die Nacherfassung eines vergangenen Tages.
assertTrue(LoanManager::issuesImmediately(true, '2026-09-03', '2026-09-03'));
assertTrue(LoanManager::issuesImmediately(true, '2026-08-20', '2026-09-03'));
});
test('Ein künftiges Ausgabedatum lässt sich nie sofort ausgeben', function () {
// Auch ein manipulierter Formularwert führt nur zur Reservierung.
assertFalse(LoanManager::issuesImmediately(true, '2026-09-04', '2026-09-03'));
assertFalse(LoanManager::issuesImmediately(true, '2027-01-01', '2026-09-03'));
});
test('Der Vergleichstag ist der heutige, wenn keiner genannt wird', function () {
$heute = date('Y-m-d');
$morgen = date('Y-m-d', strtotime('+1 day'));
assertTrue(LoanManager::issuesImmediately(true, $heute));
assertFalse(LoanManager::issuesImmediately(true, $morgen));
assertFalse(LoanManager::issuesImmediately(false, $heute));
});
// ----------------------------------------------------------------
// Was in der Ausleihe landet
// ----------------------------------------------------------------
/**
* Legt einen Vorgang an und liefert die geschriebene Zeile.
*
* @return array{row:array<string,mixed>,audit:array<int,string>}
*/
$anlegen = static function (string $start, bool $sofort): array {
$db = new class extends Database {
/** @var array<string,mixed> */
public array $lendingRow = [];
/** @var array<int,array{table:string,data:array}> */
public array $inserts = [];
public function __construct()
{
parent::__construct([]);
}
public function fetchOne(string $sql, array $params = []): ?array
{
// Gerät für die Sperre und für refreshDeviceStatus()
return ['id' => 5, 'title' => 'Leih-Notebook', 'status' => 'available'];
}
public function fetchColumn(string $sql, array $params = [], int $column = 0): mixed
{
// Keine Konflikte, keine konkurrierenden Vorgänge
return 0;
}
public function fetchAll(string $sql, array $params = []): array
{
return [];
}
public function insert(string $table, array $data): int
{
$this->inserts[] = ['table' => $table, 'data' => $data];
if ($table === 'loan_lendings') {
$this->lendingRow = $data;
}
return 42;
}
public function update(string $table, array $data, string $where = '', array $params = []): int
{
return 1;
}
};
$audit = new class($db) extends AuditLogger {
/** @var array<int,string> */
public array $aktionen = [];
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->aktionen[] = $actionType;
return 1;
}
};
$manager = new LoanManager($db, $audit);
$manager->createLending([
'device_id' => 5,
'borrower_name' => 'Kollegin',
'planned_start' => $start,
'planned_end' => $start,
'issue_now' => $sofort,
], [], 7);
return ['row' => $db->lendingRow, 'audit' => $audit->aktionen];
};
test('Eine Reservierung für heute nennt niemanden als ausgebende Person', function () use ($anlegen) {
// Der gemeldete Fall: aus dem Homeoffice für eine Kollegin gebucht.
$e = $anlegen(date('Y-m-d'), false);
assertSame('reserved', $e['row']['status']);
assertSame(null, $e['row']['issued_by'], 'Die Reservierung wird jemandem als Ausgabe zugeschrieben');
assertSame(null, $e['row']['actual_start'], 'Die Reservierung trägt einen Ausgabezeitpunkt');
assertSame(['reserve_loan_device'], $e['audit']);
});
test('Eine Sofortausgabe hält Person und Zeitpunkt fest', function () use ($anlegen) {
$e = $anlegen(date('Y-m-d'), true);
assertSame('active', $e['row']['status']);
assertSame(7, $e['row']['issued_by']);
assertTrue(is_string($e['row']['actual_start']) && $e['row']['actual_start'] !== '');
assertSame(['issue_loan_device'], $e['audit']);
});
test('Ein künftiger Termin bleibt auch mit Wunsch eine Reservierung', function () use ($anlegen) {
$e = $anlegen(date('Y-m-d', strtotime('+3 days')), true);
assertSame('reserved', $e['row']['status']);
assertSame(null, $e['row']['issued_by']);
assertSame(['reserve_loan_device'], $e['audit']);
});
// ----------------------------------------------------------------
// Zeitpunkt der Ausgabe
// ----------------------------------------------------------------
test('Eine Ausgabe heute trägt die aktuelle Uhrzeit', function () {
$heute = date('Y-m-d');
$zeitpunkt = LoanManager::issuedAtFor($heute, $heute);
assertSame($heute, substr($zeitpunkt, 0, 10));
assertFalse(str_ends_with($zeitpunkt, '00:00:00') && date('H:i:s') !== '00:00:00');
});
test('Eine Nacherfassung trägt den erfassten Tag, nicht den Eingabezeitpunkt', function () {
// Sonst wäre die Leihdauer um die Zeit bis zur Erfassung zu kurz.
assertSame('2026-09-01 00:00:00', LoanManager::issuedAtFor('2026-09-01', '2026-09-05'));
assertSame('2025-12-24 00:00:00', LoanManager::issuedAtFor('2025-12-24', '2026-09-05'));
});
// ----------------------------------------------------------------
// Sichtbarkeit nicht abgeholter Reservierungen
// ----------------------------------------------------------------
/** Liefert die WHERE-Klausel der Ausleih-Liste. */
$klausel = static function (bool $ueberfaellig, bool $nichtAbgeholt, ?string $suche = null): string {
$k = new ReflectionClass(LoanManager::class);
$m = $k->getMethod('lendingFilterClause');
$m->setAccessible(true);
return $m->invokeArgs($k->newInstanceWithoutConstructor(), [null, $suche, $ueberfaellig, $nichtAbgeholt])[0];
};
test('Nicht abgeholt meint eine Reservierung mit verstrichenem Ausgabetag', function () use ($klausel) {
$k = $klausel(false, true);
assertTrue(str_contains($k, "l.status = 'reserved'"), 'Der Status wird nicht eingegrenzt');
assertTrue(str_contains($k, 'l.planned_start < CURDATE()'), 'Der Ausgabetag wird nicht geprüft');
});
test('Beide Merkmale zusammen schließen einander nicht aus', function () use ($klausel) {
// Überfällig setzt „ausgegeben" voraus, nicht abgeholt „reserviert".
// Mit UND verknüpft bliebe die Liste zwangsläufig leer.
$k = $klausel(true, true);
assertTrue(str_contains($k, ' OR '), 'Die Merkmale sind nicht mit ODER verknüpft');
assertFalse(
str_contains($k, "l.status = 'active' AND l.planned_end < CURDATE()) AND (l.status = 'reserved'"),
'Die Merkmale sind mit UND verknüpft und ergeben nie ein Ergebnis'
);
});
test('Die Merkmale bleiben von der Suche getrennt', function () use ($klausel) {
// Ohne Klammern um die ODER-Gruppe würde die Suche eines der Merkmale
// aushebeln.
$k = $klausel(true, true, 'Meier');
assertTrue(str_contains($k, ') AND (l.borrower_name LIKE ?'), 'Die Gruppen sind nicht geklammert');
});
test('Ohne Häkchen entsteht keine Einschränkung', function () use ($klausel) {
assertSame('', $klausel(false, false));
});
test('Ohne Angabe entsteht eine Reservierung', function () use ($anlegen) {
// Aufrufer, die `issue_now` nicht setzen (etwa die Reservierung aus
// einem Ticket), reservieren – sie geben nichts aus.
$db = new class extends Database {
public array $lendingRow = [];
public function __construct() { parent::__construct([]); }
public function fetchOne(string $sql, array $params = []): ?array
{
return ['id' => 5, 'title' => 'Leih-Notebook', 'status' => 'available'];
}
public function fetchColumn(string $sql, array $params = [], int $column = 0): mixed { return 0; }
public function fetchAll(string $sql, array $params = []): array { return []; }
public function insert(string $table, array $data): int
{
if ($table === 'loan_lendings') { $this->lendingRow = $data; }
return 42;
}
public function update(string $table, array $data, string $where = '', array $params = []): int { return 1; }
};
$audit = new class($db) extends AuditLogger {
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 { return 1; }
};
(new LoanManager($db, $audit))->createLending([
'device_id' => 5,
'borrower_name' => 'Kollegin',
'planned_start' => date('Y-m-d'),
'planned_end' => date('Y-m-d'),
], [], 7);
assertSame('reserved', $db->lendingRow['status']);
assertSame(null, $db->lendingRow['issued_by']);
});
};