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" ) // aufbau 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 aufbauMitBudget. func aufbau(t *testing.T) (*Handler, *workspace.Workspace, store.Project) { t.Helper() return aufbauMitBudget(t, 1000) } // aufbauMitBudget wie aufbau, aber mit einer wählbaren Obergrenze je Stunde. func aufbauMitBudget(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() projekt, 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, projekt } // ruf 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 ruf(t *testing.T, h *Handler, ws *workspace.Workspace, projekt store.Project, rolle auth.Role, werkzeug 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: projekt.ID, Role: string(rolle)}, Project: projekt, Role: rolle, } ctx := web.WithSessionForTest(context.Background(), sess) ctx = web.WithWorkspaceForTest(ctx, ws) clientTransport, serverTransport := mcp.NewInMemoryTransports() serverSession, err := h.serverFor(projekt).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() ergebnis, err := clientSession.CallTool(ctx, &mcp.CallToolParams{Name: werkzeug, Arguments: args}) if err != nil { t.Fatalf("Aufruf %q: %v", werkzeug, err) } return ergebnis } // 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, _, projekt := aufbau(t) ctx := context.Background() clientTransport, serverTransport := mcp.NewInMemoryTransports() serverSession, err := h.serverFor(projekt).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") } liste, err := clientSession.ListTools(ctx, nil) if err != nil { t.Fatal(err) } vorhanden := make(map[string]bool) for _, tool := range liste.Tools { vorhanden[tool.Name] = true } for _, name := range []string{"recall", "get", "list", "remember", "forget", "related"} { if !vorhanden[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, projekt := aufbau(t) db := ws.Store ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ "slug": "deployment", "title": "Deployment", "content": "Erste Erkenntnis", }) ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ "slug": "deployment", "title": "Deployment", "content": "Zweite, bessere Erkenntnis", }) anzahl, err := db.CountEntries(context.Background(), projekt.ID) if err != nil { t.Fatal(err) } if anzahl != 1 { t.Errorf("%d Einträge, erwartet 1 — es wurde eine Dublette angelegt", anzahl) } e, _ := db.EntryBySlug(context.Background(), projekt.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, projekt := aufbau(t) db := ws.Store ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ "slug": "notiz", "title": "Notiz", "content": "Inhalt", }) e, _ := db.EntryBySlug(context.Background(), projekt.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, projekt := aufbau(t) db := ws.Store ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ "title": "Der Deployment-Prozess", "content": "Inhalt", }) if _, err := db.EntryBySlug(context.Background(), projekt.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, projekt := aufbau(t) ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ "slug": "geteilt", "title": "T", "content": "Fassung 1", }) // Agent A schreibt und erhöht damit auf Fassung 2. ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ "slug": "geteilt", "title": "T", "content": "von A", }) // Agent B hatte noch Fassung 1 gelesen. ergebnis := ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ "slug": "geteilt", "title": "T", "content": "von B", "expected_version": 1, }) if !ergebnis.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(ergebnis), "get") { t.Errorf("die Fehlermeldung erklärt das weitere Vorgehen nicht: %s", text(ergebnis)) } } // ── Rollen ─────────────────────────────────────────────────────────────────── // TestReadRoleCannotWrite prüft, dass ein Nur-Lese-Zugang nichts verändern kann. func TestReadRoleCannotWrite(t *testing.T) { h, ws, projekt := aufbau(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. faelle := map[string]map[string]any{ "remember": {"slug": "x", "title": "T", "content": "C"}, "forget": {"slug": "x"}, } for werkzeug, args := range faelle { t.Run(werkzeug, func(t *testing.T) { ergebnis := ruf(t, h, ws, projekt, auth.RoleRead, werkzeug, args) if !ergebnis.IsError { t.Error("der Nur-Lese-Zugang durfte schreiben") } if !strings.Contains(text(ergebnis), "Leserecht") { t.Errorf("die Fehlermeldung nennt den Grund nicht: %s", text(ergebnis)) } }) } if n, _ := db.CountEntries(context.Background(), projekt.ID); n != 0 { t.Errorf("%d Einträge angelegt, erwartet 0", n) } } func TestReadRoleCanRead(t *testing.T) { h, ws, projekt := aufbau(t) ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ "slug": "notiz", "title": "Notiz", "content": "Inhalt", }) for _, werkzeug := range []string{"recall", "list"} { args := map[string]any{} if werkzeug == "recall" { args["query"] = "Notiz" } if ergebnis := ruf(t, h, ws, projekt, auth.RoleRead, werkzeug, args); ergebnis.IsError { t.Errorf("%s wurde für den Lese-Zugang abgelehnt: %s", werkzeug, text(ergebnis)) } } } // ── 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, projekt := aufbau(t) ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ "slug": "deployment-prozess", "title": "Deployment-Prozess", "content": "Wir deployen über GitHub Actions.", }) ergebnis := ruf(t, h, ws, projekt, auth.RoleRead, "recall", map[string]any{"query": "deploy"}) if ergebnis.IsError { t.Fatalf("recall schlug fehl: %s", text(ergebnis)) } if !strings.Contains(text(ergebnis), "deployment-prozess") { t.Errorf("die Präfix-Suche fand den Eintrag nicht: %s", text(ergebnis)) } } // TestRecallEmptyGivesHint prüft, dass ein leeres Ergebnis dem Modell sagt, was // als Nächstes zu tun ist. func TestRecallEmptyGivesHint(t *testing.T) { h, ws, projekt := aufbau(t) ergebnis := ruf(t, h, ws, projekt, auth.RoleRead, "recall", map[string]any{"query": "gibtesnicht"}) if !strings.Contains(text(ergebnis), "remember") { t.Errorf("kein Hinweis auf das weitere Vorgehen: %s", text(ergebnis)) } } // TestRecallMitSynonymen 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 TestRecallMitSynonymen(t *testing.T) { h, ws, projekt := aufbau(t) ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ "slug": "release-ablauf", "title": "Release-Ablauf", "content": "Neue Fassungen gehen dienstags raus.", }) ohne := ruf(t, h, ws, projekt, auth.RoleRead, "recall", map[string]any{ "query": "wie deployen wir", }) if strings.Contains(text(ohne), "release-ablauf") { t.Fatalf("der Eintrag wurde schon ohne Synonyme gefunden — der Test prüft so nichts: %s", text(ohne)) } mit := ruf(t, h, ws, projekt, auth.RoleRead, "recall", map[string]any{ "query": "wie deployen wir", "synonyms": []any{"release", "rollout"}, }) if mit.IsError { t.Fatalf("recall schlug fehl: %s", text(mit)) } if !strings.Contains(text(mit), "release-ablauf") { t.Errorf("das Synonym fand den Eintrag nicht: %s", text(mit)) } } // TestRecallProtokolliert prüft, dass jede Suche im Suchprotokoll landet — // mit Synonymen und der Zahl der Treffer. func TestRecallProtokolliert(t *testing.T) { h, ws, projekt := aufbau(t) ruf(t, h, ws, projekt, auth.RoleRead, "recall", map[string]any{ "query": "gibtesnicht", "synonyms": []any{"auchnicht"}, }) st, err := ws.Store.SearchStatsFor(context.Background(), projekt.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) } } // TestMitSynonymenDeckelt prüft die Obergrenze für Synonyme. func TestMitSynonymenDeckelt(t *testing.T) { viele := make([]string, recallMaxSynonyms+5) for i := range viele { viele[i] = fmt.Sprintf("wort%d", i) } got := mitSynonymen("frage", begrenzeSynonyme(viele)) if strings.Contains(got, fmt.Sprintf("wort%d", recallMaxSynonyms)) { t.Errorf("mehr als %d Synonyme wurden übernommen: %q", recallMaxSynonyms, got) } if got := begrenzeSynonyme([]string{" ", "release", ""}); len(got) != 1 { t.Errorf("begrenzeSynonyme = %q, erwartet nur [release]", got) } if mitSynonymen("frage", nil) != "frage" { t.Error("ohne Synonyme soll die Anfrage unverändert bleiben") } } // TestRecallLeerVerweistAufSynonyme 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 TestRecallLeerVerweistAufSynonyme(t *testing.T) { h, ws, projekt := aufbau(t) ergebnis := ruf(t, h, ws, projekt, auth.RoleRead, "recall", map[string]any{"query": "gibtesnicht"}) if !strings.Contains(text(ergebnis), "synonyms") { t.Errorf("der Hinweis nennt keine erneute Suche mit Synonymen: %s", text(ergebnis)) } } func TestRecallFiltersByTag(t *testing.T) { h, ws, projekt := aufbau(t) ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ "slug": "a", "title": "Deployment A", "content": "deployment", "tags": []any{"ops"}, }) ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ "slug": "b", "title": "Deployment B", "content": "deployment", "tags": []any{"docs"}, "force": true, // ähnlicher Titel ist hier gewollt, siehe dubletten.go }) ergebnis := ruf(t, h, ws, projekt, auth.RoleRead, "recall", map[string]any{"query": "deployment", "tags": []any{"ops"}}) if strings.Contains(text(ergebnis), `"slug": "b"`) || strings.Contains(text(ergebnis), `"slug":"b"`) { t.Errorf("die Schlagwortfilterung griff nicht: %s", text(ergebnis)) } } // 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, projekt := aufbau(t) ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ "slug": "ziel", "title": "Ziel", "content": "Inhalt", }) ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ "slug": "quelle", "title": "Quelle", "content": "siehe [[ziel]]", }) ergebnis := ruf(t, h, ws, projekt, auth.RoleRead, "get", map[string]any{"slug": "ziel"}) if !strings.Contains(text(ergebnis), "quelle") { t.Errorf("der Rückverweis fehlt: %s", text(ergebnis)) } andersherum := ruf(t, h, ws, projekt, auth.RoleRead, "get", map[string]any{"slug": "quelle"}) if !strings.Contains(text(andersherum), "ziel") { t.Error("der ausgehende Verweis fehlt") } } func TestGetUnknownSlugExplains(t *testing.T) { h, ws, projekt := aufbau(t) ergebnis := ruf(t, h, ws, projekt, auth.RoleRead, "get", map[string]any{"slug": "gibtesnicht"}) if !ergebnis.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(ergebnis), "recall") { t.Errorf("die Meldung hilft nicht weiter: %s", text(ergebnis)) } } // ── 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, projektA := aufbau(t) db := ws.Store ctx := context.Background() projektB, err := db.CreateProject(ctx, projektA.WorkspaceID, "fremd", "Fremdes Projekt", "") if err != nil { t.Fatal(err) } // Wissen in Projekt B ablegen. ruf(t, h, ws, projektB, auth.RoleWrite, "remember", map[string]any{ "slug": "geheim", "title": "Geheim", "content": "Vertrauliches aus Projekt B", }) // Mit den Werkzeugen von Projekt A danach suchen. suche := ruf(t, h, ws, projektA, auth.RoleRead, "recall", map[string]any{"query": "Vertrauliches"}) if strings.Contains(text(suche), "Vertrauliches") { t.Error("recall in Projekt A fand Inhalte aus Projekt B") } lesen := ruf(t, h, ws, projektA, auth.RoleRead, "get", map[string]any{"slug": "geheim"}) if !lesen.IsError { t.Error("get in Projekt A lieferte einen Eintrag aus Projekt B") } liste := ruf(t, h, ws, projektA, auth.RoleRead, "list", map[string]any{}) if strings.Contains(text(liste), "geheim") { t.Error("list in Projekt A zeigte einen Eintrag aus Projekt B") } // Und der Gegentest: Löschen darf ebenfalls nicht durchschlagen. ruf(t, h, ws, projektA, auth.RoleWrite, "forget", map[string]any{"slug": "geheim"}) if n, _ := db.CountEntries(ctx, projektB.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, projekt := aufbau(t) for i := 0; i < 60; i++ { ruf(t, h, ws, projekt, 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 dubletten.go }) } ergebnis := ruf(t, h, ws, projekt, auth.RoleRead, "recall", map[string]any{"query": "gemeinsames", "limit": 9999}) // Mehr als recallMaxLimit Treffer dürfen nicht zurückkommen. if anzahl := strings.Count(text(ergebnis), `"slug"`); anzahl > recallMaxLimit { t.Errorf("%d Treffer zurückgegeben, Obergrenze ist %d", anzahl, recallMaxLimit) } } func TestRememberRejectsEmptyContent(t *testing.T) { h, ws, projekt := aufbau(t) ergebnis := ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{"slug": "leer", "title": "T", "content": ""}) if !ergebnis.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 grenze = 3 h, ws, projekt := aufbauMitBudget(t, grenze) db := ws.Store for i := 0; i < grenze; i++ { ergebnis := ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ "slug": fmt.Sprintf("eintrag-%d", i), "title": "Eintrag", "content": "Inhalt", "force": true, // gleiche Titel sind hier gewollt, siehe dubletten.go }) if ergebnis.IsError { t.Fatalf("Aufruf %d von %d abgelehnt, obwohl im Budget: %s", i+1, grenze, text(ergebnis)) } } ergebnis := ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ "slug": "eintrag-zuviel", "title": "Zu viel", "content": "Inhalt", }) if !ergebnis.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. meldung := text(ergebnis) for _, erwartet := range []string{"Schreibbudget", "NICHT sofort erneut"} { if !strings.Contains(meldung, erwartet) { t.Errorf("Meldung enthält %q nicht: %s", erwartet, meldung) } } // Und der abgelehnte Aufruf darf nichts geschrieben haben. if _, err := db.EntryBySlug(context.Background(), projekt.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, projekt := aufbauMitBudget(t, 1) ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ "slug": "vorhanden", "title": "Vorhanden", "content": "Inhalt", }) // Budget ist jetzt aufgebraucht. if ergebnis := ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ "slug": "noch-einer", "title": "Noch einer", "content": "Inhalt", }); !ergebnis.IsError { t.Fatal("Vorbedingung verfehlt: das Budget ist noch nicht erschöpft") } for _, werkzeug := range []string{"recall", "get", "list"} { args := map[string]any{} switch werkzeug { case "recall": args["query"] = "Inhalt" case "get": args["slug"] = "vorhanden" } if ergebnis := ruf(t, h, ws, projekt, auth.RoleWrite, werkzeug, args); ergebnis.IsError { t.Errorf("%q wurde trotz erschöpftem Schreibbudget abgelehnt: %s", werkzeug, text(ergebnis)) } } } // TestWriteBudgetIsPerProject prüft die Mandantentrennung: Ein Projekt, das sein // Budget verbraucht, darf kein anderes mitbremsen. func TestWriteBudgetIsPerProject(t *testing.T) { h, ws, projekt := aufbauMitBudget(t, 1) db := ws.Store ctx := context.Background() zweites, err := db.CreateProject(ctx, projekt.WorkspaceID, "zweites", "Zweites Projekt", "") if err != nil { t.Fatal(err) } ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{"slug": "a", "title": "A", "content": "Inhalt"}) if ergebnis := ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ "slug": "b", "title": "B", "content": "Inhalt", }); !ergebnis.IsError { t.Fatal("Vorbedingung verfehlt: das erste Projekt ist noch nicht am Limit") } if ergebnis := ruf(t, h, ws, zweites, auth.RoleWrite, "remember", map[string]any{ "slug": "c", "title": "C", "content": "Inhalt", }); ergebnis.IsError { t.Errorf("das zweite Projekt wurde mitgesperrt: %s", text(ergebnis)) } } // 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, projekt := aufbauMitBudget(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. projekt.WriteLimitHourly = 1 ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{"slug": "a", "title": "A", "content": "Inhalt"}) if ergebnis := ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ "slug": "b", "title": "B", "content": "Inhalt", }); !ergebnis.IsError { t.Error("die eigene Obergrenze des Projekts wurde nicht angewandt") } } // TestRememberProtokolliertAngelegtOderAktualisiert: 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 TestRememberProtokolliertAngelegtOderAktualisiert(t *testing.T) { h, ws, projekt := aufbau(t) for _, inhalt := range []string{"Erste Erkenntnis", "Zweite Erkenntnis"} { ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ "slug": "deployment", "title": "Deployment", "content": inhalt, }) } ereignisse, err := ws.Store.ListAudit(context.Background(), store.AuditFilter{ProjectID: projekt.ID}) if err != nil { t.Fatal(err) } var details []string for _, e := range ereignisse { if e.Action == store.ActionEntryWrite { details = append(details, e.Detail) } } // ListAudit liefert die neuesten zuerst. erwartet := []string{store.DetailEntryUpdated, store.DetailEntryCreated} if fmt.Sprint(details) != fmt.Sprint(erwartet) { t.Errorf("Details = %v, erwartet %v", details, erwartet) } } // ── 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, projekt := aufbau(t) ctx := context.Background() ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ "slug": "deployment", "title": "Deployment", "content": "Wir deployen mit Blaugrün", }) antwort := ruf(t, h, ws, projekt, auth.RoleWrite, "forget", map[string]any{"slug": "deployment"}) if antwort.IsError { t.Fatalf("forget: %s", text(antwort)) } if !strings.Contains(text(antwort), "Papierkorb") { t.Errorf("die Antwort erwähnt den Papierkorb nicht: %s", text(antwort)) } if r := ruf(t, h, ws, projekt, auth.RoleRead, "get", map[string]any{"slug": "deployment"}); !r.IsError { t.Error("get findet den gelöschten Eintrag noch") } if r := ruf(t, h, ws, projekt, 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 := ruf(t, h, ws, projekt, auth.RoleRead, "list", map[string]any{}); strings.Contains(text(r), "deployment") { t.Errorf("list zeigt den gelöschten Eintrag noch: %s", text(r)) } papierkorb, _ := ws.Store.ListTrash(ctx, projekt.ID, "") if len(papierkorb) != 1 || papierkorb[0].DeletedBy != "test-agent" { t.Errorf("Papierkorb = %+v, erwartet „deployment“, gelöscht von test-agent", papierkorb) } // Ein zweites forget auf denselben Bezeichner ist ein Fehler — aus Sicht // des Agenten gibt es den Eintrag nicht mehr. if r := ruf(t, h, ws, projekt, 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, projekt := aufbau(t) ctx := context.Background() ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ "slug": "deployment", "title": "Deployment", "content": "alt", }) ruf(t, h, ws, projekt, auth.RoleWrite, "forget", map[string]any{"slug": "deployment"}) antwort := ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ "slug": "deployment", "title": "Deployment", "content": "neu", }) if antwort.IsError { t.Fatalf("remember: %s", text(antwort)) } if !strings.Contains(text(antwort), "Papierkorb") { t.Errorf("die Antwort sagt nicht, dass ein gelöschter Eintrag zurückgeholt wurde: %s", text(antwort)) } if strings.Contains(text(antwort), "angelegt") { t.Errorf("die Antwort behauptet, der Eintrag sei neu angelegt: %s", text(antwort)) } e, err := ws.Store.EntryBySlug(ctx, projekt.ID, "deployment") if err != nil || e.Content != "neu" || e.Version != 2 { t.Errorf("nach remember: %+v, %v", e, err) } ereignisse, _ := ws.Store.ListAudit(ctx, store.AuditFilter{ProjectID: projekt.ID}) gefunden := false for _, ev := range ereignisse { if ev.Action == store.ActionEntryRestore && ev.EntrySlug == "deployment" { gefunden = true } } if !gefunden { 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, projekt := aufbau(t) ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ "slug": "release", "title": "Release-Prozess", "content": "Vorher [[checkliste]] durchgehen.", }) ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ "slug": "checkliste", "title": "Checkliste", "content": "Siehe auch [[rollback]].", }) ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ "slug": "rollback", "title": "Rollback", "content": "Zurück auf die letzte Fassung.", }) // Auch ein Lesezugang darf das Umfeld abfragen. ergebnis := ruf(t, h, ws, projekt, auth.RoleRead, "related", map[string]any{"slug": "release"}) if ergebnis.IsError { t.Fatalf("Fehler: %s", text(ergebnis)) } antwort := text(ergebnis) for _, erwartet := range []string{`"checkliste"`, `"rollback"`, `"distance":2`, `"via":"checkliste"`} { if !strings.Contains(antwort, erwartet) { t.Errorf("%s fehlt in der Antwort: %s", erwartet, antwort) } } nurDirekt := text(ruf(t, h, ws, projekt, auth.RoleRead, "related", map[string]any{"slug": "release", "distance": 1})) if strings.Contains(nurDirekt, "rollback") { t.Errorf("distance 1 lieferte die zweite Reihe: %s", nurDirekt) } } func TestRelatedUnknownSlugExplains(t *testing.T) { h, ws, projekt := aufbau(t) ergebnis := ruf(t, h, ws, projekt, auth.RoleRead, "related", map[string]any{"slug": "gibtesnicht"}) if !ergebnis.IsError || !strings.Contains(text(ergebnis), "recall") { t.Errorf("unbekannter Bezeichner nicht hilfreich gemeldet: %s", text(ergebnis)) } } // 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, projekt := aufbau(t) ctx := context.Background() vorZweiJahren := time.Now().AddDate(-2, 0, 0) if _, err := ws.Store.PutEntry(ctx, store.EntryWrite{ ProjectID: projekt.ID, Slug: "altes-deployment", Title: "Deployment alt", Content: "Deployment per FTP", Source: store.SourceHuman, UpdatedAt: vorZweiJahren, }); err != nil { t.Fatal(err) } ruf(t, h, ws, projekt, 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 dubletten.go }) recall := text(ruf(t, h, ws, projekt, 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) } alt := text(ruf(t, h, ws, projekt, auth.RoleRead, "get", map[string]any{"slug": "altes-deployment"})) if !strings.Contains(alt, `"stale":true`) || !strings.Contains(alt, "prüfen") { t.Errorf("get: alter Eintrag nicht markiert: %s", alt) } neu := text(ruf(t, h, ws, projekt, auth.RoleRead, "get", map[string]any{"slug": "neues-deployment"})) if strings.Contains(neu, "stale") { t.Errorf("get: frischer Eintrag als stale markiert: %s", neu) } liste := text(ruf(t, h, ws, projekt, auth.RoleRead, "list", nil)) if strings.Count(liste, `"stale":true`) != 1 { t.Errorf("list: genau ein Eintrag sollte stale sein: %s", liste) } } // ── Dubletten ──────────────────────────────────────────────────────────────── // TestRememberMeldetDublette 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 TestRememberMeldetDublette(t *testing.T) { h, ws, projekt := aufbau(t) ctx := context.Background() ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ "slug": "deployment-prozess", "title": "Deployment-Prozess", "content": "Über die Pipeline.", }) ergebnis := ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ "slug": "deployment-ablauf", "title": "Deployment-Ablauf", "content": "Auch über die Pipeline.", }) if ergebnis.IsError { t.Fatalf("die Dublettenmeldung soll kein Fehler sein: %s", text(ergebnis)) } antwort := text(ergebnis) for _, erwartet := range []string{`"judgment_required":true`, `"deployment-prozess"`, "force"} { if !strings.Contains(antwort, erwartet) { t.Errorf("%s fehlt in der Antwort: %s", erwartet, antwort) } } if _, err := ws.Store.EntryBySlug(ctx, projekt.ID, "deployment-ablauf"); !errors.Is(err, store.ErrNotFound) { t.Fatalf("die Dublette wurde trotzdem angelegt (err = %v)", err) } // Mit force wird angelegt. ergebnis = ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ "slug": "deployment-ablauf", "title": "Deployment-Ablauf", "content": "Auch über die Pipeline.", "force": true, }) if !strings.Contains(text(ergebnis), `"created":true`) { t.Errorf("mit force wurde nicht angelegt: %s", text(ergebnis)) } } // TestRememberAktualisierenOhnePruefung prüft, dass das Fortschreiben eines // vorhandenen Eintrags nie angehalten wird — auch wenn es ähnliche gibt. func TestRememberAktualisierenOhnePruefung(t *testing.T) { h, ws, projekt := aufbau(t) ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ "slug": "deployment-prozess", "title": "Deployment-Prozess", "content": "A", }) ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ "slug": "deployment-ablauf", "title": "Deployment-Ablauf", "content": "B", "force": true, }) ergebnis := ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ "slug": "deployment-ablauf", "title": "Deployment-Ablauf", "content": "B, ergänzt", }) if strings.Contains(text(ergebnis), "judgment_required") { t.Errorf("eine Aktualisierung wurde angehalten: %s", text(ergebnis)) } } // TestRememberUnaehnlichLegtAn prüft, dass ein anderes Thema ohne Umweg // angelegt wird. func TestRememberUnaehnlichLegtAn(t *testing.T) { h, ws, projekt := aufbau(t) ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ "slug": "deployment-prozess", "title": "Deployment-Prozess", "content": "Über die Pipeline.", }) ergebnis := ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ "slug": "vpn-zugang", "title": "VPN-Zugang", "content": "Über die Pipeline beantragen.", }) if !strings.Contains(text(ergebnis), `"created":true`) { t.Errorf("ein unähnlicher Eintrag wurde nicht angelegt: %s", text(ergebnis)) } } // TestRememberDubletteUeberSuchbegriffe 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 TestRememberDubletteUeberSuchbegriffe(t *testing.T) { h, ws, projekt := aufbau(t) ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ "slug": "deployment-prozess", "title": "Deployment-Prozess", "content": "Über die Pipeline.\n\nAuch gesucht als: release, rollout", }) ergebnis := ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ "slug": "release-ablauf", "title": "Release-Ablauf", "content": "Dienstags.", }) if !strings.Contains(text(ergebnis), `"judgment_required":true`) { t.Errorf("die Dublette über die Suchbegriffe wurde nicht erkannt: %s", text(ergebnis)) } } // ── Kuratierte Einträge ────────────────────────────────────────────────────── // legeKuratiertAn legt einen von einem Menschen geprüften Eintrag an. func legeKuratiertAn(t *testing.T, ws *workspace.Workspace, projekt store.Project) { t.Helper() if _, err := ws.Store.PutEntry(context.Background(), store.EntryWrite{ ProjectID: projekt.ID, Slug: "vpn", Title: "VPN-Zugang", Content: "Geprüfter Stand: Zugang über vpn.intern.", Source: store.SourceHuman, Curated: true, }); err != nil { t.Fatal(err) } } // TestRememberKuratiertBrauchtFreigabe prüft den ersten Schritt: Ohne // override_curated wird ein kuratierter Eintrag nicht angetastet. func TestRememberKuratiertBrauchtFreigabe(t *testing.T) { h, ws, projekt := aufbau(t) legeKuratiertAn(t, ws, projekt) ergebnis := ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ "slug": "vpn", "title": "VPN-Zugang", "content": "Ungeprüfte Vermutung.", }) if ergebnis.IsError { t.Fatalf("die Meldung soll kein Fehler sein: %s", text(ergebnis)) } if !strings.Contains(text(ergebnis), `"judgment_required":true`) || !strings.Contains(text(ergebnis), "override_curated") { t.Errorf("Meldung ohne Hinweis auf override_curated: %s", text(ergebnis)) } e, err := ws.Store.EntryBySlug(context.Background(), projekt.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) } } // TestRememberKuratiertOhneBegruendung prüft, dass override_curated ohne // Begründung abgelehnt wird. func TestRememberKuratiertOhneBegruendung(t *testing.T) { h, ws, projekt := aufbau(t) legeKuratiertAn(t, ws, projekt) ergebnis := ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ "slug": "vpn", "title": "VPN-Zugang", "content": "Neu.", "override_curated": true, }) if !ergebnis.IsError || !strings.Contains(text(ergebnis), "reason") { t.Errorf("override_curated ohne reason wurde nicht abgelehnt: %s", text(ergebnis)) } } // TestRememberKuratiertMitFreigabe 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 TestRememberKuratiertMitFreigabe(t *testing.T) { h, ws, projekt := aufbau(t) ctx := context.Background() legeKuratiertAn(t, ws, projekt) ergebnis := ruf(t, h, ws, projekt, 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 ergebnis.IsError { t.Fatalf("remember schlug fehl: %s", text(ergebnis)) } if !strings.Contains(text(ergebnis), "kuratiert") { t.Errorf("die Antwort sagt nicht, dass die Markierung weg ist: %s", text(ergebnis)) } e, err := ws.Store.EntryBySlug(ctx, projekt.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) } offen, err := ws.Store.CuratedOverrides(ctx, projekt.ID, 10) if err != nil { t.Fatal(err) } if len(offen) != 1 || offen[0].Slug != "vpn" || !strings.Contains(offen[0].Reason, "Ticket 4711") { t.Fatalf("Liste zur Prüfung = %+v, erwartet vpn mit Begründung", offen) } // Ein Mensch prüft und setzt den Haken neu → aus der Liste. if _, err := ws.Store.PutEntry(ctx, store.EntryWrite{ ProjectID: projekt.ID, Slug: "vpn", Title: e.Title, Content: e.Content, Source: store.SourceHuman, Curated: true, }); err != nil { t.Fatal(err) } if offen, _ := ws.Store.CuratedOverrides(ctx, projekt.ID, 10); len(offen) != 0 { t.Errorf("nach erneuter Kuratierung noch in der Liste: %+v", offen) } }