// memory_test.go -- 记忆系统完整流程的单元测试. // // 覆盖场景: // - Save 保存记忆 // - List 列出所有记忆 // - FindRelevant 查找相关记忆 // - Delete 删除记忆 // - UpdateIndex 更新索引文件 // - sanitizeFilename 文件名清理 // - 空名称报错 package memory import ( "context" "errors" "os" "path/filepath" "strings" "testing" "git.flytoex.net/yuanwei/flyto-agent/pkg/security" ) // newTestStore 创建一个临时的文件存储 func newTestStore(t *testing.T) Store { t.Helper() dir := t.TempDir() baseDir := filepath.Join(dir, "memory") os.MkdirAll(baseDir, 0755) return &fileStore{cwd: dir, baseDir: baseDir} } // TestMemory_Save 测试保存记忆 func TestMemory_Save(t *testing.T) { store := newTestStore(t) ctx := context.Background() entry := &Entry{ Name: "test-memory", Description: "a test memory", Type: TypeProject, Content: "This is test content.", } if err := store.Save(ctx, entry); err != nil { t.Fatalf("保存失败: %v", err) } if entry.Path == "" { t.Error("保存后 Path 应非空") } // 验证文件内容 data, err := os.ReadFile(entry.Path) if err != nil { t.Fatalf("读取文件失败: %v", err) } content := string(data) if !strings.Contains(content, "name: test-memory") { t.Errorf("文件应包含 frontmatter: %s", content) } if !strings.Contains(content, "This is test content.") { t.Errorf("文件应包含正文: %s", content) } } // TestMemory_Save_EmptyName 测试空名称报错 func TestMemory_Save_EmptyName(t *testing.T) { store := newTestStore(t) entry := &Entry{Name: "", Content: "content"} err := store.Save(context.Background(), entry) if err == nil { t.Error("空名称应报错") } } // TestMemory_Save_DefaultType 测试默认类型 func TestMemory_Save_DefaultType(t *testing.T) { store := newTestStore(t) entry := &Entry{Name: "no-type", Content: "content"} if err := store.Save(context.Background(), entry); err != nil { t.Fatalf("保存失败: %v", err) } if entry.Type != TypeProject { t.Errorf("默认类型应为 project, 实际: %q", entry.Type) } } // TestMemory_List 测试列出所有记忆 func TestMemory_List(t *testing.T) { store := newTestStore(t) ctx := context.Background() // 保存多个记忆 entries := []*Entry{ {Name: "memory-1", Description: "first", Type: TypeUser, Content: "c1"}, {Name: "memory-2", Description: "second", Type: TypeFeedback, Content: "c2"}, {Name: "memory-3", Description: "third", Type: TypeProject, Content: "c3"}, } for _, e := range entries { if err := store.Save(ctx, e); err != nil { t.Fatalf("保存失败: %v", err) } } list, err := store.List(ctx) if err != nil { t.Fatalf("列表失败: %v", err) } if len(list) != 3 { t.Fatalf("期望 3 个记忆, 实际 %d", len(list)) } } // TestMemory_FindRelevant 测试查找相关记忆 func TestMemory_FindRelevant(t *testing.T) { store := newTestStore(t) ctx := context.Background() // 保存不同主题的记忆 store.Save(ctx, &Entry{Name: "go-setup", Description: "golang project setup", Type: TypeProject, Content: "go mod init"}) store.Save(ctx, &Entry{Name: "python-ml", Description: "python machine learning", Type: TypeProject, Content: "pip install tensorflow"}) store.Save(ctx, &Entry{Name: "go-test", Description: "golang testing patterns", Type: TypeFeedback, Content: "use table driven tests"}) results, err := store.FindRelevant(ctx, "golang testing", 2) if err != nil { t.Fatalf("查找失败: %v", err) } if len(results) == 0 { t.Fatal("应有相关结果") } // 第一个应是最相关的(go-test) if results[0].Name != "go-test" { t.Errorf("最相关的应为 'go-test', 实际: %q", results[0].Name) } } // TestMemory_Delete 测试删除记忆 func TestMemory_Delete(t *testing.T) { store := newTestStore(t) ctx := context.Background() store.Save(ctx, &Entry{Name: "to-delete", Description: "will be deleted", Content: "content"}) err := store.Delete(ctx, "to-delete") if err != nil { t.Fatalf("删除失败: %v", err) } // 删除后列表应为空 list, _ := store.List(ctx) if len(list) != 0 { t.Errorf("删除后应为空, 实际 %d 个", len(list)) } } // TestMemory_Delete_NotFound 测试删除不存在的记忆 func TestMemory_Delete_NotFound(t *testing.T) { store := newTestStore(t) err := store.Delete(context.Background(), "nonexistent") if err == nil { t.Error("删除不存在的记忆应报错") } } // TestMemory_Delete_EmptyName 测试空名称删除 func TestMemory_Delete_EmptyName(t *testing.T) { store := newTestStore(t) err := store.Delete(context.Background(), "") if err == nil { t.Error("空名称应报错") } } // TestMemory_UpdateIndex 测试更新索引文件 func TestMemory_UpdateIndex(t *testing.T) { store := newTestStore(t) ctx := context.Background() store.Save(ctx, &Entry{Name: "idx-1", Description: "first index", Type: TypeUser, Content: "c1"}) store.Save(ctx, &Entry{Name: "idx-2", Description: "second index", Type: TypeProject, Content: "c2"}) err := store.UpdateIndex(ctx) if err != nil { t.Fatalf("更新索引失败: %v", err) } // 验证索引文件存在 fs := store.(*fileStore) indexPath := filepath.Join(fs.baseDir, "MEMORY.md") data, err := os.ReadFile(indexPath) if err != nil { t.Fatalf("读取索引文件失败: %v", err) } content := string(data) if !strings.Contains(content, "Memory Index") { t.Error("索引应包含标题") } if !strings.Contains(content, "idx-1") { t.Error("索引应包含 idx-1") } if !strings.Contains(content, "idx-2") { t.Error("索引应包含 idx-2") } } // TestMemory_UpdateIndex_DynamicTypes 测试 UpdateIndex 的动态类型分组 func TestMemory_UpdateIndex_DynamicTypes(t *testing.T) { dir := t.TempDir() baseDir := filepath.Join(dir, "memory") os.MkdirAll(baseDir, 0755) // 创建自定义注册表继承默认,并增加仓储类型 warehouseReg := NewTypeRegistry(WithParent(DefaultTypeRegistry)) warehouseReg.Register(&MemoryTypeInfo{ Name: "inventory_rule", Scope: "org", Description: "库存管理规则", SortOrder: 10, }) store := &fileStore{cwd: dir, baseDir: baseDir, typeRegistry: warehouseReg} ctx := context.Background() // 保存多种类型的记忆 store.Save(ctx, &Entry{Name: "usr-1", Description: "user info", Type: TypeUser, Content: "c1"}) store.Save(ctx, &Entry{Name: "inv-1", Description: "inventory rule 1", Type: Type("inventory_rule"), Content: "c2"}) store.Save(ctx, &Entry{Name: "weird-1", Description: "unknown type", Type: Type("alien_type"), Content: "c3"}) err := store.UpdateIndex(ctx) if err != nil { t.Fatalf("更新索引失败: %v", err) } indexPath := filepath.Join(baseDir, "MEMORY.md") data, err := os.ReadFile(indexPath) if err != nil { t.Fatalf("读取索引文件失败: %v", err) } content := string(data) // 已注册类型应出现 if !strings.Contains(content, "User (用户画像)") { t.Error("索引应包含 User 类型标题") } if !strings.Contains(content, "inventory_rule") { t.Error("索引应包含自定义 inventory_rule 类型") } // 未注册类型应归入"其他" if !strings.Contains(content, "Other (其他)") { t.Error("未注册类型应归入 'Other (其他)' 分组") } if !strings.Contains(content, "weird-1") { t.Error("索引应包含未注册类型的记忆 weird-1") } } // TestMemory_UpdateIndex_DefaultRegistry 测试 UpdateIndex 使用默认注册表 func TestMemory_UpdateIndex_DefaultRegistry(t *testing.T) { store := newTestStore(t) ctx := context.Background() store.Save(ctx, &Entry{Name: "u1", Description: "user", Type: TypeUser, Content: "c1"}) store.Save(ctx, &Entry{Name: "f1", Description: "feedback", Type: TypeFeedback, Content: "c2"}) store.Save(ctx, &Entry{Name: "p1", Description: "project", Type: TypeProject, Content: "c3"}) store.Save(ctx, &Entry{Name: "r1", Description: "reference", Type: TypeReference, Content: "c4"}) if err := store.UpdateIndex(ctx); err != nil { t.Fatalf("更新索引失败: %v", err) } fs := store.(*fileStore) data, _ := os.ReadFile(filepath.Join(fs.baseDir, "MEMORY.md")) content := string(data) // 所有 4 种类型都应有标题 for _, label := range []string{"User (用户画像)", "Feedback (行为指导)", "Project (项目上下文)", "Reference (外部指针)"} { if !strings.Contains(content, label) { t.Errorf("索引应包含类型标题 %q", label) } } } // TestSanitizeFilename 测试文件名清理 func TestSanitizeFilename(t *testing.T) { tests := []struct { name string want string }{ {"hello world", "hello_world"}, {"Hello-World", "hello-world"}, {"my/file\\name", "myfilename"}, {"Test 123!", "test_123"}, {"", "unnamed"}, {"你好世界", "你好世界"}, } for _, tt := range tests { got := sanitizeFilename(tt.name) if got != tt.want { t.Errorf("sanitizeFilename(%q) = %q, 期望 %q", tt.name, got, tt.want) } } } // ─── SecretGuard 集成 ───────────────────────────────────────────────────────── // TestMemory_Save_BlockedBySecretGuard 验证含敏感信息的记忆条目被拒绝保存. func TestMemory_Save_BlockedBySecretGuard(t *testing.T) { dir := t.TempDir() baseDir := filepath.Join(dir, "memory") os.MkdirAll(baseDir, 0755) // 使用真实的 DefaultSecretGuard(会检测 GitHub PAT) guard := &mockSecretGuard{shouldBlock: true} store := &fileStore{cwd: dir, baseDir: baseDir, guard: guard} ctx := context.Background() entry := &Entry{ Name: "secret-entry", Content: "contains_secret_data", } err := store.Save(ctx, entry) if err == nil { t.Fatal("含敏感信息的条目应被拒绝,但 Save 返回了 nil") } if !strings.Contains(err.Error(), "secret detected") { t.Errorf("错误消息应包含 'secret detected', 实际: %v", err) } // 验证文件未被写入 files, _ := os.ReadDir(baseDir) if len(files) > 0 { t.Error("被拦截的条目不应产生任何文件") } } // TestMemory_Save_PassesWithNoSecrets 验证无敏感信息的条目正常保存. func TestMemory_Save_PassesWithNoSecrets(t *testing.T) { dir := t.TempDir() baseDir := filepath.Join(dir, "memory") os.MkdirAll(baseDir, 0755) guard := &mockSecretGuard{shouldBlock: false} store := &fileStore{cwd: dir, baseDir: baseDir, guard: guard} ctx := context.Background() entry := &Entry{ Name: "safe-entry", Content: "no sensitive content", } if err := store.Save(ctx, entry); err != nil { t.Fatalf("安全内容应正常保存: %v", err) } } // TestMemory_WithSecretGuardOption 验证 WithSecretGuard option 生效. func TestMemory_WithSecretGuardOption(t *testing.T) { dir := t.TempDir() guard := &mockSecretGuard{shouldBlock: true} store := NewFileStoreWithOptions(dir, WithSecretGuard(guard)) ctx := context.Background() entry := &Entry{Name: "test", Content: "data"} err := store.Save(ctx, entry) if err == nil { t.Fatal("WithSecretGuard 注入的 guard 应拦截写入") } } // mockSecretGuard 是用于测试的 SecretGuard mock. type mockSecretGuard struct { shouldBlock bool } func (m *mockSecretGuard) Scan(path, content string) ([]security.SecretMatch, error) { if m.shouldBlock { return []security.SecretMatch{{RuleID: "mock-rule", Label: "Mock Secret"}}, nil } return nil, nil } func (m *mockSecretGuard) Redact(content string) string { return content } // ─── INF-2 备份集成 ─────────────────────────────────────────────────────────── // mockBackupper 是用于测试的 Backupper mock. type mockBackupper struct { calls []string // 每次 Backup 调用的路径 retErr error // Backup 返回的错误(nil=成功) } func (m *mockBackupper) Backup(ctx context.Context, path string) error { m.calls = append(m.calls, path) return m.retErr } // TestMemory_Save_BackupCalledOnOverwrite 验证覆盖已有文件时 Backup 被调用. func TestMemory_Save_BackupCalledOnOverwrite(t *testing.T) { dir := t.TempDir() baseDir := filepath.Join(dir, "memory") os.MkdirAll(baseDir, 0755) bk := &mockBackupper{} store := &fileStore{cwd: dir, baseDir: baseDir, fileHistory: bk} ctx := context.Background() entry := &Entry{Name: "existing", Content: "v1", Type: TypeProject} // 第一次 Save(文件不存在)--不应触发 Backup if err := store.Save(ctx, entry); err != nil { t.Fatalf("第一次 Save 失败: %v", err) } if len(bk.calls) != 0 { t.Errorf("新建文件不应触发 Backup,实际调用: %d 次", len(bk.calls)) } // 第二次 Save(文件已存在)--应触发 Backup entry.Content = "v2" if err := store.Save(ctx, entry); err != nil { t.Fatalf("第二次 Save 失败: %v", err) } if len(bk.calls) != 1 { t.Errorf("覆盖文件应触发 1 次 Backup,实际: %d 次", len(bk.calls)) } } // TestMemory_Save_BackupNotCalledOnNewFile 验证新建文件时 Backup 不被调用. func TestMemory_Save_BackupNotCalledOnNewFile(t *testing.T) { dir := t.TempDir() baseDir := filepath.Join(dir, "memory") os.MkdirAll(baseDir, 0755) bk := &mockBackupper{} store := &fileStore{cwd: dir, baseDir: baseDir, fileHistory: bk} ctx := context.Background() entry := &Entry{Name: "brand-new", Content: "first time", Type: TypeUser} if err := store.Save(ctx, entry); err != nil { t.Fatalf("Save 失败: %v", err) } if len(bk.calls) != 0 { t.Errorf("新建文件不应触发 Backup,实际: %d 次", len(bk.calls)) } } // TestMemory_Save_BackupFailDoesNotBlockSave 验证 Backup 失败不阻断 Save(fail-open). func TestMemory_Save_BackupFailDoesNotBlockSave(t *testing.T) { dir := t.TempDir() baseDir := filepath.Join(dir, "memory") os.MkdirAll(baseDir, 0755) bk := &mockBackupper{retErr: os.ErrPermission} store := &fileStore{cwd: dir, baseDir: baseDir, fileHistory: bk} ctx := context.Background() entry := &Entry{Name: "fail-backup", Content: "v1", Type: TypeProject} // 第一次 Save(新建) if err := store.Save(ctx, entry); err != nil { t.Fatalf("第一次 Save 失败: %v", err) } // 第二次 Save(Backup 会失败)--Save 应仍然成功 entry.Content = "v2" if err := store.Save(ctx, entry); err != nil { t.Fatalf("Backup 失败时 Save 应仍然成功(fail-open),实际: %v", err) } // 验证文件内容已更新(Save 没有被 Backup 失败阻断) data, err := os.ReadFile(entry.Path) if err != nil { t.Fatalf("读取文件失败: %v", err) } if !strings.Contains(string(data), "v2") { t.Error("即使 Backup 失败,内容也应更新为 v2") } } // TestMemory_WithFileHistoryOption 验证 WithFileHistory option 生效. func TestMemory_WithFileHistoryOption(t *testing.T) { dir := t.TempDir() bk := &mockBackupper{} store := NewFileStoreWithOptions(dir, WithFileHistory(bk)) ctx := context.Background() entry := &Entry{Name: "with-fh", Content: "data", Type: TypeProject} // 第一次(新建,无 Backup) store.Save(ctx, entry) if len(bk.calls) != 0 { t.Errorf("新建时不应调用 Backup,实际: %d", len(bk.calls)) } // 第二次(覆盖,应有 Backup) entry.Content = "updated" store.Save(ctx, entry) if len(bk.calls) != 1 { t.Errorf("覆盖时应调用 1 次 Backup,实际: %d", len(bk.calls)) } } // countingScorer 是一个 RelevanceScorer 测试 spy: 统计 Score 被调用的次数. // 所有 header 都返回 1.0 确保 SelectRelevant 保留全部结果 (测试不关心顺序, 只关心 Score 被调用). type countingScorer struct { calls int } func (s *countingScorer) Name() string { return "counting" } func (s *countingScorer) Score(query string, header *MemoryHeader) float64 { s.calls++ return 1.0 } // TestFileStore_FindRelevant_AIFailure_UsesStoreScorer 是 L1287 bug 的回归测试. // // 历史 bug (2026-04-14 前): AIMemorySelector 内部 fallback 调 // SelectRelevant(query, headers, limit, nil), 传 nil scorer, 静默忽略用户通过 // WithScorer 配置的自定义 RelevanceScorer. 修复后 selector 失败时上抛 error, // FindRelevant 接住并用 s.scorer 做 fallback. // // 测试方法: 注入一个 always-fail 的 AIMemorySelector + 一个 countingScorer spy, // 触发 FindRelevant 走 fallback 路径, 断言 spy 的 Score 方法被调用过. // 若将来有人回滚到 "selector 内部 fallback 传 nil scorer" 的写法, spy.calls 保持 0, // 此测试会失败, 立即发现回归. func TestFileStore_FindRelevant_AIFailure_UsesStoreScorer(t *testing.T) { dir := t.TempDir() spy := &countingScorer{} failingSelector := NewAIMemorySelector(func(ctx context.Context, sp, up string) (string, error) { return "", errors.New("simulated ai failure") }) store := NewFileStoreWithOptions(dir, WithScorer(spy), WithMemorySelector(failingSelector), ) ctx := context.Background() // 写两条记忆让 ScanMemoryDir 有内容可处理 if err := store.Save(ctx, &Entry{Name: "entry_a", Description: "alpha", Content: "content A", Type: TypeProject}); err != nil { t.Fatalf("save A: %v", err) } if err := store.Save(ctx, &Entry{Name: "entry_b", Description: "beta", Content: "content B", Type: TypeProject}); err != nil { t.Fatalf("save B: %v", err) } // 触发 fallback 路径 (AI selector 必 fail) if _, err := store.FindRelevant(ctx, "any query", 5); err != nil { t.Fatalf("FindRelevant: %v", err) } // 关键断言: 自定义 scorer 必须被调用过 (每条 header 至少一次). // spy.calls == 0 意味着 FindRelevant 没把 s.scorer 传给 SelectRelevant - // 正是修复前的 bug 行为. if spy.calls == 0 { t.Fatal("回归: FindRelevant AI 失败 fallback 未调用 s.scorer, 传了 nil scorer (2026-04-14 L1287 修复的 bug 已回滚)") } }