capsule AI-native Unix-like composition layer

src/server/internal/rag/store_test.go

8,961 bytes · 305 lines · capsule://quake0day/[email protected] raw on github

package rag

import (
	"encoding/json"
	"os"
	"path/filepath"
	"strings"
	"testing"

	"github.com/cyberverse/server/internal/character"
)

func newTestStore(t *testing.T) (*Store, string) {
	t.Helper()
	charStore, err := character.NewStore(t.TempDir())
	if err != nil {
		t.Fatalf("NewStore: %v", err)
	}
	created, err := charStore.Create(&character.Character{Name: "RAG Test"})
	if err != nil {
		t.Fatalf("Create character: %v", err)
	}
	return NewStore(charStore), created.ID
}

func TestStoreSaveFilePreservesRelativePathAndStatusLifecycle(t *testing.T) {
	store, characterID := newTestStore(t)

	result, err := store.SaveFile(characterID, "角色 资料/profile v1.md", "text/markdown", strings.NewReader("出生在海边。后来成为工程师。"))
	if err != nil {
		t.Fatalf("SaveFile: %v", err)
	}
	source := result.Source
	if source.Status != SourceStatusIndexing {
		t.Fatalf("expected indexing status, got %q", source.Status)
	}
	if !source.Indexable {
		t.Fatal("expected markdown source to be indexable")
	}
	if source.Type != "" {
		t.Fatalf("expected new sources to omit type, got %q", source.Type)
	}
	if source.Title != "profile v1" {
		t.Fatalf("expected title from filename, got %q", source.Title)
	}
	if source.RelativePath != "角色 资料/profile v1.md" || source.StoredPath != "sources/角色 资料/profile v1.md" {
		t.Fatalf("unexpected source paths: %+v", source)
	}
	if !strings.HasSuffix(result.Path, filepath.Join("knowledge", "sources", "角色 资料", "profile v1.md")) {
		t.Fatalf("unexpected source path: %s", result.Path)
	}

	ready, err := store.MarkReady(characterID, source.ID, 3)
	if err != nil {
		t.Fatalf("MarkReady: %v", err)
	}
	if ready.Status != SourceStatusReady || ready.ChunkCount != 3 || ready.IndexedAt == "" {
		t.Fatalf("unexpected ready source: %+v", ready)
	}

	failed, err := store.MarkFailed(characterID, source.ID, errTestFailure{})
	if err != nil {
		t.Fatalf("MarkFailed: %v", err)
	}
	if failed.Status != SourceStatusFailed || failed.Error == "" {
		t.Fatalf("unexpected failed source: %+v", failed)
	}
}

func TestStoreSavesNonIndexableFileAsReady(t *testing.T) {
	store, characterID := newTestStore(t)

	result, err := store.SaveFile(characterID, "images/avatar.png", "image/png", strings.NewReader("png"))
	if err != nil {
		t.Fatalf("SaveFile: %v", err)
	}
	if result.Source.Indexable {
		t.Fatalf("expected image source to be stored only, got %+v", result.Source)
	}
	if result.Source.Status != SourceStatusReady || result.Source.ChunkCount != 0 {
		t.Fatalf("expected ready/0 chunks for image, got %+v", result.Source)
	}
}

func TestStoreRejectsUnsafeRelativePath(t *testing.T) {
	store, characterID := newTestStore(t)

	if _, err := store.SaveFile(characterID, "../secret.txt", "text/plain", strings.NewReader("nope")); err == nil {
		t.Fatal("expected unsafe relative path error")
	}
}

func TestStoreDeleteRemovesSource(t *testing.T) {
	store, characterID := newTestStore(t)

	result, err := store.SaveFile(characterID, "folder/rules.txt", "text/plain", strings.NewReader("只回答事实。"))
	if err != nil {
		t.Fatalf("SaveFile: %v", err)
	}
	source := result.Source
	if err := store.Delete(characterID, source.ID); err != nil {
		t.Fatalf("Delete: %v", err)
	}
	sources, err := store.List(characterID)
	if err != nil {
		t.Fatalf("List: %v", err)
	}
	if len(sources) != 0 {
		t.Fatalf("expected no sources after delete, got %+v", sources)
	}
	if _, err := os.Stat(result.Path); !os.IsNotExist(err) {
		t.Fatalf("expected stored file to be removed, stat err=%v", err)
	}
}

func TestStoreOverwriteSameRelativePathReusesSource(t *testing.T) {
	store, characterID := newTestStore(t)

	first, err := store.SaveFile(characterID, "docs/profile.md", "text/markdown", strings.NewReader("first"))
	if err != nil {
		t.Fatalf("SaveFile first: %v", err)
	}
	second, err := store.SaveFile(characterID, "docs/profile.md", "text/markdown", strings.NewReader("second"))
	if err != nil {
		t.Fatalf("SaveFile second: %v", err)
	}
	if second.Created || first.Source.ID != second.Source.ID {
		t.Fatalf("expected same source to be updated, first=%+v second=%+v", first.Source, second.Source)
	}
	data, err := os.ReadFile(second.Path)
	if err != nil {
		t.Fatal(err)
	}
	if string(data) != "second" {
		t.Fatalf("expected overwritten file content, got %q", string(data))
	}
	sources, err := store.List(characterID)
	if err != nil {
		t.Fatal(err)
	}
	if len(sources) != 1 {
		t.Fatalf("expected one source after overwrite, got %+v", sources)
	}
}

func TestStoreSameBasenameDifferentDirectoriesDoNotConflict(t *testing.T) {
	store, characterID := newTestStore(t)

	first, err := store.SaveFile(characterID, "a/profile.md", "text/markdown", strings.NewReader("a"))
	if err != nil {
		t.Fatalf("SaveFile first: %v", err)
	}
	second, err := store.SaveFile(characterID, "b/profile.md", "text/markdown", strings.NewReader("b"))
	if err != nil {
		t.Fatalf("SaveFile second: %v", err)
	}
	if first.Source.ID == second.Source.ID {
		t.Fatalf("expected different source IDs for different relative paths")
	}
	sources, err := store.List(characterID)
	if err != nil {
		t.Fatal(err)
	}
	if len(sources) != 2 {
		t.Fatalf("expected two sources, got %+v", sources)
	}
}

func TestStoreReadsLegacySourceType(t *testing.T) {
	store, characterID := newTestStore(t)
	knowledgeDir, err := store.KnowledgeDir(characterID)
	if err != nil {
		t.Fatal(err)
	}
	if err := os.MkdirAll(knowledgeDir, 0755); err != nil {
		t.Fatal(err)
	}
	legacy := []Source{{
		ID:             "legacy-source",
		Type:           SourceType("biography"),
		Title:          "旧人物生平",
		Filename:       "bio.txt",
		Status:         SourceStatusReady,
		CreatedAt:      nowString(),
		UpdatedAt:      nowString(),
		StoredFilename: "bio.txt",
	}}
	data, err := json.Marshal(legacy)
	if err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join(knowledgeDir, "sources.json"), data, 0644); err != nil {
		t.Fatal(err)
	}

	sources, err := store.List(characterID)
	if err != nil {
		t.Fatal(err)
	}
	if len(sources) != 1 || sources[0].Type != SourceType("biography") {
		t.Fatalf("expected legacy type to round-trip, got %+v", sources)
	}
}

func TestStoreLegacySourcePathAndDelete(t *testing.T) {
	store, characterID := newTestStore(t)
	knowledgeDir, err := store.KnowledgeDir(characterID)
	if err != nil {
		t.Fatal(err)
	}
	legacyDir := filepath.Join(knowledgeDir, "sources", "legacy-source")
	if err := os.MkdirAll(legacyDir, 0755); err != nil {
		t.Fatal(err)
	}
	legacyPath := filepath.Join(legacyDir, "bio.txt")
	if err := os.WriteFile(legacyPath, []byte("legacy"), 0644); err != nil {
		t.Fatal(err)
	}
	legacy := []Source{{
		ID:             "legacy-source",
		Title:          "旧素材",
		Filename:       "bio.txt",
		Status:         SourceStatusReady,
		CreatedAt:      nowString(),
		UpdatedAt:      nowString(),
		StoredFilename: "bio.txt",
	}}
	data, err := json.Marshal(legacy)
	if err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join(knowledgeDir, "sources.json"), data, 0644); err != nil {
		t.Fatal(err)
	}

	sources, err := store.List(characterID)
	if err != nil {
		t.Fatal(err)
	}
	path, err := store.SourcePath(characterID, &sources[0])
	if err != nil {
		t.Fatal(err)
	}
	if path != legacyPath {
		t.Fatalf("expected legacy source path %q, got %q", legacyPath, path)
	}
	if err := store.Delete(characterID, "legacy-source"); err != nil {
		t.Fatal(err)
	}
	if _, err := os.Stat(legacyDir); !os.IsNotExist(err) {
		t.Fatalf("expected legacy source dir to be removed, stat err=%v", err)
	}
}

func TestStoreSourcePathPrefersRelativePathForOldMetadata(t *testing.T) {
	store, characterID := newTestStore(t)
	knowledgeDir, err := store.KnowledgeDir(characterID)
	if err != nil {
		t.Fatal(err)
	}
	newPath := filepath.Join(knowledgeDir, "sources", "资料", "九州.md")
	if err := os.MkdirAll(filepath.Dir(newPath), 0755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(newPath, []byte("new path"), 0644); err != nil {
		t.Fatal(err)
	}
	legacy := []Source{{
		ID:             "legacy-source",
		Title:          "九州",
		Filename:       "九州.md",
		RelativePath:   "资料/九州.md",
		Status:         SourceStatusReady,
		CreatedAt:      nowString(),
		UpdatedAt:      nowString(),
		StoredFilename: "九州.md",
	}}
	data, err := json.Marshal(legacy)
	if err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join(knowledgeDir, "sources.json"), data, 0644); err != nil {
		t.Fatal(err)
	}

	sources, err := store.List(characterID)
	if err != nil {
		t.Fatal(err)
	}
	if !sources[0].Indexable {
		t.Fatalf("expected old markdown metadata to normalize as indexable: %+v", sources[0])
	}
	path, err := store.SourcePath(characterID, &sources[0])
	if err != nil {
		t.Fatal(err)
	}
	if path != newPath {
		t.Fatalf("expected source path %q, got %q", newPath, path)
	}
}

type errTestFailure struct{}

func (errTestFailure) Error() string { return "index failed" }