package chunk import ( "bytes" "context" "errors" "image" "image/color" "image/png" "ragflow/internal/common" "ragflow/internal/dao" "ragflow/internal/engine/types" "ragflow/internal/entity" "ragflow/internal/entity/models" "ragflow/internal/service" "ragflow/internal/service/document" "ragflow/internal/storage" "reflect" "slices" "strings" "testing" "time" "github.com/glebarez/sqlite" "gorm.io/gorm" ) func TestIsZeroVector(t *testing.T) { if !common.IsZeroVector([]float64{0, 0, 0}) { t.Error("all zeros should be true") } if common.IsZeroVector([]float64{0, 1, 0}) { t.Error("non-zero should be false") } if !common.IsZeroVector([]float64{}) { t.Error("empty should be true (treated as zero)") } if !common.IsZeroVector(nil) { t.Error("nil should be true") } } func TestHydrateChunkVectors_AllNonZero(t *testing.T) { chunks := []map[string]interface{}{ {"id": "c1", "vector": []float64{1, 2, 3}}, {"id": "c2", "vector": []float64{4, 5, 6}}, } // No zero vectors → nothing to hydrate. hydrateChunkVectors(context.Background(), nil, chunks, nil, nil) if !reflect.DeepEqual(chunks[0]["vector"], []float64{1, 2, 3}) { t.Error("non-zero vector should not be changed") } if !reflect.DeepEqual(chunks[1]["vector"], []float64{4, 5, 6}) { t.Error("non-zero vector should not be changed") } } func TestHydrateChunkVectors_EmptyChunks(t *testing.T) { // Should not panic on empty or nil. hydrateChunkVectors(context.Background(), nil, nil, nil, nil) hydrateChunkVectors(context.Background(), nil, []map[string]interface{}{}, nil, nil) } func TestHydrateChunkVectors_MissingIDs(t *testing.T) { chunks := []map[string]interface{}{ {"vector": []float64{1.0}}, // no id — skipped } hydrateChunkVectors(context.Background(), nil, chunks, nil, nil) // Should not change anything when engine is nil (FetchChunkVectors returns zero vectors). // The function doesn't panic — it just can't hydrate because dim is 0. // With nil engine, FetchChunkVectors returns zero vectors, so the zero stays zero. } func TestHydrateChunkVectors_NoDim(t *testing.T) { chunks := []map[string]interface{}{ {"id": "c1", "vector": []float64{}}, } hydrateChunkVectors(context.Background(), nil, chunks, []string{"kb1"}, []string{"t1"}) // Empty vectors have dim=0 → early return. No crash. } func TestKnowledgebaseEmbeddingKey(t *testing.T) { tenantEmbdID := "42" tests := []struct { name string kb *entity.Knowledgebase tenantID string want string }{ { name: "uses tenant embedding id before embd id", kb: &entity.Knowledgebase{ EmbdID: "shared-model", TenantEmbdID: &tenantEmbdID, }, want: "tenant:42", }, { name: "uses embd id without tenant embedding id", kb: &entity.Knowledgebase{ EmbdID: "shared-model", }, want: "embd:shared-model", }, { name: "uses tenant default when embedding id is empty", kb: &entity.Knowledgebase{}, tenantID: "tenant-1", want: "default:tenant-1", }, { name: "ignores non-positive tenant embedding id", kb: &entity.Knowledgebase{ EmbdID: "shared-model", TenantEmbdID: func() *string { s := ""; return &s }(), }, want: "embd:shared-model", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := knowledgebaseEmbeddingKey(tt.kb, tt.tenantID); got != tt.want { t.Fatalf("knowledgebaseEmbeddingKey() = %q, want %q", got, tt.want) } }) } } func TestParsePrevalidatesDocumentsBeforeMutating(t *testing.T) { db := setupChunkTestDB(t) pushChunkTestDB(t, db) userID := "user-1" datasetID := "kb-1" insertChunkTestKB(t, datasetID, userID) insertChunkTestKB(t, "kb-2", userID) insertChunkTestDoc(t, "doc-1", datasetID) insertChunkTestDoc(t, "doc-2", "kb-2") insertChunkTestTask(t, "task-1", "doc-1") svc := &ChunkService{ docEngine: nil, kbDAO: dao.NewKnowledgebaseDAO(), documentDAO: dao.NewDocumentDAO(), } _, code, err := svc.Parse(userID, datasetID, &service.ParseFileRequest{ DocumentIDs: []string{"doc-1", "missing-doc", "doc-2"}, }) if err == nil { t.Fatal("expected parse to fail") } if code != common.CodeDataError { t.Fatalf("expected CodeDataError, got %v", code) } if !strings.Contains(err.Error(), "missing-doc") || !strings.Contains(err.Error(), "doc-2") { t.Fatalf("expected missing and foreign documents in error, got %q", err.Error()) } var taskCount int64 if err = dao.DB.Model(&entity.Task{}).Where("doc_id = ?", "doc-1").Count(&taskCount).Error; err != nil { t.Fatalf("count tasks: %v", err) } if taskCount != 1 { t.Fatalf("expected existing task to remain, got %d tasks", taskCount) } doc, err := dao.NewDocumentDAO().GetByID("doc-1") if err != nil { t.Fatalf("get doc: %v", err) } if doc.Run == nil { t.Fatalf("expected doc run to remain nil, got %q", *doc.Run) } if *doc.Run != "0" { t.Fatalf("expected doc run status is '1', got %q", *doc.Run) } if doc.ChunkNum != 7 { t.Fatalf("expected chunk_num to remain 7, got %d", doc.ChunkNum) } } func TestParseRejectsInaccessibleDataset(t *testing.T) { svc := newParseTestService(t) svc.accessibleFunc = func(string, string) bool { return false } _, code, err := svc.Parse("user-1", "kb-1", &service.ParseFileRequest{DocumentIDs: []string{"doc-1"}}) if err == nil { t.Fatal("expected parse to fail") } if code != common.CodeOperatingError { t.Fatalf("expected CodeOperatingError, got %v", code) } if !strings.Contains(err.Error(), "don't own the dataset") { t.Fatalf("unexpected error: %v", err) } } func TestParseRequiresDocumentIDs(t *testing.T) { svc := newParseTestService(t) svc.accessibleFunc = func(string, string) bool { return true } for _, req := range []*service.ParseFileRequest{nil, {DocumentIDs: nil}, {DocumentIDs: []string{}}} { _, code, err := svc.Parse("user-1", "kb-1", req) if err == nil { t.Fatal("expected parse to fail") } if code != common.CodeDataError { t.Fatalf("expected CodeDataError, got %v", code) } if !strings.Contains(err.Error(), "document_ids") { t.Fatalf("unexpected error: %v", err) } } } func TestParseReturnsDataErrorWhenDatasetMissing(t *testing.T) { svc := newParseTestService(t) svc.accessibleFunc = func(string, string) bool { return true } svc.getKnowledgebaseByIDFunc = func(string) (*entity.Knowledgebase, error) { return nil, nil } _, code, err := svc.Parse("user-1", "kb-1", &service.ParseFileRequest{DocumentIDs: []string{"doc-1"}}) if err == nil { t.Fatal("expected parse to fail") } if code != common.CodeDataError { t.Fatalf("expected CodeDataError, got %v", code) } if !strings.Contains(err.Error(), "dataset not found") { t.Fatalf("unexpected error: %v", err) } } func TestParseReturnsServerErrorWhenDocumentsQueryFails(t *testing.T) { svc := newParseTestService(t) queryErr := errors.New("documents query failed") svc.accessibleFunc = func(string, string) bool { return true } svc.getKnowledgebaseByIDFunc = func(string) (*entity.Knowledgebase, error) { return &entity.Knowledgebase{ID: "kb-1", TenantID: "user-1"}, nil } svc.getDocumentsByIDsFunc = func([]string) ([]*entity.Document, error) { return nil, queryErr } _, code, err := svc.Parse("user-1", "kb-1", &service.ParseFileRequest{DocumentIDs: []string{"doc-1"}}) if !errors.Is(err, queryErr) { t.Fatalf("expected query error, got %v", err) } if code != common.CodeServerError { t.Fatalf("expected CodeServerError, got %v", code) } } func TestParseRejectsRunningDocument(t *testing.T) { db := setupChunkTestDB(t) pushChunkTestDB(t, db) userID := "user-1" datasetID := "kb-1" insertChunkTestKB(t, datasetID, userID) insertChunkTestDoc(t, "doc-1", datasetID) running := string(entity.TaskStatusRunning) if err := dao.DB.Model(&entity.Document{}).Where("id = ?", "doc-1").Update("run", running).Error; err != nil { t.Fatalf("mark doc running: %v", err) } svc := newParseTestService(t) _, code, err := svc.Parse(userID, datasetID, &service.ParseFileRequest{DocumentIDs: []string{"doc-1"}}) if err == nil { t.Fatal("expected parse to fail") } if code != common.CodeDataError { t.Fatalf("expected CodeDataError, got %v", code) } if !strings.Contains(err.Error(), "currently being processed") { t.Fatalf("unexpected error: %v", err) } } func TestListSortsChunksByDocumentPosition(t *testing.T) { db := setupChunkTestDB(t) pushChunkTestDB(t, db) userID := "user-1" tenantID := "tenant-1" datasetID := "kb-1" documentID := "doc-1" insertChunkTestUserTenant(t, userID, tenantID) insertChunkTestKB(t, datasetID, tenantID) insertChunkTestDoc(t, documentID, datasetID) engine := &listChunksSearchEngine{} svc := &ChunkService{ docEngine: engine, kbDAO: dao.NewKnowledgebaseDAO(), userTenantDAO: dao.NewUserTenantDAO(), documentDAO: dao.NewDocumentDAO(), } page := 1 size := 30 if _, err := svc.List(&service.ListChunksRequest{ DatasetID: datasetID, DocID: documentID, Page: &page, Size: &size, }, userID); err != nil { t.Fatalf("List() error = %v", err) } if engine.searchReq == nil { t.Fatal("expected Search to be called") } if engine.searchReq.OrderBy == nil { t.Fatal("expected OrderBy to be set") } want := []types.OrderByField{ {Field: "chunk_order_int", Type: types.SortAsc}, {Field: "page_num_int", Type: types.SortAsc}, {Field: "top_int", Type: types.SortAsc}, {Field: "create_timestamp_flt", Type: types.SortDesc}, } if !reflect.DeepEqual(engine.searchReq.OrderBy.Fields, want) { t.Fatalf("OrderBy fields = %#v, want %#v", engine.searchReq.OrderBy.Fields, want) } } func TestListBuildsMatchTextExprForKeywords(t *testing.T) { db := setupChunkTestDB(t) pushChunkTestDB(t, db) userID := "user-1" tenantID := "tenant-1" datasetID := "kb-1" documentID := "doc-1" insertChunkTestUserTenant(t, userID, tenantID) insertChunkTestKB(t, datasetID, tenantID) insertChunkTestDoc(t, documentID, datasetID) engine := &listChunksSearchEngine{} svc := &ChunkService{ docEngine: engine, kbDAO: dao.NewKnowledgebaseDAO(), userTenantDAO: dao.NewUserTenantDAO(), documentDAO: dao.NewDocumentDAO(), } page := 2 size := 5 resp, err := svc.List(&service.ListChunksRequest{ DatasetID: datasetID, DocID: documentID, Page: &page, Size: &size, Keywords: " invoice terms ", }, userID) if err != nil { t.Fatalf("List() error = %v", err) } if resp.Total != 1 { t.Fatalf("total = %d, want 1", resp.Total) } if engine.searchReq == nil { t.Fatal("expected Search to be called") } if engine.searchReq.Offset != 5 || engine.searchReq.Limit != 5 { t.Fatalf("pagination = offset %d limit %d, want offset 5 limit 5", engine.searchReq.Offset, engine.searchReq.Limit) } if !reflect.DeepEqual(engine.searchReq.KbIDs, []string{datasetID}) { t.Fatalf("KbIDs = %#v, want %#v", engine.searchReq.KbIDs, []string{datasetID}) } if got := engine.searchReq.Filter["doc_id"]; got != documentID { t.Fatalf("doc_id filter = %#v, want %q", got, documentID) } if slices.Contains(engine.searchReq.SelectFields, "content") { t.Fatalf("SelectFields = %#v, should not request content with content_with_weight", engine.searchReq.SelectFields) } for _, field := range []string{"content_with_weight", "img_id", "position_int"} { if !slices.Contains(engine.searchReq.SelectFields, field) { t.Fatalf("SelectFields = %#v, missing %q", engine.searchReq.SelectFields, field) } } if got := resp.Chunks[0]["content_with_weight"]; got != "weighted invoice terms body" { t.Fatalf("formatted content_with_weight = %#v, want %q", got, "weighted invoice terms body") } if len(engine.searchReq.MatchExprs) != 1 { t.Fatalf("MatchExprs length = %d, want 1", len(engine.searchReq.MatchExprs)) } matchText, ok := engine.searchReq.MatchExprs[0].(*types.MatchTextExpr) if !ok { t.Fatalf("MatchExprs[0] = %T, want *types.MatchTextExpr", engine.searchReq.MatchExprs[0]) } if matchText.MatchingText != "invoice terms" { t.Fatalf("MatchingText = %q, want %q", matchText.MatchingText, "invoice terms") } if matchText.TopN != size { t.Fatalf("TopN = %d, want %d", matchText.TopN, size) } } func TestAddChunkSuccess(t *testing.T) { ctx := t.Context() db := setupChunkTestDB(t) pushChunkTestDB(t, db) userID, datasetID, documentID := "user-1", "kb-1", "doc-1" insertChunkTestKB(t, datasetID, userID) insertChunkTestDoc(t, documentID, datasetID) engine := &addChunkTestEngine{} var incrementTokenNum, incrementChunkNum int64 svc := &ChunkService{ docEngine: engine, kbDAO: dao.NewKnowledgebaseDAO(), documentDAO: dao.NewDocumentDAO(), accessibleFunc: func(datasetIDArg, userIDArg string) bool { return datasetIDArg == datasetID && userIDArg == userID }, getKnowledgebaseByIDFunc: func(id string) (*entity.Knowledgebase, error) { return &entity.Knowledgebase{ID: id, TenantID: userID, EmbdID: "embed-1"}, nil }, getEmbeddingModelFunc: func(string, string) (*models.EmbeddingModel, error) { driver := &stubEmbeddingDriver{ embeddings: []models.EmbeddingData{ {Embedding: []float64{1, 2}}, {Embedding: []float64{3, 4}}, }, } modelName := "embed-1" return models.NewEmbeddingModel(driver, &modelName, &models.APIConfig{}, 0), nil }, incrementChunkStatsFunc: func(docID, kbID string, tokenNum, chunkNum int64, duration float64) error { if docID != documentID || kbID != datasetID || duration != 0 { t.Fatalf("unexpected increment args doc=%s kb=%s duration=%v", docID, kbID, duration) } incrementTokenNum = tokenNum incrementChunkNum = chunkNum return nil }, tokenizeFunc: func(text string) (string, error) { return text, nil }, fineGrainedTokenizeFunc: func(text string) (string, error) { return text + "_fg", nil }, numTokensFunc: func(text string) int { return len(text) }, } resp, err := svc.AddChunk(ctx, &service.AddChunkRequest{ DatasetID: datasetID, DocumentID: documentID, Content: "chunk body", ImportantKeywords: []string{"k1"}, Questions: []string{" q1 ", ""}, TagKwd: []string{"tag1"}, TagFeas: map[string]interface{}{"tag1": float64(0.5)}, }, userID) if err != nil { t.Fatalf("AddChunk() error = %v", err) } if resp == nil || resp.Chunk == nil { t.Fatalf("expected chunk response, got %#v", resp) } if resp.Chunk["dataset_id"] != datasetID || resp.Chunk["document_id"] != documentID { t.Fatalf("unexpected response chunk: %#v", resp.Chunk) } if resp.Chunk["content"] != "chunk body" { t.Fatalf("content = %v, want chunk body", resp.Chunk["content"]) } if resp.Chunk["document"] != "doc-1.txt" { t.Fatalf("document = %v, want doc-1.txt", resp.Chunk["document"]) } if incrementChunkNum != 1 { t.Fatalf("increment chunk num = %d, want 1", incrementChunkNum) } if incrementTokenNum <= 0 { t.Fatalf("increment token num = %d, want > 0", incrementTokenNum) } if len(engine.insertedChunks) != 1 { t.Fatalf("inserted chunks = %d, want 1", len(engine.insertedChunks)) } inserted := engine.insertedChunks[0] if inserted["doc_id"] != documentID || inserted["kb_id"] != datasetID { t.Fatalf("unexpected inserted chunk: %#v", inserted) } if inserted["img_id"] != nil { t.Fatalf("did not expect image id in inserted chunk: %#v", inserted) } vec, ok := inserted["q_2_vec"].([]float64) if !ok { t.Fatalf("expected q_2_vec []float64, got %T", inserted["q_2_vec"]) } if len(vec) != 2 || vec[0] < 2.7999 || vec[0] > 2.8001 || vec[1] < 3.7999 || vec[1] > 3.8001 { t.Fatalf("vector = %v, want approximately [2.8 3.8]", vec) } } func TestAddChunkValidationErrors(t *testing.T) { ctx := t.Context() db := setupChunkTestDB(t) pushChunkTestDB(t, db) insertChunkTestKB(t, "kb-1", "user-1") insertChunkTestDoc(t, "doc-1", "kb-1") svc := &ChunkService{ docEngine: &addChunkTestEngine{}, kbDAO: dao.NewKnowledgebaseDAO(), documentDAO: dao.NewDocumentDAO(), } tests := []struct { name string req *service.AddChunkRequest setup func(*ChunkService) wantMsg string }{ { name: "nil request", req: nil, wantMsg: "invalid request payload", }, { name: "empty content", req: &service.AddChunkRequest{DatasetID: "kb-1", DocumentID: "doc-1", Content: " "}, setup: func(svc *ChunkService) { svc.accessibleFunc = func(string, string) bool { return true } svc.getKnowledgebaseByIDFunc = func(string) (*entity.Knowledgebase, error) { return &entity.Knowledgebase{ID: "kb-1", TenantID: "user-1", EmbdID: "embed-1"}, nil } }, wantMsg: "`content` is required", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if tt.setup != nil { tt.setup(svc) } _, err := svc.AddChunk(ctx, tt.req, "user-1") if err == nil || !strings.Contains(err.Error(), tt.wantMsg) { t.Fatalf("error = %v, want substring %q", err, tt.wantMsg) } }) } } func TestAddChunkImageAndTagFeatureValidation(t *testing.T) { ctx := t.Context() db := setupChunkTestDB(t) pushChunkTestDB(t, db) userID, datasetID, documentID := "user-1", "kb-1", "doc-1" insertChunkTestKB(t, datasetID, userID) insertChunkTestDoc(t, documentID, datasetID) storeCalls := 0 svc := &ChunkService{ docEngine: &addChunkTestEngine{}, kbDAO: dao.NewKnowledgebaseDAO(), documentDAO: dao.NewDocumentDAO(), accessibleFunc: func(string, string) bool { return true }, getKnowledgebaseByIDFunc: func(id string) (*entity.Knowledgebase, error) { return &entity.Knowledgebase{ID: id, TenantID: userID, EmbdID: "embed-1"}, nil }, tokenizeFunc: func(text string) (string, error) { return text, nil }, fineGrainedTokenizeFunc: func(text string) (string, error) { return text + "_fg", nil }, numTokensFunc: func(text string) int { return len(text) }, getEmbeddingModelFunc: func(string, string) (*models.EmbeddingModel, error) { driver := &stubEmbeddingDriver{ embeddings: []models.EmbeddingData{ {Embedding: []float64{1, 1}}, {Embedding: []float64{1, 1}}, }, } modelName := "embed-1" return models.NewEmbeddingModel(driver, &modelName, &models.APIConfig{}, 0), nil }, incrementChunkStatsFunc: func(string, string, int64, int64, float64) error { return nil }, storeChunkImageFunc: func(bucket, chunkID string, imageBinary []byte) error { storeCalls++ if bucket != datasetID || chunkID == "" || len(imageBinary) == 0 { t.Fatalf("unexpected store args bucket=%s chunkID=%s len=%d", bucket, chunkID, len(imageBinary)) } return nil }, } _, err := svc.AddChunk(ctx, &service.AddChunkRequest{ DatasetID: datasetID, DocumentID: documentID, Content: "chunk body", ImageBase64: strPtr("not-base64"), }, userID) if err == nil || !strings.Contains(err.Error(), "Invalid `image_base64`") { t.Fatalf("expected invalid image error, got %v", err) } validJPEG := "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO2pRZ0AAAAASUVORK5CYII=" resp, err := svc.AddChunk(ctx, &service.AddChunkRequest{ DatasetID: datasetID, DocumentID: documentID, Content: "chunk body", TagFeas: map[string]interface{}{"tag1": "bad"}, }, userID) if err == nil || !strings.Contains(err.Error(), "`tag_feas` values must be finite numbers") || resp != nil { t.Fatalf("expected tag_feas validation error, got resp=%#v err=%v", resp, err) } resp, err = svc.AddChunk(ctx, &service.AddChunkRequest{ DatasetID: datasetID, DocumentID: documentID, Content: "chunk body", TagFeas: map[string]interface{}{"tag1": float64(1)}, ImageBase64: strPtr(validJPEG), }, userID) if err != nil { t.Fatalf("AddChunk() with image error = %v", err) } if storeCalls != 1 { t.Fatalf("store image calls = %d, want 1", storeCalls) } if _, ok := resp.Chunk["image_id"]; !ok { t.Fatalf("expected image_id in response, got %#v", resp.Chunk) } } func TestAddChunkIncrementsStatsAfterInsert(t *testing.T) { ctx := t.Context() db := setupChunkTestDB(t) pushChunkTestDB(t, db) userID, datasetID, documentID := "user-1", "kb-1", "doc-1" insertChunkTestKB(t, datasetID, userID) insertChunkTestDoc(t, documentID, datasetID) var incrementCalls int engine := &addChunkTestEngine{} svc := &ChunkService{ docEngine: engine, kbDAO: dao.NewKnowledgebaseDAO(), documentDAO: dao.NewDocumentDAO(), accessibleFunc: func(string, string) bool { return true }, getKnowledgebaseByIDFunc: func(id string) (*entity.Knowledgebase, error) { return &entity.Knowledgebase{ID: id, TenantID: userID, EmbdID: "embed-1"}, nil }, getEmbeddingModelFunc: func(string, string) (*models.EmbeddingModel, error) { driver := &stubEmbeddingDriver{ embeddings: []models.EmbeddingData{ {Embedding: []float64{1, 2}}, {Embedding: []float64{3, 4}}, }, } modelName := "embed-1" return models.NewEmbeddingModel(driver, &modelName, &models.APIConfig{}, 0), nil }, incrementChunkStatsFunc: func(string, string, int64, int64, float64) error { incrementCalls++ return nil }, tokenizeFunc: func(text string) (string, error) { return text, nil }, fineGrainedTokenizeFunc: func(text string) (string, error) { return text + "_fg", nil }, numTokensFunc: func(text string) int { return len(text) }, } _, err := svc.AddChunk(ctx, &service.AddChunkRequest{ DatasetID: datasetID, DocumentID: documentID, Content: "chunk body", }, userID) if err != nil { t.Fatalf("AddChunk() error = %v", err) } if incrementCalls != 1 { t.Fatalf("increment calls = %d, want 1", incrementCalls) } if len(engine.insertedChunks) != 1 { t.Fatalf("inserted chunks = %d, want 1", len(engine.insertedChunks)) } importantKwd, ok := engine.insertedChunks[0]["important_kwd"].([]string) if !ok || len(importantKwd) != 0 { t.Fatalf("important_kwd = %#v, want empty []string", engine.insertedChunks[0]["important_kwd"]) } } func TestStoreChunkImageMergesExistingImage(t *testing.T) { oldImage := mustEncodePNG(t, image.Rect(0, 0, 2, 2)) newImage := mustEncodePNG(t, image.Rect(0, 0, 1, 1)) mockStorage := &chunkImageStorage{ exists: true, oldBinary: oldImage, } factory := storage.GetStorageFactory() originalStorage := factory.GetStorage() factory.SetStorage(mockStorage) t.Cleanup(func() { factory.SetStorage(originalStorage) }) svc := &ChunkService{} if err := svc.storeChunkImage("kb-1", "chunk-1", newImage); err != nil { t.Fatalf("storeChunkImage() error = %v", err) } if mockStorage.putCalls != 1 { t.Fatalf("put calls = %d, want 1", mockStorage.putCalls) } } func TestRemoveChunksDecrementsStatsAfterDelete(t *testing.T) { db := setupChunkTestDB(t) pushChunkTestDB(t, db) insertChunkTestUserTenant(t, "user-1", "tenant-1") insertChunkTestKB(t, "kb-1", "tenant-1") insertChunkTestDoc(t, "doc-1", "kb-1") setChunkTestStats(t, "doc-1", "kb-1", 70, 7, 100, 10) engine := &parseTestDocEngine{deleteChunksCount: 3} svc := &ChunkService{ docEngine: engine, kbDAO: dao.NewKnowledgebaseDAO(), userTenantDAO: dao.NewUserTenantDAO(), } deletedCount, err := svc.RemoveChunks(&service.RemoveChunksRequest{ DocID: "doc-1", ChunkIDs: []string{"chunk-1", "chunk-2", "chunk-3"}, }, "user-1") if err != nil { t.Fatalf("RemoveChunks() error = %v", err) } if deletedCount != 3 { t.Fatalf("deleted count = %d, want 3", deletedCount) } if engine.deleteChunksCalls != 1 { t.Fatalf("DeleteChunks calls = %d, want 1", engine.deleteChunksCalls) } if engine.deleteIndexName != "ragflow_tenant-1" || engine.deleteDatasetID != "kb-1" { t.Fatalf("unexpected delete target index=%q dataset=%q", engine.deleteIndexName, engine.deleteDatasetID) } if engine.deleteChunksCondition["doc_id"] != "doc-1" { t.Fatalf("delete doc_id condition = %#v, want doc-1", engine.deleteChunksCondition["doc_id"]) } if !reflect.DeepEqual(engine.deleteChunksCondition["id"], []interface{}{"chunk-1", "chunk-2", "chunk-3"}) { t.Fatalf("delete id condition = %#v", engine.deleteChunksCondition["id"]) } doc, err := dao.NewDocumentDAO().GetByID("doc-1") if err != nil { t.Fatalf("get doc: %v", err) } if doc.TokenNum != 70 { t.Fatalf("document token_num = %d, want 70", doc.TokenNum) } if doc.ChunkNum != 4 { t.Fatalf("document chunk_num = %d, want 4", doc.ChunkNum) } kb, err := dao.NewKnowledgebaseDAO().GetByID("kb-1") if err != nil { t.Fatalf("get kb: %v", err) } if kb.TokenNum != 100 { t.Fatalf("knowledgebase token_num = %d, want 100", kb.TokenNum) } if kb.ChunkNum != 7 { t.Fatalf("knowledgebase chunk_num = %d, want 7", kb.ChunkNum) } } func TestRemoveChunksSkipsStatsWhenNothingDeleted(t *testing.T) { db := setupChunkTestDB(t) pushChunkTestDB(t, db) insertChunkTestUserTenant(t, "user-1", "tenant-1") insertChunkTestKB(t, "kb-1", "tenant-1") insertChunkTestDoc(t, "doc-1", "kb-1") setChunkTestStats(t, "doc-1", "kb-1", 70, 7, 100, 10) var decrementCalls int svc := &ChunkService{ docEngine: &parseTestDocEngine{deleteChunksCount: 0}, kbDAO: dao.NewKnowledgebaseDAO(), userTenantDAO: dao.NewUserTenantDAO(), decrementChunkStatsFunc: func(string, string, int64, int64, float64) error { decrementCalls++ return nil }, } deletedCount, err := svc.RemoveChunks(&service.RemoveChunksRequest{ DocID: "doc-1", DeleteAll: true, }, "user-1") if err != nil { t.Fatalf("RemoveChunks() error = %v", err) } if deletedCount != 0 { t.Fatalf("deleted count = %d, want 0", deletedCount) } if decrementCalls != 0 { t.Fatalf("decrement calls = %d, want 0", decrementCalls) } } func TestRemoveChunksReturnsStatsError(t *testing.T) { db := setupChunkTestDB(t) pushChunkTestDB(t, db) insertChunkTestUserTenant(t, "user-1", "tenant-1") insertChunkTestKB(t, "kb-1", "tenant-1") insertChunkTestDoc(t, "doc-1", "kb-1") svc := &ChunkService{ docEngine: &parseTestDocEngine{deleteChunksCount: 2}, kbDAO: dao.NewKnowledgebaseDAO(), userTenantDAO: dao.NewUserTenantDAO(), decrementChunkStatsFunc: func(docID, kbID string, tokenNum, chunkNum int64, duration float64) error { if docID != "doc-1" || kbID != "kb-1" || tokenNum != 0 || chunkNum != 2 || duration != 0 { t.Fatalf("unexpected decrement args doc=%s kb=%s token=%d chunk=%d duration=%v", docID, kbID, tokenNum, chunkNum, duration) } return errors.New("stats update failed") }, } deletedCount, err := svc.RemoveChunks(&service.RemoveChunksRequest{ DocID: "doc-1", ChunkIDs: []string{"chunk-1", "chunk-2"}, }, "user-1") if err == nil || !strings.Contains(err.Error(), "failed to update chunk stats") { t.Fatalf("expected stats update error, got count=%d err=%v", deletedCount, err) } if deletedCount != 2 { t.Fatalf("deleted count on error = %d, want 2", deletedCount) } } func TestDecrementChunkStatsClampsCounters(t *testing.T) { db := setupChunkTestDB(t) pushChunkTestDB(t, db) insertChunkTestKB(t, "kb-1", "tenant-1") insertChunkTestDoc(t, "doc-1", "kb-1") setChunkTestStats(t, "doc-1", "kb-1", 2, 1, 3, 2) if err := dao.DB.Model(&entity.Document{}). Where("id = ?", "doc-1"). Update("process_duration", 0.5).Error; err != nil { t.Fatalf("set process duration: %v", err) } svc := &ChunkService{} if err := svc.decrementChunkStats("doc-1", "kb-1", 5, 7, -1); err != nil { t.Fatalf("decrementChunkStats() error = %v", err) } doc, err := dao.NewDocumentDAO().GetByID("doc-1") if err != nil { t.Fatalf("get doc: %v", err) } if doc.TokenNum != 0 || doc.ChunkNum != 0 || doc.ProcessDuration != 0 { t.Fatalf("document stats token=%d chunk=%d duration=%v, want zeros", doc.TokenNum, doc.ChunkNum, doc.ProcessDuration) } kb, err := dao.NewKnowledgebaseDAO().GetByID("kb-1") if err != nil { t.Fatalf("get kb: %v", err) } if kb.TokenNum != 0 || kb.ChunkNum != 0 { t.Fatalf("knowledgebase stats token=%d chunk=%d, want zeros", kb.TokenNum, kb.ChunkNum) } } func TestDecrementChunkStatsRollsBackWhenKnowledgebaseMissing(t *testing.T) { db := setupChunkTestDB(t) pushChunkTestDB(t, db) insertChunkTestDoc(t, "doc-1", "missing-kb") svc := &ChunkService{} err := svc.decrementChunkStats("doc-1", "missing-kb", 0, 3, 0) if err == nil || !strings.Contains(err.Error(), "knowledgebase not found") { t.Fatalf("expected missing knowledgebase error, got %v", err) } doc, err := dao.NewDocumentDAO().GetByID("doc-1") if err != nil { t.Fatalf("get doc: %v", err) } if doc.ChunkNum != 7 { t.Fatalf("document chunk_num after rollback = %d, want 7", doc.ChunkNum) } } func setupChunkTestDB(t *testing.T) *gorm.DB { t.Helper() db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{ TranslateError: true, }) if err != nil { t.Fatalf("failed to open sqlite: %v", err) } if err := db.AutoMigrate( &entity.UserTenant{}, &entity.Knowledgebase{}, &entity.Document{}, &entity.Task{}, &entity.IngestionTask{}, ); err != nil { t.Fatalf("failed to migrate: %v", err) } return db } func pushChunkTestDB(t *testing.T, testDB *gorm.DB) { t.Helper() orig := dao.DB dao.DB = testDB t.Cleanup(func() { dao.DB = orig }) } func newParseTestService(t *testing.T) *ChunkService { t.Helper() return &ChunkService{ docEngine: nil, kbDAO: dao.NewKnowledgebaseDAO(), documentDAO: dao.NewDocumentDAO(), } } func insertChunkTestUserTenant(t *testing.T, userID, tenantID string) { t.Helper() status := "1" userTenant := &entity.UserTenant{ ID: userID + "-" + tenantID, UserID: userID, TenantID: tenantID, Role: "owner", InvitedBy: userID, Status: &status, } if err := dao.DB.Create(userTenant).Error; err != nil { t.Fatalf("insert user tenant: %v", err) } } func insertChunkTestKB(t *testing.T, id, tenantID string) { t.Helper() status := string(entity.StatusValid) kb := &entity.Knowledgebase{ ID: id, TenantID: tenantID, Name: id, EmbdID: "embedding-model", Permission: string(entity.TenantPermissionMe), CreatedBy: tenantID, ParserConfig: entity.JSONMap{}, Status: &status, } if err := dao.DB.Create(kb).Error; err != nil { t.Fatalf("insert kb: %v", err) } } func insertChunkTestDoc(t *testing.T, id, kbID string) { t.Helper() location := id + ".txt" name := id + ".txt" status := string(entity.StatusValid) doc := &entity.Document{ ID: id, KbID: kbID, ParserID: string(entity.ParserTypeNaive), ParserConfig: entity.JSONMap{}, SourceType: string(entity.FileSourceLocal), Type: "txt", CreatedBy: "user-1", Name: &name, Location: &location, ChunkNum: 7, Suffix: ".txt", Status: &status, } if err := dao.DB.Create(doc).Error; err != nil { t.Fatalf("insert doc: %v", err) } } func insertChunkTestIngestionTask(t *testing.T, id, userID, docID, datasetID, status string) { t.Helper() task := &entity.IngestionTask{ ID: id, UserID: userID, DocumentID: docID, DatasetID: datasetID, Schema: entity.JSONMap{}, Status: status, } if err := dao.DB.Create(task).Error; err != nil { t.Fatalf("insert ingestion task: %v", err) } } func setChunkTestStats(t *testing.T, docID, kbID string, docTokenNum, docChunkNum, kbTokenNum, kbChunkNum int64) { t.Helper() if err := dao.DB.Model(&entity.Document{}). Where("id = ?", docID). Updates(map[string]interface{}{"token_num": docTokenNum, "chunk_num": docChunkNum}).Error; err != nil { t.Fatalf("set doc stats: %v", err) } if err := dao.DB.Model(&entity.Knowledgebase{}). Where("id = ?", kbID). Updates(map[string]interface{}{"token_num": kbTokenNum, "chunk_num": kbChunkNum}).Error; err != nil { t.Fatalf("set kb stats: %v", err) } } func insertChunkTestTask(t *testing.T, id, docID string) { t.Helper() digest := "digest" task := &entity.Task{ ID: id, DocID: docID, Digest: &digest, } if err := dao.DB.Create(task).Error; err != nil { t.Fatalf("insert task: %v", err) } } type parseTestDocEngine struct { deleteChunksErr error deleteChunksCount int64 deleteChunksCalls int deleteChunksCondition map[string]interface{} deleteIndexName string deleteDatasetID string chunkStoreExists bool // if true, ChunkStoreExists returns true } func (e *parseTestDocEngine) CreateChunkStore(context.Context, string, string, int, string) error { return nil } func (e *parseTestDocEngine) InsertChunks(context.Context, []map[string]interface{}, string, string) ([]string, error) { return nil, nil } func (e *parseTestDocEngine) UpdateChunks(context.Context, map[string]interface{}, map[string]interface{}, string, string) error { return nil } func (e *parseTestDocEngine) DeleteChunks(_ context.Context, condition map[string]interface{}, indexName string, datasetID string) (int64, error) { e.deleteChunksCalls++ e.deleteChunksCondition = copyMap(condition) e.deleteIndexName = indexName e.deleteDatasetID = datasetID return e.deleteChunksCount, e.deleteChunksErr } func (e *parseTestDocEngine) Search(context.Context, *types.SearchRequest) (*types.SearchResult, error) { return nil, nil } func (e *parseTestDocEngine) GetChunk(context.Context, string, string, []string) (interface{}, error) { return nil, nil } func (e *parseTestDocEngine) DropChunkStore(context.Context, string, string) error { return nil } func (e *parseTestDocEngine) ChunkStoreExists(context.Context, string, string) (bool, error) { return e.chunkStoreExists, nil } func (e *parseTestDocEngine) CreateMetadataStore(context.Context, string) error { return nil } func (e *parseTestDocEngine) InsertMetadata(context.Context, []map[string]interface{}, string) ([]string, error) { return nil, nil } func (e *parseTestDocEngine) UpdateMetadata(context.Context, string, string, map[string]interface{}, string) error { return nil } func (e *parseTestDocEngine) DeleteMetadata(context.Context, map[string]interface{}, string) (int64, error) { return 0, nil } func (e *parseTestDocEngine) DeleteMetadataKeys(context.Context, string, string, []string, string) error { return nil } func (e *parseTestDocEngine) DropMetadataStore(context.Context, string) error { return nil } func (e *parseTestDocEngine) MetadataStoreExists(context.Context, string) (bool, error) { return false, nil } func (e *parseTestDocEngine) SearchMetadata(context.Context, *types.SearchMetadataRequest) (*types.SearchMetadataResult, error) { return nil, nil } func (e *parseTestDocEngine) IndexDocument(context.Context, string, string, interface{}) error { return nil } func (e *parseTestDocEngine) DeleteDocument(context.Context, string, string) error { return nil } func (e *parseTestDocEngine) BulkIndex(context.Context, string, []interface{}) (interface{}, error) { return nil, nil } func (e *parseTestDocEngine) GetFields([]map[string]interface{}, []string) map[string]map[string]interface{} { return nil } func (e *parseTestDocEngine) GetAggregation([]map[string]interface{}, string) []map[string]interface{} { return nil } func (e *parseTestDocEngine) GetHighlight([]map[string]interface{}, []string, string) map[string]string { return nil } func (e *parseTestDocEngine) RunSQL(context.Context, string, string, []string, string) ([]map[string]interface{}, error) { return nil, nil } func (e *parseTestDocEngine) GetChunkIDs([]map[string]interface{}) []string { return nil } type addChunkTestEngine struct { parseTestDocEngine insertedChunks []map[string]interface{} insertIndex string insertDataset string insertErr error } func (e *addChunkTestEngine) InsertChunks(_ context.Context, chunks []map[string]interface{}, baseName string, datasetID string) ([]string, error) { e.insertedChunks = chunks e.insertIndex = baseName e.insertDataset = datasetID return nil, e.insertErr } type listChunksSearchEngine struct { parseTestDocEngine searchReq *types.SearchRequest } func (e *listChunksSearchEngine) Search(_ context.Context, req *types.SearchRequest) (*types.SearchResult, error) { e.searchReq = req return &types.SearchResult{ Chunks: []map[string]interface{}{ { "id": "chunk-1", "content": "plain invoice terms body", "content_with_weight": "weighted invoice terms body", "doc_id": "doc-1", }, }, Total: 1, }, nil } type chunkImageStorage struct { exists bool oldBinary []byte putCalls int } func (s *chunkImageStorage) Health() bool { return true } func (s *chunkImageStorage) Put(bucket, fnm string, binary []byte, tenantID ...string) error { s.putCalls++ return nil } func (s *chunkImageStorage) Get(bucket, fnm string, tenantID ...string) ([]byte, error) { return s.oldBinary, nil } func (s *chunkImageStorage) Remove(bucket, fnm string, tenantID ...string) error { return nil } func (s *chunkImageStorage) ObjExist(bucket, fnm string, tenantID ...string) bool { return s.exists } // ListObjects lists all objects in a bucket func (s *chunkImageStorage) ListObjects(bucket string, tenantID ...string) ([]string, error) { return []string{}, nil } func (s *chunkImageStorage) GetPresignedURL(bucket, fnm string, expires time.Duration, tenantID ...string) (string, error) { return "", nil } func (s *chunkImageStorage) BucketExists(bucket string) bool { return true } func (s *chunkImageStorage) RemoveBucket(bucket string) error { return nil } func (s *chunkImageStorage) Copy(srcBucket, srcPath, destBucket, destPath string) bool { return false } func (s *chunkImageStorage) Move(srcBucket, srcPath, destBucket, destPath string) bool { return false } func (s *chunkImageStorage) Close() error { return nil } func mustEncodePNG(t *testing.T, rect image.Rectangle) []byte { t.Helper() img := image.NewRGBA(rect) for y := rect.Min.Y; y < rect.Max.Y; y++ { for x := rect.Min.X; x < rect.Max.X; x++ { img.Set(x, y, color.White) } } var buf bytes.Buffer if err := png.Encode(&buf, img); err != nil { t.Fatalf("encode png: %v", err) } return buf.Bytes() } type stubEmbeddingDriver struct { embeddings []models.EmbeddingData embedErr error } func (d *stubEmbeddingDriver) NewInstance(map[string]string) models.ModelDriver { return d } func (d *stubEmbeddingDriver) Name() string { return "stub" } func (d *stubEmbeddingDriver) ChatWithMessages(context.Context, string, []models.Message, *models.APIConfig, *models.ChatConfig, *common.ModelUsage) (*models.ChatResponse, error) { return nil, nil } func (d *stubEmbeddingDriver) ChatStreamlyWithSender(context.Context, string, []models.Message, *models.APIConfig, *models.ChatConfig, *common.ModelUsage, func(*string, *string) error) error { return nil } func (d *stubEmbeddingDriver) Embed(context.Context, *string, []string, *models.APIConfig, *models.EmbeddingConfig, *common.ModelUsage) ([]models.EmbeddingData, error) { return d.embeddings, d.embedErr } func (d *stubEmbeddingDriver) Rerank(context.Context, *string, string, []string, *models.APIConfig, *models.RerankConfig, *common.ModelUsage) (*models.RerankResponse, error) { return nil, nil } func (d *stubEmbeddingDriver) TranscribeAudio(context.Context, *string, *string, *models.APIConfig, *models.ASRConfig, *common.ModelUsage) (*models.ASRResponse, error) { return nil, nil } func (d *stubEmbeddingDriver) TranscribeAudioWithSender(context.Context, *string, *string, *models.APIConfig, *models.ASRConfig, *common.ModelUsage, func(*string, *string) error) error { return nil } func (d *stubEmbeddingDriver) AudioSpeech(context.Context, *string, *string, *models.APIConfig, *models.TTSConfig, *common.ModelUsage) (*models.TTSResponse, error) { return nil, nil } func (d *stubEmbeddingDriver) AudioSpeechWithSender(context.Context, *string, *string, *models.APIConfig, *models.TTSConfig, *common.ModelUsage, func(*string, *string) error) error { return nil } func (d *stubEmbeddingDriver) OCRFile(context.Context, *string, []byte, *string, *models.APIConfig, *models.OCRConfig, *common.ModelUsage) (*models.OCRFileResponse, error) { return nil, nil } func (d *stubEmbeddingDriver) ParseFile(context.Context, *string, []byte, *string, *models.APIConfig, *models.ParseFileConfig, *common.ModelUsage) (*models.ParseFileResponse, error) { return nil, nil } func (d *stubEmbeddingDriver) ListModels(context.Context, *models.APIConfig) ([]models.ListModelResponse, error) { return nil, nil } func (d *stubEmbeddingDriver) Balance(context.Context, *models.APIConfig) (map[string]interface{}, error) { return nil, nil } func (d *stubEmbeddingDriver) CheckConnection(context.Context, *models.APIConfig) error { return nil } func (d *stubEmbeddingDriver) ListTasks(context.Context, *models.APIConfig) ([]models.ListTaskStatus, error) { return nil, nil } func (d *stubEmbeddingDriver) ShowTask(context.Context, string, *models.APIConfig) (*models.TaskResponse, error) { return nil, nil } func strPtr(v string) *string { return &v } func (e *parseTestDocEngine) KNNScores(context.Context, []map[string]interface{}, []float64, int) (map[string]interface{}, error) { return nil, nil } func (e *parseTestDocEngine) GetScores(map[string]interface{}) map[string]float64 { return nil } func (e *parseTestDocEngine) Ping(context.Context) error { return nil } func (e *parseTestDocEngine) Close() error { return nil } func (e *parseTestDocEngine) GetType() string { return "test" } func (e *parseTestDocEngine) SupportsPageRank() bool { return false } func (e *parseTestDocEngine) FilterDocIdsByMetaPushdown(context.Context, []string, []map[string]interface{}, string) []string { return nil } func TestSwitchChunksUpdatesDocEngineWithAvailableInt(t *testing.T) { db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) if err != nil { t.Fatalf("failed to open sqlite: %v", err) } if err := db.AutoMigrate(&entity.UserTenant{}, &entity.Knowledgebase{}, &entity.Document{}); err != nil { t.Fatalf("failed to migrate sqlite: %v", err) } previousDB := dao.DB dao.DB = db t.Cleanup(func() { dao.DB = previousDB }) valid := string(entity.StatusValid) if err := db.Create(&entity.UserTenant{ ID: "ut-1", UserID: "user-1", TenantID: "tenant-1", Role: "owner", Status: &valid, }).Error; err != nil { t.Fatalf("failed to create user_tenant: %v", err) } if err := db.Create(&entity.Knowledgebase{ ID: "kb-1", TenantID: "tenant-1", Name: "dataset", EmbdID: "embed", Permission: string(entity.TenantPermissionMe), CreatedBy: "user-1", ParserID: string(entity.ParserTypeNaive), ParserConfig: entity.JSONMap{}, Status: &valid, }).Error; err != nil { t.Fatalf("failed to create knowledgebase: %v", err) } if err := db.Create(&entity.Document{ ID: "doc-1", KbID: "kb-1", ParserID: string(entity.ParserTypeNaive), ParserConfig: entity.JSONMap{}, SourceType: "local", Type: "doc", CreatedBy: "user-1", Suffix: "txt", }).Error; err != nil { t.Fatalf("failed to create document: %v", err) } engine := &switchChunksEngineMock{} svc := &ChunkService{ docEngine: engine, kbDAO: dao.NewKnowledgebaseDAO(), userTenantDAO: dao.NewUserTenantDAO(), } if err := svc.SwitchChunks("user-1", "kb-1", "doc-1", 0, []string{"chunk-1", "chunk-2"}); err != nil { t.Fatalf("SwitchChunks() error = %v", err) } if len(engine.updateCalls) != 2 { t.Fatalf("UpdateChunks calls = %d, want 2", len(engine.updateCalls)) } for i, call := range engine.updateCalls { if call.indexName != "ragflow_tenant-1" { t.Fatalf("call %d indexName = %q", i, call.indexName) } if call.datasetID != "kb-1" { t.Fatalf("call %d datasetID = %q", i, call.datasetID) } wantID := []string{"chunk-1", "chunk-2"}[i] if !reflect.DeepEqual(call.condition, map[string]interface{}{ "id": wantID, "doc_id": "doc-1", }) { t.Fatalf("call %d condition = %#v", i, call.condition) } if !reflect.DeepEqual(call.newValue, map[string]interface{}{"id": wantID, "available_int": 0}) { t.Fatalf("call %d newValue = %#v", i, call.newValue) } } } type updateChunksCall struct { condition map[string]interface{} newValue map[string]interface{} indexName string datasetID string } type switchChunksEngineMock struct { updateCalls []updateChunksCall } func (m *switchChunksEngineMock) CreateChunkStore(context.Context, string, string, int, string) error { return nil } func (m *switchChunksEngineMock) InsertChunks(context.Context, []map[string]interface{}, string, string) ([]string, error) { return nil, nil } func (m *switchChunksEngineMock) UpdateChunks(_ context.Context, condition map[string]interface{}, newValue map[string]interface{}, indexName string, datasetID string) error { m.updateCalls = append(m.updateCalls, updateChunksCall{ condition: copyMap(condition), newValue: copyMap(newValue), indexName: indexName, datasetID: datasetID, }) return nil } func (m *switchChunksEngineMock) DeleteChunks(context.Context, map[string]interface{}, string, string) (int64, error) { return 0, nil } func (m *switchChunksEngineMock) Search(context.Context, *types.SearchRequest) (*types.SearchResult, error) { return nil, nil } func (m *switchChunksEngineMock) GetChunk(context.Context, string, string, []string) (interface{}, error) { return nil, nil } func (m *switchChunksEngineMock) DropChunkStore(context.Context, string, string) error { return nil } func (m *switchChunksEngineMock) ChunkStoreExists(context.Context, string, string) (bool, error) { return false, nil } func (m *switchChunksEngineMock) CreateMetadataStore(context.Context, string) error { return nil } func (m *switchChunksEngineMock) InsertMetadata(context.Context, []map[string]interface{}, string) ([]string, error) { return nil, nil } func (m *switchChunksEngineMock) UpdateMetadata(context.Context, string, string, map[string]interface{}, string) error { return nil } func (m *switchChunksEngineMock) DeleteMetadata(context.Context, map[string]interface{}, string) (int64, error) { return 0, nil } func (m *switchChunksEngineMock) DeleteMetadataKeys(context.Context, string, string, []string, string) error { return nil } func (m *switchChunksEngineMock) DropMetadataStore(context.Context, string) error { return nil } func (m *switchChunksEngineMock) MetadataStoreExists(context.Context, string) (bool, error) { return false, nil } func (m *switchChunksEngineMock) SearchMetadata(context.Context, *types.SearchMetadataRequest) (*types.SearchMetadataResult, error) { return nil, nil } func (m *switchChunksEngineMock) IndexDocument(context.Context, string, string, interface{}) error { return nil } func (m *switchChunksEngineMock) DeleteDocument(context.Context, string, string) error { return nil } func (m *switchChunksEngineMock) BulkIndex(context.Context, string, []interface{}) (interface{}, error) { return nil, nil } func (m *switchChunksEngineMock) GetFields([]map[string]interface{}, []string) map[string]map[string]interface{} { return nil } func (m *switchChunksEngineMock) GetAggregation([]map[string]interface{}, string) []map[string]interface{} { return nil } func (m *switchChunksEngineMock) GetHighlight([]map[string]interface{}, []string, string) map[string]string { return nil } func (m *switchChunksEngineMock) RunSQL(context.Context, string, string, []string, string) ([]map[string]interface{}, error) { return nil, nil } func (m *switchChunksEngineMock) GetChunkIDs([]map[string]interface{}) []string { return nil } func (m *switchChunksEngineMock) KNNScores(context.Context, []map[string]interface{}, []float64, int) (map[string]interface{}, error) { return nil, nil } func (m *switchChunksEngineMock) GetScores(map[string]interface{}) map[string]float64 { return nil } func (m *switchChunksEngineMock) Ping(context.Context) error { return nil } func (m *switchChunksEngineMock) Close() error { return nil } func (m *switchChunksEngineMock) GetType() string { return "elasticsearch" } func (m *switchChunksEngineMock) SupportsPageRank() bool { return true } func (m *switchChunksEngineMock) FilterDocIdsByMetaPushdown(context.Context, []string, []map[string]interface{}, string) []string { return nil } func copyMap(in map[string]interface{}) map[string]interface{} { out := make(map[string]interface{}, len(in)) for k, v := range in { out[k] = v } return out } func TestChunkServiceParse_RejectsBatchWithRunningIngestionTask(t *testing.T) { db := setupChunkTestDB(t) pushChunkTestDB(t, db) insertChunkTestUserTenant(t, "user-1", "tenant-1") insertChunkTestKB(t, "kb-1", "tenant-1") insertChunkTestDoc(t, "doc-1", "kb-1") insertChunkTestDoc(t, "doc-2", "kb-1") insertChunkTestIngestionTask(t, "task-2", "user-1", "doc-2", "kb-1", common.RUNNING) svc := newParseTestService(t) svc.accessibleFunc = func(string, string) bool { return true } _, _, err := svc.Parse("user-1", "kb-1", &service.ParseFileRequest{DocumentIDs: []string{"doc-1", "doc-2"}}) if err == nil { t.Fatal("expected error for RUNNING ingestion task, got nil") } } func TestChunkServiceParse_CallsStartParseDocumentsWithRerunWithDelete(t *testing.T) { db := setupChunkTestDB(t) pushChunkTestDB(t, db) insertChunkTestUserTenant(t, "user-1", "tenant-1") insertChunkTestKB(t, "kb-1", "tenant-1") insertChunkTestDoc(t, "doc-1", "kb-1") svc := newParseTestService(t) svc.accessibleFunc = func(string, string) bool { return true } var calledOpts document.StartParseOptions var calledDocID string svc.startParseDocumentsFunc = func(doc *entity.Document, kb *entity.Knowledgebase, userID string, opts document.StartParseOptions) error { calledOpts = opts calledDocID = doc.ID return nil } _, code, err := svc.Parse("user-1", "kb-1", &service.ParseFileRequest{DocumentIDs: []string{"doc-1"}}) if err != nil { t.Fatalf("Parse: %v", err) } if code != common.CodeSuccess { t.Fatalf("code = %d, want success", code) } if calledDocID != "doc-1" { t.Fatalf("startParseDocuments called with doc %s, want doc-1", calledDocID) } if !calledOpts.RerunWithDelete { t.Fatal("expected RerunWithDelete=true") } } func TestChunkServiceParse_ReturnsPartialSuccessForDuplicateDocumentIDs(t *testing.T) { db := setupChunkTestDB(t) pushChunkTestDB(t, db) userID, datasetID := "user-1", "kb-1" insertChunkTestKB(t, datasetID, userID) insertChunkTestDoc(t, "doc-1", datasetID) svc := newParseTestService(t) svc.startParseDocumentsFunc = func(*entity.Document, *entity.Knowledgebase, string, document.StartParseOptions) error { return nil } result, code, err := svc.Parse(userID, datasetID, &service.ParseFileRequest{ DocumentIDs: []string{"doc-1", "doc-1"}, }) if err == nil { t.Fatal("expected duplicate warning error") } if code != common.CodeSuccess { t.Fatalf("expected CodeSuccess, got %v", code) } if result["success_count"] != 1 { t.Fatalf("expected one successful parse, got %v", result["success_count"]) } errorsValue, ok := result["errors"].([]string) if !ok || len(errorsValue) != 1 || !strings.Contains(errorsValue[0], "Duplicate document ids: doc-1") { t.Fatalf("unexpected duplicate errors: %#v", result["errors"]) } } func TestStopParsing_CallsCancelIngestionTask(t *testing.T) { db := setupChunkTestDB(t) pushChunkTestDB(t, db) insertChunkTestKB(t, "kb-1", "user-1") // owner = user-1, Accessible passes insertChunkTestDoc(t, "doc-1", "kb-1") // StopParsing now checks the IngestionTask status (not doc.Run). insertChunkTestIngestionTask(t, "task-1", "user-1", "doc-1", "kb-1", common.RUNNING) svc := newParseTestService(t) var calledDocID string svc.cancelIngestionTaskFunc = func(doc *entity.Document) error { calledDocID = doc.ID return nil } _, _, err := svc.StopParsing("user-1", "kb-1", service.StopParsingRequest{DocumentIDs: []string{"doc-1"}}) if err != nil { t.Fatalf("StopParsing: %v", err) } if calledDocID != "doc-1" { t.Fatalf("cancelIngestionTaskFunc called with doc %s, want doc-1", calledDocID) } } func TestStopParsing_RejectsDocumentWithoutIngestionTask(t *testing.T) { db := setupChunkTestDB(t) pushChunkTestDB(t, db) insertChunkTestKB(t, "kb-1", "user-1") insertChunkTestDoc(t, "doc-1", "kb-1") // No IngestionTask — should be rejected (nothing to stop). svc := newParseTestService(t) var called bool svc.cancelIngestionTaskFunc = func(doc *entity.Document) error { called = true; return nil } _, code, err := svc.StopParsing("user-1", "kb-1", service.StopParsingRequest{DocumentIDs: []string{"doc-1"}}) if err == nil { t.Fatal("expected error for document without ingestion task") } if code != common.CodeDataError { t.Fatalf("expected CodeDataError, got %v", code) } if called { t.Fatal("cancel should not be called for doc without ingestion task") } } func TestStopParsing_DoesNotDeleteChunksOrResetCountersAfterCancel(t *testing.T) { db := setupChunkTestDB(t) pushChunkTestDB(t, db) insertChunkTestKB(t, "kb-1", "user-1") insertChunkTestDoc(t, "doc-1", "kb-1") // Pre-set counters on the doc so we can verify they are NOT reset. if err := dao.DB.Model(&entity.Document{}).Where("id = ?", "doc-1").Updates(map[string]interface{}{ "token_num": int64(10), "chunk_num": int64(5), }).Error; err != nil { t.Fatalf("set counters: %v", err) } insertChunkTestIngestionTask(t, "task-1", "user-1", "doc-1", "kb-1", common.RUNNING) svc := newParseTestService(t) svc.cancelIngestionTaskFunc = func(doc *entity.Document) error { // Simulate CancelDocParse: set doc.run=CANCEL. return dao.DB.Model(&entity.Document{}).Where("id = ?", doc.ID). Update("run", string(entity.TaskStatusCancel)).Error } engine := &parseTestDocEngine{chunkStoreExists: true} svc.docEngine = engine _, _, err := svc.StopParsing("user-1", "kb-1", service.StopParsingRequest{DocumentIDs: []string{"doc-1"}}) if err != nil { t.Fatalf("StopParsing: %v", err) } // Counters must NOT be reset. doc, err := dao.NewDocumentDAO().GetByID("doc-1") if err != nil { t.Fatalf("reload doc: %v", err) } if doc.TokenNum != 10 { t.Fatalf("token_num = %d, want 10 (not reset)", doc.TokenNum) } if doc.ChunkNum != 5 { t.Fatalf("chunk_num = %d, want 5 (not reset)", doc.ChunkNum) } // Chunks must NOT be deleted during cancel. if engine.deleteChunksCalls != 0 { t.Fatalf("DeleteChunks called %d times, want 0", engine.deleteChunksCalls) } }