mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-09-08 10:14:35 +08:00
Add Go service/handler tests for API contract parity (#16905)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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)+"%")
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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, ""
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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:"-"`
|
||||
}
|
||||
|
||||
@@ -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"] {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user