- 只翻注释/文档字符串:820 增 / 820 删 严格 1:1;剥掉整行注释与行尾注释尾巴后, 逐文件与 HEAD 比对「代码部分差异 = 0」;裁决/design 编号计数逐项与 HEAD 一致 - 保留可执行 SQL 样例、SQLite 报错原文、pgvector/HNSW/ANN/BLOB 等技术词原文 - 复核:go build 绿 + memory/projectindex/vector 三包测试绿;gofmt 仅剩 2 个既有未格式化文件(改动前即如此,未顺手修)
499 lines
14 KiB
Go
499 lines
14 KiB
Go
package projectindex
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"log/slog"
|
||
"strings"
|
||
"sync"
|
||
"testing"
|
||
"time"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
|
||
)
|
||
|
||
// testLogger 返回一个不写任何地方的 logger,保持测试输出干净。
|
||
func testLogger() *slog.Logger {
|
||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||
}
|
||
|
||
// newTestStore 打开一个隔离的内存 SQLite 库并在其上构建 Store。
|
||
// 每次调用都拥有私有 DB,因此并行测试天生相互隔离。
|
||
func newTestStore(t *testing.T) *Store {
|
||
t.Helper()
|
||
ctx := context.Background()
|
||
d, err := db.Open(ctx, ":memory:", testLogger())
|
||
if err != nil {
|
||
t.Fatalf("open in-memory db: %v", err)
|
||
}
|
||
t.Cleanup(func() { _ = d.Close() })
|
||
|
||
s, err := New(ctx, d, testLogger())
|
||
if err != nil {
|
||
t.Fatalf("New: %v", err)
|
||
}
|
||
return s
|
||
}
|
||
|
||
// baseProject 是测试用的最小合法项目。
|
||
func baseProject() Project {
|
||
return Project{ID: "proj.1", Title: "Cyrene", Desc: "陪伴型 AI 助手"}
|
||
}
|
||
|
||
func TestNew_NilDB_异常(t *testing.T) {
|
||
t.Parallel()
|
||
if _, err := New(context.Background(), nil, testLogger()); !errors.Is(err, ErrNilDB) {
|
||
t.Fatalf("New(nil db) err = %v, want ErrNilDB", err)
|
||
}
|
||
}
|
||
|
||
func TestNew_Schema_正常(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
d, err := db.Open(ctx, ":memory:", testLogger())
|
||
if err != nil {
|
||
t.Fatalf("open in-memory db: %v", err)
|
||
}
|
||
t.Cleanup(func() { _ = d.Close() })
|
||
if _, err := New(ctx, d, testLogger()); err != nil {
|
||
t.Fatalf("New: %v", err)
|
||
}
|
||
|
||
for _, tbl := range []string{"project", "project_chapter"} {
|
||
var n int
|
||
if err := d.QueryRowContext(ctx,
|
||
"SELECT count(*) FROM sqlite_master WHERE type='table' AND name=?", tbl).Scan(&n); err != nil {
|
||
t.Fatalf("check table %s: %v", tbl, err)
|
||
}
|
||
if n != 1 {
|
||
t.Fatalf("table %q missing", tbl)
|
||
}
|
||
}
|
||
var n int
|
||
if err := d.QueryRowContext(ctx,
|
||
"SELECT count(*) FROM sqlite_master WHERE type='index' AND name='idx_project_chapter_project_id'").Scan(&n); err != nil {
|
||
t.Fatalf("check index: %v", err)
|
||
}
|
||
if n != 1 {
|
||
t.Fatalf("index idx_project_chapter_project_id missing")
|
||
}
|
||
}
|
||
|
||
func TestStore_PutGetProject_Upsert(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
s := newTestStore(t)
|
||
|
||
oldTime := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC)
|
||
if err := s.PutProject(ctx, Project{ID: "proj.1", Title: "T1", Desc: "D1", CreatedAt: oldTime}); err != nil {
|
||
t.Fatalf("PutProject insert: %v", err)
|
||
}
|
||
// upsert 保留原始的 created_at,同时更新 title/desc。
|
||
if err := s.PutProject(ctx, Project{ID: "proj.1", Title: "T2", Desc: "D2", CreatedAt: oldTime.Add(time.Hour)}); err != nil {
|
||
t.Fatalf("PutProject update: %v", err)
|
||
}
|
||
got, err := s.GetProject(ctx, "proj.1")
|
||
if err != nil {
|
||
t.Fatalf("GetProject: %v", err)
|
||
}
|
||
if got.Title != "T2" {
|
||
t.Errorf("title = %q, want T2", got.Title)
|
||
}
|
||
if got.Desc != "D2" {
|
||
t.Errorf("desc = %q, want D2", got.Desc)
|
||
}
|
||
if !got.CreatedAt.Equal(oldTime) {
|
||
t.Errorf("created_at = %v, want preserved %v", got.CreatedAt, oldTime)
|
||
}
|
||
if got.UpdatedAt.IsZero() {
|
||
t.Errorf("updated_at not set")
|
||
}
|
||
}
|
||
|
||
func TestStore_PutProject_Validation_异常(t *testing.T) {
|
||
t.Parallel()
|
||
s := newTestStore(t)
|
||
ctx := context.Background()
|
||
|
||
tests := []struct {
|
||
name string
|
||
p Project
|
||
wantErr error
|
||
}{
|
||
{name: "blank id", p: Project{Title: "T"}, wantErr: ErrMissingProject},
|
||
{name: "blank title", p: Project{ID: "p"}, wantErr: ErrMissingTitle},
|
||
}
|
||
for _, tt := range tests {
|
||
tt := tt
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
t.Parallel()
|
||
if err := s.PutProject(ctx, tt.p); !errors.Is(err, tt.wantErr) {
|
||
t.Fatalf("PutProject err = %v, want wrap of %v", err, tt.wantErr)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestStore_GetProject_NotFound_异常(t *testing.T) {
|
||
t.Parallel()
|
||
s := newTestStore(t)
|
||
ctx := context.Background()
|
||
|
||
for _, id := range []string{"", "does-not-exist"} {
|
||
if _, err := s.GetProject(ctx, id); !errors.Is(err, ErrProjectNotFound) {
|
||
t.Fatalf("GetProject(%q) err = %v, want ErrProjectNotFound", id, err)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestStore_AddListChapters_正常(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
s := newTestStore(t)
|
||
|
||
if err := s.PutProject(ctx, baseProject()); err != nil {
|
||
t.Fatalf("PutProject: %v", err)
|
||
}
|
||
if err := s.AddChapter(ctx, "proj.1", Chapter{Title: "概述", Content: "第一行\n第二行"}); err != nil {
|
||
t.Fatalf("AddChapter 1: %v", err)
|
||
}
|
||
if err := s.AddChapter(ctx, "proj.1", Chapter{Title: "实现", Content: "另外一个章节"}); err != nil {
|
||
t.Fatalf("AddChapter 2: %v", err)
|
||
}
|
||
|
||
list, err := s.ListChapters(ctx, "proj.1")
|
||
if err != nil {
|
||
t.Fatalf("ListChapters: %v", err)
|
||
}
|
||
if len(list) != 2 {
|
||
t.Fatalf("ListChapters = %d chapters, want 2", len(list))
|
||
}
|
||
first := list[0]
|
||
if first.Title != "概述" || first.Content != "第一行\n第二行" {
|
||
t.Errorf("first chapter = %+v, want 概述/第一行", first)
|
||
}
|
||
if first.ProjectID != "proj.1" {
|
||
t.Errorf("ProjectID = %q, want proj.1", first.ProjectID)
|
||
}
|
||
if first.ID == "" {
|
||
t.Errorf("AddChapter did not assign an id")
|
||
}
|
||
if first.CreatedAt.IsZero() || first.UpdatedAt.IsZero() {
|
||
t.Errorf("AddChapter did not assign timestamps")
|
||
}
|
||
|
||
// 另一个项目必须看不到这些章节。
|
||
other, err := s.ListChapters(ctx, "proj.other")
|
||
if err != nil {
|
||
t.Fatalf("ListChapters(proj.other): %v", err)
|
||
}
|
||
if len(other) != 0 {
|
||
t.Fatalf("ListChapters(proj.other) = %d chapters, want 0", len(other))
|
||
}
|
||
}
|
||
|
||
func TestStore_AddChapter_Validation_异常(t *testing.T) {
|
||
t.Parallel()
|
||
s := newTestStore(t)
|
||
ctx := context.Background()
|
||
|
||
tests := []struct {
|
||
name string
|
||
projectID string
|
||
ch Chapter
|
||
wantErr error
|
||
}{
|
||
{name: "blank project", ch: Chapter{Title: "T", Content: "C"}, wantErr: ErrMissingProject},
|
||
{name: "blank title", projectID: "p", ch: Chapter{Content: "C"}, wantErr: ErrMissingTitle},
|
||
{name: "blank content", projectID: "p", ch: Chapter{Title: "T"}, wantErr: ErrMissingContent},
|
||
}
|
||
for _, tt := range tests {
|
||
tt := tt
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
t.Parallel()
|
||
if err := s.AddChapter(ctx, tt.projectID, tt.ch); !errors.Is(err, tt.wantErr) {
|
||
t.Fatalf("AddChapter err = %v, want wrap of %v", err, tt.wantErr)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestStore_ListChapters_MissingProject_异常(t *testing.T) {
|
||
t.Parallel()
|
||
s := newTestStore(t)
|
||
if _, err := s.ListChapters(context.Background(), ""); !errors.Is(err, ErrMissingProject) {
|
||
t.Fatalf("ListChapters(\"\") err = %v, want ErrMissingProject", err)
|
||
}
|
||
}
|
||
|
||
func TestStore_Search_HitProject(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
s := newTestStore(t)
|
||
|
||
if err := s.PutProject(ctx, Project{ID: "proj.1", Title: "Cyrene 重构", Desc: "陪伴型 AI 助手"}); err != nil {
|
||
t.Fatalf("PutProject: %v", err)
|
||
}
|
||
if err := s.AddChapter(ctx, "proj.1", Chapter{Title: "实现", Content: "纯文本内容"}); err != nil {
|
||
t.Fatalf("AddChapter: %v", err)
|
||
}
|
||
|
||
res, err := s.Search(ctx, "proj.1", "陪伴型")
|
||
if err != nil {
|
||
t.Fatalf("Search: %v", err)
|
||
}
|
||
if res.Project == nil {
|
||
t.Fatalf("Search result.Project nil, want project hit (desc layer)")
|
||
}
|
||
if res.Project.ID != "proj.1" || res.Project.Desc != "陪伴型 AI 助手" {
|
||
t.Errorf("Project = %+v, want proj.1/陪伴型 AI 助手", res.Project)
|
||
}
|
||
if len(res.Chapters) != 0 {
|
||
t.Fatalf("Search result.Chapters = %d, want 0 (chapter content did not match)", len(res.Chapters))
|
||
}
|
||
}
|
||
|
||
func TestStore_Search_HitChapter_分层(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
s := newTestStore(t)
|
||
|
||
if err := s.PutProject(ctx, baseProject()); err != nil {
|
||
t.Fatalf("PutProject: %v", err)
|
||
}
|
||
content := "第一行\n核心算法在第二行\n第三行\n第四行\n第五行"
|
||
if err := s.AddChapter(ctx, "proj.1", Chapter{Title: "实现", Content: content}); err != nil {
|
||
t.Fatalf("AddChapter: %v", err)
|
||
}
|
||
|
||
res, err := s.Search(ctx, "proj.1", "算法")
|
||
if err != nil {
|
||
t.Fatalf("Search: %v", err)
|
||
}
|
||
if res.Project != nil {
|
||
t.Fatalf("Search result.Project = %v, want nil (project layer did not match)", res.Project)
|
||
}
|
||
if len(res.Chapters) != 1 {
|
||
t.Fatalf("Search result.Chapters = %d, want 1", len(res.Chapters))
|
||
}
|
||
hit := res.Chapters[0]
|
||
if hit.Chapter.Title != "实现" || hit.Chapter.Content != content {
|
||
t.Errorf("Chapter = %+v, want 实现", hit.Chapter)
|
||
}
|
||
if len(hit.Matches) != 1 {
|
||
t.Fatalf("Matches = %d, want 1", len(hit.Matches))
|
||
}
|
||
m := hit.Matches[0]
|
||
if m.LineNumber != 2 {
|
||
t.Errorf("LineNumber = %d, want 2", m.LineNumber)
|
||
}
|
||
if m.Text != "核心算法在第二行" {
|
||
t.Errorf("Text = %q, want 核心算法在第二行", m.Text)
|
||
}
|
||
wantContext := "第一行\n核心算法在第二行\n第三行\n第四行"
|
||
if m.Context != wantContext {
|
||
t.Errorf("Context = %q, want %q", m.Context, wantContext)
|
||
}
|
||
}
|
||
|
||
func TestStore_Search_NoHit(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
s := newTestStore(t)
|
||
|
||
if err := s.PutProject(ctx, baseProject()); err != nil {
|
||
t.Fatalf("PutProject: %v", err)
|
||
}
|
||
if err := s.AddChapter(ctx, "proj.1", Chapter{Title: "实现", Content: "纯文本内容"}); err != nil {
|
||
t.Fatalf("AddChapter: %v", err)
|
||
}
|
||
|
||
res, err := s.Search(ctx, "proj.1", "不存在的关键词")
|
||
if err != nil {
|
||
t.Fatalf("Search: %v", err)
|
||
}
|
||
if res.Project != nil {
|
||
t.Errorf("Project = %v, want nil", res.Project)
|
||
}
|
||
if len(res.Chapters) != 0 {
|
||
t.Errorf("Chapters = %d, want 0", len(res.Chapters))
|
||
}
|
||
}
|
||
|
||
func TestStore_Search_ProjectNotFound_异常(t *testing.T) {
|
||
t.Parallel()
|
||
s := newTestStore(t)
|
||
if _, err := s.Search(context.Background(), "missing", "kw"); !errors.Is(err, ErrProjectNotFound) {
|
||
t.Fatalf("Search(missing) err = %v, want ErrProjectNotFound", err)
|
||
}
|
||
}
|
||
|
||
func TestStore_Search_BlankKeyword_异常(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
s := newTestStore(t)
|
||
if err := s.PutProject(ctx, baseProject()); err != nil {
|
||
t.Fatalf("PutProject: %v", err)
|
||
}
|
||
if _, err := s.Search(ctx, "proj.1", " "); !errors.Is(err, ErrMissingKeyword) {
|
||
t.Fatalf("Search(blank) err = %v, want ErrMissingKeyword", err)
|
||
}
|
||
}
|
||
|
||
func TestStore_DeleteProject_Cascade(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
s := newTestStore(t)
|
||
|
||
if err := s.PutProject(ctx, baseProject()); err != nil {
|
||
t.Fatalf("PutProject: %v", err)
|
||
}
|
||
if err := s.AddChapter(ctx, "proj.1", Chapter{Title: "c1", Content: "x"}); err != nil {
|
||
t.Fatalf("AddChapter c1: %v", err)
|
||
}
|
||
if err := s.AddChapter(ctx, "proj.1", Chapter{Title: "c2", Content: "y"}); err != nil {
|
||
t.Fatalf("AddChapter c2: %v", err)
|
||
}
|
||
|
||
if err := s.DeleteProject(ctx, "proj.1"); err != nil {
|
||
t.Fatalf("DeleteProject: %v", err)
|
||
}
|
||
if _, err := s.GetProject(ctx, "proj.1"); !errors.Is(err, ErrProjectNotFound) {
|
||
t.Fatalf("GetProject after delete err = %v, want ErrProjectNotFound", err)
|
||
}
|
||
ch, err := s.ListChapters(ctx, "proj.1")
|
||
if err != nil {
|
||
t.Fatalf("ListChapters after delete: %v", err)
|
||
}
|
||
if len(ch) != 0 {
|
||
t.Fatalf("chapters not cascaded, got %d", len(ch))
|
||
}
|
||
}
|
||
|
||
func TestStore_DeleteProject_NotFound_异常(t *testing.T) {
|
||
t.Parallel()
|
||
s := newTestStore(t)
|
||
ctx := context.Background()
|
||
|
||
for _, id := range []string{"", "missing"} {
|
||
if err := s.DeleteProject(ctx, id); !errors.Is(err, ErrProjectNotFound) {
|
||
t.Fatalf("DeleteProject(%q) err = %v, want ErrProjectNotFound", id, err)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestStore_Search_SQLInjectionSafe(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
s := newTestStore(t)
|
||
|
||
maliciousID := "proj.1; DROP TABLE project; --"
|
||
if err := s.PutProject(ctx, Project{ID: maliciousID, Title: "恶意', `x`", Desc: "描述"}); err != nil {
|
||
t.Fatalf("PutProject malicious: %v", err)
|
||
}
|
||
if err := s.AddChapter(ctx, maliciousID, Chapter{Title: "c'--", Content: "含有 keyword 的行\n普通行"}); err != nil {
|
||
t.Fatalf("AddChapter malicious: %v", err)
|
||
}
|
||
|
||
// 满是 SQL 元字符的关键词不得改变行为或数据。
|
||
res, err := s.Search(ctx, maliciousID, "' OR 1=1 --")
|
||
if err != nil {
|
||
t.Fatalf("Search malicious keyword: %v", err)
|
||
}
|
||
if res.Project != nil {
|
||
t.Errorf("Project = %v, want nil for non-matching keyword", res.Project)
|
||
}
|
||
if len(res.Chapters) != 0 {
|
||
t.Errorf("Chapters = %d, want 0 for non-matching keyword", len(res.Chapters))
|
||
}
|
||
if _, err := s.Search(ctx, maliciousID, "zzz_never"); err != nil {
|
||
t.Fatalf("Search benign keyword on malicious id: %v", err)
|
||
}
|
||
|
||
// 恶意的 id 必须原样往返(证明查询是参数化的)。
|
||
got, err := s.GetProject(ctx, maliciousID)
|
||
if err != nil {
|
||
t.Fatalf("GetProject malicious: %v", err)
|
||
}
|
||
if got.ID != maliciousID {
|
||
t.Errorf("ID = %q, want %q", got.ID, maliciousID)
|
||
}
|
||
// 而恶意的项目必须仍可查询,而不是被丢弃。
|
||
if _, err := s.GetProject(ctx, "project"); !errors.Is(err, ErrProjectNotFound) {
|
||
t.Fatalf("project table intact check: err = %v, want ErrProjectNotFound", err)
|
||
}
|
||
}
|
||
|
||
func TestStore_Concurrent_AddChapter_并发(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
s := newTestStore(t)
|
||
|
||
if err := s.PutProject(ctx, baseProject()); err != nil {
|
||
t.Fatalf("PutProject: %v", err)
|
||
}
|
||
|
||
const n = 30
|
||
var wg sync.WaitGroup
|
||
errs := make(chan error, n)
|
||
for i := 0; i < n; i++ {
|
||
wg.Add(1)
|
||
go func(i int) {
|
||
defer wg.Done()
|
||
errs <- s.AddChapter(ctx, "proj.1", Chapter{Title: fmt.Sprintf("ch%d", i), Content: "并发内容"})
|
||
}(i)
|
||
}
|
||
wg.Wait()
|
||
close(errs)
|
||
|
||
for err := range errs {
|
||
if err != nil {
|
||
t.Fatalf("concurrent AddChapter error: %v", err)
|
||
}
|
||
}
|
||
ch, err := s.ListChapters(ctx, "proj.1")
|
||
if err != nil {
|
||
t.Fatalf("ListChapters: %v", err)
|
||
}
|
||
if len(ch) != n {
|
||
t.Fatalf("ListChapters = %d chapters, want %d", len(ch), n)
|
||
}
|
||
}
|
||
|
||
func TestStore_Search_MatchInMultipleLines(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
s := newTestStore(t)
|
||
|
||
if err := s.PutProject(ctx, baseProject()); err != nil {
|
||
t.Fatalf("PutProject: %v", err)
|
||
}
|
||
content := "关于 AI\nAI 是一个话题\n无关行\n结尾 AI 又出现"
|
||
if err := s.AddChapter(ctx, "proj.1", Chapter{Title: "概述", Content: content}); err != nil {
|
||
t.Fatalf("AddChapter: %v", err)
|
||
}
|
||
|
||
res, err := s.Search(ctx, "proj.1", "ai")
|
||
if err != nil {
|
||
t.Fatalf("Search: %v", err)
|
||
}
|
||
if len(res.Chapters) != 1 {
|
||
t.Fatalf("Chapters = %d, want 1", len(res.Chapters))
|
||
}
|
||
hit := res.Chapters[0]
|
||
if len(hit.Matches) != 3 {
|
||
t.Fatalf("Matches = %d, want 3", len(hit.Matches))
|
||
}
|
||
if hit.Matches[0].LineNumber != 1 || hit.Matches[1].LineNumber != 2 || hit.Matches[2].LineNumber != 4 {
|
||
t.Errorf("line numbers = [%d %d %d], want [1 2 4]",
|
||
hit.Matches[0].LineNumber, hit.Matches[1].LineNumber, hit.Matches[2].LineNumber)
|
||
}
|
||
// 关键词是小写 "ai" 而内容里是大写 "AI",
|
||
// 因此匹配不区分大小写,而 Text 保留原始大小写。
|
||
if !strings.Contains(hit.Matches[0].Text, "AI") {
|
||
t.Errorf("expected case-insensitive match, Text = %q", hit.Matches[0].Text)
|
||
}
|
||
}
|