Fix #165: Ereignisse zeigen Fehler und Warnungen aus dem ganzen System

This commit is contained in:
fds2610-bot 2026-08-02 16:50:18 +02:00
parent 5df6c2c9dc
commit c9afc3e33f
13 changed files with 485 additions and 64 deletions

View file

@ -0,0 +1,18 @@
-- Ereignisse führen künftig nicht nur den Nextcloud-Import, sondern auch
-- Fehlermeldungen und Warnungen aus dem übrigen System (Issue #165).
-- Dafür bekommt jeder Eintrag einen Schweregrad.
ALTER TABLE system_events
ADD COLUMN IF NOT EXISTS level ENUM('info','warning','error') NOT NULL DEFAULT 'info' AFTER channel;
ALTER TABLE system_events
ADD INDEX IF NOT EXISTS idx_created (created_at);
ALTER TABLE system_events
ADD INDEX IF NOT EXISTS idx_level_created (level, created_at);
-- Bestehende Nextcloud-Einträge nachträglich einstufen. Bis hierher stand der
-- Schweregrad nur im Text; die Zuordnung folgt denselben Präfixen, nach denen
-- die Ereignisliste die Zeilen bisher eingefärbt hat.
UPDATE system_events SET level = 'error' WHERE level = 'info' AND message LIKE 'FEHLER%';
UPDATE system_events SET level = 'warning' WHERE level = 'info' AND message LIKE 'Duplikat%';

View file

@ -26,7 +26,17 @@ set_error_handler(static function (int $severity, string $message, string $file,
throw new \ErrorException($message, 0, $severity, $file, $line);
});
set_exception_handler(function (\Throwable $e) use ($steuerkramShowGenericError): void {
// Fehler landen zusätzlich in der Ereignisliste, damit sie ohne Zugriff auf
// das Server-Log sichtbar sind (Issue #165). Der Aufruf darf nie selbst
// scheitern – ist die Datenbank die Fehlerursache, bleibt es beim error_log.
$steuerkramLogEvent = static function (string $channel, string $message, string $level): void {
if (!class_exists(\Steuerkram\Models\EventLog::class, true)) {
return;
}
\Steuerkram\Models\EventLog::tryAdd($channel, $message, $level);
};
set_exception_handler(function (\Throwable $e) use ($steuerkramShowGenericError, $steuerkramLogEvent): void {
error_log(sprintf(
'Unhandled exception: %s in %s:%d%s%s',
$e->getMessage(),
@ -35,13 +45,30 @@ set_exception_handler(function (\Throwable $e) use ($steuerkramShowGenericError)
PHP_EOL,
$e->getTraceAsString()
));
$steuerkramLogEvent('system', sprintf(
'%s bei %s %s: %s (%s:%d)',
(new \ReflectionClass($e))->getShortName(),
$_SERVER['REQUEST_METHOD'] ?? 'CLI',
parse_url($_SERVER['REQUEST_URI'] ?? '-', PHP_URL_PATH) ?: '-',
$e->getMessage(),
basename($e->getFile()),
$e->getLine()
), \Steuerkram\Models\EventLog::LEVEL_ERROR);
$steuerkramShowGenericError();
});
register_shutdown_function(function () use ($steuerkramShowGenericError): void {
register_shutdown_function(function () use ($steuerkramShowGenericError, $steuerkramLogEvent): void {
$error = error_get_last();
if ($error !== null && in_array($error['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR], true)) {
error_log(sprintf('Fatal error: %s in %s:%d', $error['message'], $error['file'], $error['line']));
$steuerkramLogEvent('system', sprintf(
'Schwerer Fehler bei %s %s: %s (%s:%d)',
$_SERVER['REQUEST_METHOD'] ?? 'CLI',
parse_url($_SERVER['REQUEST_URI'] ?? '-', PHP_URL_PATH) ?: '-',
$error['message'],
basename($error['file']),
$error['line']
), \Steuerkram\Models\EventLog::LEVEL_ERROR);
$steuerkramShowGenericError();
}
});
@ -235,6 +262,7 @@ $router->post('/belege/{belegId}/vorlage/{vorlageId}/buchen', fn($p) =
// ── Ereignisse ───────────────────────────────────────────────
$router->get('/ereignisse', fn($p) => (new EventController())->index($p));
$router->get('/ereignisse/data', fn($p) => (new EventController())->indexData($p));
$router->get('/ereignisse/nextcloud-import', fn($p) => (new EventController())->nextcloudImport($p));
$router->get('/ereignisse/nextcloud-import/data', fn($p) => (new EventController())->nextcloudImportData($p));

View file

@ -9,6 +9,7 @@ use Steuerkram\Models\Beleg;
use Steuerkram\Models\Booking;
use Steuerkram\Models\Account;
use Steuerkram\Models\Asset;
use Steuerkram\Models\EventLog;
use Steuerkram\Models\Supplier;
use Steuerkram\View;
use Steuerkram\Services\ImageCropService;
@ -216,6 +217,15 @@ class BelegController extends BaseController
*/
private function renderUploadErrors(array $errors): void
{
$dateiname = isset($_FILES['beleg_file']['name']) && $_FILES['beleg_file']['name'] !== ''
? pathinfo((string)$_FILES['beleg_file']['name'], PATHINFO_BASENAME)
: 'ohne Dateiname';
EventLog::tryAdd(
'beleg-upload',
"Upload fehlgeschlagen ({$dateiname}): " . implode(' ', $errors),
EventLog::LEVEL_ERROR
);
if (($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'XMLHttpRequest') {
http_response_code(422);
header('Content-Type: application/json');

View file

@ -9,37 +9,76 @@ use Steuerkram\View;
class EventController extends BaseController
{
private const LIMIT = 500;
public function index(array $params): void
{
View::redirect('/ereignisse/nextcloud-import');
}
public function nextcloudImport(array $params): void
{
$events = EventLog::findByChannel('nextcloud-import', 500);
$filter = $this->filterFromRequest();
$events = EventLog::find($filter, self::LIMIT);
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Ereignisse – Nextcloud-Import',
'content' => $this->renderView('ereignisse/nextcloud_import', [
'pageTitle' => 'Ereignisse',
'content' => $this->renderView('ereignisse/index', [
'events' => $events,
'eventsHtml' => $this->renderView('ereignisse/_nextcloud_import_body', [
'filter' => $filter,
'channels' => EventLog::channels(),
'eventsHtml' => $this->renderView('ereignisse/_body', [
'events' => $events,
]),
]),
]);
}
public function indexData(array $params): void
{
$this->respondWithEvents($this->filterFromRequest());
}
public function nextcloudImport(array $params): void
{
View::redirect('/ereignisse?channel=nextcloud-import');
}
/** Datenquelle der bis Issue #165 eigenständigen Nextcloud-Ereignisseite. */
public function nextcloudImportData(array $params): void
{
$events = EventLog::findByChannel('nextcloud-import', 500);
$this->respondWithEvents(['channel' => 'nextcloud-import']);
}
/**
* @param array{channel?:string,level?:string} $filter
*/
private function respondWithEvents(array $filter): void
{
$events = EventLog::find($filter, self::LIMIT);
header('Content-Type: application/json');
echo json_encode([
'count' => count($events),
'html' => $this->renderView('ereignisse/_nextcloud_import_body', [
'html' => $this->renderView('ereignisse/_body', [
'events' => $events,
]),
], JSON_UNESCAPED_UNICODE);
}
/**
* @return array{channel?:string,level?:string}
*/
private function filterFromRequest(): array
{
$filter = [];
$channel = trim((string)($_GET['channel'] ?? ''));
if ($channel !== '') {
$filter['channel'] = $channel;
}
$level = trim((string)($_GET['level'] ?? ''));
if (in_array($level, EventLog::LEVELS, true)) {
$filter['level'] = $level;
}
return $filter;
}
}

View file

@ -8,23 +8,94 @@ use Steuerkram\Database;
class EventLog
{
public static function add(string $channel, string $message): void
public const LEVEL_INFO = 'info';
public const LEVEL_WARNING = 'warning';
public const LEVEL_ERROR = 'error';
public const LEVELS = [self::LEVEL_INFO, self::LEVEL_WARNING, self::LEVEL_ERROR];
public static function add(string $channel, string $message, string $level = self::LEVEL_INFO): void
{
if (!in_array($level, self::LEVELS, true)) {
$level = self::LEVEL_INFO;
}
Database::execute(
'INSERT INTO system_events (channel, message) VALUES (?, ?)',
[$channel, $message]
'INSERT INTO system_events (channel, level, message) VALUES (?, ?, ?)',
[$channel, $level, $message]
);
}
public static function warning(string $channel, string $message): void
{
self::add($channel, $message, self::LEVEL_WARNING);
}
public static function error(string $channel, string $message): void
{
self::add($channel, $message, self::LEVEL_ERROR);
}
/**
* Schreibt ein Ereignis, ohne selbst einen Fehler auszulösen. Für
* Aufrufer, die bereits einen Fehler behandeln – dort darf ein nicht
* erreichbares Datenbankprotokoll die eigentliche Fehlerausgabe nicht
* verdrängen.
*/
public static function tryAdd(string $channel, string $message, string $level = self::LEVEL_INFO): void
{
try {
self::add($channel, $message, $level);
} catch (\Throwable $e) {
error_log('EventLog: Ereignis konnte nicht gespeichert werden: ' . $e->getMessage());
}
}
public static function findByChannel(string $channel, int $limit = 500): array
{
return self::find(['channel' => $channel], $limit);
}
/**
* @param array{channel?:string,level?:string} $filter
*/
public static function find(array $filter = [], int $limit = 500): array
{
$where = [];
$params = [];
if (!empty($filter['channel'])) {
$where[] = 'channel = ?';
$params[] = $filter['channel'];
}
if (!empty($filter['level']) && in_array($filter['level'], self::LEVELS, true)) {
$where[] = 'level = ?';
$params[] = $filter['level'];
}
$params[] = $limit;
return Database::fetchAll(
'SELECT id, channel, message, created_at
FROM system_events
WHERE channel = ?
ORDER BY created_at DESC, id DESC
'SELECT id, channel, level, message, created_at
FROM system_events'
. ($where ? ' WHERE ' . implode(' AND ', $where) : '')
. ' ORDER BY created_at DESC, id DESC
LIMIT ?',
[$channel, $limit]
$params
);
}
/**
* Alle bisher benutzten Kanäle – Grundlage für den Filter in der Ansicht.
*
* @return string[]
*/
public static function channels(): array
{
return array_column(
Database::fetchAll('SELECT DISTINCT channel FROM system_events ORDER BY channel'),
'channel'
);
}
}

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Steuerkram;
use Steuerkram\Auth;
use Steuerkram\Models\EventLog;
class View
{
@ -37,11 +38,37 @@ class View
Auth::startSession();
if ($message !== '') {
$_SESSION['flash'][] = ['type' => $type, 'message' => $message];
self::logFlash($type, $message);
}
header('Location: ' . $path);
exit;
}
/**
* Fehler- und Warnhinweise, die dem Nutzer angezeigt werden, wandern
* zusätzlich in die Ereignisliste – dort sind sie auch dann noch
* nachvollziehbar, wenn die Meldung längst weggeklickt ist (Issue #165).
*/
private static function logFlash(string $type, string $message): void
{
$level = match ($type) {
'error' => EventLog::LEVEL_ERROR,
'warning' => EventLog::LEVEL_WARNING,
default => null,
};
if ($level === null) {
return;
}
$pfad = parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH);
EventLog::tryAdd(
'meldungen',
$pfad ? "{$message} ({$pfad})" : $message,
$level
);
}
public static function h(mixed $value): string
{
return htmlspecialchars((string)$value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');

View file

@ -0,0 +1,37 @@
<?php
use Steuerkram\View;
use Steuerkram\Models\EventLog;
$h = fn($v) => View::h($v);
$levelStyles = [
EventLog::LEVEL_ERROR => ['Fehler', 'var(--color-danger,#dc2626)'],
EventLog::LEVEL_WARNING => ['Warnung', 'var(--color-warning,#d97706)'],
EventLog::LEVEL_INFO => ['Info', 'var(--color-muted,#64748b)'],
];
?>
<?php if (empty($events)): ?>
<div style="padding:2rem;text-align:center" class="text-muted">Keine Ereignisse vorhanden.</div>
<?php else: ?>
<div style="overflow-x:auto">
<table style="width:100%;border-collapse:collapse;font-size:.875rem">
<tbody>
<?php foreach ($events as $e): ?>
<?php
$level = $e['level'] ?? EventLog::LEVEL_INFO;
[$levelLabel, $color] = $levelStyles[$level] ?? $levelStyles[EventLog::LEVEL_INFO];
// Erfolgsmeldungen des Imports bleiben grün wie bisher.
if ($level === EventLog::LEVEL_INFO && str_starts_with($e['message'], 'Importiert')) {
$color = 'var(--color-success,#16a34a)';
}
?>
<tr style="border-bottom:1px solid var(--color-border,#e2e8f0)">
<td style="padding:.35rem 1rem;white-space:nowrap;color:var(--color-muted);font-family:monospace"><?= $h(date('Y-m-d H:i:s', strtotime($e['created_at']))) ?></td>
<td style="padding:.35rem .5rem;white-space:nowrap;font-family:monospace;color:<?= $color ?>"><?= $h($levelLabel) ?></td>
<td style="padding:.35rem .5rem;white-space:nowrap;color:var(--color-muted);font-family:monospace">[<?= $h($e['channel']) ?>]</td>
<td style="padding:.35rem 1rem;color:<?= $color ?>;word-break:break-word"><?= $h($e['message']) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>

View file

@ -1,28 +0,0 @@
<?php use Steuerkram\View; $h = fn($v) => View::h($v); ?>
<?php if (empty($events)): ?>
<div style="padding:2rem;text-align:center" class="text-muted">Keine Ereignisse vorhanden. Einträge entstehen, sobald der Nextcloud-Import Dateien importiert, überspringt oder auf Fehler stößt.</div>
<?php else: ?>
<div style="overflow-x:auto">
<table style="width:100%;border-collapse:collapse;font-size:.875rem">
<tbody>
<?php foreach ($events as $e): ?>
<?php
$msg = $e['message'];
$isError = str_starts_with($msg, 'FEHLER');
$isDup = str_starts_with($msg, 'Duplikat');
$isOk = str_starts_with($msg, 'Importiert');
$color = $isError ? 'var(--color-danger,#dc2626)'
: ($isDup ? 'var(--color-warning,#d97706)'
: ($isOk ? 'var(--color-success,#16a34a)'
: 'inherit'));
?>
<tr style="border-bottom:1px solid var(--color-border,#e2e8f0)">
<td style="padding:.35rem 1rem;white-space:nowrap;color:var(--color-muted);font-family:monospace"><?= $h(date('Y-m-d H:i:s', strtotime($e['created_at']))) ?></td>
<td style="padding:.35rem .5rem;white-space:nowrap;color:var(--color-muted);font-family:monospace">[<?= $h($e['channel']) ?>]</td>
<td style="padding:.35rem 1rem;color:<?= $color ?>;word-break:break-word"><?= $h($msg) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>

View file

@ -1,3 +1,17 @@
<?php
use Steuerkram\View;
use Steuerkram\Models\EventLog;
$h = fn($v) => View::h($v);
$aktiverKanal = $filter['channel'] ?? '';
$aktivesLevel = $filter['level'] ?? '';
$levelLabels = [
EventLog::LEVEL_ERROR => 'Nur Fehler',
EventLog::LEVEL_WARNING => 'Nur Warnungen',
EventLog::LEVEL_INFO => 'Nur Infos',
];
?>
<style>
.switch { position: relative; display: inline-block; width: 42px; height: 24px; flex: 0 0 auto; }
.switch input { opacity: 0; width: 0; height: 0; }
@ -15,8 +29,24 @@
</style>
<div class="card">
<div style="display:flex;justify-content:space-between;align-items:center;padding:.75rem 1rem;border-bottom:1px solid var(--color-border)">
<strong>Nextcloud-Import</strong>
<div style="display:flex;justify-content:space-between;align-items:center;gap:1rem;flex-wrap:wrap;padding:.75rem 1rem;border-bottom:1px solid var(--color-border)">
<form method="get" action="/ereignisse" id="events-filter" style="display:flex;align-items:center;gap:.5rem;flex-wrap:wrap">
<select name="channel" onchange="this.form.submit()">
<option value="">Alle Bereiche</option>
<?php foreach ($channels as $c): ?>
<option value="<?= $h($c) ?>"<?= $c === $aktiverKanal ? ' selected' : '' ?>><?= $h($c) ?></option>
<?php endforeach; ?>
</select>
<select name="level" onchange="this.form.submit()">
<option value="">Alle Schweregrade</option>
<?php foreach ($levelLabels as $value => $label): ?>
<option value="<?= $h($value) ?>"<?= $value === $aktivesLevel ? ' selected' : '' ?>><?= $h($label) ?></option>
<?php endforeach; ?>
</select>
<?php if ($aktiverKanal !== '' || $aktivesLevel !== ''): ?>
<a href="/ereignisse" class="btn btn-secondary btn-sm">Filter zurücksetzen</a>
<?php endif; ?>
</form>
<div style="display:flex;align-items:center;gap:.75rem">
<span id="events-count" class="text-muted text-sm"><?= count($events) ?> Einträge (neueste zuerst)</span>
<label class="switch" title="Automatische Aktualisierung">
@ -39,6 +69,7 @@
var count = document.getElementById('events-count');
var timer = null;
var lastHtml = body.innerHTML;
var dataUrl = '/ereignisse/data' + window.location.search;
function isEnabled() {
return localStorage.getItem(STORAGE_KEY) !== 'off';
@ -49,7 +80,7 @@
}
function poll() {
fetch('/ereignisse/nextcloud-import/data', { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
fetch(dataUrl, { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
.then(function (res) { return res.json(); })
.then(function (data) {
if (data.html !== lastHtml) {

View file

@ -89,7 +89,11 @@ function navActive(string $prefix, string $current): string {
Ereignisse
</a>
<?php if (str_starts_with($path, '/ereignisse')): ?>
<a href="/ereignisse/nextcloud-import" class="<?= navActive('/ereignisse/nextcloud-import', $path) ?>" style="padding-left:2.25rem;font-size:.875rem">
<a href="/ereignisse?level=error" class="<?= ($_GET['level'] ?? '') === 'error' ? 'active' : '' ?>" style="padding-left:2.25rem;font-size:.875rem">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
Nur Fehler
</a>
<a href="/ereignisse?channel=nextcloud-import" class="<?= ($_GET['channel'] ?? '') === 'nextcloud-import' ? 'active' : '' ?>" style="padding-left:2.25rem;font-size:.875rem">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 16.92V20a2 2 0 0 1-2.18 2A19.79 19.79 0 0 1 11.39 19a19.5 19.5 0 0 1-4.9-4.9 19.79 19.79 0 0 1-3-9.43A2 2 0 0 1 5.32 3H8.4a2 2 0 0 1 2 1.72c.13.96.36 1.9.7 2.81a2 2 0 0 1-.45 2.11L9.4 10.9a16 16 0 0 0 3.71 3.71l1.26-1.26a2 2 0 0 1 2.11-.45c.91.34 1.85.57 2.81.7A2 2 0 0 1 22 16.92z"/></svg>
Nextcloud-Import
</a>

View file

@ -3,11 +3,14 @@
declare(strict_types=1);
/**
* Test 58: Auto-Reload der Ereignisanzeige (Nextcloud-Import)
* Test 58: Auto-Reload der Ereignisanzeige
*
* Prüft, dass die Ereignisanzeige einen Ein-/Aus-Button für automatisches
* Neuladen besitzt, per JavaScript periodisch aktualisiert wird und
* dass der zugehörige JSON-Endpunkt sowie die Route existieren.
*
* Seit Issue #165 gibt es statt der eigenen Nextcloud-Seite eine Übersicht
* über alle Bereiche; der Nextcloud-Import ist dort ein Filter.
*/
class EreignisAutoReloadTest extends TestCase
{
@ -15,9 +18,9 @@ class EreignisAutoReloadTest extends TestCase
public function run(): void
{
$viewFile = __DIR__ . '/../src/Views/ereignisse/nextcloud_import.php';
$viewFile = __DIR__ . '/../src/Views/ereignisse/index.php';
$view = file_get_contents($viewFile);
$this->assert($view !== false, 'View-Datei ereignisse/nextcloud_import.php ist lesbar');
$this->assert($view !== false, 'View-Datei ereignisse/index.php ist lesbar');
$this->assert(
str_contains($view, 'id="events-autoreload-toggle"'),
@ -30,10 +33,15 @@ class EreignisAutoReloadTest extends TestCase
);
$this->assert(
str_contains($view, 'setInterval') && str_contains($view, "'/ereignisse/nextcloud-import/data'"),
str_contains($view, 'setInterval') && str_contains($view, "'/ereignisse/data'"),
'JavaScript pollt periodisch den Daten-Endpunkt'
);
$this->assert(
str_contains($view, 'window.location.search'),
'Der Poll übernimmt den eingestellten Filter'
);
$this->assert(
str_contains($view, "toggle.addEventListener('change'"),
'Toggle-Switch schaltet Auto-Reload per Umlegen ein/aus'
@ -46,25 +54,29 @@ class EreignisAutoReloadTest extends TestCase
// Partial für Tabellen-Body existiert und wird sowohl beim initialen
// Laden als auch für den Daten-Endpunkt verwendet
$partialFile = __DIR__ . '/../src/Views/ereignisse/_nextcloud_import_body.php';
$this->assert(file_exists($partialFile), 'Partial _nextcloud_import_body.php existiert');
$partialFile = __DIR__ . '/../src/Views/ereignisse/_body.php';
$this->assert(file_exists($partialFile), 'Partial _body.php existiert');
$controllerFile = __DIR__ . '/../src/Controllers/EventController.php';
$controller = file_get_contents($controllerFile);
$this->assert(
str_contains($controller, 'function nextcloudImportData'),
'EventController::nextcloudImportData() ist implementiert'
str_contains($controller, 'function indexData'),
'EventController::indexData() ist implementiert'
);
$this->assert(
str_contains($controller, "'ereignisse/_nextcloud_import_body'"),
substr_count($controller, "'ereignisse/_body'") >= 2,
'Daten-Endpunkt rendert dasselbe Partial wie die initiale Seite'
);
$routesFile = __DIR__ . '/../public/index.php';
$routes = file_get_contents($routesFile);
$this->assert(
str_contains($routes, "'/ereignisse/nextcloud-import/data'") && str_contains($routes, 'nextcloudImportData'),
'Route /ereignisse/nextcloud-import/data ist registriert'
str_contains($routes, "'/ereignisse/data'") && str_contains($routes, 'indexData'),
'Route /ereignisse/data ist registriert'
);
$this->assert(
str_contains($routes, "'/ereignisse/nextcloud-import/data'"),
'Der bisherige Nextcloud-Endpunkt bleibt erreichbar'
);
}
}

View file

@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
use Steuerkram\Database;
use Steuerkram\Models\EventLog;
/**
* Test 81: Ereignisse umfassen alle Fehler und Warnungen (Issue #165)
*
* Bis dahin protokollierte nur der Nextcloud-Import in die Ereignisliste. Jetzt
* hat jeder Eintrag einen Schweregrad, die Liste lässt sich nach Bereich und
* Schweregrad filtern, und Fehlermeldungen aus dem übrigen System – etwa ein
* fehlgeschlagener Beleg-Upload – landen ebenfalls dort.
*/
class EreignisseAlleFehlerTest extends TestCase
{
protected string $testName = 'Test 81: Ereignisliste zeigt Fehler und Warnungen aus dem ganzen System';
public function run(): void
{
// ── Schweregrad wird gespeichert ─────────────────────────────
$spalte = Database::fetchOne("SHOW COLUMNS FROM system_events LIKE 'level'");
$this->assertNotEmpty($spalte, 'Tabelle system_events hat eine Spalte level');
$kanal = 'test-' . uniqid();
EventLog::add($kanal, 'Alles in Ordnung');
EventLog::warning($kanal, 'Etwas ist ungewöhnlich');
EventLog::error($kanal, 'Etwas ist schiefgelaufen');
$alle = EventLog::findByChannel($kanal);
$this->assertEquals(3, count($alle), 'Drei Einträge im Testkanal');
$this->assertEquals('error', $alle[0]['level'], 'Fehler wird als error gespeichert');
$this->assertEquals('warning', $alle[1]['level'], 'Warnung wird als warning gespeichert');
$this->assertEquals('info', $alle[2]['level'], 'Ohne Angabe gilt info');
// Ungültiger Schweregrad fällt auf info zurück
EventLog::add($kanal, 'Unbekannter Grad', 'katastrophe');
$this->assertEquals('info', EventLog::findByChannel($kanal)[0]['level'], 'Ungültiger Schweregrad wird zu info');
// ── Filter nach Schweregrad und Bereich ──────────────────────
$nurFehler = EventLog::find(['channel' => $kanal, 'level' => 'error']);
$this->assertEquals(1, count($nurFehler), 'Filter nach Schweregrad liefert nur den Fehler');
$this->assertEquals('Etwas ist schiefgelaufen', $nurFehler[0]['message'], 'Der richtige Eintrag wird geliefert');
$andererKanal = 'test-' . uniqid();
EventLog::error($andererKanal, 'Fehler im anderen Bereich');
$kanalgefiltert = EventLog::find(['channel' => $kanal]);
$this->assertEquals(4, count($kanalgefiltert), 'Bereichsfilter blendet fremde Kanäle aus');
$ohneFilter = EventLog::find([], 500);
$this->assert(count($ohneFilter) >= 5, 'Ohne Filter erscheinen die Einträge aller Bereiche');
$kanaele = EventLog::channels();
$this->assert(in_array($kanal, $kanaele, true), 'Der Bereich taucht in der Kanalliste auf');
$this->assert(in_array($andererKanal, $kanaele, true), 'Auch der zweite Bereich wird gelistet');
// Ein unbekannter Schweregrad im Filter wird ignoriert statt zu filtern
$this->assertEquals(
4,
count(EventLog::find(['channel' => $kanal, 'level' => 'quatsch'])),
'Unbekannter Schweregrad im Filter wird ignoriert'
);
// ── tryAdd schluckt Fehler statt sie weiterzureichen ─────────
EventLog::tryAdd($kanal, str_repeat('x', 100), EventLog::LEVEL_ERROR);
$this->assertEquals(5, count(EventLog::findByChannel($kanal)), 'tryAdd schreibt den Eintrag');
// ── Fehlerquellen sind angebunden ────────────────────────────
$view = (string)file_get_contents(__DIR__ . '/../src/View.php');
$this->assert(
str_contains($view, 'logFlash') && str_contains($view, 'EventLog::tryAdd'),
'Fehler- und Warnhinweise aus View::redirect() werden protokolliert'
);
$beleg = (string)file_get_contents(__DIR__ . '/../src/Controllers/BelegController.php');
$this->assert(
str_contains($beleg, "EventLog::tryAdd(\n 'beleg-upload'"),
'Fehlgeschlagene Beleg-Uploads werden protokolliert'
);
$front = (string)file_get_contents(__DIR__ . '/../public/index.php');
$this->assert(
str_contains($front, 'steuerkramLogEvent') && substr_count($front, "'system'") >= 2,
'Unbehandelte Ausnahmen und schwere Fehler landen im Bereich system'
);
// ── Ansicht und Routen ───────────────────────────────────────
$controller = (string)file_get_contents(__DIR__ . '/../src/Controllers/EventController.php');
$this->assert(str_contains($controller, 'function indexData'), 'EventController::indexData() existiert');
$this->assert(
str_contains($controller, 'filterFromRequest'),
'Bereich und Schweregrad werden aus der Anfrage übernommen'
);
$routes = (string)file_get_contents(__DIR__ . '/../public/index.php');
$this->assert(str_contains($routes, "'/ereignisse/data'"), 'Route /ereignisse/data ist registriert');
$index = (string)file_get_contents(__DIR__ . '/../src/Views/ereignisse/index.php');
$this->assert(
str_contains($index, 'name="channel"') && str_contains($index, 'name="level"'),
'Die Ansicht bietet Filter für Bereich und Schweregrad'
);
$this->assert(
str_contains($index, 'id="events-autoreload-toggle"') && str_contains($index, 'setInterval'),
'Der automatische Neuaufbau bleibt erhalten'
);
}
}

View file

@ -0,0 +1,63 @@
// @ts-check
// Hotspot: Ereignisliste über alle Bereiche (Issue #165).
//
// Die Liste zeigt nicht mehr nur den Nextcloud-Import, sondern auch Fehler und
// Warnungen aus dem übrigen System. Der Weg „Fehlermeldung im Browser → Eintrag
// in der Ereignisliste“ führt über eine Weiterleitung mit Flash-Meldung und ist
// deshalb nur mit Browser prüfbar; die Speicherlogik selbst deckt
// tests/81_EreignisseAlleFehlerTest.php ab.
//
// Die Tests legen keine Geschäftsdaten an. Ein Aufruf einer ungültigen URL
// erzeugt einen Protokolleintrag – genau das ist der geprüfte Effekt.
const { test, expect } = require('@playwright/test')
const { gotoOk } = require('../helpers/app')
test.describe('Ereignisse', () => {
test('Übersicht listet alle Bereiche und bietet Filter', async ({ page }) => {
await gotoOk(page, '/ereignisse')
await expect(page.locator('select[name="channel"]')).toHaveCount(1)
await expect(page.locator('select[name="level"]')).toHaveCount(1)
await expect(page.locator('#events-autoreload-toggle')).toHaveCount(1)
await expect(page.locator('select[name="channel"] option[value=""]')).toContainText('Alle Bereiche')
})
test('Der alte Nextcloud-Pfad landet auf der gefilterten Übersicht', async ({ page }) => {
await page.goto('/ereignisse/nextcloud-import')
const url = new URL(page.url())
expect(url.pathname).toBe('/ereignisse')
expect(url.searchParams.get('channel')).toBe('nextcloud-import')
await expect(page.locator('select[name="channel"]')).toHaveValue('nextcloud-import')
})
test('Eine Fehlermeldung aus dem Browser erscheint in der Ereignisliste', async ({ page }) => {
// Erzeugt garantiert eine Fehlermeldung samt Weiterleitung (Issue #167).
await page.goto('/dashboard/buchungen/unsinn/2026')
await expect(page.locator('body')).toContainText('Unbekannte Auswertung.')
await gotoOk(page, '/ereignisse?level=error')
await expect(page.locator('body')).toContainText('Unbekannte Auswertung.')
await expect(page.locator('body')).toContainText('[meldungen]')
})
test('Filter nach Schweregrad blendet Infos aus', async ({ page }) => {
await gotoOk(page, '/ereignisse?level=error')
await expect(page.locator('select[name="level"]')).toHaveValue('error')
const zeilen = page.locator('#events-body table tbody tr')
const anzahl = await zeilen.count()
for (let i = 0; i < Math.min(anzahl, 10); i++) {
await expect(zeilen.nth(i)).toContainText('Fehler')
}
})
test('Der Daten-Endpunkt liefert dieselbe gefilterte Liste als JSON', async ({ page }) => {
const antwort = await page.request.get('/ereignisse/data?level=error')
expect(antwort.status()).toBe(200)
const daten = await antwort.json()
expect(typeof daten.count).toBe('number')
expect(typeof daten.html).toBe('string')
})
})