test: cover dedup hint, reparse guard, store gate and CoW contract
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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, "未命中") {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user