1101 lines
42 KiB
Go
1101 lines
42 KiB
Go
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)
|
||
}
|
||
}
|