189 lines
6.3 KiB
Go
189 lines
6.3 KiB
Go
package memory
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"sysagent/internal/llm"
|
|
)
|
|
|
|
func TestUpsertCreatesStructure(t *testing.T) {
|
|
s := NewStore(filepath.Join(t.TempDir(), "memoria.md"))
|
|
if _, err := s.Ensure(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
res, err := s.Upsert("Hosts", "db01", []string{"IP: 10.10.10.15", "Servicio: PostgreSQL", "Puerto: 5432"}, false)
|
|
if err != nil || !res.Created || len(res.Added) != 3 {
|
|
t.Fatalf("res=%+v err=%v", res, err)
|
|
}
|
|
got, _ := s.Read()
|
|
want := "# memoria.md\n\nConocimiento persistente descubierto por sysagent.\n\n## Hosts\n\n### db01\n\n- IP: 10.10.10.15\n- Servicio: PostgreSQL\n- Puerto: 5432\n"
|
|
if got != want {
|
|
t.Errorf("content:\n%s\nwant:\n%s", got, want)
|
|
}
|
|
st, _ := os.Stat(s.Path())
|
|
if st.Mode().Perm() != 0o600 {
|
|
t.Errorf("perm = %o", st.Mode().Perm())
|
|
}
|
|
}
|
|
|
|
func TestUpsertNoDuplicatesAndUpdates(t *testing.T) {
|
|
s := NewStore(filepath.Join(t.TempDir(), "memoria.md"))
|
|
s.Upsert("Hosts", "db01", []string{"IP: 10.10.10.15", "Rol primario"}, false)
|
|
|
|
res, _ := s.Upsert("hosts", "DB01", []string{"IP: 10.10.10.15", "Rol primario"}, false)
|
|
if res.Changed() || len(res.Existing) != 2 {
|
|
t.Errorf("duplicates must not be added: %+v", res)
|
|
}
|
|
res, _ = s.Upsert("Hosts", "db01", []string{"ip: 10.10.10.99", "Puerto: 5433"}, false)
|
|
if len(res.Updated) != 1 || len(res.Added) != 1 {
|
|
t.Errorf("res = %+v", res)
|
|
}
|
|
got, _ := s.Read()
|
|
if strings.Contains(got, "10.10.10.15") {
|
|
t.Errorf("old IP must be replaced:\n%s", got)
|
|
}
|
|
if !strings.Contains(got, "- ip: 10.10.10.99") || strings.Count(got, "### ") != 1 || strings.Count(got, "## Hosts") != 1 {
|
|
t.Errorf("content:\n%s", got)
|
|
}
|
|
|
|
res, _ = s.Upsert("Hosts", "db01", []string{"IP: 1.2.3.4"}, true)
|
|
got, _ = s.Read()
|
|
if strings.Contains(got, "Puerto") || !strings.Contains(got, "- IP: 1.2.3.4") {
|
|
t.Errorf("replace=true must rewrite the entry:\n%s", got)
|
|
}
|
|
}
|
|
|
|
func TestUpsertPreservesManualContent(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "memoria.md")
|
|
manual := "# Notas\n\nTexto del operador.\n\n## Hosts\n\nIntro de hosts.\n\n### web01\n\n- IP: 10.0.0.2\n```\n## no es sección\n```\n\n## Servicios\n\n- nginx en web01\n"
|
|
os.WriteFile(path, []byte(manual), 0o600)
|
|
s := NewStore(path)
|
|
if _, err := s.Upsert("Hosts", "db01", []string{"IP: 10.0.0.3"}, false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, _ := s.Read()
|
|
for _, want := range []string{"Texto del operador.", "Intro de hosts.", "### web01\n\n- IP: 10.0.0.2", "## no es sección", "## Servicios\n\n- nginx en web01", "### db01\n\n- IP: 10.0.0.3"} {
|
|
if !strings.Contains(got, want) {
|
|
t.Errorf("missing %q in:\n%s", want, got)
|
|
}
|
|
}
|
|
if strings.Index(got, "### db01") > strings.Index(got, "## Servicios") {
|
|
t.Error("new entry must go inside its section")
|
|
}
|
|
}
|
|
|
|
func TestForget(t *testing.T) {
|
|
s := NewStore(filepath.Join(t.TempDir(), "memoria.md"))
|
|
s.Upsert("Hosts", "a", []string{"x"}, false)
|
|
s.Upsert("Hosts", "b", []string{"y"}, false)
|
|
if ok, _ := s.Forget("Hosts", "a"); !ok {
|
|
t.Error("forget a")
|
|
}
|
|
if ok, _ := s.Forget("Hosts", "zz"); ok {
|
|
t.Error("forget missing entry must return false")
|
|
}
|
|
got, _ := s.Read()
|
|
if strings.Contains(got, "### a") || !strings.Contains(got, "### b") {
|
|
t.Errorf("content:\n%s", got)
|
|
}
|
|
s.Forget("Hosts", "")
|
|
got, _ = s.Read()
|
|
if strings.Contains(got, "Hosts") {
|
|
t.Errorf("section not removed:\n%s", got)
|
|
}
|
|
}
|
|
|
|
func TestLooksSecret(t *testing.T) {
|
|
secrets := []string{"password: hunter2", "Contraseña: abc123", "token=ghp_xxx", "API_KEY: sk-123", "-----BEGIN RSA PRIVATE KEY-----"}
|
|
for _, s := range secrets {
|
|
if !LooksSecret(s) {
|
|
t.Errorf("%q should look secret", s)
|
|
}
|
|
}
|
|
safe := []string{"IP: 10.0.0.1", "Password almacenado en: secret-tool lookup service postgres-admin", "Puerto: 5432", "Usuario: admin"}
|
|
for _, s := range safe {
|
|
if LooksSecret(s) {
|
|
t.Errorf("%q should not look secret", s)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestStateRoundTrip(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "contexto.mem")
|
|
st, warn, err := LoadState(path)
|
|
if err != nil || warn != "" || st.Summary != "" {
|
|
t.Fatalf("missing file: %v %q", err, warn)
|
|
}
|
|
st.Summary = "## OBJECTIVE\nkey sk-SECRET-999"
|
|
st.Recent = []llm.Message{{Role: llm.RoleUser, Content: strings.Repeat("ñ", 5000)}}
|
|
st.AddJournal(JournalEntry{Kind: "tool", Task: "01.md", Text: "systemctl restart nginx → exit=0"})
|
|
st.Tasks["01.md"] = TaskRecord{Status: "DONE", Hash: "abc"}
|
|
redact := func(s string) string { return strings.ReplaceAll(s, "sk-SECRET-999", "sk-SEC...999") }
|
|
if err := st.Save(path, redact); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fi, _ := os.Stat(path)
|
|
if fi.Mode().Perm() != 0o600 {
|
|
t.Errorf("perm = %o", fi.Mode().Perm())
|
|
}
|
|
raw, _ := os.ReadFile(path)
|
|
if strings.Contains(string(raw), "sk-SECRET-999") {
|
|
t.Error("secret persisted")
|
|
}
|
|
if st.Summary != "## OBJECTIVE\nkey sk-SECRET-999" {
|
|
t.Error("Save must not mutate in-memory state")
|
|
}
|
|
|
|
st2, warn, err := LoadState(path)
|
|
if err != nil || warn != "" {
|
|
t.Fatal(err, warn)
|
|
}
|
|
if st2.Tasks["01.md"].Status != "DONE" || len(st2.Journal) != 1 || len(st2.Unsummarized()) != 1 {
|
|
t.Errorf("state = %+v", st2)
|
|
}
|
|
if len(st2.Recent[0].Content) > maxPersistedChars+100 {
|
|
t.Error("recent messages must be clipped when persisted")
|
|
}
|
|
st2.MarkSummarized()
|
|
if len(st2.Unsummarized()) != 0 {
|
|
t.Error("MarkSummarized")
|
|
}
|
|
}
|
|
|
|
func TestLoadStatePlainTextAndCorrupt(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "contexto.mem")
|
|
os.WriteFile(path, []byte("Servidor 192.168.20.15 con PostgreSQL 17"), 0o600)
|
|
st, warn, err := LoadState(path)
|
|
if err != nil || warn == "" || !strings.Contains(st.Summary, "192.168.20.15") {
|
|
t.Fatalf("plain text: %v %q %q", err, warn, st.Summary)
|
|
}
|
|
|
|
os.WriteFile(path, []byte("{broken json"), 0o600)
|
|
st, warn, err = LoadState(path)
|
|
if err != nil || !strings.Contains(warn, "moved to") || st.Summary != "" {
|
|
t.Fatalf("corrupt: %v %q", err, warn)
|
|
}
|
|
matches, _ := filepath.Glob(filepath.Join(dir, "contexto.mem.corrupt-*"))
|
|
if len(matches) != 1 {
|
|
t.Error("corrupt file must be preserved")
|
|
}
|
|
}
|
|
|
|
func TestStateTrimDropsSummarizedFirst(t *testing.T) {
|
|
st := NewState()
|
|
for i := 0; i < maxJournal+50; i++ {
|
|
st.AddJournal(JournalEntry{Kind: "tool", Text: "x", Summarized: i < 100})
|
|
}
|
|
st.trim()
|
|
if len(st.Journal) != maxJournal {
|
|
t.Fatalf("len = %d", len(st.Journal))
|
|
}
|
|
if len(st.Unsummarized()) != maxJournal+50-100 {
|
|
t.Errorf("unsummarized entries must be kept: %d", len(st.Unsummarized()))
|
|
}
|
|
}
|