Die Avalonia-Portierung ist abgeschlossen, damit ist die in ClawdDotNet.slnx angekuendigte Aufgabe "WinForms-Oberflaeche entfernen" faellig. Der Stand davor liegt unter dem Tag vor-fruehjahrsputz-2026-08. Entfernt (56 Dateien, seit dem Herausloesen der Anwendungsschicht nicht mehr Teil des Builds): ClawdDotNet.csproj, Program.cs, sieben frm_*-Formulare, UI/, Models/, EmbeddedUI/, Properties/, Resources/, Services/, das alte Anwendungssymbol und Deploy-Build.ps1 (ersetzt durch deploy/publish.py). Dazu configs/*.json - Beispielkonfigurationen aus der Zeit vor dem Instanzverzeichnis, auf die nur noch die alten Prompts verwiesen. Die vier Entwicklungs-Prompts der Anfangszeit ziehen nach docs/archiv/ um, mit README, das ihren Stand einordnet. Eine Regel darin gilt weiter - die Pflichtfelder fetchedAt/dataAsOf/source der Internet-Tools -, deshalb Archiv statt Loeschen; der WebSearch-Plan verweist auf den neuen Pfad. Toter Code - PlaceholderPageViewModel samt Ansicht: Es gibt keinen Platzhalter-Bereich mehr, seit alle neun Seiten portiert sind. - Snappier als direkter Paketverweis: MongoDB.Driver loest es ohnehin auf dieselbe Fassung auf, der Verweis hob nichts an. Zwei Fehler, die dabei sichtbar wurden - Die taegliche Sicherung lief ins Leere. Die Oberflaeche bot sie an und schrieb Uhrzeit, Zielordner und Anzahl in die Einstellungen, aber der BackupScheduler wurde nirgends erzeugt. Jetzt am AppHost verdrahtet und in den geordneten Abbau aufgenommen. - SettingsPageViewModel hielt die vier Sicherungs-Einstellungen doppelt. Aus der Ansicht waren sie laengst verschwunden, gelesen und beim Speichern zurueckgeschrieben wurden sie weiter: Wer die Uhrzeit auf der Sicherungs-Seite aenderte und danach die Einstellungen speicherte, bekam den alten Wert zurueck. Pakete: keine bekannten Sicherheitsluecken mehr - SQLitePCLRaw.bundle_e_sqlite3 auf 2.1.13 angehoben. Microsoft.Data.Sqlite bringt 2.1.11 mit, darin steckt GHSA-2m69-gcr7-jv3q (NU1903, hoch). - SharpCompress bleibt als direkter Verweis stehen. Beim Aufraeumen erst als ungenutzt entfernt - dabei kam die von MongoDB.Driver gezogene Fassung 0.30.1 mit GHSA-6c8g-7p36-r338 zurueck. Der Verweis ist eine Anhebung, kein Ballast; das steht jetzt als Kommentar dabei. Dokumentation - Roadmap mit Statusblock: A1 und A3 erledigt, A2 nur zur Haelfte - Gate, Policy und Dienst greifen, aber keine Ansicht ruft ApproveAsync auf, ein gestagter Aufruf liegt unbeantwortet. Das ist jetzt Punkt 1 der Reihung. Rocket.Chat steht und kollidiert mit A5 (Matrix) - Entscheidung faellig. - Avalonia-Portierungsleitfaden -> Oberflaechen-Leitfaden: kein Auftrag mehr, sondern Beschreibung des Stands. - Bestandsaufnahme und Linux-Analyse als datierte Befunde gekennzeichnet; der teure Teil der Linux-Analyse (8.900 Zeilen WinForms) ist hinfaellig. - Verweise auf frm_*, WebView2 und ClawdDotNet.csproj in den lebenden Dokumenten richtiggestellt. Build fehlerfrei, 585 Tests gruen (6 uebersprungen). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
464 lines
21 KiB
Markdown
464 lines
21 KiB
Markdown
# ClawdDotNet — Teststrategie
|
||
|
||
Ergänzung zur [Bestandsaufnahme](Bestandsaufnahme-2026-07.md).
|
||
Ziel: Fehlerklassen wie B1 (Compaction zerstört tool_call-Paarung) und B2 (Race Condition)
|
||
strukturell unmöglich machen, statt sie im Betrieb zu entdecken.
|
||
|
||
**Leitgedanke:** Nicht Coverage maximieren, sondern die *teuren* Fehler abfangen. In diesem
|
||
Projekt sind das drei Kategorien:
|
||
1. **Kontext-Invarianten** — ungültige Nachrichtensequenzen kosten API-Fehler mitten im Lauf
|
||
2. **Sicherheitsgrenzen** — Pfad-, SQL-, URL- und Empfänger-Prüfungen
|
||
3. **Nebenläufigkeit & Zeit** — Scheduler, paralleler Chat-Zugriff, Cron
|
||
|
||
UI-Layout, Designer-Code und Fremdbibliotheken werden bewusst *nicht* getestet.
|
||
|
||
---
|
||
|
||
## 1. Voraussetzung: Testbarkeit herstellen
|
||
|
||
Vier kleine Refactorings, ohne die die wichtigsten Tests nicht schreibbar sind.
|
||
|
||
### R1 — `IChatCompletionClient` extrahieren ⚠️ blockiert alles Weitere
|
||
[`AgentEngine.cs:15`](../src/ClawdDotNet.Core/Engine/AgentEngine.cs#L15) und
|
||
[`ContextCompactor.cs:10`](../src/ClawdDotNet.Core/Engine/ContextCompactor.cs#L10) hängen an der
|
||
konkreten, `sealed`-Klasse `OpenRouterClient`. Ohne Interface ist kein einziger Engine-Test möglich.
|
||
|
||
```csharp
|
||
public interface IChatCompletionClient
|
||
{
|
||
Task<ChatResponse> CompleteAsync(ChatRequest request, CancellationToken ct);
|
||
}
|
||
|
||
public sealed class OpenRouterClient : IChatCompletionClient, IDisposable { … }
|
||
```
|
||
|
||
Dazu ein `FakeChatClient` im Testprojekt, der eine vorprogrammierte Antwortfolge liefert und
|
||
alle empfangenen Requests mitschreibt:
|
||
|
||
```csharp
|
||
internal sealed class FakeChatClient : IChatCompletionClient
|
||
{
|
||
private readonly Queue<Func<ChatRequest, ChatResponse>> _responses = new();
|
||
public List<ChatRequest> ReceivedRequests { get; } = new();
|
||
|
||
public FakeChatClient RespondsWithText(string text) { … }
|
||
public FakeChatClient RespondsWithToolCall(string tool, string argsJson) { … }
|
||
public FakeChatClient Throws(Exception ex) { … }
|
||
|
||
public Task<ChatResponse> CompleteAsync(ChatRequest request, CancellationToken ct)
|
||
{
|
||
// Tiefe Kopie ablegen — die Engine mutiert die Nachrichtenliste weiter!
|
||
ReceivedRequests.Add(DeepClone(request));
|
||
…
|
||
}
|
||
}
|
||
```
|
||
|
||
> Die tiefe Kopie ist wesentlich: `AgentEngine` reicht dieselbe `List<ChatMessage>`-Instanz
|
||
> weiter und verändert sie danach. Ohne Kopie prüfen die Tests den Endzustand statt des
|
||
> tatsächlich gesendeten Requests.
|
||
|
||
### R2 — `TimeProvider` statt `DateTime.Now`
|
||
.NET 10 bringt `TimeProvider` mit; `FakeTimeProvider` steckt in
|
||
`Microsoft.Extensions.TimeProvider.Testing`. Betrifft
|
||
[`CronExpression.cs:53`](../src/ClawdDotNet.Core/Scheduling/CronExpression.cs#L53),
|
||
[`AgentScheduler.cs:75`](../src/ClawdDotNet.Core/Scheduling/AgentScheduler.cs#L75),
|
||
[`ToolJobScheduler.cs:111`](../src/ClawdDotNet.Core/Scheduling/ToolJobScheduler.cs#L111).
|
||
|
||
Damit werden Zeitumstellungs- und Langzeit-Tests deterministisch und laufen in Millisekunden
|
||
statt in Echtzeit.
|
||
|
||
### R3 — `IProcessRunner` für externe Prozesse
|
||
[`SocialMediaManagerTool.cs:442`](../src/ClawdDotNet.Tools.SocialMediaManager/SocialMediaManagerTool.cs#L442)
|
||
startet yt-dlp/ffmpeg direkt. Für den Injection-Test (S2) muss die **Argumentbildung** ohne
|
||
Prozessstart prüfbar sein:
|
||
|
||
```csharp
|
||
internal static string[] BuildYtDlpArgs(string url, …); // rein, testbar
|
||
public interface IProcessRunner { Task<ProcessResult> RunAsync(string exe, string[] args, CancellationToken ct); }
|
||
```
|
||
|
||
### R4 — Dateisystem **nicht** abstrahieren
|
||
Bewusste Entscheidung: Die Path-Traversal-Tests sollen echte Windows-Pfadsemantik prüfen
|
||
(`..`, UNC, Alternate Data Streams, Trailing Dots). Eine `IFileSystem`-Abstraktion würde genau
|
||
die Fehlerklasse verstecken, um die es geht. Stattdessen ein Temp-Verzeichnis-Fixture:
|
||
|
||
```csharp
|
||
internal sealed class TempWorkspace : IDisposable
|
||
{
|
||
public string Personal { get; }
|
||
public string Shared { get; }
|
||
public TempWorkspace() { /* unter Path.GetTempPath()/clawd-tests/{guid} anlegen */ }
|
||
public void Dispose() { /* rekursiv löschen, Fehler schlucken */ }
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 2. Projektstruktur & Stack
|
||
|
||
```
|
||
tests/
|
||
ClawdDotNet.Core.Tests/ ← Engine, Compaction, LoopGuard, Cron, Config, State
|
||
ClawdDotNet.Tools.Tests/ ← alle Tool-Projekte, v.a. Sicherheitsgrenzen
|
||
ClawdDotNet.Integration.Tests/ ← echte Endpunkte, standardmäßig übersprungen
|
||
```
|
||
|
||
| Paket | Zweck |
|
||
|---|---|
|
||
| `xunit.v3` | Test-Framework |
|
||
| `Shouldly` | Assertions — lesbar, frei nutzbar. *(FluentAssertions ist ab v8 für kommerzielle Nutzung kostenpflichtig; falls gewünscht ist `AwesomeAssertions` ein freier Fork von v7.)* |
|
||
| `NSubstitute` | Mocks für `ILogger`, `IStateStore`, `IAgentTool` |
|
||
| `Microsoft.Extensions.TimeProvider.Testing` | `FakeTimeProvider` |
|
||
| `FsCheck.Xunit` | Property-Based Tests für die Kontext-Invarianten |
|
||
| `Verify.Xunit` | Snapshot-Tests des Request-JSON |
|
||
|
||
Die drei Projekte in `ClawdDotNet.slnx` unter einem Ordner `/tests/` eintragen.
|
||
|
||
> Nebenbefund: In `ClawdDotNet.slnx` fehlen vier Tool-Projekte (AgentComm, AgentSpawn,
|
||
> AgentEditor, SocialMediaManager), obwohl sie in `src/ClawdDotNet.App/ClawdDotNet.App.csproj` referenziert sind.
|
||
> Sie sollten mit aufgenommen werden, sonst laufen sie in der IDE-Solution nicht mit.
|
||
|
||
---
|
||
|
||
## 3. Die Tests
|
||
|
||
### Prio 1 — Regressionstests für die gefundenen Bugs
|
||
|
||
Vorgehen: **erst den roten Test, dann den Fix.** Diese Tests sind der eigentliche Beleg, dass
|
||
die Bugs behoben sind — und sie bleiben als Wächter erhalten.
|
||
|
||
#### 3.1 ContextCompactor — Kontext-Invarianten (deckt B1 ab)
|
||
|
||
Das Herzstück. Ich würde eine wiederverwendbare Prüffunktion bauen und sie überall einsetzen:
|
||
|
||
```csharp
|
||
internal static class ContextInvariants
|
||
{
|
||
/// Wirft, wenn die Nachrichtenfolge von der API abgelehnt würde.
|
||
public static void AssertValid(IReadOnlyList<ChatMessage> messages)
|
||
{
|
||
// 1. Höchstens eine system-Nachricht, und zwar an Position 0
|
||
// 2. Jede tool-Nachricht hat eine unmittelbar vorausgehende Gruppe,
|
||
// deren assistant-Nachricht ein tool_call mit passender Id enthält
|
||
// 3. Jede assistant-Nachricht mit N tool_calls hat genau N tool-Antworten
|
||
// 4. Keine tool-Nachricht ohne ToolCallId
|
||
}
|
||
}
|
||
```
|
||
|
||
| # | Test | Erwartung |
|
||
|---|---|---|
|
||
| C1 | Compaction bei Tail-Grenze mitten in einer Tool-Sequenz | `AssertValid` besteht — **schlägt heute fehl** |
|
||
| C2 | Compaction bei `assistant(2 tool_calls) → tool → tool` an der Grenze | Beide tool-Antworten bleiben zusammen mit ihrem Aufruf |
|
||
| C3 | System-Prompt vorhanden | Bleibt an Position 0 erhalten |
|
||
| C4 | Kein System-Prompt vorhanden | Kein leerer/erfundener System-Eintrag |
|
||
| C5 | LLM-Zusammenfassung wirft Exception | Nachrichtenliste bleibt **unverändert und gültig** (kein halb geleerter Zustand) |
|
||
| C6 | LLM liefert leeren String zurück | dito |
|
||
| C7 | `PruneToolResults` | Kürzt nur außerhalb der letzten 6 Nachrichten |
|
||
| C8 | `PruneToolResults` bei Inhalt ≤ 2000 Zeichen | Bleibt unverändert (kein Marker angehängt) |
|
||
| C9 | `EstimateTokens` | Monoton: mehr Inhalt ⇒ nie weniger Tokens |
|
||
| C10 | Compaction unterhalb der Schwelle | Kein API-Call an den Fake (spart Geld!) |
|
||
|
||
**Property-Based (FsCheck) — der wertvollste Test des ganzen Projekts:**
|
||
|
||
```csharp
|
||
[Property(MaxTest = 500)]
|
||
public Property Compaction_erhaelt_immer_eine_gueltige_Sequenz()
|
||
{
|
||
return Prop.ForAll(ValidConversationGenerator(), // 1..200 gültige Nachrichten
|
||
async conversation =>
|
||
{
|
||
await _compactor.CompactIfNeededAsync(conversation, 999_999, _guard, "m", default);
|
||
ContextInvariants.AssertValid(conversation);
|
||
});
|
||
}
|
||
```
|
||
|
||
Ein Generator für gültige Konversationen (System, dann abwechselnd User/Assistant, gelegentlich
|
||
Assistant-mit-N-Tool-Calls gefolgt von genau N Tool-Antworten) findet Grenzfälle, die von Hand
|
||
niemand schreibt — genau die Klasse, in die B1 fällt.
|
||
|
||
#### 3.2 LoopGuard (deckt B3 ab)
|
||
|
||
| # | Test | Erwartung |
|
||
|---|---|---|
|
||
| L1 | `MaxSteps` erreicht | Wirft bei Schritt N+1, nicht bei N |
|
||
| L2 | **Konstanter 20k-Kontext über 10 Schritte** | Bricht **nicht** ab — definiert die Zielsemantik nach dem Fix |
|
||
| L3 | Kostenbudget überschritten | Wirft mit klarer Meldung, welches Limit griff |
|
||
| L4 | 100 parallele `RecordStep` | Zähler exakt 100 (Interlocked-Korrektheit) |
|
||
|
||
L2 ist der Test, der den heutigen Fehlentwurf festnagelt: Er schlägt mit den aktuellen Defaults
|
||
fehl und geht nach der Trennung von `maxCumulativeTokens` und `maxContextTokens` durch.
|
||
|
||
#### 3.3 AgentEngine — Nebenläufigkeit (deckt B2 ab)
|
||
|
||
| # | Test | Erwartung |
|
||
|---|---|---|
|
||
| E1 | Zwei parallele `ChatAsync` für **denselben** Agenten | Kontext danach `AssertValid`; **schlägt heute fehl** |
|
||
| E2 | 50 parallele `ChatAsync`, je 2 Tool-Schritte | Keine Exception, Nachrichtenzahl deterministisch |
|
||
| E3 | `AbortChat` bei zwei laufenden Läufen | **Beide** werden abgebrochen |
|
||
| E4 | Parallele Läufe für **verschiedene** Agenten | Laufen wirklich parallel (keine globale Serialisierung) |
|
||
|
||
E4 ist wichtig als Gegenprobe: Der Fix für B2 darf nicht versehentlich alle Agenten
|
||
gegeneinander sperren.
|
||
|
||
#### 3.4 CronExpression & Scheduler (deckt B6, B7 ab)
|
||
|
||
| # | Test | Erwartung |
|
||
|---|---|---|
|
||
| R1 | Tabelle: `*/30 * * * *`, `0 3 * * 1-5`, `0 0 29 2 *`, `15,45 * * * *` | Korrekte nächste Zeitpunkte |
|
||
| R2 | Invariante über viele Ausdrücke | `GetNextOccurrence(t) > t` **und** `Matches(ergebnis)` |
|
||
| R3 | Ungültig: `"* * *"`, `"99 * * * *"`, `"a b c d e"`, `"*/0 * * * *"` | Saubere `FormatException` — heute wirft teils `int.Parse`/`DivideByZero` |
|
||
| R4 | Zeitumstellung Oktober, `0 2 * * *` | Feuert genau **einmal** |
|
||
| R5 | Zeitumstellung März, `0 2 * * *` | Feuert trotzdem (wird nicht übersprungen) |
|
||
| R6 | `0 3 1 1 *` (jährlich) | Scheduler überlebt Wartezeit > 24 Tage — **schlägt heute fehl** |
|
||
| R7 | Lauf dauert länger als das Intervall | Keine Überlappung, keine Aufstauung |
|
||
| R8 | Exception in einem Job-Tick | Scheduler-Schleife läuft weiter, Fehler wird geloggt |
|
||
|
||
#### 3.5 Kostenerfassung (deckt B4 ab)
|
||
|
||
| # | Test | Erwartung |
|
||
|---|---|---|
|
||
| K1 | Modell nicht in der Preistabelle | Sichtbar markiert (nicht stillschweigend 0) |
|
||
| K2 | `anthropic/claude-sonnet-4-5` (der Default!) | Liefert einen Preis ≠ 0 |
|
||
| K3 | 95k Prompt / 5k Completion | Exakter Betrag gegen handgerechneten Wert — kein 50/50-Split |
|
||
|
||
---
|
||
|
||
### Prio 2 — Sicherheitstests
|
||
|
||
Diese Tests sind gleichzeitig die dauerhafte Dokumentation der Bedrohungsmodelle. Jeder
|
||
Angriffsvektor aus der Bestandsaufnahme wird ein `[Theory]`-Fall.
|
||
|
||
#### 3.6 FileRW — Pfadsicherheit (deckt S6 ab)
|
||
|
||
```csharp
|
||
[Theory]
|
||
[InlineData("../../../Windows/System32/drivers/etc/hosts")]
|
||
[InlineData(@"..\..\evil.txt")]
|
||
[InlineData(@"C:\Windows\System32\config\SAM")]
|
||
[InlineData(@"\\server\share\evil.txt")]
|
||
[InlineData("subdir/../../../outside.txt")]
|
||
[InlineData("file.txt:hidden")] // Alternate Data Stream
|
||
[InlineData("file.txt.")] // Windows entfernt den Punkt
|
||
[InlineData(" .. /../evil.txt")]
|
||
public async Task Read_lehnt_Pfade_ausserhalb_des_Workspace_ab(string path) { … }
|
||
```
|
||
|
||
Plus der Präfix-Angriff, der heute durchgeht:
|
||
|
||
| # | Test | Erwartung |
|
||
|---|---|---|
|
||
| F1 | Root `…\Workspace`, Ziel `…\Workspace-Backup\x.txt` | Abgelehnt — **schlägt heute fehl** |
|
||
| F2 | Endungs-Whitelist: `.exe`, `.ps1`, `.dll` | Abgelehnt |
|
||
| F3 | `.TXT` bei Whitelist `.txt` | Erlaubt (Groß/Kleinschreibung) |
|
||
| F4 | `report.txt.exe` | Abgelehnt |
|
||
| F5 | `copy` mit `destinationPath` außerhalb des Ziel-Workspace | Abgelehnt (eigener Pfad, eigener Test!) |
|
||
| F6 | `sharedAccessLevel: Denied` | Jede shared-Aktion abgelehnt |
|
||
| F7 | `ReadWrite` + `delete` in shared | Abgelehnt (nur Admin) |
|
||
| F8 | protectedPath `stocks/`, Schreiben auf existierende `stocks/x.json` | Abgelehnt |
|
||
| F9 | protectedPath `stocks/`, Schreiben auf **neue** `stocks/y.json` | Erlaubt |
|
||
| F10 | protectedPath `stocks/`, Pfad `stocks-alt/x.json` | **Nicht** geschützt (Präfix-Verwechslung) |
|
||
| F11 | Admin-Level + protectedPath | Umgeht den Schutz |
|
||
| F12 | 20 parallele `stock_add` auf denselben Ticker | `_index.json` enthält exakt 20 Einträge, gültiges JSON |
|
||
|
||
F12 deckt B9 ab und ist ein echter Nebenläufigkeitstest gegen das Dateisystem.
|
||
|
||
#### 3.7 DatabaseTool — SQL-Whitelist (deckt S1 ab)
|
||
|
||
Die Bypass-Tests. **Alle schlagen heute fehl** und definieren die Zielsemantik:
|
||
|
||
| # | SQL | AccessLevel | Erwartung |
|
||
|---|---|---|---|
|
||
| D1 | `DELETE FROM users -- prices` | ReadWrite | Abgelehnt |
|
||
| D2 | `SELECT * FROM users WHERE note='prices'` | ReadOnly | Abgelehnt |
|
||
| D3 | `SELECT * FROM prices; DROP TABLE users` | Admin | Abgelehnt (Mehrfach-Statement) |
|
||
| D4 | `SELECT * FROM prices` | ReadOnly | **Erlaubt** |
|
||
| D5 | `SELECT * FROM prices WHERE note='update'` | ReadOnly | **Erlaubt** (heutiger False Positive) |
|
||
| D6 | `UPDATE prices SET x=1` | ReadOnly | Abgelehnt |
|
||
| D7 | `UPDATE prices SET x=1` | ReadWrite | Erlaubt |
|
||
| D8 | `DROP TABLE prices` | ReadWrite | Abgelehnt |
|
||
| D9 | `allowedTables` fehlt komplett | egal | Abgelehnt (fail closed) |
|
||
| D10 | `SELECT` mit 10.000 Zeilen Ergebnis | ReadOnly | Ergebnis wird gekappt |
|
||
|
||
D4/D5/D7 sind die Gegenproben — sie verhindern, dass der Fix zu restriktiv wird und legitime
|
||
Abfragen blockiert.
|
||
|
||
#### 3.8 WebFetch — SSRF (deckt S5 ab)
|
||
|
||
Mit einem `FakeHttpMessageHandler`, der Redirects simuliert:
|
||
|
||
| # | Test | Erwartung |
|
||
|---|---|---|
|
||
| W1 | Erlaubte Domain → Redirect auf nicht erlaubte | Abgelehnt — **schlägt heute fehl** |
|
||
| W2 | `http://169.254.169.254/latest/meta-data/` | Abgelehnt |
|
||
| W3 | `http://localhost:8418/` | Abgelehnt (privates Netz) |
|
||
| W4 | `http://192.168.178.10/` | Abgelehnt |
|
||
| W5 | `file:///C:/Windows/win.ini` | Abgelehnt |
|
||
| W6 | Whitelist `example.com`, URL `example.com.attacker.net` | Abgelehnt |
|
||
| W7 | Whitelist `example.com`, URL `sub.example.com` | Erlaubt |
|
||
| W8 | Host `mywww.example.com` | Wird nicht zu `myexample.com` verstümmelt |
|
||
| W9 | Antwort größer als `maxResponseKb` | Sauber gekappt, kein OOM |
|
||
|
||
#### 3.9 Mail — Empfängerprüfung
|
||
|
||
| # | Test | Erwartung |
|
||
|---|---|---|
|
||
| M1 | Nicht gelistete Adresse | Abgelehnt |
|
||
| M2 | `ALLOWED@X.DE` bei Whitelist `allowed@x.de` | Erlaubt |
|
||
| M3 | `allowed@x.de, evil@y.de` (zwei in einem Feld) | Abgelehnt |
|
||
| M4 | `"allowed@x.de" <evil@y.de>` (Display-Name-Trick) | Abgelehnt |
|
||
| M5 | `allowedRecipients` fehlt | Abgelehnt (fail closed) |
|
||
| M6 | `read_inbox` mit 500 Mails | Ergebnis gekappt |
|
||
|
||
#### 3.10 SocialMediaManager — Argument-Injection (deckt S2 ab)
|
||
|
||
| # | Test | Erwartung |
|
||
|---|---|---|
|
||
| Y1 | `BuildYtDlpArgs("--exec calc.exe https://youtube.com/x")` | Wirft oder quotet — nie als eigene Option |
|
||
| Y2 | URL `https://evil.com/x` | Abgelehnt (nur YouTube-Domains) |
|
||
| Y3 | URL mit Leerzeichen/Anführungszeichen | Erscheint als **ein** Argument in `ArgumentList` |
|
||
| Y4 | Legitime URL/Handle (`@kanal`, volle URL) | Wird korrekt aufgelöst |
|
||
|
||
#### 3.11 DirectAPI — Key-Leak (deckt S3 ab)
|
||
|
||
Ein einzelner, sehr einfacher Test mit hohem Wert:
|
||
|
||
```csharp
|
||
[Fact]
|
||
public async Task Ergebnis_enthaelt_niemals_den_ApiKey()
|
||
{
|
||
const string key = "SECRET-KEY-12345";
|
||
var result = await _tool.ExecuteAsync(QuoteFor("NVDA"), ContextWithKey(key), default);
|
||
result.Content.ShouldNotContain(key); // schlägt heute fehl
|
||
}
|
||
```
|
||
|
||
Analog für alle Provider (`twelvedata`, `alphavantage`, `massive`) und alle Aktionen — als
|
||
`[Theory]`. Dieselbe Prüfung sollte es zusätzlich für die Log-Ausgabe geben.
|
||
|
||
---
|
||
|
||
### Prio 3 — Engine-Verhalten (mit `FakeChatClient`)
|
||
|
||
| # | Test | Erwartung |
|
||
|---|---|---|
|
||
| A1 | Antwort ohne tool_calls | Genau 1 API-Call, `FinalMessage` korrekt, Status `Completed` |
|
||
| A2 | Antwort mit 2 tool_calls, dann Text | 2 Tools ausgeführt, 2 tool-Nachrichten mit passenden Ids, dann 2. Call |
|
||
| A3 | Tool wirft Exception | Lauf läuft weiter, Fehler kommt als tool-Antwort ins Modell |
|
||
| A4 | Modell ruft nicht zugewiesenes Tool auf | `PermissionGate` greift, Fehlermeldung als tool-Antwort, kein Absturz |
|
||
| A5 | Modell ruft unbekanntes Tool auf | Saubere Fehlermeldung |
|
||
| A6 | Timeout (`FakeTimeProvider`) | Status `Cancelled` |
|
||
| A7 | Externe Cancellation | Status `Cancelled` |
|
||
| A8 | Loop-Limit erreicht | Status `LoopLimitExceeded` |
|
||
| A9 | **`OnRunCompleted` feuert in jedem Endzustand genau einmal** | Parametrisiert über alle 5 Status |
|
||
| A10 | System-Prompt | Im Request **genau einmal** enthalten |
|
||
| A11 | `FullSystemPrompt` leer | Kein leerer system-Eintrag im Request |
|
||
| A12 | Tool-Ergebnis über Limit | Gekappt (nach T2-Fix) |
|
||
| A13 | `send_message` A→B→A | Nach `maxDelegationDepth` abgebrochen (nach B8-Fix) |
|
||
| A14 | `spawn` auf bereits laufenden Agenten | Abgelehnt mit Hinweis |
|
||
| A15 | Snapshot des Request-JSON (Verify) | Wire-Format bleibt stabil |
|
||
|
||
A9 und A15 sind Invarianten-Tests mit überproportionalem Nutzen: A9 verhindert, dass beim
|
||
Erweitern der Fehlerbehandlung die Token-Erfassung stillschweigend ausfällt; A15 schützt beim
|
||
Caching-Umbau (T1) davor, das Wire-Format versehentlich zu verändern.
|
||
|
||
---
|
||
|
||
### Prio 4 — Konfiguration & Persistenz
|
||
|
||
| # | Bereich | Test |
|
||
|---|---|---|
|
||
| P1 | `ConfigLoader` | Fehlender `openRouterApiKey` → aussagekräftige Exception |
|
||
| P2 | `ConfigLoader` | Doppelte `agentId` → Exception mit beiden Namen |
|
||
| P3 | `ConfigLoader` | JSON mit Kommentaren und trailing commas wird akzeptiert |
|
||
| P4 | `ConfigLoader` | Datei fehlt → `FileNotFoundException` mit Pfad |
|
||
| P5 | Chat-Persistenz | Round-Trip History + Context, inkl. tool_calls |
|
||
| P6 | Chat-Persistenz | Beide Alt-Formate lesbar (Array **und** `{"messages":[…]}`) |
|
||
| P7 | Chat-Persistenz | Korrupte JSON-Datei → Warnung, Agent startet trotzdem |
|
||
| P8 | Chat-Persistenz | Umlaute/Emoji überleben den Round-Trip (UTF-8) |
|
||
| P9 | `SqliteStateStore` | Set/Get/Delete Round-Trip; Überschreiben; unbekannter Key → `null` |
|
||
| P10 | `SqliteStateStore` | 50 parallele Schreibzugriffe → kein `database is locked` |
|
||
| P11 | `AgentConfig.FullSystemPrompt` | Reihenfolge Identity → Soul → SystemPrompt |
|
||
| P12 | `AgentConfig.FullSystemPrompt` | Leere Teile werden ausgelassen, kein doppelter Leerraum |
|
||
| P13 | `ToolRegistry` | `GetForAgent` liefert nur zugewiesene Tools |
|
||
|
||
P10 ist relevant, weil alle Tool-Jobs denselben Store teilen.
|
||
|
||
---
|
||
|
||
### Prio 5 — Token & Caching (nach dem Umbau aus T1)
|
||
|
||
Diese Tests sichern ab, dass die Optimierung tatsächlich greift — ein stillschweigend
|
||
*nicht* funktionierender Cache wäre sonst nicht bemerkbar.
|
||
|
||
| # | Test | Erwartung |
|
||
|---|---|---|
|
||
| T1 | Cache-Breakpoint | Sitzt am Ende von System-Prompt und Tool-Definitionen |
|
||
| T2 | **Präfix-Stabilität über 10 Schritte** | Serialisierter Präfix ist byte-identisch — der entscheidende Test |
|
||
| T3 | `cached_tokens` | Wird aus der Antwort gelesen und in `AgentRunResult` weitergereicht |
|
||
| T4 | Compaction | Nutzt `summaryModel`, nicht `agentConfig.Model` |
|
||
| T5 | Tool-Ergebnis-Kappung | Greift bei allen Tools zentral, nicht nur bei WebFetch |
|
||
| T6 | `max_tokens` | Ist im Request gesetzt |
|
||
|
||
T2 ist der Test, der den ganzen Caching-Aufwand absichert: Ein einziger Zeitstempel im
|
||
System-Prompt würde den Cache bei jedem Schritt invalidieren — die Kosten blieben unverändert,
|
||
ohne dass es irgendwo auffällt.
|
||
|
||
---
|
||
|
||
## 4. Arbeitsregeln
|
||
|
||
**Test-first bei Bugfixes.** Für jeden Punkt aus der Bestandsaufnahme zuerst den roten Test,
|
||
dann den Fix. Das beweist, dass der Test den Fehler wirklich fängt — ein nachträglich
|
||
geschriebener Test bestätigt oft nur die Implementierung.
|
||
|
||
**Keine echten Netzwerkzugriffe in Unit-Tests.** Sie sind langsam, flaky und kosten bei
|
||
OpenRouter echtes Geld. Integrationstests kommen ins separate Projekt und werden markiert:
|
||
|
||
```csharp
|
||
[Trait("Category", "Integration")]
|
||
```
|
||
|
||
Standardlauf:
|
||
```bash
|
||
dotnet test --filter "Category!=Integration"
|
||
```
|
||
|
||
**Zeit immer über `TimeProvider`.** Kein `Thread.Sleep`, kein `Task.Delay` in Tests — sonst
|
||
dauert die Suite Minuten und wird instabil.
|
||
|
||
**Nebenläufigkeitstests wiederholen.** Race Conditions treten sporadisch auf; einmaliges
|
||
Bestehen sagt wenig:
|
||
```csharp
|
||
[Theory]
|
||
[Repeat(50)]
|
||
```
|
||
|
||
**Benennung** durchgängig deutsch, im Stil `Methode_Bedingung_Erwartung`:
|
||
`Compaction_bei_Toolsequenz_an_der_Grenze_erhaelt_gueltige_Paarung`.
|
||
|
||
---
|
||
|
||
## 5. Startpaket
|
||
|
||
Für die erste Sitzung schlage ich ~35 Tests vor, die exakt die fünf Sofort-Punkte abdecken:
|
||
|
||
| Reihenfolge | Umfang | Deckt ab |
|
||
|---|---|---|
|
||
| 1. R1 (`IChatCompletionClient`) + Testprojekte anlegen | Refactoring | Voraussetzung |
|
||
| 2. `ContextInvariants.AssertValid` + C1–C10 + Property-Test | ~12 Tests | **B1** |
|
||
| 3. L1–L4 | 4 Tests | **B3** |
|
||
| 4. E1–E4 | 4 Tests | **B2** |
|
||
| 5. Y1–Y4 | 4 Tests | **S2** |
|
||
| 6. DirectAPI Key-Leak (alle Provider) | ~6 Tests | **S3** |
|
||
| 7. A9 + A15 (Invarianten) | 6 Tests | Absicherung für den Umbau |
|
||
|
||
Danach ist die Basis da, um die Fixes aus der Bestandsaufnahme mit Netz umzusetzen — und die
|
||
Prio-2-Sicherheitstests lassen sich schrittweise ergänzen, wenn die jeweiligen Tools
|
||
angefasst werden.
|
||
|
||
**Langfristig sinnvoll:** ein Gitea-Actions-Workflow, der bei jedem Push
|
||
`dotnet test --filter "Category!=Integration"` ausführt.
|