Files
PolyTraderSharp/tests/PolyTrader.Tests/WebUi/LogViewerEndpointTests.cs
T
RichardandClaude Opus 5 0067e0b761 WebUI: Log-Viewer fuer vergangene Tage und Kopieren
Umsetzung von #564.

Das WebUI zeigte bisher nur den Live-Stream seit dem Prozessstart. GET
/api/logs lieferte den Verlauf im Speicher, die Oberflaeche nutzte ihn nicht.
Der Desktop hat einen Log-Viewer ueber die Tagesdateien. Den gibt es jetzt auch
im WebUI.

- GET /api/logs/files listet die vorhandenen Tage (Logs/yyyy-MM-dd.jsonl),
  neueste zuerst. Die .log-Textdateien und fremde Dateien bleiben aussen vor.
- GET /api/logs/file filtert nach Level, Korrelations-ID und Text, alle ohne
  Gross-/Kleinschreibung. Hoechstens 20000 Treffer wie im Desktop, dazu
  Seitenaufteilung. Unlesbare Zeilen werden uebersprungen und gezaehlt.
- Der Dateiname entsteht aus dem geprueften Datum, nie aus der Eingabe.
  "../../etc/passwd" kommt deshalb gar nicht erst in den Pfad; die Tests halten
  das fest.
- Gelesen wird mit FileShare.ReadWrite, weil der Logger laufend an die
  Tagesdatei anhaengt.

Oberflaeche: "Live" und "Log-Viewer (vergangene Tage)" als Unter-Tabs.
"Alles kopieren" im Live-Tab, "Seite kopieren" im Viewer. navigator.clipboard
gibt es nur in sicheren Kontexten, ueber LAN/VPN laeuft das WebUI aber per
http. Dafuer gibt es einen Rueckweg ueber ein unsichtbares Textfeld, sonst
schluege das Kopieren dort still fehl. Logzeilen werden per textContent
eingesetzt, nicht per innerHTML: Logtexte enthalten, was andere eingegeben
haben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 14:16:12 +02:00

127 lines
5.2 KiB
C#

using System;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using PolyTrader.Web.Configuration;
using PolyTraderSharp.Services;
using Xunit;
namespace PolyTrader.Tests.WebUi
{
/// <summary>
/// Log-Viewer für vergangene Tage über das WebUI (#564).
/// </summary>
public sealed class LogViewerEndpointTests : IDisposable
{
private readonly string _dir = Path.Combine(Path.GetTempPath(), "polytrader-logs-" + Guid.NewGuid().ToString("N"));
public LogViewerEndpointTests() => Directory.CreateDirectory(_dir);
public void Dispose()
{
try { Directory.Delete(_dir, recursive: true); } catch { }
}
private void WriteDay(string date, params (string Msg, LogLevel Level, string Cid)[] lines) =>
WriteRaw(date, lines.Select(l => LogJson.Format(new LogMessageEventArgs(l.Msg, l.Level, l.Cid))).ToArray());
private void WriteRaw(string date, params string[] lines) =>
File.WriteAllLines(Path.Combine(_dir, date + ".jsonl"), lines, new UTF8Encoding(false));
private Task<WebUiTestHost> StartAsync() =>
WebUiTestHost.StartAsync(s => s.AddSingleton(new LogDirectory(_dir)));
[Fact]
public async Task Tagesliste_zeigt_nur_Tagesdateien_neueste_zuerst()
{
WriteDay("2026-09-12", ("a", LogLevel.Info, ""));
WriteDay("2026-09-13", ("b", LogLevel.Info, ""));
File.WriteAllText(Path.Combine(_dir, "2026-09-13-Info.log"), "[12:00:00] b");
File.WriteAllText(Path.Combine(_dir, "notizen.jsonl"), "{}");
await using var web = await StartAsync();
var files = await web.GetJsonAsync("/api/logs/files");
Assert.Equal(new[] { "2026-09-13", "2026-09-12" },
files.EnumerateArray().Select(f => f.GetProperty("date").GetString()).ToArray());
}
[Fact]
public async Task Filter_nach_Level_Korrelations_ID_und_Text()
{
WriteDay("2026-09-12",
("Start", LogLevel.Info, ""),
("Signal abgelehnt", LogLevel.Warning, "sig-42"),
("VPN weg", LogLevel.Error, ""));
await using var web = await StartAsync();
var all = await web.GetJsonAsync("/api/logs/file?date=2026-09-12");
var warnings = await web.GetJsonAsync("/api/logs/file?date=2026-09-12&level=warning");
var byCid = await web.GetJsonAsync("/api/logs/file?date=2026-09-12&cid=SIG-42");
var byText = await web.GetJsonAsync("/api/logs/file?date=2026-09-12&q=vpn");
Assert.Equal(3, all.GetProperty("total").GetInt32());
Assert.Equal("Signal abgelehnt", Assert.Single(warnings.GetProperty("items").EnumerateArray()).GetProperty("msg").GetString());
Assert.Equal("sig-42", Assert.Single(byCid.GetProperty("items").EnumerateArray()).GetProperty("cid").GetString());
Assert.Equal("Error", Assert.Single(byText.GetProperty("items").EnumerateArray()).GetProperty("level").GetString());
}
[Fact]
public async Task Kaputte_Zeilen_werden_uebersprungen_und_gezaehlt()
{
WriteRaw("2026-09-12",
LogJson.Format(new LogMessageEventArgs("gut", LogLevel.Info)),
"{kaputt",
"",
LogJson.Format(new LogMessageEventArgs("auch gut", LogLevel.Info)));
await using var web = await StartAsync();
var result = await web.GetJsonAsync("/api/logs/file?date=2026-09-12");
Assert.Equal(2, result.GetProperty("total").GetInt32());
Assert.Equal(1, result.GetProperty("unreadableLines").GetInt32()); // Leerzeilen zaehlen nicht
}
[Fact]
public async Task Seitenaufteilung()
{
WriteDay("2026-09-12", Enumerable.Range(1, 120).Select(i => ($"Zeile {i}", LogLevel.Info, "")).ToArray());
await using var web = await StartAsync();
var last = await web.GetJsonAsync("/api/logs/file?date=2026-09-12&pageSize=50&page=9");
Assert.Equal(3, last.GetProperty("totalPages").GetInt32());
Assert.Equal(3, last.GetProperty("page").GetInt32()); // auf die letzte Seite begrenzt
Assert.Equal(20, last.GetProperty("items").GetArrayLength());
Assert.Equal("Zeile 101", last.GetProperty("items")[0].GetProperty("msg").GetString());
}
[Theory]
[InlineData("../../etc/passwd")]
[InlineData("2026-09-12/../../geheim")]
[InlineData("2026-02-30")]
[InlineData("")]
public async Task Ungueltiges_Datum_400(string date)
{
WriteDay("2026-09-12", ("a", LogLevel.Info, ""));
await using var web = await StartAsync();
using var res = await web.Client.GetAsync("/api/logs/file?date=" + Uri.EscapeDataString(date));
Assert.Equal(400, (int)res.StatusCode);
}
[Fact]
public async Task Tag_ohne_Datei_404()
{
await using var web = await StartAsync();
using var res = await web.Client.GetAsync("/api/logs/file?date=2020-01-01");
Assert.Equal(404, (int)res.StatusCode);
}
}
}