Files
llmgw/internal/store/memory_test.go
Ilker ManapandClaude Opus 5 ebce41e22d Stop calling the PostgreSQL store SQLite, and stop guessing where it is
Two things that were only ever confusing, and one of them stopped a command
working on the cluster.

The store type was named SQLite and kept the name when PostgreSQL was added, so
OpenPostgres returned a *SQLite and all seventy-odd methods on the production
store were spelled after the database it was not running on. Nothing broke;
people did. Earlier today I added three methods to *SQLite for a table that on
this cluster only ever lives in PostgreSQL, and the name did not once look
wrong. It is a field, which is all it ever was.

`llmgw users` and `llmgw keys` defaulted --store to a SQLite path. On a
deployment whose store is PostgreSQL every one of them failed with "unable to
open database file" while the right answer sat in auth.store in the config file
beside them; the conclusion an operator draws is that the tool is broken, and
they are not far off. They now ask the server's own configuration before
falling back, and the error names which of the three sources chose the address
— the failure was never the path, it was which one picked it.

The single-machine default still applies last, so a deployment with no config
file keeps working with no flags.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 12:20:40 +02:00

209 lines
6.7 KiB
Go

package store
import (
"context"
"errors"
"fmt"
"strings"
"testing"
"time"
)
func memoryProject(t *testing.T, s *DB) string {
t.Helper()
proj := tree(t, s)
if err := s.SetMemoryRetention(context.Background(), proj, 24); err != nil {
t.Fatal(err)
}
return proj
}
func TestAProjectKeepsNoNotesUnlessItAsks(t *testing.T) {
ctx := context.Background()
s := openDB(t)
proj := tree(t, s)
err := s.PutNote(ctx, proj, "a", Note{Key: "k", Text: "secret"})
if !errors.Is(err, ErrMemoryNotEnabled) {
t.Fatalf("writing into a project that never asked returned %v", err)
}
if _, err := s.GetNote(ctx, proj, "a", "k"); !errors.Is(err, ErrNoNote) {
t.Errorf("a refused write left a note behind: %v", err)
}
}
func TestANoteIsWrittenReadBackAndReplaced(t *testing.T) {
ctx := context.Background()
s := openDB(t)
proj := memoryProject(t, s)
if err := s.PutNote(ctx, proj, "a", Note{Key: "city", Text: "Ankara", Tags: []string{"Geo", "geo", "facts"}}); err != nil {
t.Fatal(err)
}
got, err := s.GetNote(ctx, proj, "a", "city")
if err != nil {
t.Fatal(err)
}
if got.Text != "Ankara" || len(got.Tags) != 2 || got.Tags[0] != "geo" || got.Tags[1] != "facts" {
t.Fatalf("note came back as %+v: tags should be lowercased and deduplicated", got)
}
if err := s.PutNote(ctx, proj, "a", Note{Key: "city", Text: "Istanbul"}); err != nil {
t.Fatal(err)
}
got, _ = s.GetNote(ctx, proj, "a", "city")
if got.Text != "Istanbul" || len(got.Tags) != 0 {
t.Errorf("rewriting a key should replace text and tags: %+v", got)
}
notes, _ := s.SearchNotes(ctx, proj, "a", "", "", 0)
if len(notes) != 1 {
t.Errorf("rewriting a key created %d notes, want 1", len(notes))
}
}
func TestNotesAreFoundByWordsAndTagsAndSpecialCharactersAreHarmless(t *testing.T) {
ctx := context.Background()
s := openDB(t)
proj := memoryProject(t, s)
put := func(key, text string, tags ...string) {
t.Helper()
if err := s.PutNote(ctx, proj, "a", Note{Key: key, Text: text, Tags: tags}); err != nil {
t.Fatal(err)
}
}
put("deploy", "the staging cluster is on port 8443", "ops")
put("food", "she prefers green tea", "personal")
put("old", "deploy notes from last year", "ops", "archive")
byWord, err := s.SearchNotes(ctx, proj, "a", "staging cluster", "", 0)
if err != nil || len(byWord) != 1 || byWord[0].Key != "deploy" {
t.Fatalf("search by words: %+v (err %v)", byWord, err)
}
byTag, _ := s.SearchNotes(ctx, proj, "a", "", "ops", 0)
if len(byTag) != 2 {
t.Errorf("tag ops matched %d notes, want 2", len(byTag))
}
both, _ := s.SearchNotes(ctx, proj, "a", "deploy", "archive", 0)
if len(both) != 1 || both[0].Key != "old" {
t.Errorf("words and tag together: %+v", both)
}
// A tag is matched whole: "op" must not match "ops".
if part, _ := s.SearchNotes(ctx, proj, "a", "", "op", 0); len(part) != 0 {
t.Errorf("a partial tag matched %d notes", len(part))
}
// FTS operators in an agent's words must not become a syntax error.
for _, q := range []string{`"`, `a -b`, `x:y`, `foo*`, `AND OR NOT`, `(`} {
if _, err := s.SearchNotes(ctx, proj, "a", q, "", 0); err != nil {
t.Errorf("query %q failed: %v", q, err)
}
}
}
func TestOneAgentOrProjectCannotReadAnothersNotes(t *testing.T) {
ctx := context.Background()
s := openDB(t)
proj := memoryProject(t, s)
if err := s.PutOrg(ctx, Org{ID: "o2", Name: "Other"}); err != nil {
t.Fatal(err)
}
if err := s.PutTeam(ctx, Team{ID: "t2", OrgID: "o2", Name: "T"}); err != nil {
t.Fatal(err)
}
if err := s.PutProject(ctx, Project{ID: "proj2", TeamID: "t2", Name: "P"}); err != nil {
t.Fatal(err)
}
if err := s.SetMemoryRetention(ctx, "proj2", 24); err != nil {
t.Fatal(err)
}
if err := s.PutNote(ctx, proj, "agent1", Note{Key: "k", Text: "private"}); err != nil {
t.Fatal(err)
}
if _, err := s.GetNote(ctx, proj, "agent2", "k"); !errors.Is(err, ErrNoNote) {
t.Errorf("another agent in the same project read the note: %v", err)
}
if _, err := s.GetNote(ctx, "proj2", "agent1", "k"); !errors.Is(err, ErrNoNote) {
t.Errorf("another project read the note: %v", err)
}
if got, _ := s.SearchNotes(ctx, "proj2", "agent1", "private", "", 0); len(got) != 0 {
t.Errorf("another project found the note by search: %+v", got)
}
if err := s.DeleteNote(ctx, "proj2", "agent1", "k"); !errors.Is(err, ErrNoNote) {
t.Errorf("another project deleted the note: %v", err)
}
}
func TestNotesAreBoundedAndBadInputIsRefused(t *testing.T) {
ctx := context.Background()
s := openDB(t)
proj := memoryProject(t, s)
bad := []Note{
{Key: "", Text: "x"},
{Key: "k", Text: ""},
{Key: strings.Repeat("k", MaxNoteKey+1), Text: "x"},
{Key: "k", Text: strings.Repeat("x", MaxNoteText+1)},
{Key: "k", Text: "x", Tags: []string{"has space"}},
}
for i, n := range bad {
if err := s.PutNote(ctx, proj, "a", n); err == nil {
t.Errorf("bad note %d was accepted", i)
}
}
for i := 0; i < MaxNotesPerAgent; i++ {
if err := s.PutNote(ctx, proj, "a", Note{Key: fmt.Sprintf("note-%d", i), Text: "x"}); err != nil {
t.Fatalf("note %d: %v", i, err)
}
}
if err := s.PutNote(ctx, proj, "a", Note{Key: "one-too-many", Text: "x"}); !errors.Is(err, ErrMemoryFull) {
t.Errorf("exceeding the per-agent limit returned %v", err)
}
// Replacing an existing note is not growth and must still work when full.
if err := s.PutNote(ctx, proj, "a", Note{Key: "note-0", Text: "y"}); err != nil {
t.Errorf("replacing a note in a full agent failed: %v", err)
}
// Another agent has its own allowance.
if err := s.PutNote(ctx, proj, "b", Note{Key: "note-0", Text: "x"}); err != nil {
t.Errorf("a second agent was blocked by the first one's notes: %v", err)
}
}
func TestAForgottenNoteIsGoneFromSearchToo(t *testing.T) {
ctx := context.Background()
s := openDB(t)
proj := memoryProject(t, s)
if err := s.PutNote(ctx, proj, "a", Note{Key: "k", Text: "needle in a haystack"}); err != nil {
t.Fatal(err)
}
if err := s.DeleteNote(ctx, proj, "a", "k"); err != nil {
t.Fatal(err)
}
if got, _ := s.SearchNotes(ctx, proj, "a", "needle", "", 0); len(got) != 0 {
t.Errorf("a deleted note is still searchable: %+v", got)
}
if err := s.DeleteNote(ctx, proj, "a", "k"); !errors.Is(err, ErrNoNote) {
t.Errorf("deleting twice returned %v", err)
}
}
func TestNotesNotRefreshedWithinRetentionArePruned(t *testing.T) {
ctx := context.Background()
s := openDB(t)
proj := memoryProject(t, s)
if err := s.PutNote(ctx, proj, "a", Note{Key: "k", Text: "x"}); err != nil {
t.Fatal(err)
}
if n, err := s.PruneMemory(ctx, time.Now()); err != nil || n != 0 {
t.Fatalf("a fresh note was pruned: %d, %v", n, err)
}
if n, err := s.PruneMemory(ctx, time.Now().Add(25*time.Hour)); err != nil || n != 1 {
t.Fatalf("an expired note survived: %d, %v", n, err)
}
if got, _ := s.SearchNotes(ctx, proj, "a", "", "", 0); len(got) != 0 {
t.Errorf("pruned note still listed: %+v", got)
}
}