Files
llmgw/internal/kb/kbsync/sync_test.go
T
Ilker ManapandClaude Opus 5.5 ba5df90602 llmgw-kb: sync a person's documents into a knowledge base from their own machine
Its own binary for Windows, macOS and Linux, carrying no server code. It reads
the files there, cuts them, has the gateway embed them with the person's key
and sends text and vectors for what changed only: a SQLite ledger on the
machine remembers each document's file, size, time, digest and what happened,
so a rerun reads nothing unchanged, a file skipped stays skipped until
--retry, and --delete removes only what this machine sent under the paths
given. --dry-run says what would leave the machine. Commands: login,
collections, sync, status, history, formats.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 13:43:47 +02:00

273 lines
8.8 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 kbsync
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
// fakeGateway holds one collection in memory and embeds by counting letters.
type fakeGateway struct {
mu sync.Mutex
writable bool
docs map[string]RemoteDoc
puts int
embedded int
busyFirst bool
}
func (g *fakeGateway) serve(t *testing.T) *Client {
t.Helper()
mux := http.NewServeMux()
coll := Collection{ID: "kb_x", Name: "ik", EmbedModel: "emb", Dims: 3}
mux.HandleFunc("GET /v1/kb/collections/{id}/manifest", func(w http.ResponseWriter, r *http.Request) {
g.mu.Lock()
defer g.mu.Unlock()
if r.PathValue("id") != "ik" && r.PathValue("id") != "kb_x" {
http.Error(w, `{"error":{"message":"no collection"}}`, http.StatusNotFound)
return
}
m := Manifest{Collection: coll}
m.Collection.Writable = g.writable
for _, d := range g.docs {
m.Documents = append(m.Documents, d)
}
_ = json.NewEncoder(w).Encode(m)
})
mux.HandleFunc("PUT /v1/kb/collections/{id}/documents", func(w http.ResponseWriter, r *http.Request) {
var d DocOut
_ = json.NewDecoder(r.Body).Decode(&d)
g.mu.Lock()
defer g.mu.Unlock()
g.puts++
g.docs[d.Name] = RemoteDoc{Name: d.Name, Digest: d.Digest, Chunks: len(d.Chunks)}
_, _ = w.Write([]byte(`{"changed":true}`))
})
mux.HandleFunc("DELETE /v1/kb/collections/{id}/documents", func(w http.ResponseWriter, r *http.Request) {
g.mu.Lock()
defer g.mu.Unlock()
delete(g.docs, r.URL.Query().Get("name"))
_, _ = w.Write([]byte(`{}`))
})
mux.HandleFunc("POST /v1/embeddings", func(w http.ResponseWriter, r *http.Request) {
g.mu.Lock()
busy := g.busyFirst
g.busyFirst = false
g.mu.Unlock()
if busy {
w.Header().Set("Retry-After", "1")
http.Error(w, `{"error":{"message":"the model is loading"}}`, http.StatusServiceUnavailable)
return
}
var in struct {
Input []string `json:"input"`
}
_ = json.NewDecoder(r.Body).Decode(&in)
type row struct {
Index int `json:"index"`
Embedding []float32 `json:"embedding"`
}
var out struct {
Data []row `json:"data"`
}
for i, s := range in.Input {
out.Data = append(out.Data, row{i, []float32{float32(len(s)), 1, 0}})
}
g.mu.Lock()
g.embedded += len(in.Input)
g.mu.Unlock()
_ = json.NewEncoder(w).Encode(out)
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
h, _ := NewHTTP("")
return &Client{Base: srv.URL, Key: "k", HTTP: h}
}
func setup(t *testing.T) (*fakeGateway, *Client, *Ledger, string) {
t.Helper()
g := &fakeGateway{writable: true, docs: map[string]RemoteDoc{}}
c := g.serve(t)
l, err := OpenLedger(context.Background(), filepath.Join(t.TempDir(), "ledger.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = l.Close() })
dir := t.TempDir()
write(t, dir, "politika/izin.md", "# İzin\n\nYirmi gün.\n")
write(t, dir, "politika/masraf.txt", "Masraflar finansa.\n")
write(t, dir, "eski.doc", "\xd0\xcf\x11\xe0\x00binary")
write(t, dir, "resim.bin", "\x00\x01")
write(t, dir, ".git/config", "[core]")
write(t, dir, "politika/~$izin.docx", "lock")
return g, c, l, dir
}
func write(t *testing.T, dir, name, s string) {
t.Helper()
p := filepath.Join(dir, filepath.FromSlash(name))
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(s), 0o644); err != nil {
t.Fatal(err)
}
}
func kinds(events []Event) map[string][]string {
out := map[string][]string{}
for _, e := range events {
out[e.Kind] = append(out[e.Kind], e.Name)
}
return out
}
func run(t *testing.T, c *Client, l *Ledger, o Options) (Report, map[string][]string) {
t.Helper()
var mu sync.Mutex
var ev []Event
rep, err := Sync(context.Background(), c, l, o, func(e Event) {
mu.Lock()
ev = append(ev, e)
mu.Unlock()
})
if err != nil {
t.Fatal(err)
}
return rep, kinds(ev)
}
func TestASecondRunSendsNothingAndReadsNothing(t *testing.T) {
g, c, l, dir := setup(t)
rep, k := run(t, c, l, Options{Collection: "ik", Roots: []string{dir}})
if rep.Sent != 2 || len(g.docs) != 2 {
t.Fatalf("first run %+v %v", rep, k)
}
if _, ok := g.docs["politika/izin.md"]; !ok {
t.Errorf("documents %v", g.docs)
}
// The legacy Word file is skipped with the reason, and no hidden,
// lock or unknown file is touched.
if len(k["skipped"]) != 1 || k["skipped"][0] != "eski.doc" {
t.Errorf("skipped %v", k["skipped"])
}
puts, embedded := g.puts, g.embedded
rep, k = run(t, c, l, Options{Collection: "ik", Roots: []string{dir}})
if rep.Sent != 0 || rep.Unchanged != 2 || g.puts != puts || g.embedded != embedded {
t.Errorf("second run %+v %v", rep, k)
}
if rep.Skipped != 1 {
t.Errorf("the skipped file was not reported as before: %+v", rep)
}
es, _ := l.Entries(context.Background(), c.Base, "kb_x")
if len(es) != 3 {
t.Errorf("ledger %+v", es)
}
runs, _ := l.Runs(context.Background(), 10)
if len(runs) != 2 || runs[0].Unchanged != 2 {
t.Errorf("runs %+v", runs)
}
}
func TestAChangedFileIsSentAgain(t *testing.T) {
g, c, l, dir := setup(t)
run(t, c, l, Options{Collection: "ik", Roots: []string{dir}})
old := g.docs["politika/izin.md"].Digest
write(t, dir, "politika/izin.md", "# İzin\n\nOtuz gün.\n")
// A changed time is what makes it look again; some filesystems keep
// seconds only.
later := time.Now().Add(2 * time.Second)
_ = os.Chtimes(filepath.Join(dir, "politika", "izin.md"), later, later)
rep, k := run(t, c, l, Options{Collection: "ik", Roots: []string{dir}})
if rep.Sent != 1 || k["sent"][0] != "politika/izin.md" || g.docs["politika/izin.md"].Digest == old {
t.Errorf("after a change %+v %v", rep, k)
}
}
func TestDeleteRemovesOnlyWhatThisMachineSentUnderTheseRoots(t *testing.T) {
g, c, l, dir := setup(t)
run(t, c, l, Options{Collection: "ik", Roots: []string{dir}})
// Somebody else's document, sent from another machine.
g.docs["baska/makine.md"] = RemoteDoc{Name: "baska/makine.md", Digest: "x"}
// A file of this machine from another folder.
other := t.TempDir()
write(t, other, "not.md", "başka klasör")
run(t, c, l, Options{Collection: "ik", Roots: []string{other}})
if err := os.Remove(filepath.Join(dir, "politika", "masraf.txt")); err != nil {
t.Fatal(err)
}
rep, k := run(t, c, l, Options{Collection: "ik", Roots: []string{dir}, Delete: true})
if rep.Deleted != 1 || k["deleted"][0] != "politika/masraf.txt" {
t.Errorf("deleted %+v %v", rep, k)
}
for _, keep := range []string{"baska/makine.md", "not.md", "politika/izin.md"} {
if _, ok := g.docs[keep]; !ok {
t.Errorf("%s was deleted", keep)
}
}
}
func TestADryRunSendsAndRecordsNothing(t *testing.T) {
g, c, l, dir := setup(t)
rep, k := run(t, c, l, Options{Collection: "ik", Roots: []string{dir}, DryRun: true})
if rep.Sent != 2 || len(k["would-send"]) != 2 || rep.Bytes == 0 || rep.Chunks != 2 {
t.Errorf("dry run %+v %v", rep, k)
}
if g.puts != 0 || g.embedded != 0 {
t.Errorf("a dry run sent %d documents, embedded %d chunks", g.puts, g.embedded)
}
if es, _ := l.Entries(context.Background(), c.Base, "kb_x"); len(es) != 0 {
t.Errorf("a dry run wrote the ledger: %+v", es)
}
}
func TestAReadOnlyCollectionIsRefusedAndALoadingModelWaitedFor(t *testing.T) {
g, c, l, dir := setup(t)
g.writable = false
if _, err := Sync(context.Background(), c, l, Options{Collection: "ik", Roots: []string{dir}}, nil); err == nil ||
!strings.Contains(err.Error(), "not add") {
t.Errorf("read-only gave %v", err)
}
g.writable, g.busyFirst = true, true
var waited []string
c.Waiting = func(why string, _ time.Duration) { waited = append(waited, why) }
rep, _ := run(t, c, l, Options{Collection: "ik", Roots: []string{dir}, Workers: 1})
if rep.Sent != 2 || len(waited) != 1 || !strings.Contains(waited[0], "loading") {
t.Errorf("after a 503 %+v, waited %v", rep, waited)
}
if _, err := Sync(context.Background(), c, l, Options{Collection: "yok", Roots: []string{dir}}, nil); err == nil {
t.Error("a collection that is not there was synced")
}
}
func TestFiltersAndNames(t *testing.T) {
_, c, l, dir := setup(t)
write(t, dir, "rapor/2026/ocak.md", "ocak")
write(t, dir, "rapor/taslak.md", "taslak")
rep, k := run(t, c, l, Options{Collection: "ik", Roots: []string{dir}, DryRun: true,
Include: []string{"rapor/**/*.md"}, Exclude: []string{"taslak*"}})
if rep.Sent != 1 || k["would-send"][0] != "rapor/2026/ocak.md" {
t.Errorf("filtered %+v %v", rep, k)
}
// Two roots keep their names apart.
other := t.TempDir()
write(t, other, "politika/izin.md", "aynı ad")
_, k = run(t, c, l, Options{Collection: "ik", Roots: []string{dir, other}, DryRun: true, Include: []string{"izin.md"}})
if len(k["would-send"]) != 2 || k["would-send"][0] == k["would-send"][1] {
t.Errorf("two roots: %v", k["would-send"])
}
if _, err := Sync(context.Background(), c, l, Options{Collection: "ik", Roots: []string{t.TempDir()}}, nil); !errors.Is(err, ErrNothing) {
t.Errorf("an empty folder gave %v", err)
}
}