munin/internal/mcpserver/mcpserver_test.go
2026-10-19 10:13:56 +02:00

1101 lines
42 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package mcpserver
import (
"context"
"errors"
"fmt"
"path/filepath"
"strings"
"testing"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp"
"munin/internal/auth"
"munin/internal/search"
"munin/internal/store"
"munin/internal/web"
"munin/internal/workspace"
)
// setup legt Datenbank, Projekt und Handler an.
//
// Das Schreibbudget ist bewusst großzügig: Die meisten Tests haben mit der
// Begrenzung nichts zu tun, sollen aber trotzdem durch denselben Code laufen wie
// der Betrieb. Wer die Grenze prüfen will, nimmt setupWithBudget.
func setup(t *testing.T) (*Handler, *workspace.Workspace, store.Project) {
t.Helper()
return setupWithBudget(t, 1000)
}
// setupWithBudget wie setup, aber mit einer wählbaren Obergrenze je Stunde.
func setupWithBudget(t *testing.T, limit int) (*Handler, *workspace.Workspace, store.Project) {
t.Helper()
db, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("Datenbank: %v", err)
}
t.Cleanup(func() { db.Close() })
ctx := context.Background()
// Eine Workspace-Datei kennt keine "workspaces"-Tabelle mehr — die lebt in
// der zentralen Kontroll-Datenbank (siehe internal/store/control.go). Für
// den Test reicht eine beliebige ID.
workspaceID := store.NewID()
project, err := db.CreateProject(ctx, workspaceID, "test", "Testprojekt", "")
if err != nil {
t.Fatal(err)
}
ws := &workspace.Workspace{ID: workspaceID, Store: db, Search: search.NewRegistry(db)}
budget := auth.NewWriteBudget(limit, time.Hour)
return New(budget), ws, project
}
// call ruft ein MCP-Werkzeug auf.
//
// Statt einen HTTP-Server zu starten, verbindet sich der Client über ein
// In-Memory-Transportpaar direkt mit dem Server — dieselbe Logik, ohne Netzwerk.
// Die Sitzung (Projekt und Rolle) wird im Context mitgegeben, so wie es sonst
// web.APIAuth tut.
func call(t *testing.T, h *Handler, ws *workspace.Workspace, project store.Project, role auth.Role, tool string, args map[string]any) *mcp.CallToolResult {
t.Helper()
sess := web.Session{
Subject: "key:" + ws.ID + ":test",
WorkspaceID: ws.ID,
Key: store.APIKey{ID: "test-key", Name: "test-agent", ProjectID: project.ID, Role: string(role)},
Project: project,
Role: role,
}
ctx := web.WithSessionForTest(context.Background(), sess)
ctx = web.WithWorkspaceForTest(ctx, ws)
clientTransport, serverTransport := mcp.NewInMemoryTransports()
serverSession, err := h.serverFor(project).Connect(ctx, serverTransport, nil)
if err != nil {
t.Fatalf("Serververbindung: %v", err)
}
defer serverSession.Close()
client := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "1"}, nil)
clientSession, err := client.Connect(ctx, clientTransport, nil)
if err != nil {
t.Fatalf("Clientverbindung: %v", err)
}
defer clientSession.Close()
result, err := clientSession.CallTool(ctx, &mcp.CallToolParams{Name: tool, Arguments: args})
if err != nil {
t.Fatalf("Aufruf %q: %v", tool, err)
}
return result
}
// text fasst die Textbausteine eines Ergebnisses zusammen.
func text(r *mcp.CallToolResult) string {
var b strings.Builder
for _, c := range r.Content {
if t, ok := c.(*mcp.TextContent); ok {
b.WriteString(t.Text)
}
}
return b.String()
}
// ── Werkzeugbestand ──────────────────────────────────────────────────────────
// TestToolsAndInstructions prüft, dass alle Werkzeuge da sind und die
// Anleitung mitgeliefert wird. Die Anleitung ist das wirksamste Stellrad für die
// Qualität der Antworten — sie darf nicht versehentlich wegfallen.
func TestToolsAndInstructions(t *testing.T) {
h, _, project := setup(t)
ctx := context.Background()
clientTransport, serverTransport := mcp.NewInMemoryTransports()
serverSession, err := h.serverFor(project).Connect(ctx, serverTransport, nil)
if err != nil {
t.Fatal(err)
}
defer serverSession.Close()
client := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "1"}, nil)
clientSession, err := client.Connect(ctx, clientTransport, nil)
if err != nil {
t.Fatal(err)
}
defer clientSession.Close()
if instr := clientSession.InitializeResult().Instructions; !strings.Contains(instr, "recall") {
t.Error("die Anleitung fehlt oder erklärt recall nicht")
}
list, err := clientSession.ListTools(ctx, nil)
if err != nil {
t.Fatal(err)
}
existing := make(map[string]bool)
for _, tool := range list.Tools {
existing[tool.Name] = true
}
for _, name := range []string{"recall", "get", "list", "remember", "forget", "related"} {
if !existing[name] {
t.Errorf("Werkzeug %q fehlt", name)
}
}
}
// ── Schreiben und Dublettenvermeidung ────────────────────────────────────────
// TestRememberDeduplicatesBySlug prüft den Kern des Gedächtnisses: Zweimal
// dasselbe Thema unter demselben Bezeichner ergibt einen Eintrag, nicht zwei.
func TestRememberDeduplicatesBySlug(t *testing.T) {
h, ws, project := setup(t)
db := ws.Store
call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{
"slug": "deployment", "title": "Deployment", "content": "Erste Erkenntnis",
})
call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{
"slug": "deployment", "title": "Deployment", "content": "Zweite, bessere Erkenntnis",
})
count, err := db.CountEntries(context.Background(), project.ID)
if err != nil {
t.Fatal(err)
}
if count != 1 {
t.Errorf("%d Einträge, erwartet 1 — es wurde eine Dublette angelegt", count)
}
e, _ := db.EntryBySlug(context.Background(), project.ID, "deployment")
if e.Content != "Zweite, bessere Erkenntnis" {
t.Error("der Eintrag wurde nicht aktualisiert")
}
if e.Version != 2 {
t.Errorf("Version = %d, erwartet 2", e.Version)
}
}
// TestRememberRecordsProvenance prüft, dass nachvollziehbar bleibt, wer geschrieben hat.
func TestRememberRecordsProvenance(t *testing.T) {
h, ws, project := setup(t)
db := ws.Store
call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{
"slug": "notiz", "title": "Notiz", "content": "Inhalt",
})
e, _ := db.EntryBySlug(context.Background(), project.ID, "notiz")
if e.Source != store.SourceAgent {
t.Errorf("Source = %q, erwartet %q", e.Source, store.SourceAgent)
}
if e.Author != "test-agent" {
t.Errorf("Author = %q, erwartet den Namen des Zugangs", e.Author)
}
}
// TestRememberDerivesSlug prüft, dass ein fehlender Bezeichner aus dem Titel
// abgeleitet wird — das Modell soll nicht an Formalien scheitern.
func TestRememberDerivesSlug(t *testing.T) {
h, ws, project := setup(t)
db := ws.Store
call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{
"title": "Der Deployment-Prozess", "content": "Inhalt",
})
if _, err := db.EntryBySlug(context.Background(), project.ID, "der-deployment-prozess"); err != nil {
t.Errorf("kein Eintrag unter dem abgeleiteten Bezeichner: %v", err)
}
}
// TestRememberDetectsConflict prüft das optimistische Sperren.
func TestRememberDetectsConflict(t *testing.T) {
h, ws, project := setup(t)
call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{
"slug": "geteilt", "title": "T", "content": "Fassung 1",
})
// Agent A schreibt und erhöht damit auf Fassung 2.
call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{
"slug": "geteilt", "title": "T", "content": "von A",
})
// Agent B hatte noch Fassung 1 gelesen.
result := call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{
"slug": "geteilt", "title": "T", "content": "von B", "expected_version": 1,
})
if !result.IsError {
t.Fatal("der Konflikt wurde nicht erkannt — B hat A stillschweigend überschrieben")
}
// Die Fehlermeldung muss dem Modell sagen, was zu tun ist.
if !strings.Contains(text(result), "get") {
t.Errorf("die Fehlermeldung erklärt das weitere Vorgehen nicht: %s", text(result))
}
}
// ── Rollen ───────────────────────────────────────────────────────────────────
// TestReadRoleCannotWrite prüft, dass ein Nur-Lese-Zugang nichts verändern kann.
func TestReadRoleCannotWrite(t *testing.T) {
h, ws, project := setup(t)
db := ws.Store
// Jedes Werkzeug bekommt nur die Parameter, die es kennt — sonst weist schon
// die Schemaprüfung des SDK den Aufruf ab und die Rollenprüfung käme nie dran.
cases := map[string]map[string]any{
"remember": {"slug": "x", "title": "T", "content": "C"},
"forget": {"slug": "x"},
}
for tool, args := range cases {
t.Run(tool, func(t *testing.T) {
result := call(t, h, ws, project, auth.RoleRead, tool, args)
if !result.IsError {
t.Error("der Nur-Lese-Zugang durfte schreiben")
}
if !strings.Contains(text(result), "Leserecht") {
t.Errorf("die Fehlermeldung nennt den Grund nicht: %s", text(result))
}
})
}
if n, _ := db.CountEntries(context.Background(), project.ID); n != 0 {
t.Errorf("%d Einträge angelegt, erwartet 0", n)
}
}
func TestReadRoleCanRead(t *testing.T) {
h, ws, project := setup(t)
call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{
"slug": "notiz", "title": "Notiz", "content": "Inhalt",
})
for _, tool := range []string{"recall", "list"} {
args := map[string]any{}
if tool == "recall" {
args["query"] = "Notiz"
}
if result := call(t, h, ws, project, auth.RoleRead, tool, args); result.IsError {
t.Errorf("%s wurde für den Lese-Zugang abgelehnt: %s", tool, text(result))
}
}
}
// ── Suchen und Lesen ─────────────────────────────────────────────────────────
// TestRecallFindsByPrefix prüft die Suche nach dem Wortanfang: "deploy" muss
// "Deployment-Prozess" finden. Ohne sie liefert die Suche ein leeres Ergebnis,
// und das Modell erfährt nie, dass es den Eintrag gäbe.
func TestRecallFindsByPrefix(t *testing.T) {
h, ws, project := setup(t)
call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{
"slug": "deployment-prozess", "title": "Deployment-Prozess",
"content": "Wir deployen über GitHub Actions.",
})
result := call(t, h, ws, project, auth.RoleRead, "recall", map[string]any{"query": "deploy"})
if result.IsError {
t.Fatalf("recall schlug fehl: %s", text(result))
}
if !strings.Contains(text(result), "deployment-prozess") {
t.Errorf("die Präfix-Suche fand den Eintrag nicht: %s", text(result))
}
}
// TestRecallEmptyGivesHint prüft, dass ein leeres Ergebnis dem Modell sagt, was
// als Nächstes zu tun ist.
func TestRecallEmptyGivesHint(t *testing.T) {
h, ws, project := setup(t)
result := call(t, h, ws, project, auth.RoleRead, "recall", map[string]any{"query": "gibtesnicht"})
if !strings.Contains(text(result), "remember") {
t.Errorf("kein Hinweis auf das weitere Vorgehen: %s", text(result))
}
}
// TestRecallWithSynonyms prüft, dass Synonyme einen Eintrag finden, der das
// Thema unter einem anderen Namen führt. Ohne sie findet eine Frage nach dem
// „Deployment" den „Release-Ablauf" nicht — kein Wort passt.
func TestRecallWithSynonyms(t *testing.T) {
h, ws, project := setup(t)
call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{
"slug": "release-ablauf", "title": "Release-Ablauf",
"content": "Neue Fassungen gehen dienstags raus.",
})
without := call(t, h, ws, project, auth.RoleRead, "recall", map[string]any{
"query": "wie deployen wir",
})
if strings.Contains(text(without), "release-ablauf") {
t.Fatalf("der Eintrag wurde schon ohne Synonyme gefunden — der Test prüft so nichts: %s", text(without))
}
with := call(t, h, ws, project, auth.RoleRead, "recall", map[string]any{
"query": "wie deployen wir",
"synonyms": []any{"release", "rollout"},
})
if with.IsError {
t.Fatalf("recall schlug fehl: %s", text(with))
}
if !strings.Contains(text(with), "release-ablauf") {
t.Errorf("das Synonym fand den Eintrag nicht: %s", text(with))
}
}
// TestRecallIsLogged prüft, dass jede Suche im Suchprotokoll landet —
// mit Synonymen und der Zahl der Treffer.
func TestRecallIsLogged(t *testing.T) {
h, ws, project := setup(t)
call(t, h, ws, project, auth.RoleRead, "recall", map[string]any{
"query": "gibtesnicht", "synonyms": []any{"auchnicht"},
})
st, err := ws.Store.SearchStatsFor(context.Background(), project.ID, time.Now())
if err != nil {
t.Fatal(err)
}
if st.Total != 1 || st.Empty != 1 || st.WithSynonyms != 1 {
t.Errorf("SearchStats = %+v, erwartet je 1", st)
}
}
// TestWithSynonymsCaps prüft die Obergrenze für Synonyme.
func TestWithSynonymsCaps(t *testing.T) {
many := make([]string, recallMaxSynonyms+5)
for i := range many {
many[i] = fmt.Sprintf("wort%d", i)
}
got := withSynonyms("frage", limitSynonyms(many))
if strings.Contains(got, fmt.Sprintf("wort%d", recallMaxSynonyms)) {
t.Errorf("mehr als %d Synonyme wurden übernommen: %q", recallMaxSynonyms, got)
}
if got := limitSynonyms([]string{" ", "release", ""}); len(got) != 1 {
t.Errorf("begrenzeSynonyme = %q, erwartet nur [release]", got)
}
if withSynonyms("frage", nil) != "frage" {
t.Error("ohne Synonyme soll die Anfrage unverändert bleiben")
}
}
// TestRecallEmptyPointsToSynonyms prüft, dass ein leeres Ergebnis zuerst
// zum erneuten Suchen mit anderen Begriffen anhält — nicht gleich zum Anlegen
// eines neuen Eintrags, der dann womöglich eine Dublette ist.
func TestRecallEmptyPointsToSynonyms(t *testing.T) {
h, ws, project := setup(t)
result := call(t, h, ws, project, auth.RoleRead, "recall", map[string]any{"query": "gibtesnicht"})
if !strings.Contains(text(result), "synonyms") {
t.Errorf("der Hinweis nennt keine erneute Suche mit Synonymen: %s", text(result))
}
}
func TestRecallFiltersByTag(t *testing.T) {
h, ws, project := setup(t)
call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{
"slug": "a", "title": "Deployment A", "content": "deployment", "tags": []any{"ops"},
})
call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{
"slug": "b", "title": "Deployment B", "content": "deployment", "tags": []any{"docs"},
"force": true, // ähnlicher Titel ist hier gewollt, siehe duplicates.go
})
result := call(t, h, ws, project, auth.RoleRead, "recall",
map[string]any{"query": "deployment", "tags": []any{"ops"}})
if strings.Contains(text(result), `"slug": "b"`) || strings.Contains(text(result), `"slug":"b"`) {
t.Errorf("die Schlagwortfilterung griff nicht: %s", text(result))
}
}
// TestGetReturnsBacklinks prüft die Verweise in beide Richtungen — sie ersetzen
// die frühere Ordnerhierarchie als Struktur zwischen Einträgen.
func TestGetReturnsBacklinks(t *testing.T) {
h, ws, project := setup(t)
call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{
"slug": "ziel", "title": "Ziel", "content": "Inhalt",
})
call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{
"slug": "quelle", "title": "Quelle", "content": "siehe [[ziel]]",
})
result := call(t, h, ws, project, auth.RoleRead, "get", map[string]any{"slug": "ziel"})
if !strings.Contains(text(result), "quelle") {
t.Errorf("der Rückverweis fehlt: %s", text(result))
}
reversed := call(t, h, ws, project, auth.RoleRead, "get", map[string]any{"slug": "quelle"})
if !strings.Contains(text(reversed), "ziel") {
t.Error("der ausgehende Verweis fehlt")
}
}
func TestGetUnknownSlugExplains(t *testing.T) {
h, ws, project := setup(t)
result := call(t, h, ws, project, auth.RoleRead, "get", map[string]any{"slug": "gibtesnicht"})
if !result.IsError {
t.Fatal("ein unbekannter Bezeichner wurde nicht als Fehler gemeldet")
}
// Die Meldung soll dem Modell den nächsten Schritt nennen.
if !strings.Contains(text(result), "recall") {
t.Errorf("die Meldung hilft nicht weiter: %s", text(result))
}
}
// ── Abschottung ──────────────────────────────────────────────────────────────
// TestProjectIsolation ist der wichtigste Test: Die Werkzeuge eines Projekts
// dürfen unter keinen Umständen an die Daten eines anderen kommen.
func TestProjectIsolation(t *testing.T) {
h, ws, projectA := setup(t)
db := ws.Store
ctx := context.Background()
projectB, err := db.CreateProject(ctx, projectA.WorkspaceID, "fremd", "Fremdes Projekt", "")
if err != nil {
t.Fatal(err)
}
// Wissen in Projekt B ablegen.
call(t, h, ws, projectB, auth.RoleWrite, "remember", map[string]any{
"slug": "geheim", "title": "Geheim", "content": "Vertrauliches aus Projekt B",
})
// Mit den Werkzeugen von Projekt A danach suchen.
search := call(t, h, ws, projectA, auth.RoleRead, "recall", map[string]any{"query": "Vertrauliches"})
if strings.Contains(text(search), "Vertrauliches") {
t.Error("recall in Projekt A fand Inhalte aus Projekt B")
}
read := call(t, h, ws, projectA, auth.RoleRead, "get", map[string]any{"slug": "geheim"})
if !read.IsError {
t.Error("get in Projekt A lieferte einen Eintrag aus Projekt B")
}
list := call(t, h, ws, projectA, auth.RoleRead, "list", map[string]any{})
if strings.Contains(text(list), "geheim") {
t.Error("list in Projekt A zeigte einen Eintrag aus Projekt B")
}
// Und der Gegentest: Löschen darf ebenfalls nicht durchschlagen.
call(t, h, ws, projectA, auth.RoleWrite, "forget", map[string]any{"slug": "geheim"})
if n, _ := db.CountEntries(ctx, projectB.ID); n != 1 {
t.Error("forget in Projekt A hat einen Eintrag in Projekt B gelöscht")
}
}
// ── Grenzen ──────────────────────────────────────────────────────────────────
// TestRecallLimitIsCapped prüft, dass ein Client nicht beliebig viele Treffer
// anfordern kann — eine riesige Antwort sprengt das Kontextfenster des Modells.
func TestRecallLimitIsCapped(t *testing.T) {
h, ws, project := setup(t)
for i := 0; i < 60; i++ {
call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{
"slug": "eintrag-" + string(rune('a'+i%26)) + string(rune('a'+i/26)),
"title": "Eintrag",
"content": "gemeinsames Stichwort",
"force": true, // 60 gleiche Titel sind hier gewollt, siehe duplicates.go
})
}
result := call(t, h, ws, project, auth.RoleRead, "recall",
map[string]any{"query": "gemeinsames", "limit": 9999})
// Mehr als recallMaxLimit Treffer dürfen nicht zurückkommen.
if count := strings.Count(text(result), `"slug"`); count > recallMaxLimit {
t.Errorf("%d Treffer zurückgegeben, Obergrenze ist %d", count, recallMaxLimit)
}
}
func TestRememberRejectsEmptyContent(t *testing.T) {
h, ws, project := setup(t)
result := call(t, h, ws, project, auth.RoleWrite, "remember",
map[string]any{"slug": "leer", "title": "T", "content": ""})
if !result.IsError {
t.Error("ein leerer Inhalt wurde angenommen")
}
}
// ── Schreibbudget ────────────────────────────────────────────────────────────
// TestWriteBudgetStopsRunawayAgent prüft den eigentlichen Zweck der Begrenzung:
// Ein Agent in einer Schleife wird gestoppt, statt bis zum Plattenende zu schreiben.
func TestWriteBudgetStopsRunawayAgent(t *testing.T) {
const limit = 3
h, ws, project := setupWithBudget(t, limit)
db := ws.Store
for i := 0; i < limit; i++ {
result := call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{
"slug": fmt.Sprintf("eintrag-%d", i), "title": "Eintrag", "content": "Inhalt",
"force": true, // gleiche Titel sind hier gewollt, siehe duplicates.go
})
if result.IsError {
t.Fatalf("Aufruf %d von %d abgelehnt, obwohl im Budget: %s", i+1, limit, text(result))
}
}
result := call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{
"slug": "eintrag-zuviel", "title": "Zu viel", "content": "Inhalt",
})
if !result.IsError {
t.Fatal("Aufruf über der Grenze wurde angenommen")
}
// Die Meldung geht an ein Sprachmodell. Sie muss sagen, was los ist UND was
// zu tun ist — sonst versucht es das Modell sofort wieder.
message := text(result)
for _, want := range []string{"Schreibbudget", "NICHT sofort erneut"} {
if !strings.Contains(message, want) {
t.Errorf("Meldung enthält %q nicht: %s", want, message)
}
}
// Und der abgelehnte Aufruf darf nichts geschrieben haben.
if _, err := db.EntryBySlug(context.Background(), project.ID, "eintrag-zuviel"); err == nil {
t.Error("der abgelehnte Aufruf hat den Eintrag trotzdem angelegt")
}
}
// TestWriteBudgetLeavesReadingAlone stellt sicher, dass nur das Schreiben
// gedeckelt ist. Ein erschöpftes Budget darf das Gedächtnis nicht unlesbar machen —
// sonst wäre der Agent nicht gebremst, sondern blind.
func TestWriteBudgetLeavesReadingAlone(t *testing.T) {
h, ws, project := setupWithBudget(t, 1)
call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{
"slug": "vorhanden", "title": "Vorhanden", "content": "Inhalt",
})
// Budget ist jetzt aufgebraucht.
if result := call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{
"slug": "noch-einer", "title": "Noch einer", "content": "Inhalt",
}); !result.IsError {
t.Fatal("Vorbedingung verfehlt: das Budget ist noch nicht erschöpft")
}
for _, tool := range []string{"recall", "get", "list"} {
args := map[string]any{}
switch tool {
case "recall":
args["query"] = "Inhalt"
case "get":
args["slug"] = "vorhanden"
}
if result := call(t, h, ws, project, auth.RoleWrite, tool, args); result.IsError {
t.Errorf("%q wurde trotz erschöpftem Schreibbudget abgelehnt: %s", tool, text(result))
}
}
}
// TestWriteBudgetIsPerProject prüft die Mandantentrennung: Ein Projekt, das sein
// Budget verbraucht, darf kein anderes mitbremsen.
func TestWriteBudgetIsPerProject(t *testing.T) {
h, ws, project := setupWithBudget(t, 1)
db := ws.Store
ctx := context.Background()
second, err := db.CreateProject(ctx, project.WorkspaceID, "zweites", "Zweites Projekt", "")
if err != nil {
t.Fatal(err)
}
call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{"slug": "a", "title": "A", "content": "Inhalt"})
if result := call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{
"slug": "b", "title": "B", "content": "Inhalt",
}); !result.IsError {
t.Fatal("Vorbedingung verfehlt: das erste Projekt ist noch nicht am Limit")
}
if result := call(t, h, ws, second, auth.RoleWrite, "remember", map[string]any{
"slug": "c", "title": "C", "content": "Inhalt",
}); result.IsError {
t.Errorf("das zweite Projekt wurde mitgesperrt: %s", text(result))
}
}
// TestWriteBudgetUsesProjectLimit prüft, dass die eigene Obergrenze eines
// Projekts den Standard schlägt — und zwar die aus der Sitzung, damit eine
// Änderung in der Verwaltung sofort wirkt.
func TestWriteBudgetUsesProjectLimit(t *testing.T) {
h, ws, project := setupWithBudget(t, 100)
// Dasselbe Projekt, aber mit einer eigenen, engeren Grenze — so, wie es
// web.APIAuth nach einer Änderung in der Verwaltung aus der Datenbank läse.
project.WriteLimitHourly = 1
call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{"slug": "a", "title": "A", "content": "Inhalt"})
if result := call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{
"slug": "b", "title": "B", "content": "Inhalt",
}); !result.IsError {
t.Error("die eigene Obergrenze des Projekts wurde nicht angewandt")
}
}
// TestRememberLogsCreatedOrUpdated: Das Protokoll muss
// unterscheiden, ob ein Schreibvorgang neu angelegt oder aktualisiert hat —
// daran lässt sich ablesen, ob Agenten vorhandene Einträge fortschreiben oder
// Dubletten erzeugen (AUSBAUSTUFEN.md 4.6).
func TestRememberLogsCreatedOrUpdated(t *testing.T) {
h, ws, project := setup(t)
for _, content := range []string{"Erste Erkenntnis", "Zweite Erkenntnis"} {
call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{
"slug": "deployment", "title": "Deployment", "content": content,
})
}
events, err := ws.Store.ListAudit(context.Background(), store.AuditFilter{ProjectID: project.ID})
if err != nil {
t.Fatal(err)
}
var details []string
for _, e := range events {
if e.Action == store.ActionEntryWrite {
details = append(details, e.Detail)
}
}
// ListAudit liefert die neuesten zuerst.
want := []string{store.DetailEntryUpdated, store.DetailEntryCreated}
if fmt.Sprint(details) != fmt.Sprint(want) {
t.Errorf("Details = %v, erwartet %v", details, want)
}
}
// ── Papierkorb ───────────────────────────────────────────────────────────────
// TestForgetMovesToTrash: "forget" löscht nicht mehr endgültig. Für den Agenten
// ist der Eintrag weg — get, recall und list sehen ihn nicht mehr —, in der
// Datenbank liegt er aber im Papierkorb, samt Name des Zugangs.
func TestForgetMovesToTrash(t *testing.T) {
h, ws, project := setup(t)
ctx := context.Background()
call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{
"slug": "deployment", "title": "Deployment", "content": "Wir deployen mit Blaugrün",
})
answer := call(t, h, ws, project, auth.RoleWrite, "forget", map[string]any{"slug": "deployment"})
if answer.IsError {
t.Fatalf("forget: %s", text(answer))
}
if !strings.Contains(text(answer), "Papierkorb") {
t.Errorf("die Antwort erwähnt den Papierkorb nicht: %s", text(answer))
}
if r := call(t, h, ws, project, auth.RoleRead, "get", map[string]any{"slug": "deployment"}); !r.IsError {
t.Error("get findet den gelöschten Eintrag noch")
}
if r := call(t, h, ws, project, auth.RoleRead, "recall", map[string]any{"query": "Blaugrün"}); strings.Contains(text(r), "deployment") {
t.Errorf("recall findet den gelöschten Eintrag noch: %s", text(r))
}
if r := call(t, h, ws, project, auth.RoleRead, "list", map[string]any{}); strings.Contains(text(r), "deployment") {
t.Errorf("list zeigt den gelöschten Eintrag noch: %s", text(r))
}
trash, _ := ws.Store.ListTrash(ctx, project.ID, "")
if len(trash) != 1 || trash[0].DeletedBy != "test-agent" {
t.Errorf("Papierkorb = %+v, erwartet „deployment“, gelöscht von test-agent", trash)
}
// Ein zweites forget auf denselben Bezeichner ist ein Fehler — aus Sicht
// des Agenten gibt es den Eintrag nicht mehr.
if r := call(t, h, ws, project, auth.RoleWrite, "forget", map[string]any{"slug": "deployment"}); !r.IsError {
t.Error("zweites forget meldete keinen Fehler")
}
}
// TestRememberRevivesFromTrash: Schreibt ein Agent unter einem Bezeichner, der
// im Papierkorb liegt, wird der Eintrag zurückgeholt und überschrieben — und
// der Agent erfährt das in der Antwort.
func TestRememberRevivesFromTrash(t *testing.T) {
h, ws, project := setup(t)
ctx := context.Background()
call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{
"slug": "deployment", "title": "Deployment", "content": "alt",
})
call(t, h, ws, project, auth.RoleWrite, "forget", map[string]any{"slug": "deployment"})
answer := call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{
"slug": "deployment", "title": "Deployment", "content": "neu",
})
if answer.IsError {
t.Fatalf("remember: %s", text(answer))
}
if !strings.Contains(text(answer), "Papierkorb") {
t.Errorf("die Antwort sagt nicht, dass ein gelöschter Eintrag zurückgeholt wurde: %s", text(answer))
}
if strings.Contains(text(answer), "angelegt") {
t.Errorf("die Antwort behauptet, der Eintrag sei neu angelegt: %s", text(answer))
}
e, err := ws.Store.EntryBySlug(ctx, project.ID, "deployment")
if err != nil || e.Content != "neu" || e.Version != 2 {
t.Errorf("nach remember: %+v, %v", e, err)
}
events, _ := ws.Store.ListAudit(ctx, store.AuditFilter{ProjectID: project.ID})
found := false
for _, ev := range events {
if ev.Action == store.ActionEntryRestore && ev.EntrySlug == "deployment" {
found = true
}
}
if !found {
t.Error("das Protokoll vermerkt das Zurückholen nicht")
}
}
// ── Verwandte Einträge und Alter (AUSBAUSTUFEN.md 3.7, 3.5) ──────────────────
func TestRelatedFindsNeighbours(t *testing.T) {
h, ws, project := setup(t)
call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{
"slug": "release", "title": "Release-Prozess", "content": "Vorher [[checkliste]] durchgehen.",
})
call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{
"slug": "checkliste", "title": "Checkliste", "content": "Siehe auch [[rollback]].",
})
call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{
"slug": "rollback", "title": "Rollback", "content": "Zurück auf die letzte Fassung.",
})
// Auch ein Lesezugang darf das Umfeld abfragen.
result := call(t, h, ws, project, auth.RoleRead, "related", map[string]any{"slug": "release"})
if result.IsError {
t.Fatalf("Fehler: %s", text(result))
}
answer := text(result)
for _, want := range []string{`"checkliste"`, `"rollback"`, `"distance":2`, `"via":"checkliste"`} {
if !strings.Contains(answer, want) {
t.Errorf("%s fehlt in der Antwort: %s", want, answer)
}
}
directOnly := text(call(t, h, ws, project, auth.RoleRead, "related", map[string]any{"slug": "release", "distance": 1}))
if strings.Contains(directOnly, "rollback") {
t.Errorf("distance 1 lieferte die zweite Reihe: %s", directOnly)
}
}
func TestRelatedUnknownSlugExplains(t *testing.T) {
h, ws, project := setup(t)
result := call(t, h, ws, project, auth.RoleRead, "related", map[string]any{"slug": "gibtesnicht"})
if !result.IsError || !strings.Contains(text(result), "recall") {
t.Errorf("unbekannter Bezeichner nicht hilfreich gemeldet: %s", text(result))
}
}
// TestStaleEntriesAreMarked prüft, dass alte Einträge in recall, get und list
// als "stale" erscheinen und frische nicht.
func TestStaleEntriesAreMarked(t *testing.T) {
h, ws, project := setup(t)
ctx := context.Background()
twoYearsAgo := time.Now().AddDate(-2, 0, 0)
if _, err := ws.Store.PutEntry(ctx, store.EntryWrite{
ProjectID: project.ID, Slug: "altes-deployment", Title: "Deployment alt",
Content: "Deployment per FTP", Source: store.SourceHuman, UpdatedAt: twoYearsAgo,
}); err != nil {
t.Fatal(err)
}
call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{
"slug": "neues-deployment", "title": "Deployment neu", "content": "Deployment per Pipeline",
"force": true, // ähnlich zu „Deployment alt" ist hier gewollt, siehe duplicates.go
})
recall := text(call(t, h, ws, project, auth.RoleRead, "recall", map[string]any{"query": "Deployment"}))
if strings.Count(recall, `"stale":true`) != 1 {
t.Errorf("recall: genau ein Treffer sollte stale sein: %s", recall)
}
if !strings.Contains(recall, "seit über einem Jahr") {
t.Errorf("recall: Hinweis auf veraltete Treffer fehlt: %s", recall)
}
old := text(call(t, h, ws, project, auth.RoleRead, "get", map[string]any{"slug": "altes-deployment"}))
if !strings.Contains(old, `"stale":true`) || !strings.Contains(old, "prüfen") {
t.Errorf("get: alter Eintrag nicht markiert: %s", old)
}
newer := text(call(t, h, ws, project, auth.RoleRead, "get", map[string]any{"slug": "neues-deployment"}))
if strings.Contains(newer, "stale") {
t.Errorf("get: frischer Eintrag als stale markiert: %s", newer)
}
list := text(call(t, h, ws, project, auth.RoleRead, "list", nil))
if strings.Count(list, `"stale":true`) != 1 {
t.Errorf("list: genau ein Eintrag sollte stale sein: %s", list)
}
}
// ── Dubletten ────────────────────────────────────────────────────────────────
// TestRememberReportsDuplicate prüft den Kern von 3.3: Ein neuer Eintrag mit
// ähnlichem Titel wird nicht angelegt, stattdessen kommen die Kandidaten
// zurück — und erst mit force wird doch angelegt.
func TestRememberReportsDuplicate(t *testing.T) {
h, ws, project := setup(t)
ctx := context.Background()
call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{
"slug": "deployment-prozess", "title": "Deployment-Prozess", "content": "Über die Pipeline.",
})
result := call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{
"slug": "deployment-ablauf", "title": "Deployment-Ablauf", "content": "Auch über die Pipeline.",
})
if result.IsError {
t.Fatalf("die Dublettenmeldung soll kein Fehler sein: %s", text(result))
}
answer := text(result)
for _, want := range []string{`"judgment_required":true`, `"deployment-prozess"`, "force", `"matched_words":["deployment"]`} {
if !strings.Contains(answer, want) {
t.Errorf("%s fehlt in der Antwort: %s", want, answer)
}
}
if _, err := ws.Store.EntryBySlug(ctx, project.ID, "deployment-ablauf"); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("die Dublette wurde trotzdem angelegt (err = %v)", err)
}
// Mit force wird angelegt.
result = call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{
"slug": "deployment-ablauf", "title": "Deployment-Ablauf", "content": "Auch über die Pipeline.",
"force": true,
})
if !strings.Contains(text(result), `"created":true`) {
t.Errorf("mit force wurde nicht angelegt: %s", text(result))
}
}
// TestRememberUpdateWithoutCheck prüft, dass das Fortschreiben eines
// vorhandenen Eintrags nie angehalten wird — auch wenn es ähnliche gibt.
func TestRememberUpdateWithoutCheck(t *testing.T) {
h, ws, project := setup(t)
call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{
"slug": "deployment-prozess", "title": "Deployment-Prozess", "content": "A",
})
call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{
"slug": "deployment-ablauf", "title": "Deployment-Ablauf", "content": "B", "force": true,
})
result := call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{
"slug": "deployment-ablauf", "title": "Deployment-Ablauf", "content": "B, ergänzt",
})
if strings.Contains(text(result), "judgment_required") {
t.Errorf("eine Aktualisierung wurde angehalten: %s", text(result))
}
}
// TestRememberDissimilarCreates prüft, dass ein anderes Thema ohne Umweg
// angelegt wird.
func TestRememberDissimilarCreates(t *testing.T) {
h, ws, project := setup(t)
call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{
"slug": "deployment-prozess", "title": "Deployment-Prozess", "content": "Über die Pipeline.",
})
result := call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{
"slug": "vpn-zugang", "title": "VPN-Zugang", "content": "Über die Pipeline beantragen.",
})
if !strings.Contains(text(result), `"created":true`) {
t.Errorf("ein unähnlicher Eintrag wurde nicht angelegt: %s", text(result))
}
}
// TestRememberDuplicateViaSearchTerms prüft das Zusammenspiel mit der Zeile
// „Auch gesucht als": Ein neuer „Release-Ablauf" findet den vorhandenen
// „Deployment-Prozess", weil dort „release" als Suchbegriff steht.
func TestRememberDuplicateViaSearchTerms(t *testing.T) {
h, ws, project := setup(t)
call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{
"slug": "deployment-prozess", "title": "Deployment-Prozess",
"content": "Über die Pipeline.\n\nAuch gesucht als: release, rollout",
})
result := call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{
"slug": "release-ablauf", "title": "Release-Ablauf", "content": "Dienstags.",
})
if !strings.Contains(text(result), `"judgment_required":true`) {
t.Errorf("die Dublette über die Suchbegriffe wurde nicht erkannt: %s", text(result))
}
}
// TestRememberCommonPrefixNoDuplicate prüft den Fall aus dem Betrieb
// (2026-10-16): Titel nach dem Muster „Projekt – Thema" lösten allein wegen
// des gleichen Präfixes eine Dublettenmeldung aus. „Munin – Architektur"
// wurde nicht angelegt, Kandidat war „Munin – Überblick" mit 50 %.
//
// Eine echte Dublette unter demselben Präfix muss aber weiter auffallen.
func TestRememberCommonPrefixNoDuplicate(t *testing.T) {
h, ws, project := setup(t)
call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{
"slug": "munin-ueberblick", "title": "Munin – Überblick", "content": "Was Munin ist.",
})
for _, newer := range []map[string]any{
{"slug": "munin-architektur", "title": "Munin – Architektur", "content": "Aufbau."},
{"slug": "munin-ausbaustand", "title": "Munin – Ausbaustand", "content": "Was fertig ist."},
} {
result := call(t, h, ws, project, auth.RoleWrite, "remember", newer)
if !strings.Contains(text(result), `"created":true`) {
t.Errorf("%s wurde nicht angelegt: %s", newer["slug"], text(result))
}
}
call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{
"slug": "munin-deployment-prozess", "title": "Munin – Deployment-Prozess", "content": "Über die Pipeline.",
})
result := call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{
"slug": "munin-deployment-ablauf", "title": "Munin – Deployment-Ablauf", "content": "Auch über die Pipeline.",
})
if !strings.Contains(text(result), `"judgment_required":true`) ||
!strings.Contains(text(result), `"munin-deployment-prozess"`) {
t.Errorf("die Dublette unter gleichem Präfix wurde nicht erkannt: %s", text(result))
}
}
// TestRememberSearchTermOnlyLiteral prüft den zweiten Fall aus dem Betrieb
// (2026-10-16): Ein Statuseintrag mit dem Suchbegriff „postgres-migration"
// machte „Munin – Betrieb mit PostgreSQL" zur vermeintlichen Dublette, weil
// „postgres" der Wortanfang von „postgresql" ist. Suchbegriffe zählen jetzt
// nur wörtlich.
func TestRememberSearchTermOnlyLiteral(t *testing.T) {
h, ws, project := setup(t)
call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{
"slug": "munin-ausbaustand", "title": "Munin – Ausbaustand und nächste Schritte",
"content": "Stand der Dinge.\n\nAuch gesucht als: roadmap, status, postgres-migration",
})
result := call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{
"slug": "munin-postgres-betrieb", "title": "Munin – Betrieb mit PostgreSQL", "content": "Sichern und so.",
})
if !strings.Contains(text(result), `"created":true`) {
t.Errorf("wurde wegen eines Suchbegriffs als Dublette gemeldet: %s", text(result))
}
}
// ── Kuratierte Einträge ──────────────────────────────────────────────────────
// createCurated legt einen von einem Menschen geprüften Eintrag an.
func createCurated(t *testing.T, ws *workspace.Workspace, project store.Project) {
t.Helper()
if _, err := ws.Store.PutEntry(context.Background(), store.EntryWrite{
ProjectID: project.ID, Slug: "vpn", Title: "VPN-Zugang",
Content: "Geprüfter Stand: Zugang über vpn.intern.", Source: store.SourceHuman, Curated: true,
}); err != nil {
t.Fatal(err)
}
}
// TestRememberCuratedNeedsApproval prüft den ersten Schritt: Ohne
// override_curated wird ein kuratierter Eintrag nicht angetastet.
func TestRememberCuratedNeedsApproval(t *testing.T) {
h, ws, project := setup(t)
createCurated(t, ws, project)
result := call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{
"slug": "vpn", "title": "VPN-Zugang", "content": "Ungeprüfte Vermutung.",
})
if result.IsError {
t.Fatalf("die Meldung soll kein Fehler sein: %s", text(result))
}
if !strings.Contains(text(result), `"judgment_required":true`) ||
!strings.Contains(text(result), "override_curated") {
t.Errorf("Meldung ohne Hinweis auf override_curated: %s", text(result))
}
e, err := ws.Store.EntryBySlug(context.Background(), project.ID, "vpn")
if err != nil {
t.Fatal(err)
}
if !e.Curated || !strings.Contains(e.Content, "Geprüfter Stand") || e.Version != 1 {
t.Errorf("der kuratierte Eintrag wurde verändert: %+v", e)
}
}
// TestRememberCuratedWithoutJustification prüft, dass override_curated ohne
// Begründung abgelehnt wird.
func TestRememberCuratedWithoutJustification(t *testing.T) {
h, ws, project := setup(t)
createCurated(t, ws, project)
result := call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{
"slug": "vpn", "title": "VPN-Zugang", "content": "Neu.", "override_curated": true,
})
if !result.IsError || !strings.Contains(text(result), "reason") {
t.Errorf("override_curated ohne reason wurde nicht abgelehnt: %s", text(result))
}
}
// TestRememberCuratedWithApproval prüft den zweiten Schritt: Mit Begründung
// wird geschrieben, die Markierung fällt, und der Eintrag steht zur Prüfung
// auf der Nutzungsseite — bis ein Mensch ihn wieder kuratiert.
func TestRememberCuratedWithApproval(t *testing.T) {
h, ws, project := setup(t)
ctx := context.Background()
createCurated(t, ws, project)
result := call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{
"slug": "vpn", "title": "VPN-Zugang", "content": "Zugang jetzt über vpn2.intern.",
"override_curated": true, "reason": "vpn.intern antwortet nicht mehr, Umzug laut Ticket 4711",
})
if result.IsError {
t.Fatalf("remember schlug fehl: %s", text(result))
}
if !strings.Contains(text(result), "kuratiert") {
t.Errorf("die Antwort sagt nicht, dass die Markierung weg ist: %s", text(result))
}
e, err := ws.Store.EntryBySlug(ctx, project.ID, "vpn")
if err != nil {
t.Fatal(err)
}
if e.Curated || !strings.Contains(e.Content, "vpn2") {
t.Errorf("Eintrag nach der Änderung: curated=%v, content=%q", e.Curated, e.Content)
}
pending, err := ws.Store.CuratedOverrides(ctx, project.ID, 10)
if err != nil {
t.Fatal(err)
}
if len(pending) != 1 || pending[0].Slug != "vpn" || !strings.Contains(pending[0].Reason, "Ticket 4711") {
t.Fatalf("Liste zur Prüfung = %+v, erwartet vpn mit Begründung", pending)
}
// Ein Mensch prüft und setzt den Haken neu → aus der Liste.
if _, err := ws.Store.PutEntry(ctx, store.EntryWrite{
ProjectID: project.ID, Slug: "vpn", Title: e.Title, Content: e.Content,
Source: store.SourceHuman, Curated: true,
}); err != nil {
t.Fatal(err)
}
if pending, _ := ws.Store.CuratedOverrides(ctx, project.ID, 10); len(pending) != 0 {
t.Errorf("nach erneuter Kuratierung noch in der Liste: %+v", pending)
}
}