163 lines
4.8 KiB
Go
163 lines
4.8 KiB
Go
package mcpserver
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"sync"
|
||
"testing"
|
||
|
||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||
|
||
"munin/internal/auth"
|
||
"munin/internal/store"
|
||
"munin/internal/web"
|
||
"munin/internal/workspace"
|
||
)
|
||
|
||
// verbinde öffnet eine MCP-Sitzung, über die man mehrere Aufrufe schicken kann.
|
||
//
|
||
// Anders als ruf meldet es Fehler zurück, statt t.Fatalf aufzurufen — t.Fatalf
|
||
// darf nur aus der Goroutine des Tests selbst kommen, nicht aus den
|
||
// nebenläufigen Agenten unten.
|
||
func verbinde(h *Handler, ws *workspace.Workspace, projekt store.Project, name string) (*mcp.ClientSession, func(), error) {
|
||
sess := web.Session{
|
||
Subject: "key:" + ws.ID + ":" + name,
|
||
WorkspaceID: ws.ID,
|
||
Key: store.APIKey{ID: name, Name: name, ProjectID: projekt.ID, Role: string(auth.RoleWrite)},
|
||
Project: projekt,
|
||
Role: auth.RoleWrite,
|
||
}
|
||
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 {
|
||
return nil, nil, err
|
||
}
|
||
client := mcp.NewClient(&mcp.Implementation{Name: name, Version: "1"}, nil)
|
||
clientSession, err := client.Connect(ctx, clientTransport, nil)
|
||
if err != nil {
|
||
serverSession.Close()
|
||
return nil, nil, err
|
||
}
|
||
schliessen := func() {
|
||
clientSession.Close()
|
||
serverSession.Close()
|
||
}
|
||
return clientSession, schliessen, nil
|
||
}
|
||
|
||
// TestGleichzeitigesSchreibenHaeltSuchindexAktuell prüft, dass der Suchindex
|
||
// nach gleichzeitigen Schreibvorgängen auf denselben Eintrag den Stand der
|
||
// Datenbank kennt.
|
||
//
|
||
// Die Gefahr: remember schreibt erst in die Datenbank und aktualisiert danach
|
||
// den Index — zwei getrennte Schritte. Überholen sich zwei Agenten zwischen
|
||
// diesen Schritten, landet Fassung 3 in der Datenbank, aber Fassung 2 im Index.
|
||
// recall fände den Eintrag dann bis zum Neustart über veraltete Wörter.
|
||
//
|
||
// Jeder Schreibvorgang bekommt deshalb ein eigenes, eindeutiges Wort. Am Ende
|
||
// muss das Wort der letzten Fassung in der Datenbank auch im Index zu finden
|
||
// sein.
|
||
func TestGleichzeitigesSchreibenHaeltSuchindexAktuell(t *testing.T) {
|
||
h, ws, projekt := aufbauMitBudget(t, 100000)
|
||
ctx := context.Background()
|
||
|
||
// Den Index vorher anlegen. Ohne ihn wäre Search.Update ein No-op und der
|
||
// Test würde nichts prüfen (siehe Registry.Update).
|
||
idx, err := ws.Search.For(ctx, projekt.ID)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
const runden, agenten, jeAgent = 20, 8, 10
|
||
|
||
for runde := 0; runde < runden; runde++ {
|
||
slug := fmt.Sprintf("umkaempft-%02d", runde)
|
||
|
||
var wg sync.WaitGroup
|
||
var mu sync.Mutex
|
||
var fehler []error
|
||
|
||
for a := 0; a < agenten; a++ {
|
||
wg.Add(1)
|
||
go func(a int) {
|
||
defer wg.Done()
|
||
melde := func(err error) {
|
||
mu.Lock()
|
||
fehler = append(fehler, err)
|
||
mu.Unlock()
|
||
}
|
||
|
||
client, schliessen, err := verbinde(h, ws, projekt, fmt.Sprintf("agent-%d", a))
|
||
if err != nil {
|
||
melde(err)
|
||
return
|
||
}
|
||
defer schliessen()
|
||
|
||
for i := 0; i < jeAgent; i++ {
|
||
// Nur Buchstaben, damit der Tokenizer das Wort nicht zerlegt.
|
||
wort := fmt.Sprintf("marke%sx%s", buchstaben(runde*1000+a*100+i), "ende")
|
||
erg, err := client.CallTool(ctx, &mcp.CallToolParams{
|
||
Name: "remember",
|
||
// force: Jede Runde legt unter neuem Bezeichner mit
|
||
// demselben Titel an. Die Dublettenprüfung würde das
|
||
// zu Recht anhalten — hier geht es aber ums
|
||
// gleichzeitige Schreiben, nicht um Dubletten.
|
||
Arguments: map[string]any{
|
||
"slug": slug, "title": "Umkämpft", "content": "Stand " + wort, "force": true,
|
||
},
|
||
})
|
||
if err != nil {
|
||
melde(err)
|
||
return
|
||
}
|
||
if erg.IsError {
|
||
melde(fmt.Errorf("remember: %s", text(erg)))
|
||
return
|
||
}
|
||
}
|
||
}(a)
|
||
}
|
||
wg.Wait()
|
||
|
||
if len(fehler) > 0 {
|
||
t.Fatalf("Runde %d: %d Fehler, erster: %v", runde, len(fehler), fehler[0])
|
||
}
|
||
|
||
e, err := ws.Store.EntryBySlug(ctx, projekt.ID, slug)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if e.Version != agenten*jeAgent {
|
||
t.Errorf("Runde %d: Fassung %d, erwartet %d — ein Schreibvorgang ging verloren",
|
||
runde, e.Version, agenten*jeAgent)
|
||
}
|
||
|
||
// Das Wort der letzten Fassung steht nach "Stand " im Inhalt.
|
||
letztesWort := e.Content[len("Stand "):]
|
||
gefunden := false
|
||
for _, r := range idx.Search(letztesWort, 5) {
|
||
if r.DocID == slug {
|
||
gefunden = true
|
||
}
|
||
}
|
||
if !gefunden {
|
||
t.Errorf("Runde %d: Datenbank hat Fassung %d mit %q, der Suchindex kennt das Wort nicht — "+
|
||
"er hält einen älteren Stand", runde, e.Version, letztesWort)
|
||
}
|
||
}
|
||
}
|
||
|
||
// buchstaben schreibt eine Zahl mit den Buchstaben a–j statt der Ziffern 0–9.
|
||
// So entsteht für jeden Schreibvorgang ein eindeutiges Wort ohne Ziffern.
|
||
func buchstaben(n int) string {
|
||
s := fmt.Sprintf("%06d", n)
|
||
b := []byte(s)
|
||
for i := range b {
|
||
b[i] = 'a' + (b[i] - '0')
|
||
}
|
||
return string(b)
|
||
}
|