mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-22 07:31:05 +08:00
Add Go service/handler tests for API contract parity (#16905)
This commit is contained in:
@@ -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())
|
||||
|
||||
@@ -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:
|
||||
|
||||
100
internal/handler/search_handler_test.go
Normal file
100
internal/handler/search_handler_test.go
Normal 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"])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user