test: cover dedup hint, reparse guard, store gate and CoW contract

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-21 09:47:06 +00:00
co-authored by Cursor
parent 5f9fbb9923
commit b867f4f19c
3 changed files with 222 additions and 0 deletions
+18
View File
@@ -1,6 +1,7 @@
package materialindex_test
import (
"context"
"strings"
"testing"
@@ -26,6 +27,23 @@ func TestBuildTOCAndFormatRAG(t *testing.T) {
}
}
func TestStoreGatedHelpers(t *testing.T) {
// nil pool(内存降级模式):store 能力必须为 false,防止无状态机时打真实 API。
p := materialindex.NewPipeline(nil, nil)
if p.HasStore() {
t.Fatalf("nil pool must report HasStore=false")
}
ctx := context.Background()
if got := p.GetParseInfos(ctx, []string{"abc"}); len(got) != 0 {
t.Fatalf("GetParseInfos without store must be empty, got %v", got)
}
if p.ResetForReparse(ctx, "abc") {
t.Fatalf("ResetForReparse without store must be false")
}
// EnqueueAsync 无 store 时必须静默跳过(不 panic、不触网)。
p.EnqueueAsync("doc.pdf", "abc", []byte("%PDF-1.4"), 1)
}
func TestFormatRAG_Empty(t *testing.T) {
out := materialindex.FormatRAGMarkdown("x.pdf", nil)
if !strings.Contains(out, "未命中") {
+123
View File
@@ -168,3 +168,126 @@ func TestGetProjectAssetRaw_RoundTrip(t *testing.T) {
t.Fatalf("expected non-200 for mismatched project, got %d", wrongResp.StatusCode)
}
}
func uploadAssetBytes(t *testing.T, url, token string, filename string, payload []byte) projectAssetResponse {
t.Helper()
var body bytes.Buffer
w := multipart.NewWriter(&body)
declared := "application/octet-stream"
if strings.HasSuffix(strings.ToLower(filename), ".pdf") {
declared = "application/pdf"
}
part, _ := w.CreatePart(map[string][]string{
"Content-Disposition": {`form-data; name="file"; filename="` + filename + `"`},
"Content-Type": {declared},
})
_, _ = part.Write(payload)
_ = w.Close()
req, _ := http.NewRequest(http.MethodPost, url, &body)
req.Header.Set("Content-Type", w.FormDataContentType())
withBearerAuth(req, token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("upload %s: %v", filename, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
raw, _ := io.ReadAll(resp.Body)
t.Fatalf("upload %s status=%d body=%s", filename, resp.StatusCode, raw)
}
var out projectAssetResponse
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
t.Fatalf("decode upload: %v", err)
}
return out
}
// 相同内容第二次上传:响应标注 deduplicated,列表接口同样标注非首条引用。
func TestUploadProjectAsset_DedupHint(t *testing.T) {
cfg := &config.Config{Env: "test", DataDir: t.TempDir()}
svc := bizdev.MustNewService(t, cfg)
srv := NewServer(cfg, svc)
ts := httptest.NewServer(srv.Routes())
defer ts.Close()
token := loginTestAdmin(t, srv)
payload := []byte("%PDF-1.4 dedup hint shared content")
first := uploadAssetBytes(t, ts.URL+"/api/v1/projects/7/assets", token, "a.pdf", payload)
if first.Deduplicated {
t.Fatalf("first upload must not be deduplicated")
}
second := uploadAssetBytes(t, ts.URL+"/api/v1/projects/7/assets", token, "b.pdf", payload)
if !second.Deduplicated {
t.Fatalf("second identical upload must carry deduplicated=true, got %+v", second)
}
if second.SHA256 != first.SHA256 {
t.Fatalf("dedup must share the same blob sha")
}
listReq, _ := http.NewRequest(http.MethodGet, ts.URL+"/api/v1/projects/7/assets", nil)
withBearerAuth(listReq, token)
listResp, err := http.DefaultClient.Do(listReq)
if err != nil {
t.Fatal(err)
}
defer listResp.Body.Close()
var assets []projectAssetResponse
if err := json.NewDecoder(listResp.Body).Decode(&assets); err != nil {
t.Fatal(err)
}
var flagged int
for _, a := range assets {
if a.Deduplicated {
flagged++
}
if a.AssetID == first.AssetID && a.Deduplicated {
t.Fatalf("first reference must stay unflagged")
}
}
if flagged != 1 {
t.Fatalf("expected exactly one dedup-flagged row in list, got %+v", assets)
}
}
// 重新解析:内存模式(无 PG 状态存储)下必须 503,且不触发任何解析。
func TestReparseProjectAsset_NoStore503(t *testing.T) {
cfg := &config.Config{Env: "test", DataDir: t.TempDir()}
svc := bizdev.MustNewService(t, cfg)
srv := NewServer(cfg, svc)
ts := httptest.NewServer(srv.Routes())
defer ts.Close()
token := loginTestAdmin(t, srv)
up := uploadAssetBytes(t, ts.URL+"/api/v1/projects/7/assets", token, "doc.pdf", []byte("%PDF-1.4 fake"))
req, _ := http.NewRequest(http.MethodPost,
ts.URL+"/api/v1/projects/7/assets/"+strconv.FormatInt(up.AssetID, 10)+"/reparse", nil)
withBearerAuth(req, token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusServiceUnavailable {
t.Fatalf("reparse without store want 503 got %d", resp.StatusCode)
}
}
// 不存在的资产 reparse 必须 404。
func TestReparseProjectAsset_UnknownAsset404(t *testing.T) {
cfg := &config.Config{Env: "test", DataDir: t.TempDir()}
srv := NewServer(cfg, bizdev.MustNewService(t, cfg))
ts := httptest.NewServer(srv.Routes())
defer ts.Close()
token := loginTestAdmin(t, srv)
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/v1/projects/7/assets/999999/reparse", nil)
withBearerAuth(req, token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("reparse unknown asset want 404 got %d", resp.StatusCode)
}
}
+81
View File
@@ -201,3 +201,84 @@ func TestCAS_TimeDecayCalculation(t *testing.T) {
t.Errorf("expected 14-day old chunk to decay below 0.5, got %.3f", hits[1].TimeDecay)
}
}
// TestCAS_ImmutabilityCoWContract 固化 CoW(写时复制)契约:
// 1. 同内容重复写入只增引用计数,不写第二份物理文件,且旧 blob 字节保持不变;
// 2. 可编辑文档产生新内容时按新 hash 落新 blob(写入即复制),旧版本 blob
// 不被覆写,历史引用仍可读出原始字节。
//
// 该测试是防回归守卫:未来任何「就地改写 blob 文件」的实现都会在此失败。
func TestCAS_ImmutabilityCoWContract(t *testing.T) {
tempDir, err := os.MkdirTemp("", "cas_cow_test_*")
if err != nil {
t.Fatalf("mkdir temp failed: %v", err)
}
defer os.RemoveAll(tempDir)
cas, err := NewCAS(tempDir, nil, nil)
if err != nil {
t.Fatalf("new cas failed: %v", err)
}
ctx := context.Background()
v1 := []byte("V1:产品需求说明书初始版本内容")
refA, err := cas.StoreFile(ctx, 900, 1, "prd.docx", "material", v1, "application/octet-stream")
if err != nil {
t.Fatalf("store v1 failed: %v", err)
}
blobPath := filepath.Join(tempDir, "blobs", refA.BlobSHA256[:2], refA.BlobSHA256[2:4], refA.BlobSHA256+".bin")
v1Bytes, err := os.ReadFile(blobPath)
if err != nil {
t.Fatalf("v1 blob file missing: %v", err)
}
// 1. 相同内容再传:命中去重,物理文件数量与字节内容不变。
refB, err := cas.StoreFile(ctx, 900, 2, "prd_copy.docx", "material", v1, "application/octet-stream")
if err != nil {
t.Fatalf("store duplicate failed: %v", err)
}
if !refB.IsDeduplicated || refB.BlobSHA256 != refA.BlobSHA256 {
t.Fatalf("expected dedup hit on identical content")
}
afterDup, err := os.ReadFile(blobPath)
if err != nil || string(afterDup) != string(v1Bytes) {
t.Fatalf("duplicate upload mutated existing blob bytes (CoW violation)")
}
// 2. 编辑后写入(内容变化):必须产生新 blob;旧 blob 不被覆写。
v2 := []byte("V2:产品需求说明书——评审后修改条款")
refC, err := cas.StoreFile(ctx, 900, 1, "prd.docx", "material", v2, "application/octet-stream")
if err != nil {
t.Fatalf("store v2 failed: %v", err)
}
if refC.BlobSHA256 == refA.BlobSHA256 {
t.Fatalf("modified content must get a new blob hash")
}
if refC.IsDeduplicated {
t.Fatalf("modified content must not be marked dedup")
}
stillV1, err := os.ReadFile(blobPath)
if err != nil || string(stillV1) != string(v1Bytes) {
t.Fatalf("write of new version overwrote old blob (CoW violation)")
}
gotV1, _, err := cas.ReadRefBytes(ctx, refA.ID)
if err != nil || string(gotV1) != string(v1) {
t.Fatalf("historical ref must still read original V1 bytes, got %q err=%v", gotV1, err)
}
gotV2, _, err := cas.ReadRefBytes(ctx, refC.ID)
if err != nil || string(gotV2) != string(v2) {
t.Fatalf("new ref must read V2 bytes, got %q err=%v", gotV2, err)
}
// 3. 物理盘上恰好两份 blob(V1 一份共享 + V2 一份)。
count := 0
_ = filepath.Walk(filepath.Join(tempDir, "blobs"), func(path string, info os.FileInfo, err error) error {
if err == nil && !info.IsDir() && filepath.Ext(path) == ".bin" {
count++
}
return nil
})
if count != 2 {
t.Fatalf("expected exactly 2 physical blobs (CoW), got %d", count)
}
}