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