Files
iasysadmin/internal/memory/memory_test.go
T
2026-09-27 16:04:02 -06:00

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()))
}
}