From 16202c1058c0a9bd1fb648e72b254b7544df4186 Mon Sep 17 00:00:00 2001 From: TuNi02 Date: Wed, 28 Oct 2026 13:30:35 +0100 Subject: [PATCH] feat: add local activity logging backend --- docs/logging-backend.md | 63 ++++++ src/Sortify.Core/Models/LogEntry.cs | 3 + src/Sortify.Core/Models/LogLevel.cs | 3 + src/Sortify.Core/Models/LogReadResult.cs | 6 + src/Sortify.Core/Services/IActivityLog.cs | 12 ++ .../Logging/ActivityLogExtensions.cs | 14 ++ .../Logging/JsonActivityLog.cs | 121 +++++++++++ .../Logging/LoggedFileMoveService.cs | 46 +++++ .../Logging/LoggedRuleEngine.cs | 29 +++ .../Logging/LoggedSettingsStore.cs | 42 ++++ .../Monitoring/FolderMonitor.cs | 24 ++- .../ActivityLogTests.cs | 191 ++++++++++++++++++ 12 files changed, 553 insertions(+), 1 deletion(-) create mode 100644 docs/logging-backend.md create mode 100644 src/Sortify.Core/Models/LogEntry.cs create mode 100644 src/Sortify.Core/Models/LogLevel.cs create mode 100644 src/Sortify.Core/Models/LogReadResult.cs create mode 100644 src/Sortify.Core/Services/IActivityLog.cs create mode 100644 src/Sortify.Infrastructure/Logging/ActivityLogExtensions.cs create mode 100644 src/Sortify.Infrastructure/Logging/JsonActivityLog.cs create mode 100644 src/Sortify.Infrastructure/Logging/LoggedFileMoveService.cs create mode 100644 src/Sortify.Infrastructure/Logging/LoggedRuleEngine.cs create mode 100644 src/Sortify.Infrastructure/Logging/LoggedSettingsStore.cs create mode 100644 tests/Sortify.Infrastructure.Tests/ActivityLogTests.cs diff --git a/docs/logging-backend.md b/docs/logging-backend.md new file mode 100644 index 0000000..f1717b1 --- /dev/null +++ b/docs/logging-backend.md @@ -0,0 +1,63 @@ +# Lokale Protokollierung – Backend (#78) + +## Zuständigkeit und Stand + +Tuni implementiert Core-Vertrag und Infrastructure-Dienste. Manuel übernimmt App-Komposition, ViewModels und Anzeige. Ezra betreut die zukünftigen KI-Tools. Dieses Issue ändert keine Dateien unter `Sortify.App` und ergänzt keine UI. + +Das Backend ist vorbereitet, aber ohne die nachfolgende Anbindung noch nicht im laufenden Programm aktiv. Kein automatischer Export, kein Netzwerk und keine zusätzliche Bibliothek. + +## Vertrag + +`IActivityLog.TryWrite(level, eventName, message)` persistiert einen Eintrag und meldet Fehler mit `false`. Ungültige oder überlange Einträge werden abgelehnt (Ereigniskennung maximal 100, Nachricht maximal 2000 Zeichen). `Information`, `Warning` und `Error` werden unterstützt. `LogEntry` enthält `TimestampUtc`, `Level`, `EventName` und `Message`. + +`ReadRecent(limit)` liefert 1–1000 Einträge, standardmäßig 100, neuester zuerst. Die Reihenfolge entspricht der Schreibreihenfolge, auch bei korrigierter Systemzeit. Fehlende Dateien ergeben eine leere erfolgreiche Liste, ohne Verzeichnisse anzulegen. `ErrorMessage` meldet Lesefehler; beschädigte Zeilen werden übersprungen und in `SkippedEntries` gezählt. Die UI soll bei solchen Ergebnissen einen Hinweis anzeigen. Keine Exceptions oder Dateiinhalte an die UI weiterreichen. + +## Speicherung und Grenzen + +`JsonActivityLog.GetDefaultDirectory()` liefert den Ordner `logs` neben der bestehenden `settings.json`, auf Basis von `LocalApplicationData` des Betriebssystems. Unter Linux ist dies üblicherweise `~/.local/share/Sortify/logs`. Keine hartcodierten Benutzerpfade. + +JSONL mit UTF-8 und einem JSON-Objekt je Zeile: `activity.jsonl`, danach `activity.1.jsonl` (neuestes Archiv) bis `activity.3.jsonl`. Standard: maximal 1 MiB pro Datei und drei Archive, also höchstens ungefähr 4 MiB. Rotation vor dem Schreiben; älteste Archive werden ersetzt. Es gibt keine zeitbasierte Aufbewahrungsfrist. Größe und Archivanzahl sind Konstruktorparameter (4 KiB–16 MiB, 1–10 Archive); für denselben Ordner einheitlich konfigurieren. + +Schreiben und Lesen sind innerhalb eines Prozesses auch über mehrere Logger-Instanzen synchronisiert. Mehrere unabhängige Prozesse dürfen nicht denselben Log-Ordner verwenden; Sortify verwendet bereits eine Einzelinstanz-Sitzung. Symlink-Aliase eines Ordners sind keine zusätzlich unterstützte Synchronisationsgrenze. + +Die API verwendet kurze synchrone Dateioperationen, keine Warteschlange und keinen Shutdown-Flush. Bei einer späteren UI-Anbindung `ReadRecent` außerhalb des UI-Threads aufrufen. Sehr langsame Datenträger können die aufrufende Backend-Operation verzögern. Ein Prozessabsturz kann eine unvollständige letzte Zeile hinterlassen; diese wird beim Lesen übersprungen. Extern vergrößerte Dateien oberhalb des Limits werden beim Lesen mit Fehler abgelehnt. + +## Anbindung für Manuel + +Eine gemeinsame Instanz bei der App-Zusammenstellung erstellen und weitergeben: + +```csharp +IActivityLog log = new JsonActivityLog(JsonActivityLog.GetDefaultDirectory()); +ISettingsStore store = new LoggedSettingsStore( + new JsonSettingsStore(settingsPath, settingsValidator), log); +IFileMoveService moves = new LoggedFileMoveService(new FileMoveService(), log); +IRuleEngine engine = new LoggedRuleEngine( + new RuleEngine(ruleValidator, ruleTargetValidator), log); +var monitor = new FolderMonitor(log); +``` + +Namespaces: `Sortify.Core.Services`, `Sortify.Infrastructure.Logging` sowie die bestehenden Namespaces der umschlossenen Dienste. `settingsPath` und Validatoren entsprechen der vorhandenen App-Komposition. Die dekorierten Dienste müssen an sämtliche Verbraucher übergeben werden; vorhandene direkte `new FileMoveService()`-Aufrufe im App-Projekt werden durch diesen Backend-Branch nicht umgestellt. Keine neuen Dienste parallel zum vorhandenen Monitor starten. + +Für die Anzeige die gleiche `IActivityLog`-Instanz bereitstellen. Eine spätere Aktualisierung erfolgt durch erneutes `ReadRecent`, kein UI-Event und keine Dateisystemabhängigkeit im ViewModel. Kein Ereignis pro Polling-Schritt der Dateibereitschaft; nur das Endergebnis wird protokolliert. + +## Ereignisse + +| Kennung | Bedeutung | +| --- | --- | +| `FileMove.` | Ergebnis einschließlich Erfolg, fehlender Bestätigung, geänderter Quelle oder existierendem Ziel | +| `FileMove.Cancelled` / `FileMove.Exception` | Abbruch beziehungsweise unerwartete Exception | +| `FileSource.Ready` / `FileSource.Unavailable` / `FileSource.Exception` | Ergebnis der Bereitschaftsprüfung | +| `Settings.Load.` / `Settings.Save.Succeeded` / `Settings.Save.Failed` | Laden/Speichern; unerwartete Exceptions mit `.Exception` | +| `Rules.` / `Rules.Exception` | Ergebnis der Regelauswertung; mehrdeutig = Warnung, fehlgeschlagen = Fehler | +| `Monitor.Started` / `Monitor.Stopped` / `Monitor.StartFailed` / `Monitor.Interrupted` | Überwachungszustand | +| `Monitor.FileDetected` | Erkennung einer relevanten neuen Datei | + +Automatische Nachrichten enthalten keine vollständigen Pfade, Dateinamen, Dateiinhalte, Regeltexte, Einstellungswerte oder Exception-Details. Lokale Logs liefern damit technische Ablaufdiagnose, keine vollständige Historie einzelner Dateipfade. Erweiterungen mit sensiblen Informationen müssen bewusst vereinbart werden. Benutzereingaben mit Zeilenumbrüchen werden JSON-escaped; sie erzeugen keine zusätzlichen Log-Zeilen. + +Die Dekoratoren geben Originalergebnisse unverändert zurück und reichen ursprüngliche Exceptions/Abbrüche weiter. Logger-Fehler werden isoliert, auch wenn ein anderer `IActivityLog` entgegen dem Vertrag wirft. Es gibt keine rekursive Protokollierung von Log-Fehlern. Die aufrufende Anwendung kann direkte `TryWrite`-Fehler oder `ReadRecent.ErrorMessage` bei Bedarf sichtbar machen. + +## Verifikation + +Gezielte Tests für paralleles Schreiben, erneutes Lesen, Rotation, beschädigte Zeilen, Fehler/Rückkehr des Speichers, Logger-Ausfälle bei echten Dateioperationen, fehlende Bestätigung, Abbruch, Einstellungen, Überwachung und Regelauswertung. Bestehende Tests sichern die ursprüngliche Dateisicherheit weiterhin ab. + +Geprüfter Stand: `dotnet build Sortify.sln --configuration Release --no-restore` ohne Warnungen/Fehler; `dotnet test Sortify.sln --configuration Release --no-build --no-restore`: 411 erfolgreich (237 Core, 107 Infrastructure, 67 App). Neun gezielte Backend-Tests wurden ergänzt. `git diff --check` ist sauber. Die Live-Anbindung und visuelle Abnahme gehören zum Frontend-Folgeauftrag. diff --git a/src/Sortify.Core/Models/LogEntry.cs b/src/Sortify.Core/Models/LogEntry.cs new file mode 100644 index 0000000..40f002a --- /dev/null +++ b/src/Sortify.Core/Models/LogEntry.cs @@ -0,0 +1,3 @@ +namespace Sortify.Core.Models; + +public sealed record LogEntry(DateTimeOffset TimestampUtc, LogLevel Level, string EventName, string Message); diff --git a/src/Sortify.Core/Models/LogLevel.cs b/src/Sortify.Core/Models/LogLevel.cs new file mode 100644 index 0000000..88c1cde --- /dev/null +++ b/src/Sortify.Core/Models/LogLevel.cs @@ -0,0 +1,3 @@ +namespace Sortify.Core.Models; + +public enum LogLevel { Information, Warning, Error } diff --git a/src/Sortify.Core/Models/LogReadResult.cs b/src/Sortify.Core/Models/LogReadResult.cs new file mode 100644 index 0000000..4fc66c3 --- /dev/null +++ b/src/Sortify.Core/Models/LogReadResult.cs @@ -0,0 +1,6 @@ +namespace Sortify.Core.Models; + +public sealed record LogReadResult(IReadOnlyList Entries, string? ErrorMessage, int SkippedEntries = 0) +{ + public bool IsSuccess => ErrorMessage is null; +} diff --git a/src/Sortify.Core/Services/IActivityLog.cs b/src/Sortify.Core/Services/IActivityLog.cs new file mode 100644 index 0000000..e88451b --- /dev/null +++ b/src/Sortify.Core/Services/IActivityLog.cs @@ -0,0 +1,12 @@ +using Sortify.Core.Models; + +namespace Sortify.Core.Services; + +public interface IActivityLog +{ + // Returns false when persistence fails. Does not throw for unavailable storage. + bool TryWrite(LogLevel level, string eventName, string message); + + // Newest entries first. Reading never creates a directory or file. + LogReadResult ReadRecent(int limit = 100); +} diff --git a/src/Sortify.Infrastructure/Logging/ActivityLogExtensions.cs b/src/Sortify.Infrastructure/Logging/ActivityLogExtensions.cs new file mode 100644 index 0000000..140351e --- /dev/null +++ b/src/Sortify.Infrastructure/Logging/ActivityLogExtensions.cs @@ -0,0 +1,14 @@ +using Sortify.Core.Models; +using Sortify.Core.Services; + +namespace Sortify.Infrastructure.Logging; + +internal static class ActivityLogExtensions +{ + // Even a replacement logger must never change an application's operation result. + internal static void Record(this IActivityLog? log, LogLevel level, string name, string message) + { + try { log?.TryWrite(level, name, message); } + catch (Exception) { /* Logging is best effort; never recursively log its own failure. */ } + } +} diff --git a/src/Sortify.Infrastructure/Logging/JsonActivityLog.cs b/src/Sortify.Infrastructure/Logging/JsonActivityLog.cs new file mode 100644 index 0000000..2414676 --- /dev/null +++ b/src/Sortify.Infrastructure/Logging/JsonActivityLog.cs @@ -0,0 +1,121 @@ +using System.Collections.Concurrent; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Sortify.Core.Models; +using Sortify.Core.Services; +using Sortify.Infrastructure.Persistence; + +namespace Sortify.Infrastructure.Logging; + +public sealed class JsonActivityLog : IActivityLog +{ + private static readonly ConcurrentDictionary Gates = new( + OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal); + private readonly string directory; + private readonly long maxFileBytes; + private readonly int archiveCount; + private readonly TimeProvider clock; + private readonly object gate; + private static readonly JsonSerializerOptions JsonOptions = new() + { + RespectRequiredConstructorParameters = true, + Converters = { new JsonStringEnumConverter() } + }; + private static readonly Encoding Utf8 = new UTF8Encoding(false); + + public JsonActivityLog(string directory, long maxFileBytes = 1_048_576, int archiveCount = 3, + TimeProvider? clock = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(directory); + if (!Path.IsPathFullyQualified(directory)) throw new ArgumentException("An absolute log directory is required.", nameof(directory)); + if (maxFileBytes < 4096 || maxFileBytes > 16_777_216) throw new ArgumentOutOfRangeException(nameof(maxFileBytes)); + if (archiveCount < 1 || archiveCount > 10) throw new ArgumentOutOfRangeException(nameof(archiveCount)); + this.directory = Path.TrimEndingDirectorySeparator(Path.GetFullPath(directory)); + this.maxFileBytes = maxFileBytes; + this.archiveCount = archiveCount; + this.clock = clock ?? TimeProvider.System; + gate = Gates.GetOrAdd(this.directory, _ => new object()); + } + + public static string GetDefaultDirectory() => Path.Combine( + Path.GetDirectoryName(SettingsStoragePath.GetDefaultFilePath())!, "logs"); + + private string FilePath(int index) => Path.Combine(directory, + index == 0 ? "activity.jsonl" : $"activity.{index}.jsonl"); + + public bool TryWrite(LogLevel level, string eventName, string message) + { + if (!Enum.IsDefined(level) || string.IsNullOrWhiteSpace(eventName) || eventName.Length > 100 || + string.IsNullOrWhiteSpace(message) || message.Length > 2000) return false; + try + { + var entry = new LogEntry(clock.GetUtcNow(), level, eventName, message); + var bytes = Utf8.GetBytes(JsonSerializer.Serialize(entry, JsonOptions) + "\n"); + if (bytes.Length > maxFileBytes) return false; + lock (gate) + { + Directory.CreateDirectory(directory); + var current = FilePath(0); + if (File.Exists(current) && new FileInfo(current).Length + bytes.Length > maxFileBytes) + { + for (var index = archiveCount; index >= 1; index--) + { + var previous = FilePath(index - 1); + if (File.Exists(previous)) File.Move(previous, FilePath(index), overwrite: true); + } + } + using var stream = new FileStream(current, FileMode.Append, FileAccess.Write, FileShare.Read); + stream.Write(bytes); + stream.Flush(); + } + return true; + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or NotSupportedException) + { + return false; + } + } + + public LogReadResult ReadRecent(int limit = 100) + { + if (limit is < 1 or > 1000) throw new ArgumentOutOfRangeException(nameof(limit)); + var entries = new Queue(); + var skipped = 0; + try + { + lock (gate) + { + if (File.Exists(directory)) return new([], "Der Protokollordner ist nicht verfügbar."); + for (var index = archiveCount; index >= 0; index--) + { + var path = FilePath(index); + if (!File.Exists(path)) continue; + // Refuse externally replaced oversized files rather than reading unbounded data. + if (new FileInfo(path).Length > maxFileBytes) + return new([], "Eine Log-Datei überschreitet die zulässige Größe.", skipped); + foreach (var line in File.ReadLines(path, Utf8)) + { + if (string.IsNullOrWhiteSpace(line)) continue; + try + { + var entry = JsonSerializer.Deserialize(line, JsonOptions); + if (entry is null || entry.TimestampUtc == default || !Enum.IsDefined(entry.Level) || + string.IsNullOrWhiteSpace(entry.EventName) || string.IsNullOrWhiteSpace(entry.Message)) + { skipped++; continue; } + entries.Enqueue(entry); + if (entries.Count > limit) entries.Dequeue(); + } + catch (JsonException) { skipped++; } + } + } + } + // Storage order breaks timestamp ties and tolerates system-clock corrections. + return new(entries.Reverse().ToArray(), null, skipped); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or NotSupportedException) + { + return new([], "Die Protokolldateien konnten nicht gelesen werden.", skipped); + } + } +} diff --git a/src/Sortify.Infrastructure/Logging/LoggedFileMoveService.cs b/src/Sortify.Infrastructure/Logging/LoggedFileMoveService.cs new file mode 100644 index 0000000..9bf7492 --- /dev/null +++ b/src/Sortify.Infrastructure/Logging/LoggedFileMoveService.cs @@ -0,0 +1,46 @@ +using Sortify.Core.Models; +using Sortify.Core.Services; + +namespace Sortify.Infrastructure.Logging; + +public sealed class LoggedFileMoveService(IFileMoveService inner, IActivityLog log) : IFileMoveService +{ + public async Task MoveAsync(FileMoveRequest request, CancellationToken cancellationToken = default) + { + try + { + var result = await inner.MoveAsync(request, cancellationToken); + log.Record(result.Status == FileMoveStatus.Succeeded ? LogLevel.Information : LogLevel.Warning, + "FileMove." + result.Status, "Dateivorgang abgeschlossen: " + result.Status); + return result; + } + catch (OperationCanceledException) + { + log.Record(LogLevel.Information, "FileMove.Cancelled", "Dateivorgang abgebrochen."); + throw; + } + catch (Exception) + { + log.Record(LogLevel.Error, "FileMove.Exception", "Unerwarteter Fehler beim Dateivorgang."); + throw; + } + } + + public async Task InspectSourceAsync(string sourcePath, CancellationToken cancellationToken = default) + { + try + { + var result = await inner.InspectSourceAsync(sourcePath, cancellationToken); + log.Record(result.Snapshot is null ? LogLevel.Warning : LogLevel.Information, + result.Snapshot is null ? "FileSource.Unavailable" : "FileSource.Ready", + result.Snapshot is null ? "Datei noch nicht bereit zum Einsortieren." : "Datei bereit zum Einsortieren."); + return result; + } + catch (OperationCanceledException) { throw; } + catch (Exception) + { + log.Record(LogLevel.Error, "FileSource.Exception", "Unerwarteter Fehler bei der Dateiprüfung."); + throw; + } + } +} diff --git a/src/Sortify.Infrastructure/Logging/LoggedRuleEngine.cs b/src/Sortify.Infrastructure/Logging/LoggedRuleEngine.cs new file mode 100644 index 0000000..3126119 --- /dev/null +++ b/src/Sortify.Infrastructure/Logging/LoggedRuleEngine.cs @@ -0,0 +1,29 @@ +using Sortify.Core.Models; +using Sortify.Core.Services; + +namespace Sortify.Infrastructure.Logging; + +public sealed class LoggedRuleEngine(IRuleEngine inner, IActivityLog log) : IRuleEngine +{ + public RuleEvaluationResult Evaluate(FileDescriptor file, IReadOnlyCollection rules, + IReadOnlyCollection targets) + { + try + { + var result = inner.Evaluate(file, rules, targets); + var level = result.Status switch + { + RuleEvaluationStatus.Failed => LogLevel.Error, + RuleEvaluationStatus.Ambiguous => LogLevel.Warning, + _ => LogLevel.Information + }; + log.Record(level, "Rules." + result.Status, "Regelauswertung abgeschlossen: " + result.Status); + return result; + } + catch (Exception) + { + log.Record(LogLevel.Error, "Rules.Exception", "Unerwarteter Fehler bei der Regelauswertung."); + throw; + } + } +} diff --git a/src/Sortify.Infrastructure/Logging/LoggedSettingsStore.cs b/src/Sortify.Infrastructure/Logging/LoggedSettingsStore.cs new file mode 100644 index 0000000..8dbf535 --- /dev/null +++ b/src/Sortify.Infrastructure/Logging/LoggedSettingsStore.cs @@ -0,0 +1,42 @@ +using Sortify.Core.Models; +using Sortify.Core.Services; + +namespace Sortify.Infrastructure.Logging; + +public sealed class LoggedSettingsStore(ISettingsStore inner, IActivityLog log) : ISettingsStore +{ + public async Task LoadAsync(CancellationToken cancellationToken = default) + { + try + { + var result = await inner.LoadAsync(cancellationToken); + log.Record(result.Status == SettingsLoadStatus.Failed ? LogLevel.Error : LogLevel.Information, + "Settings.Load." + result.Status, "Einstellungen geladen: " + result.Status); + return result; + } + catch (OperationCanceledException) { throw; } + catch (Exception) + { + log.Record(LogLevel.Error, "Settings.Load.Exception", "Unerwarteter Fehler beim Laden der Einstellungen."); + throw; + } + } + + public async Task SaveAsync(AppSettings settings, CancellationToken cancellationToken = default) + { + try + { + var result = await inner.SaveAsync(settings, cancellationToken); + log.Record(result.IsSuccess ? LogLevel.Information : LogLevel.Error, + result.IsSuccess ? "Settings.Save.Succeeded" : "Settings.Save.Failed", + result.IsSuccess ? "Einstellungen gespeichert." : "Einstellungen konnten nicht gespeichert werden."); + return result; + } + catch (OperationCanceledException) { throw; } + catch (Exception) + { + log.Record(LogLevel.Error, "Settings.Save.Exception", "Unerwarteter Fehler beim Speichern der Einstellungen."); + throw; + } + } +} diff --git a/src/Sortify.Infrastructure/Monitoring/FolderMonitor.cs b/src/Sortify.Infrastructure/Monitoring/FolderMonitor.cs index 3203c11..27d6d4d 100644 --- a/src/Sortify.Infrastructure/Monitoring/FolderMonitor.cs +++ b/src/Sortify.Infrastructure/Monitoring/FolderMonitor.cs @@ -1,12 +1,26 @@ +using Sortify.Core.Models; +using Sortify.Core.Services; +using Sortify.Infrastructure.Logging; using System.Collections.Concurrent; namespace Sortify.Infrastructure.Monitoring; -public sealed class FolderMonitor : IDisposable +public sealed class FolderMonitor(IActivityLog? log = null) : IDisposable { private FileSystemWatcher? watcher; public void Start(string directory, Action onFile, Action onError) + { + try { StartCore(directory, onFile, onError); } + catch (Exception) + { + Dispose(); + log.Record(LogLevel.Error, "Monitor.StartFailed", "Ordnerüberwachung konnte nicht gestartet werden."); + throw; + } + } + + private void StartCore(string directory, Action onFile, Action onError) { Dispose(); var current = new FileSystemWatcher(directory) { NotifyFilter = NotifyFilters.FileName }; @@ -20,7 +34,10 @@ void Report(string path) && extension is not (".crdownload" or ".part" or ".partial" or ".tmp") && !Path.GetFileName(path).StartsWith("~$", StringComparison.Ordinal) && seen.TryAdd(path, 0)) + { + log.Record(LogLevel.Information, "Monitor.FileDetected", "Neue Datei im Überwachungsordner erkannt."); onFile(path); + } } current.Created += (_, args) => Report(args.FullPath); @@ -29,10 +46,14 @@ void Report(string path) current.Error += (_, _) => { if (ReferenceEquals(watcher, current)) + { + log.Record(LogLevel.Error, "Monitor.Interrupted", "Ordnerüberwachung unterbrochen."); onError("Ordnerueberwachung unterbrochen. Bitte den Ordner erneut auswaehlen."); + } }; watcher = current; current.EnableRaisingEvents = true; + log.Record(LogLevel.Information, "Monitor.Started", "Ordnerüberwachung gestartet."); } public void Dispose() @@ -40,5 +61,6 @@ public void Dispose() var previous = watcher; watcher = null; previous?.Dispose(); + if (previous is not null) log.Record(LogLevel.Information, "Monitor.Stopped", "Ordnerüberwachung beendet."); } } diff --git a/tests/Sortify.Infrastructure.Tests/ActivityLogTests.cs b/tests/Sortify.Infrastructure.Tests/ActivityLogTests.cs new file mode 100644 index 0000000..164d811 --- /dev/null +++ b/tests/Sortify.Infrastructure.Tests/ActivityLogTests.cs @@ -0,0 +1,191 @@ +using Sortify.Core.Models; +using Sortify.Core.Services; +using Sortify.Core.Rules; +using Sortify.Infrastructure.FileSystem; +using Sortify.Infrastructure.Logging; +using Sortify.Infrastructure.Monitoring; +using Sortify.Infrastructure.Persistence; + +namespace Sortify.Infrastructure.Tests; + +public sealed class ActivityLogTests : IDisposable +{ + private readonly string root = Path.Combine(Path.GetTempPath(), "sortify-log-" + Guid.NewGuid()); + private string Logs => Path.Combine(root, "logs"); + + [Fact] + public async Task ConcurrentInstancesPersistCompleteEntriesAndNewestFirstReadSurvivesRestart() + { + var first = new JsonActivityLog(Logs); + Assert.Empty(first.ReadRecent().Entries); + Assert.False(Directory.Exists(Logs)); + var second = new JsonActivityLog(Logs); + await Task.WhenAll(Enumerable.Range(0, 100).Select(index => Task.Run(() => + Assert.True((index % 2 == 0 ? first : second).TryWrite(LogLevel.Information, "Test." + index, "Line\nwith ünicode"))))); + var result = new JsonActivityLog(Logs).ReadRecent(1000); + Assert.True(result.IsSuccess); + Assert.Equal(100, result.Entries.Count); + Assert.Equal(100, result.Entries.Select(entry => entry.EventName).Distinct().Count()); + Assert.All(result.Entries, entry => Assert.Equal(TimeSpan.Zero, entry.TimestampUtc.Offset)); + Assert.True(first.TryWrite(LogLevel.Warning, "Last", "Last message")); + Assert.Equal("Last", Assert.Single(second.ReadRecent(1).Entries).EventName); + using var timestampDocument = System.Text.Json.JsonDocument.Parse(File.ReadLines(Path.Combine(Logs, "activity.jsonl")).Last()); + Assert.Equal("Warning", timestampDocument.RootElement.GetProperty("Level").GetString()); + } + + [Fact] + public void RotationBoundsStorageAndReadsAcrossArchivesInWriteOrder() + { + var log = new JsonActivityLog(Logs, maxFileBytes: 4096, archiveCount: 2); + for (var index = 0; index < 50; index++) + Assert.True(log.TryWrite(LogLevel.Information, "Event." + index, new string('x', 1000))); + var files = Directory.GetFiles(Logs); + Assert.Equal(3, files.Length); + Assert.All(files, path => Assert.InRange(new FileInfo(path).Length, 1, 4096)); + var recent = log.ReadRecent(1000); + Assert.True(recent.IsSuccess); + var numbers = recent.Entries.Select(entry => int.Parse(entry.EventName.Split('.')[1])).ToArray(); + Assert.Equal(49, numbers[0]); + Assert.Equal(numbers.OrderDescending(), numbers); + Assert.True(numbers.Length > File.ReadAllLines(Path.Combine(Logs, "activity.jsonl")).Length); + Assert.DoesNotContain(0, numbers); + } + + [Fact] + public void DamagedLinesAreReportedButValidEntriesRemainReadableAndInputCannotInjectLines() + { + var log = new JsonActivityLog(Logs); + Assert.True(log.TryWrite(LogLevel.Error, "Test", "first\nsecond")); + var path = Path.Combine(Logs, "activity.jsonl"); + Assert.Single(File.ReadAllLines(path)); + File.AppendAllText(path, "{broken\n{}\n"); + var result = log.ReadRecent(); + Assert.True(result.IsSuccess); + Assert.Equal(2, result.SkippedEntries); + Assert.Equal("first\nsecond", Assert.Single(result.Entries).Message); + Assert.False(log.TryWrite(LogLevel.Information, "", "message")); + Assert.False(log.TryWrite(LogLevel.Information, "Test", new string('x', 2001))); + Assert.Throws(() => log.ReadRecent(1001)); + } + + [Fact] + public void UnavailableStorageReturnsFailuresAndCanRecover() + { + Directory.CreateDirectory(root); + File.WriteAllText(Logs, "blocked"); + var log = new JsonActivityLog(Logs); + Assert.False(log.TryWrite(LogLevel.Error, "Test", "Message")); + Assert.False(log.ReadRecent().IsSuccess); + File.Delete(Logs); + Assert.True(log.TryWrite(LogLevel.Information, "Recovered", "Message")); + File.WriteAllText(Path.Combine(Logs, "activity.jsonl"), new string('x', 1_048_577)); + Assert.False(log.ReadRecent().IsSuccess); + } + + [Fact] + public async Task FileMoveLogsSuccessAndRejectionWithoutChangingSafetyOrLeakingPaths() + { + Directory.CreateDirectory(root); + var source = Path.Combine(root, "private-invoice.txt"); + var target = Path.Combine(root, "sorted.txt"); + File.WriteAllText(source, "content"); + var info = new FileInfo(source); + var log = new JsonActivityLog(Logs); + var service = new LoggedFileMoveService(new FileMoveService(), log); + var request = new FileMoveRequest(source, target, info.Length, new DateTimeOffset(info.LastWriteTimeUtc), false); + Assert.Equal(FileMoveStatus.NotConfirmed, (await service.MoveAsync(request)).Status); + Assert.True(File.Exists(source)); + Assert.Equal(FileMoveStatus.Succeeded, (await service.MoveAsync(request with { IsConfirmed = true })).Status); + Assert.Equal("content", File.ReadAllText(target)); + Assert.False(File.Exists(source)); + var entries = log.ReadRecent().Entries; + Assert.Equal(new[] { "FileMove.Succeeded", "FileMove.NotConfirmed" }, entries.Select(entry => entry.EventName)); + Assert.DoesNotContain(root, File.ReadAllText(Path.Combine(Logs, "activity.jsonl"))); + var inspection = new LoggedFileMoveService(new FileMoveService( + TimeSpan.FromMilliseconds(20), TimeSpan.FromMilliseconds(5), TimeSpan.FromSeconds(1)), log); + Assert.NotNull((await inspection.InspectSourceAsync(target)).Snapshot); + Assert.Equal("FileSource.Ready", log.ReadRecent().Entries[0].EventName); + } + + [Fact] + public async Task ThrowingLoggerDoesNotChangeSuccessfulMoveOrCancellation() + { + Directory.CreateDirectory(root); + var source = Path.Combine(root, "source.txt"); + File.WriteAllText(source, "safe"); + var info = new FileInfo(source); + var service = new LoggedFileMoveService(new FileMoveService(), new ThrowingLog()); + var request = new FileMoveRequest(source, Path.Combine(root, "target.txt"), info.Length, + new DateTimeOffset(info.LastWriteTimeUtc), true); + Assert.Equal(FileMoveStatus.Succeeded, (await service.MoveAsync(request)).Status); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + await Assert.ThrowsAnyAsync(() => service.MoveAsync(request, cancellation.Token)); + } + + [Fact] + public async Task SettingsLogsFailuresAndSuccessfulReloadWithoutExposingSettings() + { + Directory.CreateDirectory(root); + var log = new JsonActivityLog(Logs); + var service = new LoggedSettingsStore(new JsonSettingsStore(Path.Combine(root, "settings.json"), + new SettingsValidator(new RuleValidator())), log); + Assert.Equal(SettingsLoadStatus.NotFound, (await service.LoadAsync()).Status); + Assert.True((await service.SaveAsync(new AppSettings(null, null, false, []))).IsSuccess); + Assert.Equal(SettingsLoadStatus.Loaded, (await service.LoadAsync()).Status); + File.WriteAllText(Path.Combine(root, "settings.json"), "{broken"); + Assert.Equal(SettingsLoadStatus.Failed, (await service.LoadAsync()).Status); + Assert.Equal(LogLevel.Error, log.ReadRecent().Entries[0].Level); + Assert.Equal(4, log.ReadRecent().Entries.Count); + File.Delete(Path.Combine(root, "settings.json")); + Directory.CreateDirectory(Path.Combine(root, "settings.json")); + Assert.False((await service.SaveAsync(new AppSettings(null, null, false, []))).IsSuccess); + Assert.Equal("Settings.Save.Failed", log.ReadRecent().Entries[0].EventName); + Directory.Delete(Path.Combine(root, "settings.json")); + var throwing = new LoggedSettingsStore(new JsonSettingsStore(Path.Combine(root, "settings.json"), + new SettingsValidator(new RuleValidator())), new ThrowingLog()); + Assert.True((await throwing.SaveAsync(new AppSettings(null, null, false, []))).IsSuccess); + } + + [Fact] + public async Task MonitorLogsLifecycleDetectionAndFailedStart() + { + Directory.CreateDirectory(root); + var log = new JsonActivityLog(Logs); + using var monitor = new FolderMonitor(log); + var detected = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + monitor.Start(root, _ => detected.TrySetResult(), _ => { }); + File.WriteAllText(Path.Combine(root, "new.pdf"), "content"); + await detected.Task.WaitAsync(TimeSpan.FromSeconds(5)); + monitor.Dispose(); + Assert.Contains(log.ReadRecent().Entries, entry => entry.EventName == "Monitor.FileDetected"); + Assert.Contains(log.ReadRecent().Entries, entry => entry.EventName == "Monitor.Stopped"); + Assert.Throws(() => monitor.Start(Path.Combine(root, "missing"), _ => { }, _ => { })); + Assert.Equal("Monitor.StartFailed", log.ReadRecent().Entries[0].EventName); + } + + [Fact] + public void RuleEvaluationPreservesResultAndLogsNoMatchEvenWhenReplacementLoggerFails() + { + var log = new JsonActivityLog(Logs); + var engine = new RuleEngine(new RuleValidator(), new RuleTargetValidator()); + var file = new FileDescriptor(root, "private-name", ".pdf", "private-name.pdf", 1, DateTimeOffset.UnixEpoch); + var expected = engine.Evaluate(file, [], []); + var result = new LoggedRuleEngine(engine, log).Evaluate(file, [], []); + Assert.Equal(expected.Status, result.Status); + Assert.Equal(expected.Candidates, result.Candidates); + Assert.Equal("Rules.NoMatch", Assert.Single(log.ReadRecent().Entries).EventName); + Assert.Equal(expected.Status, new LoggedRuleEngine(engine, new ThrowingLog()).Evaluate(file, [], []).Status); + } + + public void Dispose() + { + if (Directory.Exists(root)) Directory.Delete(root, true); + } + + private sealed class ThrowingLog : IActivityLog + { + public bool TryWrite(LogLevel level, string eventName, string message) => throw new IOException("Log failure"); + public LogReadResult ReadRecent(int limit = 100) => throw new IOException("Log failure"); + } +}