Add Go service/handler tests for API contract parity (#16905)

This commit is contained in:
euvre
2026-07-20 20:02:41 +08:00
committed by GitHub
parent 2f7c2eb53c
commit f45f03a016
46 changed files with 1553 additions and 214 deletions
+3 -1
View File
@@ -184,7 +184,9 @@ func (dao *ChatDAO) ExistsByNameTenantStatus(name, tenantID, status string) (boo
// Create creates a new chat/dialog
func (dao *ChatDAO) Create(chat *entity.Chat) error {
return DB.Create(chat).Error
// Select("*") forces GORM to persist explicit zero values (e.g. similarity_threshold=0,
// vector_similarity_weight=0, top_n=0) instead of substituting the column defaults.
return DB.Select("*").Create(chat).Error
}
// UpdateByID updates a chat by ID
+3 -6
View File
@@ -245,12 +245,9 @@ func applyDocumentListFilters(query *gorm.DB, opts DocumentListOptions, qualifie
query = query.Where(column("id")+" IN ?", opts.DocIDs)
}
}
if opts.CreateTimeFrom > 0 {
query = query.Where(column("create_time")+" >= ?", opts.CreateTimeFrom)
}
if opts.CreateTimeTo > 0 {
query = query.Where(column("create_time")+" <= ?", opts.CreateTimeTo)
}
// Note: create_time_from / create_time_to are NOT applied at DB level.
// They are filtered post-query in the handler so total reflects the
// unfiltered count, matching the Python API contract.
return query
}
+15 -2
View File
@@ -48,6 +48,11 @@ func IsNotFoundErr(err error) bool {
return errors.Is(err, gorm.ErrRecordNotFound)
}
// IsDuplicateKeyErr returns true if the error is a unique-constraint violation.
func IsDuplicateKeyErr(err error) bool {
return errors.Is(err, gorm.ErrDuplicatedKey)
}
// NewKnowledgebaseDAO create knowledge base DAO
func NewKnowledgebaseDAO() *KnowledgebaseDAO {
return &KnowledgebaseDAO{}
@@ -103,7 +108,7 @@ func (dao *KnowledgebaseDAO) GetByIDs(ids []string) ([]*entity.Knowledgebase, er
// GetByName retrieves a knowledge base by name and tenant ID
func (dao *KnowledgebaseDAO) GetByName(name, tenantID string) (*entity.Knowledgebase, error) {
var kb entity.Knowledgebase
err := DB.Where("name = ? AND tenant_id = ? AND status = ?", name, tenantID, string(entity.StatusValid)).First(&kb).Error
err := DB.Where("LOWER(name) = LOWER(?) AND tenant_id = ? AND status = ?", name, tenantID, string(entity.StatusValid)).First(&kb).Error
if err != nil {
return nil, err
}
@@ -167,7 +172,7 @@ func (dao *KnowledgebaseDAO) Count(filters map[string]interface{}) (int64, error
// GetByTenantIDs retrieves knowledge bases by tenant IDs with pagination
// This matches the Python get_by_tenant_ids method
func (dao *KnowledgebaseDAO) GetByTenantIDs(tenantIDs []string, userID string, pageNumber, itemsPerPage int, orderby string, desc bool, keywords, parserID string) ([]*entity.KnowledgebaseListItem, int64, error) {
func (dao *KnowledgebaseDAO) GetByTenantIDs(tenantIDs []string, userID string, pageNumber, itemsPerPage int, orderby string, desc bool, keywords, parserID, id, name string) ([]*entity.KnowledgebaseListItem, int64, error) {
var kbs []*entity.KnowledgebaseListItem
var total int64
@@ -181,6 +186,14 @@ func (dao *KnowledgebaseDAO) GetByTenantIDs(tenantIDs []string, userID string, p
Where("((knowledgebase.tenant_id IN ? AND knowledgebase.permission = ?) OR knowledgebase.tenant_id = ?) AND knowledgebase.status = ?",
tenantIDs, string(entity.TenantPermissionTeam), userID, string(entity.StatusValid))
if id != "" {
query = query.Where("knowledgebase.id = ?", id)
}
if name != "" {
query = query.Where("knowledgebase.name = ?", name)
}
if keywords != "" {
query = query.Where("LOWER(knowledgebase.name) LIKE ?", "%"+strings.ToLower(keywords)+"%")
}
+78 -1
View File
@@ -57,6 +57,11 @@ func RunMigrations(db *gorm.DB) error {
return fmt.Errorf("failed to modify column types: %w", err)
}
// Add case-insensitive unique constraint on knowledgebase (tenant_id, name)
if err := migrateKnowledgebaseNameUnique(db); err != nil {
return fmt.Errorf("failed to add unique index on knowledgebase (tenant_id, name): %w", err)
}
common.Info("All manual migrations completed successfully")
return nil
}
@@ -253,9 +258,81 @@ func migrateIngestionTaskDocumentIDUnique(db *gorm.DB) error {
return nil
}
// migrateKnowledgebaseNameUnique adds a case-insensitive unique constraint on
// (tenant_id, name) for valid knowledge bases. A VIRTUAL generated column
// (name_ci) computes LOWER(name) only for status='1' rows and is NULL otherwise,
// so soft-deleted rows never block name reuse. The unique index backstops the
// check-then-write path in CreateDataset/UpdateDataset against concurrent
// duplicate inserts, and the resulting duplicate-key error is mapped back to the
// "already exists" domain error at the service layer.
func migrateKnowledgebaseNameUnique(db *gorm.DB) error {
if !db.Migrator().HasTable("knowledgebase") {
return nil
}
// Add the generated column if it does not exist yet.
var colExists int64
if err := db.Raw(`SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'knowledgebase' AND COLUMN_NAME = 'name_ci'`).Scan(&colExists).Error; err != nil {
return err
}
if colExists == 0 {
common.Info("Adding generated column name_ci to knowledgebase...")
if err := db.Exec(`ALTER TABLE knowledgebase
ADD COLUMN name_ci VARCHAR(128) GENERATED ALWAYS AS (
CASE WHEN status = '1' THEN LOWER(name) ELSE NULL END
) VIRTUAL`).Error; err != nil {
errStr := err.Error()
if strings.Contains(errStr, "Error 1060") && strings.Contains(errStr, "Duplicate column name") {
common.Info("Column name_ci already exists, skipping", zap.String("error", errStr))
} else {
return fmt.Errorf("failed to add generated column name_ci: %w", err)
}
}
}
const indexName = "idx_kb_tenant_name_ci"
// Check whether the unique index already exists.
var idxExists int64
if err := db.Raw(`SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'knowledgebase' AND INDEX_NAME = ?`, indexName).Scan(&idxExists).Error; err != nil {
return err
}
if idxExists > 0 {
return nil
}
// Check for duplicate valid names before adding the index.
var duplicateCount int64
if err := db.Raw(`
SELECT COUNT(*) FROM (
SELECT tenant_id, name_ci FROM knowledgebase
WHERE name_ci IS NOT NULL
GROUP BY tenant_id, name_ci HAVING COUNT(*) > 1
) AS duplicates
`).Scan(&duplicateCount).Error; err != nil {
return err
}
if duplicateCount > 0 {
return fmt.Errorf("found %d duplicate (tenant_id, name) pairs among valid knowledge bases; resolve these before the unique index can be created", duplicateCount)
}
common.Info("Adding unique index on knowledgebase (tenant_id, name_ci)...")
if err := db.Exec("ALTER TABLE knowledgebase ADD UNIQUE INDEX " + indexName + " (tenant_id, name_ci)").Error; err != nil {
errStr := err.Error()
if strings.Contains(errStr, "Error 1061") && strings.Contains(errStr, "Duplicate key name") {
common.Info("Index already exists, skipping", zap.String("error", errStr))
return nil
}
return fmt.Errorf("failed to add unique index on knowledgebase (tenant_id, name_ci): %w", err)
}
return nil
}
// modifyColumnTypes modifies column types that need explicit ALTER statements
func modifyColumnTypes(db *gorm.DB) error {
// Helper function to check if column exists
columnExists := func(table, column string) bool {
var count int64
db.Raw(`SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
+11 -4
View File
@@ -203,11 +203,18 @@ func (dao *SearchDAO) DeleteByID(tenantID, id string) error {
// Accessible4Deletion checks if a search can be deleted by a specific user
// Reference: Python search_service.py::accessible4deletion
// Returns true if the search exists, is valid, and was created by the user
// Returns true if the search exists, is valid, and was created by the user.
// A missing or non-owned search returns (false, nil) so callers can distinguish
// "not authorized" from a genuine database error (which is returned as the error).
func (dao *SearchDAO) Accessible4Deletion(searchID string, userID string) (bool, error) {
var search entity.Search
err := DB.Where("id = ? AND created_by = ? AND status = ?", searchID, userID, "1").First(&search).Error
return err == nil, err
var count int64
err := DB.Model(&entity.Search{}).
Where("id = ? AND created_by = ? AND status = ?", searchID, userID, "1").
Count(&count).Error
if err != nil {
return false, err
}
return count > 0, nil
}
// GetByTenantIDAndID gets search by tenant ID and search ID
+23 -17
View File
@@ -20,23 +20,29 @@ import "encoding/json"
// Chat chat model (mapped to dialog table)
type Chat struct {
ID string `gorm:"column:id;primaryKey;size:32" json:"id"`
TenantID string `gorm:"column:tenant_id;size:32;not null;index" json:"tenant_id"`
Name *string `gorm:"column:name;size:255;index" json:"name,omitempty"`
Description *string `gorm:"column:description;type:longtext" json:"description,omitempty"`
Icon *string `gorm:"column:icon;type:longtext" json:"icon,omitempty"`
Language *string `gorm:"column:language;size:32;index" json:"language,omitempty"`
LLMID string `gorm:"column:llm_id;size:128;not null" json:"llm_id"`
TenantLLMID *string `gorm:"column:tenant_llm_id;size:32;index" json:"tenant_llm_id,omitempty"`
LLMSetting JSONMap `gorm:"column:llm_setting;type:longtext;not null" json:"llm_setting"`
PromptType string `gorm:"column:prompt_type;size:16;not null;default:'simple';index" json:"prompt_type"`
PromptConfig JSONMap `gorm:"column:prompt_config;type:longtext;not null" json:"prompt_config"`
MetaDataFilter *JSONMap `gorm:"column:meta_data_filter;type:longtext" json:"meta_data_filter,omitempty"`
SimilarityThreshold float64 `gorm:"column:similarity_threshold;default:0.2" json:"similarity_threshold"`
VectorSimilarityWeight float64 `gorm:"column:vector_similarity_weight;default:0.3" json:"vector_similarity_weight"`
TopN int64 `gorm:"column:top_n;default:6" json:"top_n"`
TopK int64 `gorm:"column:top_k;default:1024" json:"top_k"`
DoRefer string `gorm:"column:do_refer;size:1;not null;default:1" json:"do_refer"`
ID string `gorm:"column:id;primaryKey;size:32" json:"id"`
TenantID string `gorm:"column:tenant_id;size:32;not null;index" json:"tenant_id"`
Name *string `gorm:"column:name;size:255;index" json:"name,omitempty"`
Description *string `gorm:"column:description;type:longtext" json:"description,omitempty"`
Icon *string `gorm:"column:icon;type:longtext" json:"icon,omitempty"`
Language *string `gorm:"column:language;size:32;index" json:"language,omitempty"`
LLMID string `gorm:"column:llm_id;size:128;not null" json:"llm_id"`
TenantLLMID *string `gorm:"column:tenant_llm_id;size:32;index" json:"tenant_llm_id,omitempty"`
LLMSetting JSONMap `gorm:"column:llm_setting;type:longtext;not null" json:"llm_setting"`
PromptType string `gorm:"column:prompt_type;size:16;not null;default:'simple';index" json:"prompt_type"`
PromptConfig JSONMap `gorm:"column:prompt_config;type:longtext;not null" json:"prompt_config"`
MetaDataFilter *JSONMap `gorm:"column:meta_data_filter;type:longtext" json:"meta_data_filter,omitempty"`
// NOTE: No `default:` GORM tags here. The service layer (chat.go Create)
// supplies sensible defaults (0.1 / 0.3 / 6 / 1024 / "1") when a field is
// omitted, and honors an explicitly provided zero value. A GORM `default:`
// tag would force GORM to overwrite an explicit zero (e.g.
// similarity_threshold=0) with the column default during Create, breaking
// the API contract that permits 0.
SimilarityThreshold float64 `gorm:"column:similarity_threshold" json:"similarity_threshold"`
VectorSimilarityWeight float64 `gorm:"column:vector_similarity_weight" json:"vector_similarity_weight"`
TopN int64 `gorm:"column:top_n" json:"top_n"`
TopK int64 `gorm:"column:top_k" json:"top_k"`
DoRefer string `gorm:"column:do_refer;size:1;not null" json:"do_refer"`
RerankID string `gorm:"column:rerank_id;size:128;not null;default:''" json:"rerank_id"`
TenantRerankID *string `gorm:"column:tenant_rerank_id;size:32;index" json:"tenant_rerank_id,omitempty"`
KBIDs JSONSlice `gorm:"column:kb_ids;type:longtext;not null" json:"kb_ids"`
+1
View File
@@ -59,6 +59,7 @@ const (
ParserTypeAudio ParserType = "audio"
ParserTypeEmail ParserType = "email"
ParserTypeTag ParserType = "tag"
ParserTypeKG ParserType = "knowledge_graph"
)
// TaskStatus represents the status of a processing task
+3 -3
View File
@@ -232,7 +232,7 @@ func (h *ChatHandler) DeleteChat(c *gin.Context) {
if err := h.chatService.DeleteChat(userID, chatID); err != nil {
if err.Error() == "no authorization" {
common.ResponseWithCodeData(c, common.CodeDataError, false, "No authorization")
common.ResponseWithCodeData(c, common.CodeAuthenticationError, false, "No authorization.")
return
}
common.ErrorWithCode(c, common.CodeDataError, err.Error())
@@ -330,7 +330,7 @@ func (h *ChatHandler) GetChat(c *gin.Context) {
errMsg := err.Error()
// Check if it's an authorization error
if errMsg == "no authorization" {
common.ResponseWithCodeData(c, common.CodeDataError, false, "No authorization")
common.ResponseWithCodeData(c, common.CodeAuthenticationError, false, "No authorization.")
return
}
// Not found error
@@ -413,7 +413,7 @@ func (h *ChatHandler) updateChatByMethod(c *gin.Context, patch bool) {
}
if err != nil {
if err.Error() == "no authorization" {
common.ResponseWithCodeData(c, common.CodeDataError, false, "No authorization")
common.ResponseWithCodeData(c, common.CodeAuthenticationError, false, "No authorization.")
return
}
common.ResponseWithCodeData(c, common.CodeDataError, nil, err.Error())
+56 -1
View File
@@ -25,7 +25,7 @@ func setupChatHandlerTestDB(t *testing.T) *gorm.DB {
t.Fatalf("failed to open sqlite: %v", err)
}
if err := db.AutoMigrate(&entity.Chat{}, &entity.Tenant{}); err != nil {
if err := db.AutoMigrate(&entity.Chat{}, &entity.Tenant{}, &entity.UserTenant{}); err != nil {
t.Fatalf("failed to migrate test schema: %v", err)
}
@@ -199,3 +199,58 @@ func TestUpdateChatHandlerRejectsNonOwner(t *testing.T) {
t.Fatalf("unexpected message: %v", resp["message"])
}
}
func TestGetChatHandlerRejectsNonOwner(t *testing.T) {
db := setupChatHandlerTestDB(t)
createChatHandlerTestChat(t, db, "chat-1", "tenant-2")
h := NewChatHandler(service.NewChatService(), service.NewUserService())
c, w := setupGinContextWithUser("GET", "/api/v1/chats/chat-1", "")
c.Params = []gin.Param{{Key: "chat_id", Value: "chat-1"}}
h.GetChat(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp["code"] != float64(common.CodeAuthenticationError) {
t.Fatalf("expected auth error code 109, got %v", resp["code"])
}
if resp["data"] != false {
t.Fatalf("expected data=false, got %v", resp["data"])
}
if resp["message"] != "No authorization." {
t.Fatalf("unexpected message: %v", resp["message"])
}
}
func TestDeleteChatHandlerRejectsNonOwner(t *testing.T) {
db := setupChatHandlerTestDB(t)
createChatHandlerTestChat(t, db, "chat-1", "tenant-2")
h := NewChatHandler(service.NewChatService(), service.NewUserService())
c, w := setupGinContextWithUser("DELETE", "/api/v1/chats/chat-1", "")
c.Params = []gin.Param{{Key: "chat_id", Value: "chat-1"}}
h.DeleteChat(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp["code"] != float64(common.CodeAuthenticationError) {
t.Fatalf("expected auth error code 109, got %v", resp["code"])
}
if resp["message"] != "No authorization." {
t.Fatalf("unexpected message: %v", resp["message"])
}
}
+15 -2
View File
@@ -201,12 +201,25 @@ func (h *DatasetsHandler) UpdateDataset(c *gin.Context) {
return
}
var req service.UpdateDatasetRequest
if err := c.ShouldBindJSON(&req); err != nil {
bodyBytes, err := c.GetRawData()
if err != nil {
common.ResponseWithCodeData(c, common.CodeDataError, nil, err.Error())
return
}
var req service.UpdateDatasetRequest
if err := json.Unmarshal(bodyBytes, &req); err != nil {
common.ResponseWithCodeData(c, common.CodeDataError, nil, err.Error())
return
}
// Detect an explicitly provided parser_config key (even {} or null) so it is not
// rejected as "No properties were modified", mirroring the Python contract.
var providedFields map[string]json.RawMessage
if err := json.Unmarshal(bodyBytes, &providedFields); err == nil {
_, req.ParserConfigProvided = providedFields["parser_config"]
}
result, code, err := h.datasetsService.UpdateDataset(datasetID, userID, req)
if err != nil {
common.ErrorWithCode(c, code, err.Error())
+141 -21
View File
@@ -402,11 +402,11 @@ func (h *DocumentHandler) DeleteDocuments(c *gin.Context) {
ids = *req.IDs
}
if len(ids) > 0 && req.DeleteAll {
common.ResponseWithCodeData(c, common.CodeArgumentError, nil, "should not provide both ids and delete_all")
common.ResponseWithCodeData(c, common.CodeDataError, nil, "should not provide both ids and delete_all")
return
}
if len(ids) == 0 && !req.DeleteAll {
common.ResponseWithCodeData(c, common.CodeArgumentError, nil, "should either provide doc ids or set delete_all(true)")
common.ResponseWithCodeData(c, common.CodeDataError, nil, "should either provide doc ids or set delete_all(true)")
return
}
@@ -498,7 +498,7 @@ func (h *DocumentHandler) ListDocuments(c *gin.Context) {
userID := c.GetString("user_id")
if !h.datasetService.Accessible(datasetID, userID) {
common.ResponseWithCodeData(c, common.CodeAuthenticationError, nil, "No authorization to access the dataset.")
common.ResponseWithCodeData(c, common.CodeDataError, nil, fmt.Sprintf("You don't own the dataset %s.", datasetID))
return
}
@@ -530,7 +530,9 @@ func (h *DocumentHandler) ListDocuments(c *gin.Context) {
return
}
// Use kbID to filter documents
// Use kbID to filter documents.
// Note: create_time_from / create_time_to are applied post-query (not at DB level)
// so that total reflects the unfiltered count, matching the Python API contract.
documents, total, err := h.documentService.ListDocumentsByDatasetIDWithOptions(opts, page, pageSize)
if err != nil {
common.ResponseWithCodeData(c, 1, map[string]interface{}{"total": 0, "docs": []interface{}{}}, "failed to get documents")
@@ -539,6 +541,12 @@ func (h *DocumentHandler) ListDocuments(c *gin.Context) {
docs := make([]map[string]interface{}, 0, len(documents))
for _, doc := range documents {
if opts.CreateTimeFrom > 0 && doc.CreateTime != nil && *doc.CreateTime < opts.CreateTimeFrom {
continue
}
if opts.CreateTimeTo > 0 && doc.CreateTime != nil && *doc.CreateTime > opts.CreateTimeTo {
continue
}
metaFields, err := h.documentService.GetDocumentMetadataByID(doc.ID)
if err != nil {
metaFields = make(map[string]interface{})
@@ -1387,31 +1395,38 @@ func (h *DocumentHandler) ListIngestionTasks(c *gin.Context) {
}
type StartParseDocumentsRequest struct {
DatasetID string `json:"dataset_id"`
Documents []string `json:"documents" binding:"required"`
DocumentIDs []string `json:"document_ids" binding:"required"`
}
func (h *DocumentHandler) StartIngestionTask(c *gin.Context) {
datasetID := c.Param("dataset_id")
var req StartParseDocumentsRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.ErrorWithCode(c, common.CodeBadRequest, err.Error())
common.ResponseWithCodeData(c, common.CodeDataError, nil, "`document_ids` is required")
return
}
userID := c.GetString("user_id")
if !h.datasetService.Accessible(req.DatasetID, userID) {
common.ResponseWithCodeData(c, common.CodeAuthenticationError, nil, "No authorization to access the dataset.")
if !h.datasetService.Accessible(datasetID, userID) {
common.ResponseWithCodeData(c, common.CodeDataError, nil, fmt.Sprintf("You don't own the dataset %s.", datasetID))
return
}
parseResult, err := h.documentService.IngestDocuments(req.DatasetID, userID, req.Documents)
parseResult, err := h.documentService.IngestDocuments(datasetID, userID, req.DocumentIDs)
if err != nil {
common.ResponseWithCodeData(c, common.CodeExceptionError, nil, err.Error())
return
}
common.SuccessWithData(c, parseResult, "success")
successCount := 0
for _, r := range parseResult {
if strings.HasPrefix(r.Result, "task_id:") {
successCount++
}
}
common.SuccessWithData(c, map[string]interface{}{"success_count": successCount}, "success")
}
type StopIngestionsRequest struct {
@@ -1510,7 +1525,7 @@ func (h *DocumentHandler) StopParseDocuments(c *gin.Context) {
userID := c.GetString("user_id")
if !h.datasetService.Accessible(datasetID, userID) {
common.ResponseWithCodeData(c, common.CodeAuthenticationError, nil, "You don't own the dataset.")
common.ResponseWithCodeData(c, common.CodeDataError, nil, fmt.Sprintf("You don't own the dataset %s.", datasetID))
return
}
@@ -1684,19 +1699,32 @@ func (h *DocumentHandler) handleBatchUpdateDocumentMetadatas(c *gin.Context) {
return
}
var req documentMetadataBatchRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.ResponseWithCodeData(c, common.CodeDataError, nil, err.Error())
var rawBody map[string]interface{}
if err := c.ShouldBindJSON(&rawBody); err != nil {
common.ResponseWithCodeData(c, common.CodeDataError, nil, "Invalid request payload: expected object, got "+inferJSONType(err))
return
}
if req.Selector == nil {
req.Selector = &document.DocumentMetadataSelector{}
selector, errMsg := parseMetadataSelector(rawBody["selector"])
if errMsg != "" {
common.ResponseWithCodeData(c, common.CodeDataError, nil, errMsg)
return
}
if req.Updates == nil {
req.Updates = []document.DocumentMetadataUpdate{}
updates, errMsg := parseMetadataUpdates(rawBody["updates"])
if errMsg != "" {
common.ResponseWithCodeData(c, common.CodeDataError, nil, errMsg)
return
}
if req.Deletes == nil {
req.Deletes = []document.DocumentMetadataDelete{}
deletes, errMsg := parseMetadataDeletes(rawBody["deletes"])
if errMsg != "" {
common.ResponseWithCodeData(c, common.CodeDataError, nil, errMsg)
return
}
req := documentMetadataBatchRequest{
Selector: selector,
Updates: updates,
Deletes: deletes,
}
resp, code, err := h.documentService.BatchUpdateDocumentMetadatas(datasetID, req.Selector, req.Updates, req.Deletes)
@@ -1706,3 +1734,95 @@ func (h *DocumentHandler) handleBatchUpdateDocumentMetadatas(c *gin.Context) {
}
common.SuccessWithData(c, resp, "success")
}
func inferJSONType(err error) string {
s := err.Error()
if strings.Contains(s, "array") {
return "array"
}
if strings.Contains(s, "number") {
return "number"
}
if strings.Contains(s, "string") {
return "string"
}
if strings.Contains(s, "bool") {
return "bool"
}
return "unknown"
}
func parseMetadataSelector(raw interface{}) (*document.DocumentMetadataSelector, string) {
if raw == nil {
return &document.DocumentMetadataSelector{}, ""
}
m, ok := raw.(map[string]interface{})
if !ok {
return nil, "selector must be an object."
}
selector := &document.DocumentMetadataSelector{}
if v, ok := m["document_ids"]; ok && v != nil {
ids, ok := v.([]interface{})
if !ok {
return nil, "document_ids must be a list."
}
for _, id := range ids {
selector.DocumentIDs = append(selector.DocumentIDs, id.(string))
}
}
if v, ok := m["metadata_condition"]; ok && v != nil {
mc, ok := v.(map[string]interface{})
if !ok {
return nil, "metadata_condition must be an object."
}
selector.MetadataCondition = mc
}
return selector, ""
}
func parseMetadataUpdates(raw interface{}) ([]document.DocumentMetadataUpdate, string) {
if raw == nil {
return []document.DocumentMetadataUpdate{}, ""
}
arr, ok := raw.([]interface{})
if !ok {
return nil, "updates and deletes must be lists."
}
updates := make([]document.DocumentMetadataUpdate, 0, len(arr))
for _, item := range arr {
m, ok := item.(map[string]interface{})
if !ok {
return nil, "Each update requires key and value."
}
key, _ := m["key"].(string)
if key == "" {
return nil, "Each update requires key and value."
}
value := m["value"]
updates = append(updates, document.DocumentMetadataUpdate{Key: key, Value: value})
}
return updates, ""
}
func parseMetadataDeletes(raw interface{}) ([]document.DocumentMetadataDelete, string) {
if raw == nil {
return []document.DocumentMetadataDelete{}, ""
}
arr, ok := raw.([]interface{})
if !ok {
return nil, "updates and deletes must be lists."
}
deletes := make([]document.DocumentMetadataDelete, 0, len(arr))
for _, item := range arr {
m, ok := item.(map[string]interface{})
if !ok {
return nil, "Each delete requires key."
}
key, _ := m["key"].(string)
if key == "" {
return nil, "Each delete requires key."
}
deletes = append(deletes, document.DocumentMetadataDelete{Key: key})
}
return deletes, ""
}
+3 -3
View File
@@ -148,8 +148,8 @@ func (h *SearchHandler) ListSearches(c *gin.Context) {
// @Router /api/v1/searches [post]
type CreateSearchRequest struct {
Name string `json:"name" binding:"required"` // required field, max 255 bytes
Description *string `json:"description,omitempty"` // optional description
Name string `json:"name"` // required, validated via common.ValidateName (max 255 bytes)
Description *string `json:"description,omitempty"` // optional description
}
func (h *SearchHandler) CreateSearch(c *gin.Context) {
@@ -329,7 +329,7 @@ func (h *SearchHandler) UpdateSearch(c *gin.Context) {
errMsg := err.Error()
switch errMsg {
case "no authorization":
common.ResponseWithCodeData(c, common.CodeDataError, false, "No authorization")
common.ResponseWithCodeData(c, common.CodeAuthenticationError, false, "No authorization.")
case "duplicated search name":
common.ResponseWithCodeData(c, common.CodeDataError, nil, "Duplicated search name.")
default:
+100
View File
@@ -0,0 +1,100 @@
//
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
package handler
import (
"encoding/json"
"net/http"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"ragflow/internal/common"
"ragflow/internal/dao"
"ragflow/internal/entity"
"ragflow/internal/service"
)
func setupSearchHandlerTestDB(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.Search{}, &entity.UserTenant{}); err != nil {
t.Fatalf("failed to migrate test schema: %v", err)
}
origDB := dao.DB
dao.DB = db
t.Cleanup(func() { dao.DB = origDB })
return db
}
func TestSearchHandlerCreateRejectsEmptyName(t *testing.T) {
setupSearchHandlerTestDB(t)
h := NewSearchHandler(service.NewSearchService(), service.NewUserService())
c, w := setupGinContextWithUser("POST", "/api/v1/searches", `{"name": " "}`)
h.CreateSearch(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp["code"] != float64(common.CodeDataError) {
t.Fatalf("expected code 102, got %v", resp["code"])
}
if !strings.Contains(resp["message"].(string), "empty") {
t.Fatalf("expected message containing 'empty', got %v", resp["message"])
}
}
func TestSearchHandlerUpdateRejectsInvalidSearchID(t *testing.T) {
setupSearchHandlerTestDB(t)
h := NewSearchHandler(service.NewSearchService(), service.NewUserService())
c, w := setupGinContextWithUser("PUT", "/api/v1/searches/invalid_search_id", `{"name": "invalid", "search_config": {}}`)
c.Params = []gin.Param{{Key: "search_id", Value: "invalid_search_id"}}
h.UpdateSearch(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp["code"] != float64(common.CodeAuthenticationError) {
t.Fatalf("expected code 109, got %v", resp["code"])
}
if !strings.Contains(resp["message"].(string), "No authorization") {
t.Fatalf("expected 'No authorization' in message, got %v", resp["message"])
}
}
+1 -1
View File
@@ -401,11 +401,11 @@ func (r *Router) Setup(engine *gin.Engine) {
datasets.POST("/:dataset_id/chunks", r.chunkHandler.Parse)
datasets.PATCH("/:dataset_id/documents/:document_id/chunks/:chunk_id", r.chunkHandler.UpdateChunk)
datasets.POST("/:dataset_id/documents/parse", r.documentHandler.StartIngestionTask)
datasets.POST("/:dataset_id/documents/stop", r.documentHandler.StopParseDocuments)
datasets.GET("/ingestion/tasks", r.documentHandler.ListIngestionTasks)
datasets.PUT("/ingestion/tasks", r.documentHandler.StopIngestionTasks)
datasets.DELETE("/ingestion/tasks", r.documentHandler.RemoveIngestionTasks)
//datasets.POST("/:dataset_id/documents/parse", r.documentHandler.ParseDocuments)
//datasets.POST("/:dataset_id/documents/stop", r.documentHandler.StopParseDocuments)
datasets.DELETE("/:dataset_id/chunks", r.chunkHandler.StopParsing)
datasets.DELETE("/:dataset_id/documents/:document_id/chunks", r.chunkHandler.RemoveChunks)
datasets.PUT("/:dataset_id/documents/:document_id/metadata/config", r.datasetsHandler.UpdateDocumentMetadataConfig)
+21 -18
View File
@@ -131,7 +131,7 @@ func (s *ChatService) Create(userID string, req map[string]interface{}) (map[str
}
if tenantValue, ok := req["tenant_id"]; ok && isTruthy(tenantValue) {
return nil, common.CodeDataError, errors.New("`tenant_id` must not be provided")
return nil, common.CodeDataError, errors.New("`tenant_id` must not be provided.")
}
name, err := validateCreateChatName(req["name"])
@@ -174,13 +174,13 @@ func (s *ChatService) Create(userID string, req map[string]interface{}) (map[str
if promptConfigValue, ok := req["prompt_config"]; ok {
if _, ok := mapFromValue(promptConfigValue); !ok {
return nil, common.CodeDataError, errors.New("`prompt_config` should be an object")
return nil, common.CodeDataError, errors.New("`prompt_config` should be an object.")
}
}
if metaDataFilterValue, ok := req["meta_data_filter"]; ok && metaDataFilterValue != nil {
if _, ok := mapFromValue(metaDataFilterValue); !ok {
return nil, common.CodeDataError, errors.New("`meta_data_filter` should be an object")
return nil, common.CodeDataError, errors.New("`meta_data_filter` should be an object.")
}
}
@@ -225,6 +225,9 @@ func (s *ChatService) Create(userID string, req map[string]interface{}) (map[str
if _, ok := req["vector_similarity_weight"]; !ok {
req["vector_similarity_weight"] = 0.3
}
if _, ok := req["do_refer"]; !ok {
req["do_refer"] = "1"
}
if _, ok := req["icon"]; !ok {
req["icon"] = ""
}
@@ -240,7 +243,7 @@ func (s *ChatService) Create(userID string, req map[string]interface{}) (map[str
return nil, common.CodeServerError, err
}
if exists {
return nil, common.CodeDataError, errors.New("duplicated chat name in creating chat")
return nil, common.CodeDataError, errors.New("Duplicated chat name in creating chat.")
}
chat := buildCreateChatEntity(req, userID)
@@ -262,18 +265,18 @@ func (s *ChatService) Create(userID string, req map[string]interface{}) (map[str
func validateCreateChatName(value interface{}) (string, error) {
if value == nil {
return "", errors.New("`name` is required")
return "", errors.New("`name` is required.")
}
name, ok := value.(string)
if !ok {
return "", errors.New("chat name must be a string")
return "", errors.New("Chat name must be a string.")
}
name = strings.TrimSpace(name)
if name == "" {
return "", errors.New("`name` is required")
return "", errors.New("`name` is required.")
}
if len([]byte(name)) > 255 {
return "", fmt.Errorf("chat name length is %d which is larger than 255", len([]byte(name)))
return "", fmt.Errorf("Chat name length is %d which is larger than 255.", len([]byte(name)))
}
return name, nil
}
@@ -767,7 +770,7 @@ func (s *ChatService) updateChatREST(userID, chatID string, req map[string]inter
}
if !patch && isTruthy(req["tenant_id"]) {
return nil, errors.New("`tenant_id` must not be provided")
return nil, errors.New("`tenant_id` must not be provided.")
}
if value, ok := req["name"]; ok {
@@ -827,7 +830,7 @@ func (s *ChatService) updateChatREST(userID, chatID string, req map[string]inter
if value, ok := req["prompt_config"]; ok {
promptConfig, ok := mapFromValue(value)
if !ok {
return nil, errors.New("`prompt_config` should be an object")
return nil, errors.New("`prompt_config` should be an object.")
}
if patch {
req["prompt_config"] = mergeJSONMap(currentChat.PromptConfig, promptConfig)
@@ -850,7 +853,7 @@ func (s *ChatService) updateChatREST(userID, chatID string, req map[string]inter
} else {
metaDataFilter, ok := mapFromValue(value)
if !ok {
return nil, errors.New("`meta_data_filter` should be an object")
return nil, errors.New("`meta_data_filter` should be an object.")
}
req["meta_data_filter"] = entity.JSONMap(metaDataFilter)
}
@@ -871,8 +874,8 @@ func (s *ChatService) updateChatREST(userID, chatID string, req map[string]inter
return nil, err
}
for _, existingName := range existingNames {
if existingName == name {
return nil, errors.New("duplicated chat name")
if strings.EqualFold(existingName, name) {
return nil, errors.New("Duplicated chat name.")
}
}
}
@@ -897,23 +900,23 @@ func (s *ChatService) updateChatREST(userID, chatID string, req map[string]inter
func validateRESTChatName(value interface{}, required bool) (string, bool, error) {
if value == nil {
if required {
return "", false, errors.New("`name` is required")
return "", false, errors.New("`name` is required.")
}
return "", false, nil
}
name, ok := value.(string)
if !ok {
return "", false, errors.New("chat name must be a string")
return "", false, errors.New("Chat name must be a string.")
}
name = strings.TrimSpace(name)
if name == "" {
if required {
return "", false, errors.New("`name` is required")
return "", false, errors.New("`name` is required.")
}
return "", false, errors.New("`name` cannot be empty")
return "", false, errors.New("`name` cannot be empty.")
}
if len([]byte(name)) > 255 {
return "", false, fmt.Errorf("chat name length is %d which is larger than 255", len([]byte(name)))
return "", false, fmt.Errorf("Chat name length is %d which is larger than 255.", len([]byte(name)))
}
return name, true, nil
}
+180
View File
@@ -0,0 +1,180 @@
package service
import (
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"ragflow/internal/dao"
"ragflow/internal/entity"
)
func setupChatListTestDB(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.Chat{},
&entity.Tenant{},
&entity.User{},
&entity.UserTenant{},
); err != nil {
t.Fatalf("failed to migrate test schema: %v", err)
}
origDB := dao.DB
dao.DB = db
t.Cleanup(func() { dao.DB = origDB })
status := string(entity.StatusValid)
if err := db.Create(&entity.Tenant{
ID: "user-1",
LLMID: "model-a",
EmbdID: "embd-a",
ParserIDs: "naive",
Status: &status,
}).Error; err != nil {
t.Fatalf("failed to create tenant: %v", err)
}
if err := db.Create(&entity.User{
ID: "user-1",
Nickname: "tester",
Status: sptr("1"),
}).Error; err != nil {
t.Fatalf("failed to create user: %v", err)
}
return db
}
func createChatListTestChat(t *testing.T, db *gorm.DB, id, tenantID, name string) {
t.Helper()
status := string(entity.StatusValid)
chat := &entity.Chat{
ID: id,
TenantID: tenantID,
Name: &name,
LLMID: "model-a",
LLMSetting: entity.JSONMap{},
PromptType: "simple",
PromptConfig: entity.JSONMap{},
KBIDs: entity.JSONSlice{},
Status: &status,
}
if err := db.Create(chat).Error; err != nil {
t.Fatalf("failed to create chat: %v", err)
}
}
func TestChatServiceListChatsDefaultReturnsAllWithCorrectTotal(t *testing.T) {
db := setupChatListTestDB(t)
createChatListTestChat(t, db, "chat-1", "user-1", "list_test_0")
createChatListTestChat(t, db, "chat-2", "user-1", "list_test_1")
createChatListTestChat(t, db, "chat-3", "user-1", "list_test_2")
svc := NewChatService()
result, err := svc.ListChats("user-1", "1", "", 0, 0, "create_time", true)
if err != nil {
t.Fatalf("ListChats failed: %v", err)
}
if result.Total != 3 {
t.Fatalf("expected total=3, got %d", result.Total)
}
if len(result.Chats) != 3 {
t.Fatalf("expected 3 chats, got %d", len(result.Chats))
}
}
func TestChatServiceListChatsKeywordFiltersCorrectly(t *testing.T) {
db := setupChatListTestDB(t)
createChatListTestChat(t, db, "chat-1", "user-1", "list_keyword_0")
createChatListTestChat(t, db, "chat-2", "user-1", "list_keyword_1")
createChatListTestChat(t, db, "chat-3", "user-1", "list_other_2")
svc := NewChatService()
exactResult, err := svc.ListChats("user-1", "1", "list_keyword_1", 0, 0, "create_time", true)
if err != nil {
t.Fatalf("ListChats keyword exact failed: %v", err)
}
if len(exactResult.Chats) != 1 {
t.Fatalf("expected 1 chat for keyword 'list_keyword_1', got %d", len(exactResult.Chats))
}
if exactResult.Chats[0].Name == nil || *exactResult.Chats[0].Name != "list_keyword_1" {
t.Fatalf("expected chat name 'list_keyword_1', got %+v", exactResult.Chats[0].Name)
}
unknownResult, err := svc.ListChats("user-1", "1", "unknown_keyword", 0, 0, "create_time", true)
if err != nil {
t.Fatalf("ListChats unknown keyword failed: %v", err)
}
if len(unknownResult.Chats) != 0 {
t.Fatalf("expected 0 chats for unknown keyword, got %d", len(unknownResult.Chats))
}
partialResult, err := svc.ListChats("user-1", "1", "list_keyword", 0, 0, "create_time", true)
if err != nil {
t.Fatalf("ListChats partial keyword failed: %v", err)
}
if len(partialResult.Chats) != 2 {
t.Fatalf("expected 2 chats for keyword 'list_keyword', got %d", len(partialResult.Chats))
}
}
func TestChatServiceListChatsPagination(t *testing.T) {
db := setupChatListTestDB(t)
for i := 0; i < 5; i++ {
createChatListTestChat(t, db, "chat-"+string(rune('a'+i)), "user-1", "page_test")
}
svc := NewChatService()
page1, err := svc.ListChats("user-1", "1", "", 1, 2, "create_time", true)
if err != nil {
t.Fatalf("ListChats page 1 failed: %v", err)
}
if len(page1.Chats) != 2 {
t.Fatalf("expected 2 chats on page 1, got %d", len(page1.Chats))
}
if page1.Total != 5 {
t.Fatalf("expected total=5, got %d", page1.Total)
}
page3, err := svc.ListChats("user-1", "1", "", 3, 2, "create_time", true)
if err != nil {
t.Fatalf("ListChats page 3 failed: %v", err)
}
if len(page3.Chats) != 1 {
t.Fatalf("expected 1 chat on page 3, got %d", len(page3.Chats))
}
}
func TestChatServiceListChatsExcludesDeletedChats(t *testing.T) {
db := setupChatListTestDB(t)
createChatListTestChat(t, db, "chat-1", "user-1", "active_chat")
createChatListTestChat(t, db, "chat-2", "user-1", "deleted_chat")
invalidStatus := string(entity.StatusInvalid)
db.Model(&entity.Chat{}).Where("id = ?", "chat-2").Update("status", invalidStatus)
svc := NewChatService()
result, err := svc.ListChats("user-1", "1", "", 0, 0, "create_time", true)
if err != nil {
t.Fatalf("ListChats failed: %v", err)
}
if len(result.Chats) != 1 {
t.Fatalf("expected 1 active chat, got %d", len(result.Chats))
}
if result.Chats[0].Name == nil || *result.Chats[0].Name != "active_chat" {
t.Fatalf("expected active_chat, got %+v", result.Chats[0].Name)
}
}
+186 -2
View File
@@ -1,6 +1,7 @@
package service
import (
"strings"
"testing"
"github.com/glebarez/sqlite"
@@ -174,7 +175,7 @@ func TestChatServiceCreateRejectsInvalidMetaDataFilter(t *testing.T) {
"name": "created chat",
"meta_data_filter": []interface{}{"invalid"},
})
if err == nil || err.Error() != "`meta_data_filter` should be an object" {
if err == nil || err.Error() != "`meta_data_filter` should be an object." {
t.Fatalf("expected meta_data_filter error, got %v", err)
}
if code != common.CodeDataError {
@@ -227,7 +228,7 @@ func TestChatServiceUpdateChatRejectsTenantID(t *testing.T) {
_, err := svc.UpdateChat("user-1", "chat-1", map[string]interface{}{
"tenant_id": "tenant-2",
})
if err == nil || err.Error() != "`tenant_id` must not be provided" {
if err == nil || err.Error() != "`tenant_id` must not be provided." {
t.Fatalf("expected tenant_id error, got %v", err)
}
}
@@ -325,3 +326,186 @@ func TestChatServicePatchChatIgnoresTenantIDAndUpdatesName(t *testing.T) {
t.Fatalf("expected trimmed name, got %+v", chat.Name)
}
}
func TestChatServiceCreateValidatesName(t *testing.T) {
setupChatRESTUpdateServiceTestDB(t)
svc := NewChatService()
_, code, err := svc.Create("user-1", map[string]interface{}{"name": " "})
if err == nil {
t.Fatal("expected name validation error")
}
if code != common.CodeDataError {
t.Fatalf("expected data error code, got %d", code)
}
if err.Error() != "`name` is required." {
t.Fatalf("unexpected error: %v", err)
}
}
func TestChatServiceCreateRejectsDuplicateName(t *testing.T) {
db := setupChatRESTUpdateServiceTestDB(t)
createChatRESTUpdateServiceTestChat(t, db, "chat-1", "user-1")
svc := NewChatService()
_, code, err := svc.Create("user-1", map[string]interface{}{"name": "chat-chat-1"})
if err == nil {
t.Fatal("expected duplicate name error")
}
if code != common.CodeDataError {
t.Fatalf("expected data error code, got %d", code)
}
if !strings.Contains(err.Error(), "Duplicated chat name") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestChatServiceUpdateValidatesName(t *testing.T) {
db := setupChatRESTUpdateServiceTestDB(t)
createChatRESTUpdateServiceTestChat(t, db, "chat-1", "user-1")
svc := NewChatService()
_, err := svc.UpdateChat("user-1", "chat-1", map[string]interface{}{"name": " "})
if err == nil {
t.Fatal("expected name validation error")
}
if err.Error() != "`name` is required." {
t.Fatalf("unexpected error: %v", err)
}
}
func TestChatServiceUpdateRejectsDuplicateName(t *testing.T) {
db := setupChatRESTUpdateServiceTestDB(t)
createChatRESTUpdateServiceTestChat(t, db, "chat-1", "user-1")
createChatRESTUpdateServiceTestChat(t, db, "chat-2", "user-1")
svc := NewChatService()
_, err := svc.UpdateChat("user-1", "chat-1", map[string]interface{}{"name": "chat-chat-2"})
if err == nil {
t.Fatal("expected duplicate name error")
}
if err.Error() != "Duplicated chat name." {
t.Fatalf("unexpected error: %v", err)
}
}
func TestChatServicePatchChatRejectsEmptyName(t *testing.T) {
db := setupChatRESTUpdateServiceTestDB(t)
createChatRESTUpdateServiceTestChat(t, db, "chat-1", "user-1")
svc := NewChatService()
_, err := svc.PatchChat("user-1", "chat-1", map[string]interface{}{"name": ""})
if err == nil || err.Error() != "`name` cannot be empty." {
t.Fatalf("expected empty name error, got %v", err)
}
}
func TestChatServicePatchChatRejectsNonStringName(t *testing.T) {
db := setupChatRESTUpdateServiceTestDB(t)
createChatRESTUpdateServiceTestChat(t, db, "chat-1", "user-1")
svc := NewChatService()
_, err := svc.PatchChat("user-1", "chat-1", map[string]interface{}{"name": 123})
if err == nil || err.Error() != "Chat name must be a string." {
t.Fatalf("expected non-string name error, got %v", err)
}
}
func TestChatServicePatchChatRejectsTooLongName(t *testing.T) {
db := setupChatRESTUpdateServiceTestDB(t)
createChatRESTUpdateServiceTestChat(t, db, "chat-1", "user-1")
svc := NewChatService()
longName := strings.Repeat("a", 256)
_, err := svc.PatchChat("user-1", "chat-1", map[string]interface{}{"name": longName})
if err == nil {
t.Fatal("expected too long name error")
}
if err.Error() != "Chat name length is 256 which is larger than 255." {
t.Fatalf("unexpected error: %v", err)
}
}
func TestChatServiceUpdateRejectsDuplicateNameCaseInsensitive(t *testing.T) {
db := setupChatRESTUpdateServiceTestDB(t)
createChatRESTUpdateServiceTestChat(t, db, "chat-1", "user-1")
createChatRESTUpdateServiceTestChat(t, db, "chat-2", "user-1")
svc := NewChatService()
_, err := svc.PatchChat("user-1", "chat-1", map[string]interface{}{"name": "CHAT-CHAT-2"})
if err == nil || err.Error() != "Duplicated chat name." {
t.Fatalf("expected case-insensitive duplicate name error, got %v", err)
}
}
func TestChatServiceCreateRejectsTenantID(t *testing.T) {
setupChatRESTUpdateServiceTestDB(t)
svc := NewChatService()
_, code, err := svc.Create("user-1", map[string]interface{}{
"name": "valid chat",
"tenant_id": "other-tenant",
})
if err == nil || err.Error() != "`tenant_id` must not be provided." {
t.Fatalf("expected tenant_id error, got %v", err)
}
if code != common.CodeDataError {
t.Fatalf("expected data error code, got %d", code)
}
}
func TestChatServiceCreateRejectsInvalidPromptConfig(t *testing.T) {
setupChatRESTUpdateServiceTestDB(t)
svc := NewChatService()
_, code, err := svc.Create("user-1", map[string]interface{}{
"name": "valid chat",
"prompt_config": "invalid",
})
if err == nil || err.Error() != "`prompt_config` should be an object." {
t.Fatalf("expected prompt_config error, got %v", err)
}
if code != common.CodeDataError {
t.Fatalf("expected data error code, got %d", code)
}
}
func TestChatServiceCreatePromptDefaultsContract(t *testing.T) {
setupChatRESTUpdateServiceTestDB(t)
svc := NewChatService()
resp, code, err := svc.Create("user-1", map[string]interface{}{
"name": "prompt defaults chat",
})
if err != nil {
t.Fatalf("Create failed: %v", err)
}
if code != common.CodeSuccess {
t.Fatalf("expected success code, got %d", code)
}
promptConfig, ok := resp["prompt_config"].(map[string]interface{})
if !ok {
t.Fatalf("expected prompt_config map, got %T: %+v", resp["prompt_config"], resp["prompt_config"])
}
if promptConfig["quote"] != true {
t.Fatalf("expected default quote=true, got %#v", promptConfig["quote"])
}
params, ok := promptConfig["parameters"].([]interface{})
if !ok || len(params) != 1 {
t.Fatalf("expected default parameters list with 1 entry, got %#v", promptConfig["parameters"])
}
param, ok := params[0].(map[string]interface{})
if !ok || param["key"] != "knowledge" || param["optional"] != false {
t.Fatalf("expected knowledge parameter, got %#v", params[0])
}
if promptConfig["system"] == "" {
t.Fatal("expected non-empty default system prompt")
}
if promptConfig["prologue"] == "" {
t.Fatal("expected non-empty default prologue")
}
if promptConfig["empty_response"] == "" {
t.Fatal("expected non-empty default empty_response")
}
}
@@ -0,0 +1,210 @@
//
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
package service
import (
"strings"
"testing"
"ragflow/internal/common"
"ragflow/internal/entity"
)
// ---------------------------------------------------------------------------
// CreateSession contract — mirrors the assertions in
// test_session_create_validation_and_deleted_chat_contract that exercise the
// Go service layer (name validation -> 102, auth -> 109, truncation, success).
// ---------------------------------------------------------------------------
func TestCreateSession_Success(t *testing.T) {
store := newFakeSessionStore()
store.dialogExists["user-1|chat-1"] = true
store.dialogs["chat-1"] = &entity.Chat{ID: "chat-1", PromptConfig: entity.JSONMap{"prologue": "hi"}}
svc := &ChatSessionService{
chatSessionDAO: store,
userTenantDAO: &fakeTenantStore{},
pipeline: &fakePipeline{},
}
resp, code, err := svc.CreateSession("user-1", "chat-1", map[string]interface{}{"name": "valid"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if code != common.CodeSuccess {
t.Fatalf("code=%v", code)
}
if resp.Name == nil || *resp.Name != "valid" {
t.Fatalf("name=%v", resp.Name)
}
if resp.ChatID != "chat-1" {
t.Fatalf("chat_id=%q", resp.ChatID)
}
if len(store.sessions) != 1 {
t.Fatalf("expected 1 session created, got %d", len(store.sessions))
}
}
func TestCreateSession_RejectsEmptyOrNonStringName(t *testing.T) {
store := newFakeSessionStore()
store.dialogExists["user-1|chat-1"] = true
store.dialogs["chat-1"] = &entity.Chat{ID: "chat-1"}
svc := &ChatSessionService{
chatSessionDAO: store,
userTenantDAO: &fakeTenantStore{},
pipeline: &fakePipeline{},
}
for _, name := range []interface{}{"", " ", 1} {
_, code, err := svc.CreateSession("user-1", "chat-1", map[string]interface{}{"name": name})
if err == nil || err.Error() != "`name` can not be empty." {
t.Fatalf("name=%#v err=%v", name, err)
}
if code != common.CodeDataError {
t.Fatalf("name=%#v code=%v", name, code)
}
}
}
func TestCreateSession_TruncatesLongName(t *testing.T) {
store := newFakeSessionStore()
store.dialogExists["user-1|chat-1"] = true
store.dialogs["chat-1"] = &entity.Chat{ID: "chat-1"}
svc := &ChatSessionService{
chatSessionDAO: store,
userTenantDAO: &fakeTenantStore{},
pipeline: &fakePipeline{},
}
longName := strings.Repeat("a", 300)
resp, code, err := svc.CreateSession("user-1", "chat-1", map[string]interface{}{"name": longName})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if code != common.CodeSuccess {
t.Fatalf("code=%v", code)
}
if resp.Name == nil || len([]rune(*resp.Name)) != 255 {
t.Fatalf("expected name truncated to 255 runes, got %v", resp.Name)
}
}
func TestCreateSession_NotOwner(t *testing.T) {
store := newFakeSessionStore()
svc := &ChatSessionService{
chatSessionDAO: store,
userTenantDAO: &fakeTenantStore{},
pipeline: &fakePipeline{},
}
_, code, err := svc.CreateSession("user-1", "chat-1", map[string]interface{}{"name": "x"})
if err == nil || err.Error() != "No authorization." {
t.Fatalf("err=%v", err)
}
if code != common.CodeAuthenticationError {
t.Fatalf("code=%v", code)
}
}
// ---------------------------------------------------------------------------
// DeleteSessions contract — mirrors the service-layer assertions in
// test_session_delete_basic_scenarios.
// ---------------------------------------------------------------------------
func TestDeleteSessions_SuccessByIDs(t *testing.T) {
store := newFakeSessionStore()
store.dialogExists["user-1|chat-1"] = true
store.sessions["s1"] = &entity.ChatSession{ID: "s1", DialogID: "chat-1"}
store.sessions["s2"] = &entity.ChatSession{ID: "s2", DialogID: "chat-1"}
svc := &ChatSessionService{
chatSessionDAO: store,
userTenantDAO: &fakeTenantStore{},
pipeline: &fakePipeline{},
}
result, message, code, err := svc.DeleteSessions("user-1", "chat-1", map[string]interface{}{"ids": []interface{}{"s1"}})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if code != common.CodeSuccess {
t.Fatalf("code=%v", code)
}
if message != "success" {
t.Fatalf("message=%q", message)
}
if result != true {
t.Fatalf("result=%v", result)
}
if len(store.sessions) != 1 {
t.Fatalf("expected 1 session remaining, got %d", len(store.sessions))
}
}
func TestDeleteSessions_DeleteAllAndInvalidID(t *testing.T) {
store := newFakeSessionStore()
store.dialogExists["user-1|chat-1"] = true
svc := &ChatSessionService{
chatSessionDAO: store,
userTenantDAO: &fakeTenantStore{},
pipeline: &fakePipeline{},
}
// Empty payload -> success with empty result map.
if _, _, code, err := svc.DeleteSessions("user-1", "chat-1", map[string]interface{}{}); err != nil || code != common.CodeSuccess {
t.Fatalf("empty payload: code=%v err=%v", code, err)
}
// delete_all removes every session for the chat.
store.sessions["s1"] = &entity.ChatSession{ID: "s1", DialogID: "chat-1"}
if _, _, code, err := svc.DeleteSessions("user-1", "chat-1", map[string]interface{}{"delete_all": true}); err != nil || code != common.CodeSuccess {
t.Fatalf("delete_all: code=%v err=%v", code, err)
}
if len(store.sessions) != 0 {
t.Fatalf("delete_all should remove all, got %d", len(store.sessions))
}
// Unknown id -> DataError reporting the unowned session.
store.sessions["s1"] = &entity.ChatSession{ID: "s1", DialogID: "chat-1"}
_, _, code, err := svc.DeleteSessions("user-1", "chat-1", map[string]interface{}{"ids": []interface{}{"missing"}})
if err == nil || !strings.Contains(err.Error(), "The chat doesn't own the session missing") {
t.Fatalf("invalid id: err=%v", err)
}
if code != common.CodeDataError {
t.Fatalf("invalid id code=%v", code)
}
}
func TestDeleteSessions_NotOwner(t *testing.T) {
store := newFakeSessionStore()
svc := &ChatSessionService{
chatSessionDAO: store,
userTenantDAO: &fakeTenantStore{},
pipeline: &fakePipeline{},
}
_, _, code, err := svc.DeleteSessions("user-1", "chat-1", map[string]interface{}{"ids": []interface{}{"s1"}})
if err == nil || err.Error() != "No authorization." {
t.Fatalf("err=%v", err)
}
if code != common.CodeAuthenticationError {
t.Fatalf("code=%v", code)
}
}
+83
View File
@@ -136,6 +136,89 @@ func TestCreateDataset_ParseTypePipelineIgnoresParserID(t *testing.T) {
}
}
func TestCreateDataset_ValidatesName(t *testing.T) {
db := setupServiceTestDB(t)
pushServiceDB(t, db)
insertCreateDatasetTenant(t, "tenant-1")
_, code, err := testDatasetCreateService(t).CreateDataset(&service.CreateDatasetRequest{Name: " "}, "tenant-1")
if err == nil {
t.Fatal("expected name validation error")
}
if code != common.CodeDataError {
t.Fatalf("expected data error code, got %d", code)
}
if err.Error() != "Dataset name can't be empty." {
t.Fatalf("unexpected error: %v", err)
}
}
func TestCreateDataset_RejectsDuplicateName(t *testing.T) {
db := setupServiceTestDB(t)
pushServiceDB(t, db)
insertCreateDatasetTenant(t, "tenant-1")
if err := db.Create(&entity.Knowledgebase{
ID: "kb-1",
TenantID: "tenant-1",
Name: "Existing",
ParserID: "naive",
CreatedBy: "tenant-1",
Status: sptr(string(entity.StatusValid)),
}).Error; err != nil {
t.Fatalf("failed to create existing kb: %v", err)
}
_, code, err := testDatasetCreateService(t).CreateDataset(&service.CreateDatasetRequest{Name: "Existing"}, "tenant-1")
if err == nil {
t.Fatal("expected duplicate name error")
}
if code != common.CodeDataError {
t.Fatalf("expected data error code, got %d", code)
}
if !strings.Contains(err.Error(), "already exists") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestCreateDataset_RejectsInvalidEmbeddingModel(t *testing.T) {
cases := []struct {
name string
embeddingModel string
expectedMessage string
}{
{"empty", "", "Embedding model identifier must follow <model_name>@<provider> format"},
{"whitespace", " ", "Embedding model identifier must follow <model_name>@<provider> format"},
{"missing_at", "BAAI/bge-small-en-v1.5Builtin", "Embedding model identifier must follow <model_name>@<provider> format"},
{"empty_model_name", "@Builtin", "Both model_name and provider must be non-empty strings"},
{"empty_provider", "BAAI/bge-small-en-v1.5@", "Both model_name and provider must be non-empty strings"},
{"whitespace_model_name", " @Builtin", "Both model_name and provider must be non-empty strings"},
{"whitespace_provider", "BAAI/bge-small-en-v1.5@ ", "Both model_name and provider must be non-empty strings"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
db := setupServiceTestDB(t)
pushServiceDB(t, db)
insertCreateDatasetTenant(t, "tenant-1")
_, code, err := testDatasetCreateService(t).CreateDataset(&service.CreateDatasetRequest{
Name: "ds-embd-" + tc.name,
EmbeddingModel: &tc.embeddingModel,
}, "tenant-1")
if err == nil {
t.Fatal("expected embedding model validation error")
}
if code != common.CodeDataError {
t.Fatalf("expected data error code, got %d", code)
}
if err.Error() != tc.expectedMessage {
t.Fatalf("unexpected error: got %q, want %q", err.Error(), tc.expectedMessage)
}
})
}
}
func TestCreateDataset_RejectsBothWithoutParseType(t *testing.T) {
db := setupServiceTestDB(t)
pushServiceDB(t, db)
+12 -8
View File
@@ -118,17 +118,18 @@ func (d *DatasetService) CreateDataset(req *service.CreateDatasetRequest, tenant
kbID := utility.GenerateToken()
status := string(entity.StatusValid)
duplicateName, err := common.DuplicateName(func(n, tid string) bool {
existing, err := d.kbDAO.GetByName(n, tid)
return err == nil && existing != nil
}, name, tenantID)
if err != nil {
return nil, common.CodeDataError, err
// Reject duplicate name within tenant to match the established API contract.
existing, err := d.kbDAO.GetByName(name, tenantID)
if err != nil && !dao.IsNotFoundErr(err) {
return nil, common.CodeServerError, errors.New("Database operation failed")
}
if existing != nil {
return nil, common.CodeDataError, fmt.Errorf("Dataset name '%s' already exists", name)
}
kb := &entity.Knowledgebase{
ID: kbID,
Name: duplicateName,
Name: name,
TenantID: tenantID,
CreatedBy: tenantID,
ParserID: parserID,
@@ -141,6 +142,9 @@ func (d *DatasetService) CreateDataset(req *service.CreateDatasetRequest, tenant
}
if err = d.kbDAO.Create(kb); err != nil {
if dao.IsDuplicateKeyErr(err) {
return nil, common.CodeDataError, fmt.Errorf("Dataset name '%s' already exists", name)
}
return nil, common.CodeServerError, errors.New("Failed to save dataset")
}
@@ -374,7 +378,7 @@ func (d *DatasetService) ListDatasets(id, name string, page, pageSize int, order
}
}
kbs, total, err := d.kbDAO.GetByTenantIDs(tenantIDs, userID, page, pageSize, orderby, desc, keywords, parserID)
kbs, total, err := d.kbDAO.GetByTenantIDs(tenantIDs, userID, page, pageSize, orderby, desc, keywords, parserID, id, name)
if err != nil {
return nil, 0, common.CodeServerError, errors.New("Database operation failed")
}
+26 -16
View File
@@ -46,6 +46,9 @@ const (
// validateParserID validates parser_id against the built-in pipeline registry.
func validateParserID(chunkMethod string) error {
if chunkMethod == "knowledge_graph" {
return nil
}
registry, err := pipelinepkg.DefaultRegistry()
if err != nil || registry == nil {
return errors.New("parser_id validation unavailable: builtin pipeline registry not loaded")
@@ -95,23 +98,32 @@ func validateDatasetAvatar(avatar string) error {
return nil
}
func validateDatasetEmbeddingModel(embeddingModel string) error {
if embeddingModel == "" {
return errors.New("Embedding model identifier is required")
func isHexID(s string) bool {
if len(s) != 32 {
return false
}
if !strings.Contains(embeddingModel, "@") {
return nil
}
parts := strings.Split(embeddingModel, "@")
for _, part := range parts {
if strings.TrimSpace(part) == "" {
return errors.New("Both model_name and provider must be non-empty strings")
for _, c := range s {
if !strings.ContainsRune("0123456789abcdefABCDEF", c) {
return false
}
}
if len(parts) < 2 {
return true
}
func validateDatasetEmbeddingModel(embeddingModel string) error {
if isHexID(embeddingModel) {
return nil
}
if !strings.Contains(embeddingModel, "@") {
return errors.New("Embedding model identifier must follow <model_name>@<provider> format")
}
if strings.TrimSpace(parts[0]) == "" || strings.TrimSpace(parts[len(parts)-1]) == "" {
parts := strings.SplitN(embeddingModel, "@", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return errors.New("Both model_name and provider must be non-empty strings")
}
if strings.TrimSpace(parts[0]) == "" || strings.TrimSpace(parts[1]) == "" {
return errors.New("Both model_name and provider must be non-empty strings")
}
return nil
@@ -212,10 +224,8 @@ func datasetUpdateEmbeddingID(req service.UpdateDatasetRequest) (string, bool, e
if !provided {
return "", false, nil
}
if embdID != "" {
if err := validateDatasetEmbeddingModel(embdID); err != nil {
return "", true, err
}
if err := validateDatasetEmbeddingModel(embdID); err != nil {
return "", true, err
}
return embdID, true, nil
}
+10 -2
View File
@@ -89,8 +89,16 @@ func TestValidateDatasetEmbeddingModel_Empty(t *testing.T) {
}
func TestValidateDatasetEmbeddingModel_NameOnlyNoProvider(t *testing.T) {
if err := validateDatasetEmbeddingModel("BAAI/bge-large-zh-v1.5"); err != nil {
t.Fatalf("expected nil for name without @, got %v", err)
// A bare model name without @provider (and not a 32-char hex model ID) is
// rejected, mirroring the Python contract.
if err := validateDatasetEmbeddingModel("BAAI/bge-large-zh-v1.5"); err == nil {
t.Fatal("expected error for name without @provider")
}
}
func TestValidateDatasetEmbeddingModel_HexModelID(t *testing.T) {
if err := validateDatasetEmbeddingModel("aabbccdd11223344aabbccdd11223344"); err != nil {
t.Fatalf("expected nil for 32-char hex model ID, got %v", err)
}
}
+8 -2
View File
@@ -39,7 +39,7 @@ func (d *DatasetService) UpdateDataset(datasetID, tenantID string, req service.U
if req.Name != nil {
name := strings.TrimSpace(*req.Name)
if name == "" {
return nil, common.CodeDataError, errors.New("String should have at least 1 character")
return nil, common.CodeDataError, errors.New("`name` is required.")
}
if len(name) > 128 {
return nil, common.CodeDataError, errors.New("String should have at most 128 characters")
@@ -227,12 +227,18 @@ func (d *DatasetService) UpdateDataset(datasetID, tenantID string, req service.U
}
}
if len(updates) == 0 && !connectorsProvided {
if len(updates) == 0 && !connectorsProvided && !req.ParserConfigProvided {
return nil, common.CodeDataError, errors.New("No properties were modified")
}
if len(updates) > 0 {
if err = d.kbDAO.UpdateByID(kb.ID, updates); err != nil {
if dao.IsDuplicateKeyErr(err) {
if nameValue, ok := updates["name"].(string); ok {
return nil, common.CodeDataError, fmt.Errorf("Dataset name '%s' already exists", nameValue)
}
return nil, common.CodeDataError, errors.New("Dataset name already exists")
}
return nil, common.CodeServerError, errors.New("Update dataset error.(Database error)")
}
}
+176 -3
View File
@@ -250,7 +250,7 @@ func TestDatasetServiceUpdateDatasetValidatesName(t *testing.T) {
if code != common.CodeDataError {
t.Fatalf("expected data error code, got %d", code)
}
if err.Error() != "String should have at least 1 character" {
if err.Error() != "`name` is required." {
t.Fatalf("unexpected error: %v", err)
}
}
@@ -364,9 +364,9 @@ func TestDatasetServiceUpdateDatasetAcceptsEmbeddingModelID(t *testing.T) {
insertDatasetUpdateKB(t, "kb-1", "tenant-1", "Original")
insertDatasetUpdateModelProvider(t, "provider-1", "tenant-1", "ZHIPU-AI")
insertDatasetUpdateModelInstance(t, "instance-1", "provider-1", "test")
insertDatasetUpdateTenantModel(t, "model-1", "provider-1", "instance-1", "embedding-2", int(entity.ModelTypeEmbedding))
insertDatasetUpdateTenantModel(t, "aabbccdd11223344aabbccdd11223344", "provider-1", "instance-1", "embedding-2", int(entity.ModelTypeEmbedding))
embeddingModelID := "model-1"
embeddingModelID := "aabbccdd11223344aabbccdd11223344"
result, code, err := testDatasetUpdateService(t).UpdateDataset("kb-1", "tenant-1", service.UpdateDatasetRequest{
EmbeddingModel: &embeddingModelID,
})
@@ -412,6 +412,179 @@ func TestDatasetServiceUpdateDatasetRejectsEmptyConnectorID(t *testing.T) {
}
}
func TestDatasetServiceUpdateDatasetRejectsInvalidEmbeddingModelFormat(t *testing.T) {
db := setupDatasetUpdateTestDB(t)
pushServiceDB(t, db)
insertDatasetUpdateKB(t, "kb-1", "tenant-1", "Original")
cases := []struct {
name string
embeddingModel string
expectedMessage string
}{
{"empty", "", "Embedding model identifier must follow <model_name>@<provider> format"},
{"whitespace", " ", "Embedding model identifier must follow <model_name>@<provider> format"},
{"missing_at", "BAAI/bge-small-en-v1.5Builtin", "Embedding model identifier must follow <model_name>@<provider> format"},
{"empty_model_name", "@Builtin", "Both model_name and provider must be non-empty strings"},
{"empty_provider", "BAAI/bge-small-en-v1.5@", "Both model_name and provider must be non-empty strings"},
}
svc := testDatasetUpdateService(t)
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
embdModel := tc.embeddingModel
_, code, err := svc.UpdateDataset("kb-1", "tenant-1", service.UpdateDatasetRequest{
EmbeddingModel: &embdModel,
})
if err == nil {
t.Fatal("expected embedding model format error")
}
if code != common.CodeDataError {
t.Fatalf("expected data error code, got %d", code)
}
if err.Error() != tc.expectedMessage {
t.Fatalf("unexpected error: got %q, want %q", err.Error(), tc.expectedMessage)
}
})
}
}
func TestDatasetServiceUpdateDatasetRejectsDuplicateNameCaseInsensitive(t *testing.T) {
db := setupDatasetUpdateTestDB(t)
pushServiceDB(t, db)
insertDatasetUpdateKB(t, "kb-1", "tenant-1", "Original")
insertDatasetUpdateKB(t, "kb-2", "tenant-1", "Existing")
uppercaseName := "EXISTING"
_, code, err := testDatasetUpdateService(t).UpdateDataset("kb-1", "tenant-1", service.UpdateDatasetRequest{
Name: &uppercaseName,
})
if err == nil {
t.Fatal("expected case-insensitive duplicate name error")
}
if code != common.CodeDataError {
t.Fatalf("expected data error code, got %d", code)
}
if !strings.Contains(err.Error(), "already exists") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestDatasetServiceUpdateDatasetPreservesUnmodifiedFields(t *testing.T) {
db := setupDatasetUpdateTestDB(t)
pushServiceDB(t, db)
description := "original description"
language := "English"
insertDatasetUpdateKB(t, "kb-1", "tenant-1", "Original")
dao.DB.Model(&entity.Knowledgebase{}).Where("id = ?", "kb-1").Updates(map[string]interface{}{
"description": description,
"language": language,
})
newName := "Renamed Only"
result, code, err := testDatasetUpdateService(t).UpdateDataset("kb-1", "tenant-1", service.UpdateDatasetRequest{
Name: &newName,
})
if err != nil {
t.Fatalf("UpdateDataset failed: %v", err)
}
if code != common.CodeSuccess {
t.Fatalf("expected success code, got %d", code)
}
if result["name"] != newName {
t.Fatalf("expected updated name %q, got %#v", newName, result["name"])
}
if result["description"] != description {
t.Fatalf("expected description preserved, got %#v", result["description"])
}
if result["language"] != language {
t.Fatalf("expected language preserved, got %#v", result["language"])
}
if result["embedding_model"] != "BAAI/bge-large-zh-v1.5@Builtin" {
t.Fatalf("expected embedding_model preserved, got %#v", result["embedding_model"])
}
persisted, err := dao.NewKnowledgebaseDAO().GetByID("kb-1")
if err != nil {
t.Fatalf("get updated kb: %v", err)
}
if persisted.Name != newName {
t.Fatalf("expected persisted name %q, got %q", newName, persisted.Name)
}
if persisted.Description == nil || *persisted.Description != description {
t.Fatalf("expected persisted description %q, got %#v", description, persisted.Description)
}
}
func TestDatasetServiceUpdateDatasetPreservesParserConfigOnEmptyUpdate(t *testing.T) {
db := setupDatasetUpdateTestDB(t)
pushServiceDB(t, db)
insertDatasetUpdateKB(t, "kb-1", "tenant-1", "Original")
dao.DB.Model(&entity.Knowledgebase{}).Where("id = ?", "kb-1").Update("parser_config", entity.JSONMap{
"chunk_token_num": float64(512),
"delimiter": "\n",
})
name := "Updated Name"
_, code, err := testDatasetUpdateService(t).UpdateDataset("kb-1", "tenant-1", service.UpdateDatasetRequest{
Name: &name,
})
if err != nil {
t.Fatalf("UpdateDataset failed: %v", err)
}
if code != common.CodeSuccess {
t.Fatalf("expected success code, got %d", code)
}
persisted, err := dao.NewKnowledgebaseDAO().GetByID("kb-1")
if err != nil {
t.Fatalf("get updated kb: %v", err)
}
if persisted.ParserConfig["chunk_token_num"] != float64(512) {
t.Fatalf("expected chunk_token_num preserved, got %#v", persisted.ParserConfig["chunk_token_num"])
}
if persisted.ParserConfig["delimiter"] != "\n" {
t.Fatalf("expected delimiter preserved, got %#v", persisted.ParserConfig["delimiter"])
}
}
func TestDatasetServiceDeleteDatasetsRejectsUnauthorizedID(t *testing.T) {
db := setupDatasetUpdateTestDB(t)
pushServiceDB(t, db)
insertDatasetUpdateKB(t, "11111111111141118111111111111111", "tenant-1", "Test")
svc := NewDatasetService()
normalizedID := "11111111111141118111111111111111"
_, code, err := svc.DeleteDatasets([]string{normalizedID}, false, "tenant-2")
if err == nil {
t.Fatal("expected unauthorized error")
}
if code != common.CodeDataError {
t.Fatalf("expected data error code, got %d", code)
}
if !strings.Contains(err.Error(), "lacks permission") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestDatasetServiceDeleteDatasetsRejectsAllUnauthorized(t *testing.T) {
db := setupDatasetUpdateTestDB(t)
pushServiceDB(t, db)
svc := NewDatasetService()
_, code, err := svc.DeleteDatasets([]string{"d94a8dc02c9711f0930f7fbc369eab6d"}, false, "tenant-1")
if err == nil {
t.Fatal("expected unauthorized error")
}
if code != common.CodeDataError {
t.Fatalf("expected data error code, got %d", code)
}
if !strings.Contains(err.Error(), "lacks permission") {
t.Fatalf("unexpected error: %v", err)
}
}
func setupDatasetUpdateTestDB(t *testing.T) *gorm.DB {
t.Helper()
+5
View File
@@ -156,4 +156,9 @@ type UpdateDatasetRequest struct {
// ParseType indicates pipeline selection mode: 1 = BuiltIn (parser_id),
// 2 = Pipeline (pipeline_id). nil means unspecified (backward compat).
ParseType *int `json:"parse_type,omitempty"`
// ParserConfigProvided reports whether the raw request body contained a
// "parser_config" key. An explicitly provided parser_config ({} or null) is a
// valid no-op that must succeed, unlike a truly empty request body.
ParserConfigProvided bool `json:"-"`
}
+1 -1
View File
@@ -68,7 +68,7 @@ func (s *DocumentService) DownloadDocument(datasetID, docID string) (*DownloadDo
}
doc, err := s.documentDAO.GetByID(docID)
if err != nil || doc.KbID != datasetID {
return nil, fmt.Errorf("The dataset not own the document %s.", docID)
return nil, fmt.Errorf("Document not found!")
}
bucket, name, err := s.GetDocumentStorageAddress(doc)
if err != nil {
@@ -232,8 +232,13 @@ func (s *DocumentService) validateDatasetDocumentUpdate(datasetID, documentID, u
if present["token_count"] && req.TokenCount != nil && *req.TokenCount != 0 && *req.TokenCount != doc.TokenNum {
return common.CodeDataError, errors.New("Can't change `token_count`.")
}
if present["progress"] && req.Progress != nil && *req.Progress != 0 && math.Abs(*req.Progress-doc.Progress) > 1e-9 {
return common.CodeDataError, errors.New("Can't change `progress`.")
if present["progress"] && req.Progress != nil {
if *req.Progress > 1 {
return common.CodeDataError, fmt.Errorf("Field: <progress> - Message: <Input should be less than or equal to 1> - Value: <%v>", *req.Progress)
}
if *req.Progress != 0 && math.Abs(*req.Progress-doc.Progress) > 1e-9 {
return common.CodeDataError, errors.New("Can't change `progress`.")
}
}
if present["enabled"] {
+1 -1
View File
@@ -288,7 +288,7 @@ func (s *DocumentService) validateDocsInDataset(docIDs []string, datasetID strin
}
}
if len(invalid) > 0 {
return nil, fmt.Errorf("these documents do not belong to dataset %s: %v", datasetID, invalid)
return nil, fmt.Errorf("These documents do not belong to dataset %s: %v", datasetID, invalid)
}
return docs, nil
}
+9 -15
View File
@@ -17,15 +17,12 @@
package service
import (
"errors"
"fmt"
"ragflow/internal/common"
"ragflow/internal/dao"
"ragflow/internal/entity"
"ragflow/internal/utility"
"strings"
"gorm.io/gorm"
)
// SearchService search service
@@ -211,6 +208,10 @@ type CreateSearchResponse struct {
// 6. Save to database within DB.atomic() transaction
// 7. Return {search_id: id} on success
func (s *SearchService) CreateSearch(userID string, name string, description *string) (*CreateSearchResponse, error) {
if err := common.ValidateName(name); err != nil {
return nil, err
}
// Generate UUID for search ID (same as Python get_uuid())
searchID := utility.GenerateUUID()
@@ -337,14 +338,7 @@ func (s *SearchService) DeleteSearch(userID string, searchID string) error {
// AccessibleForCompletion check if it is accessible
func (s *SearchService) AccessibleForCompletion(userID string, searchID string) (bool, error) {
ok, err := s.searchDAO.Accessible4Deletion(searchID, userID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil
}
return false, err
}
return ok, nil
return s.searchDAO.Accessible4Deletion(searchID, userID)
}
type SearchCompletionPlan struct {
@@ -561,14 +555,14 @@ type UpdateSearchRequest struct {
func (s *SearchService) UpdateSearch(userID string, searchID string, req *UpdateSearchRequest) (*entity.Search, error) {
// Step 1: Check update permission (same as delete - uses accessible4deletion)
// Only creator can update
// Only creator can update. A missing or non-owned search is treated as
// unauthorized so the contract returns a clear "no authorization" error.
status, err := s.searchDAO.Accessible4Deletion(searchID, userID)
accessible, err := s.searchDAO.Accessible4Deletion(searchID, userID)
if err != nil {
return nil, fmt.Errorf("failed to check deletion permission: %w", err)
}
if !status {
if !accessible {
return nil, fmt.Errorf("no authorization")
}
+108
View File
@@ -0,0 +1,108 @@
//
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
package service
import (
"strings"
"testing"
"ragflow/internal/dao"
"ragflow/internal/entity"
)
func setupSearchServiceTestDB(t *testing.T) {
t.Helper()
db := setupServiceTestDB(t)
if err := db.AutoMigrate(&entity.Search{}); err != nil {
t.Fatalf("failed to migrate search: %v", err)
}
pushServiceDB(t, db)
}
func TestSearchServiceCreateRejectsEmptyName(t *testing.T) {
setupSearchServiceTestDB(t)
_, err := NewSearchService().CreateSearch("tenant-1", " ", nil)
if err == nil {
t.Fatal("expected empty name validation error")
}
if !strings.Contains(err.Error(), "empty") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSearchServiceUpdateRejectsUnauthorizedSearchID(t *testing.T) {
setupSearchServiceTestDB(t)
req := &UpdateSearchRequest{
Name: "New Name",
SearchConfig: map[string]interface{}{},
}
_, err := NewSearchService().UpdateSearch("user-2", "invalid_search_id", req)
if err == nil {
t.Fatal("expected authorization error")
}
if err.Error() != "no authorization" {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSearchServiceCreateAndUpdateRoundTrip(t *testing.T) {
setupSearchServiceTestDB(t)
created, err := NewSearchService().CreateSearch("tenant-1", "My Search", nil)
if err != nil {
t.Fatalf("CreateSearch failed: %v", err)
}
if created.SearchID == "" {
t.Fatal("expected non-empty search_id")
}
// A different user must not be able to update it.
req := &UpdateSearchRequest{
Name: "Hijacked Name",
SearchConfig: map[string]interface{}{},
}
_, err = NewSearchService().UpdateSearch("user-2", created.SearchID, req)
if err == nil || err.Error() != "no authorization" {
t.Fatalf("expected no authorization, got %v", err)
}
// The owner can update name + merge config.
req = &UpdateSearchRequest{
Name: "Updated Name",
SearchConfig: map[string]interface{}{"summary": true},
}
updated, err := NewSearchService().UpdateSearch("tenant-1", created.SearchID, req)
if err != nil {
t.Fatalf("owner UpdateSearch failed: %v", err)
}
if updated.Name != "Updated Name" {
t.Fatalf("expected updated name, got %q", updated.Name)
}
if updated.SearchConfig["summary"] != true {
t.Fatalf("expected merged search_config, got %#v", updated.SearchConfig)
}
persisted, err := dao.NewSearchDAO().GetByID(created.SearchID)
if err != nil {
t.Fatalf("get updated search: %v", err)
}
if persisted.Name != "Updated Name" {
t.Fatalf("expected persisted name, got %q", persisted.Name)
}
}