munin/internal/mcpserver/mcpserver_test.go

1101 lines
42 KiB
Go
Raw 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"
)
// 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", `"matched_words":["deployment"]`} {
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))
}
}
// TestRememberGemeinsamesPraefixKeineDublette 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 TestRememberGemeinsamesPraefixKeineDublette(t *testing.T) {
h, ws, projekt := aufbau(t)
ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
"slug": "munin-ueberblick", "title": "Munin – Überblick", "content": "Was Munin ist.",
})
for _, neu := range []map[string]any{
{"slug": "munin-architektur", "title": "Munin – Architektur", "content": "Aufbau."},
{"slug": "munin-ausbaustand", "title": "Munin – Ausbaustand", "content": "Was fertig ist."},
} {
ergebnis := ruf(t, h, ws, projekt, auth.RoleWrite, "remember", neu)
if !strings.Contains(text(ergebnis), `"created":true`) {
t.Errorf("%s wurde nicht angelegt: %s", neu["slug"], text(ergebnis))
}
}
ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
"slug": "munin-deployment-prozess", "title": "Munin – Deployment-Prozess", "content": "Über die Pipeline.",
})
ergebnis := ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
"slug": "munin-deployment-ablauf", "title": "Munin – Deployment-Ablauf", "content": "Auch über die Pipeline.",
})
if !strings.Contains(text(ergebnis), `"judgment_required":true`) ||
!strings.Contains(text(ergebnis), `"munin-deployment-prozess"`) {
t.Errorf("die Dublette unter gleichem Präfix wurde nicht erkannt: %s", text(ergebnis))
}
}
// TestRememberSuchbegriffNurWoertlich 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 TestRememberSuchbegriffNurWoertlich(t *testing.T) {
h, ws, projekt := aufbau(t)
ruf(t, h, ws, projekt, 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",
})
ergebnis := ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
"slug": "munin-postgres-betrieb", "title": "Munin – Betrieb mit PostgreSQL", "content": "Sichern und so.",
})
if !strings.Contains(text(ergebnis), `"created":true`) {
t.Errorf("wurde wegen eines Suchbegriffs als Dublette gemeldet: %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)
}
}