Quelltext des aktuellen Standes
<?php
declare(strict_types=1);
/**
* Das persönliche Postfach im Self-Service-Portal und die Kennzahlen dazu.
*
* Zwei Zusagen stehen hier im Mittelpunkt. Erstens: Eine Nachricht entsteht in
* Vorgängen, die mit dem Portal sonst nichts zu tun haben – sie darf keinen
* davon aufhalten und nirgendwo hinführen als ins Portal. Zweitens: Eine
* Kennzahl beruht auf Aufgezeichnetem; wo nichts aufgezeichnet ist, steht Null
* und keine Schätzung.
*/
use OpenNIT\Core\Database;
use OpenNIT\Portal\PortalMetrics;
use OpenNIT\Portal\PortalNotifications;
return static function (): void {
$wurzel = dirname(__DIR__);
$quelle = static fn (string $pfad): string => (string)file_get_contents($wurzel . '/' . $pfad);
/**
* Datenbank-Doppel: schreibt mit und beantwortet das Nötigste.
*
* `portal_enabled` beantwortet die Frage von `PortalAccess::isEnabled()`,
* ohne die keine Nachricht abgelegt wird.
*/
$db = static function (bool $portalAn = true, ?int $personId = 7): Database {
return new class($portalAn, $personId) extends Database {
/** @var list<array{tabelle:string, daten:array<string,mixed>}> */
public array $eingefuegt = [];
/** @var list<string> */
public array $abfragen = [];
public function __construct(private bool $portalAn, private ?int $personId)
{
parent::__construct([]);
}
public function fetchOne(string $sql, array $params = []): ?array
{
$this->abfragen[] = $sql;
if (str_contains($sql, 'directory_persons')) {
return $this->personId !== null ? ['id' => $this->personId] : null;
}
if (str_contains($sql, 'system_settings')) {
return ['value' => $this->portalAn ? '1' : '0'];
}
return null;
}
public function fetchAll(string $sql, array $params = []): array
{
$this->abfragen[] = $sql;
return [];
}
public function insert(string $table, array $data): int
{
$this->eingefuegt[] = ['tabelle' => $table, 'daten' => $data];
return 1;
}
public function execute(string $sql, array $params = []): \PDOStatement
{
$this->abfragen[] = $sql;
return new class extends \PDOStatement {
public function rowCount(): int { return 0; }
};
}
};
};
// ----------------------------------------------------------------
// Ablegen
// ----------------------------------------------------------------
test('Eine Nachricht führt nur ins Portal', function () use ($db) {
// Titel und Ziel stammen aus dem Vorgang, der die Nachricht auslöst.
// Zugelassen ist deshalb ausschließlich ein Pfad unterhalb von /portal.
foreach ([
['/portal/tickets/12', '/portal/tickets/12'],
['/portal', '/portal'],
['/portal/hilfe/3?von=melden', '/portal/hilfe/3?von=melden'],
['https://fremd.example/uebernahme', null],
['//fremd.example/uebernahme', null],
['/admin/settings', null],
['javascript:alert(1)', null],
["/portal/x\nLocation: /admin", null],
['', null],
] as [$eingabe, $erwartet]) {
$doppel = $db();
(new PortalNotifications($doppel))->fuerPerson(7, 'info', 'Titel', 'Text', $eingabe);
assertSame(1, count($doppel->eingefuegt), 'Die Nachricht wurde nicht abgelegt');
assertSame(
$erwartet,
$doppel->eingefuegt[0]['daten']['link'],
'Das Ziel „' . str_replace("\n", '\\n', $eingabe) . '" wurde falsch behandelt'
);
}
});
test('Ohne eingeschaltetes Portal entsteht keine Nachricht', function () use ($db) {
// Sie hätte keinen Ort, an dem sie je zu sehen wäre.
$doppel = $db(false);
$ok = (new PortalNotifications($doppel))->fuerPerson(7, 'ticket_reply', 'Antwort', 'Text', '/portal/tickets/1');
assertFalse($ok, 'Die Nachricht gilt als abgelegt');
assertSame([], $doppel->eingefuegt, 'Es wurde trotzdem geschrieben');
});
test('Eine unbekannte Art wird zu einem Hinweis, nicht zu einem Fehler', function () use ($db) {
$doppel = $db();
(new PortalNotifications($doppel))->fuerPerson(7, 'gibtesnicht', 'Titel');
assertSame('info', $doppel->eingefuegt[0]['daten']['type'], 'Die Art wird ungeprüft übernommen');
});
test('Ohne Person und ohne Titel geschieht nichts', function () use ($db) {
$doppel = $db();
$p = new PortalNotifications($doppel);
assertFalse($p->fuerPerson(0, 'info', 'Titel'), 'Eine Nachricht ohne Person wird abgelegt');
assertFalse($p->fuerPerson(7, 'info', ' '), 'Eine Nachricht ohne Titel wird abgelegt');
assertSame([], $doppel->eingefuegt, 'Es wurde trotzdem geschrieben');
});
test('Zu einer unbekannten Adresse entsteht keine Nachricht', function () use ($db) {
// Anträge kennen ihre Antragstellenden nur über die Adresse; gibt es
// dazu keine aktive Person, geht die E-Mail trotzdem hinaus.
$doppel = $db(true, null);
$ok = (new PortalNotifications($doppel))->fuerEmail('fremd@example.org', 'request_decided', 'Antrag');
assertFalse($ok, 'Zu einer unbekannten Adresse wird abgelegt');
assertSame([], $doppel->eingefuegt, 'Es wurde trotzdem geschrieben');
});
test('Ein Fehler beim Ablegen hält den Vorgang nicht auf', function () {
// Fehlt die Tabelle, darf das weder eine Antwort auf ein Ticket noch
// die Entscheidung über einen Antrag verhindern.
$doppel = new class extends Database {
public function __construct() { parent::__construct([]); }
public function fetchOne(string $sql, array $params = []): ?array { return ['value' => '1']; }
public function insert(string $table, array $data): int
{
throw new \PDOException("Table 'person_notifications' doesn't exist");
}
};
$ok = (new PortalNotifications($doppel))->fuerPerson(7, 'ticket_reply', 'Antwort');
assertFalse($ok, 'Ein Fehlschlag gilt als geglückt');
});
// ----------------------------------------------------------------
// Zustand ändern
// ----------------------------------------------------------------
test('Eine fremde Nachricht lässt sich nicht anfassen', function () use ($db) {
// Die Person steht in der Bedingung, nicht bloß in einer Prüfung davor.
$doppel = $db();
$p = new PortalNotifications($doppel);
$p->alsGelesen(7, 42);
$p->loeschen(7, 42);
foreach ($doppel->abfragen as $sql) {
if (str_contains($sql, 'person_notifications') && !str_contains($sql, 'SELECT')) {
assertTrue(str_contains($sql, 'person_id = ?'), 'Die Person fehlt in der Bedingung: ' . $sql);
}
}
});
test('Ungelesenes bleibt beim Aufräumen stehen', function () use ($quelle) {
// Das Postfach ist der einzige Ort, an dem die Person diese Meldung
// noch findet.
$src = $quelle('src/Portal/PortalNotifications.php');
$start = (int)strpos($src, 'public function aufraeumen(');
$body = substr($src, $start, 900);
assertTrue(str_contains($body, 'is_read = 1'), 'Es wird auch Ungelesenes entfernt');
assertTrue(str_contains($body, 'read_at IS NOT NULL'), 'Eine Nachricht ohne Lesezeitpunkt wird entfernt');
});
// ----------------------------------------------------------------
// Die Stellen, an denen Nachrichten entstehen
// ----------------------------------------------------------------
test('Jeder Anlass legt eine Nachricht ab, ohne den Vorgang zu gefährden', function () use ($quelle) {
foreach ([
'src/Tickets/TicketManager.php' => ['ticket_reply', 'ticket_solved', 'ticket_closed'],
'src/Workflows/WorkflowManager.php' => ['request_decided', 'request_question'],
'src/Content/LoanManager.php' => ['loan_decided'],
'src/Content/OrderManager.php' => ['order_ready', 'order_delivered'],
] as $datei => $arten) {
$src = $quelle($datei);
foreach ($arten as $art) {
assertTrue(
str_contains($src, "'" . $art . "'"),
'In ' . $datei . ' fehlt der Anlass ' . $art
);
}
// Der Zugriff läuft über einen eigenen Helfer, und der fängt ab.
$start = (int)strpos($src, 'private function portalNachricht(');
assertTrue($start > 0, 'In ' . $datei . ' fehlt der Helfer');
$body = substr($src, $start, 1600);
assertTrue(str_contains($body, 'try {'), 'Der Helfer in ' . $datei . ' fängt nichts ab');
assertTrue(str_contains($body, 'catch (\\Throwable)'), 'Der Helfer in ' . $datei . ' fängt nichts ab');
}
});
test('Jede Art kennt Symbol, Farbe und Bezeichnung', function () {
// Die Vorlage liest alle drei; fehlt eines, steht im Portal ein Eintrag
// ohne Symbol oder ohne Bezeichnung.
foreach (PortalNotifications::TYPES as $art => $darstellung) {
foreach (['icon', 'tone', 'label'] as $feld) {
assertTrue(
isset($darstellung[$feld]) && trim((string)$darstellung[$feld]) !== '',
'Der Art ' . $art . ' fehlt ' . $feld
);
}
assertTrue(
str_starts_with((string)$darstellung['icon'], 'fa-'),
'Das Symbol der Art ' . $art . ' trägt kein Font-Awesome-Präfix'
);
assertTrue(
in_array($darstellung['tone'], ['info', 'success', 'warning'], true),
'Die Farbe der Art ' . $art . ' ist im Stylesheet nicht vorgesehen'
);
}
});
test('Das Postfach hängt an der Portal-Wache, nicht an einem Recht', function () use ($quelle) {
$app = $quelle('src/Core/Application.php');
foreach (['notificationList', 'notificationOpen', 'notificationReadAll', 'notificationDelete'] as $aktion) {
$zeile = strstr($app, 'PortalController@' . $aktion . "'");
assertTrue(is_string($zeile), 'Route fehlt: ' . $aktion);
assertTrue(
str_contains(substr($zeile, 0, 160), "['portal' => true]"),
'Die Route ' . $aktion . ' steht ohne Portal-Wache'
);
}
// Und die spezifischen Pfade stehen vor der {id}-Route.
$gelesen = strpos($app, '/portal/nachrichten/gelesen');
$id = strpos($app, '/portal/nachrichten/{id}');
assertTrue($gelesen !== false && $id !== false && $gelesen < $id, 'Die {id}-Route steht zu früh');
});
// ----------------------------------------------------------------
// Kennzahlen
// ----------------------------------------------------------------
test('Ohne Aufzeichnung steht Null, keine Schätzung', function () {
// Eine Instanz, auf der die Migration fehlt, darf keine erfundenen
// Zahlen liefern.
$doppel = new class extends Database {
public function __construct() { parent::__construct([]); }
public function fetchOne(string $sql, array $params = []): ?array
{
throw new \PDOException("Table 'portal_article_views' doesn't exist");
}
public function fetchAll(string $sql, array $params = []): array
{
throw new \PDOException("Unknown column 'source'");
}
};
$m = (new PortalMetrics($doppel))->uebersicht(90);
assertSame(0, $m['nutzung']['aktiv'], 'Es werden aktive Personen erfunden');
assertSame(0.0, $m['nutzung']['anteil'], 'Es wird ein Anteil erfunden');
assertSame(0, $m['herkunft']['gesamt'], 'Es werden Meldungen erfunden');
assertSame(0.0, $m['selbsthilfe']['quote'], 'Es wird eine Quote erfunden');
assertSame([], $m['top_artikel'], 'Es werden Artikel erfunden');
assertSame(12, count($m['verlauf']), 'Der Verlauf hat nicht zwölf Monate');
});
test('Der Anteil misst Portal gegen Mail, nicht gegen alles', function () {
// Was die IT selbst erfasst, wäre ohnehin nicht über das Portal
// gekommen und darf den Anteil nicht kleinrechnen.
$doppel = new class extends Database {
public function __construct() { parent::__construct([]); }
public function fetchOne(string $sql, array $params = []): ?array { return ['n' => 0]; }
public function fetchAll(string $sql, array $params = []): array
{
return str_contains($sql, 'GROUP BY quelle')
? [
['quelle' => 'portal', 'anzahl' => 30],
['quelle' => 'mail', 'anzahl' => 10],
['quelle' => 'backend', 'anzahl' => 60],
]
: [];
}
};
$m = (new PortalMetrics($doppel))->uebersicht(90);
assertSame(100, $m['herkunft']['gesamt'], 'Nicht alle Meldungen sind gezählt');
assertSame(75.0, $m['herkunft']['anteil_portal'], 'Der Anteil misst gegen die falsche Grundmenge');
});
test('Eine unbekannte Herkunft zählt weder zum Portal noch zur Mail', function () {
$doppel = new class extends Database {
public function __construct() { parent::__construct([]); }
public function fetchOne(string $sql, array $params = []): ?array { return ['n' => 0]; }
public function fetchAll(string $sql, array $params = []): array
{
return str_contains($sql, 'GROUP BY quelle')
? [['quelle' => 'unbekannt', 'anzahl' => 50], ['quelle' => 'portal', 'anzahl' => 50]]
: [];
}
};
$m = (new PortalMetrics($doppel))->uebersicht(90);
assertSame(50, $m['herkunft']['unbekannt'], 'Die unbekannten Meldungen fehlen');
assertSame(100.0, $m['herkunft']['anteil_portal'], 'Unbekanntes wird der Mail zugeschlagen');
});
test('Die Regel hinter der Selbsthilfe-Zahl steht auf der Seite', function () use ($quelle) {
// Eine Schätzung ohne genannte Regel ist eine Behauptung.
$vorlage = $quelle('templates/admin/portal/metrics.php');
assertTrue(str_contains($vorlage, 'Die Regel:'), 'Die Regel wird nicht genannt');
assertTrue(str_contains($vorlage, 'Minuten'), 'Das Fenster wird nicht genannt');
assertTrue(
str_contains($vorlage, 'eher zu hoch als zu niedrig'),
'Die Richtung der Unschärfe wird verschwiegen'
);
assertTrue(str_contains($vorlage, '$hilfe[\'fenster\']'), 'Das Fenster steht doppelt gepflegt in der Vorlage');
});
test('Ein Artikelaufruf beim Melden ist als solcher erkennbar', function () use ($quelle) {
// Nur er lässt die Frage zu, ob die Meldung dadurch entfallen ist.
$ctrl = $quelle('src/Controllers/PortalController.php');
assertTrue(str_contains($ctrl, "\$request->getString('von') === 'melden'"), 'Der Kontext wird nicht gelesen');
assertTrue(str_contains($ctrl, 'merkeArtikelaufruf('), 'Der Aufruf wird nicht festgehalten');
$vorlage = $quelle('templates/portal/ticket_new.php');
assertTrue(str_contains($vorlage, "'?von=melden'"), 'Der Vorschlag trägt den Zusatz nicht');
// Und das Festhalten darf das Lesen nicht verhindern.
$start = (int)strpos($ctrl, 'private function merkeArtikelaufruf(');
$body = substr($ctrl, $start, 900);
assertTrue(str_contains($body, 'catch (\\Throwable)'), 'Ein Fehler beim Zählen bricht den Aufruf ab');
});
test('Die Herkunft steht am Ticket, statt erraten zu werden', function () use ($quelle) {
$tm = $quelle('src/Tickets/TicketManager.php');
assertTrue(str_contains($tm, "'source' => \$quelle"), 'Die Herkunft wird nicht geschrieben');
assertTrue(
str_contains($tm, 'if (!isset(self::SOURCES[$quelle])) {'),
'Eine beliebige Herkunft wird übernommen'
);
$portal = $quelle('src/Controllers/PortalController.php');
assertSame(
2,
substr_count($portal, "'source' => 'portal',"),
'Nicht jeder Weg, auf dem das Portal ein Ticket anlegt, kennzeichnet sich'
);
// Und der Bestand wird abgeleitet statt geraten.
$sql = $quelle('database/migrations/229_portal_metrics.sql');
assertTrue(str_contains($sql, 'ADD COLUMN `source`'), 'Die Spalte fehlt');
assertSame(3, substr_count($sql, 'WHERE `source` IS NULL'), 'Der Bestand wird nicht zustandsunabhängig gefüllt');
});
};