Files
AskaEth 61cf80d5ee
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
docs(comments): 注释中文化 · internal/runtime/memory(31 文件,纯替换)
- 只翻注释/文档字符串:820 增 / 820 删 严格 1:1;剥掉整行注释与行尾注释尾巴后,
  逐文件与 HEAD 比对「代码部分差异 = 0」;裁决/design 编号计数逐项与 HEAD 一致
- 保留可执行 SQL 样例、SQLite 报错原文、pgvector/HNSW/ANN/BLOB 等技术词原文
- 复核:go build 绿 + memory/projectindex/vector 三包测试绿;gofmt 仅剩 2 个既有未格式化文件(改动前即如此,未顺手修)
2026-10-04 13:32:54 +08:00

499 lines
14 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}