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

This commit is contained in:
euvre
2026-07-20 20:02:41 +08:00
committed by GitHub
parent 2f7c2eb53c
commit f45f03a016
46 changed files with 1553 additions and 214 deletions

View File

@@ -232,7 +232,7 @@ func (h *ChatHandler) DeleteChat(c *gin.Context) {
if err := h.chatService.DeleteChat(userID, chatID); err != nil {
if err.Error() == "no authorization" {
common.ResponseWithCodeData(c, common.CodeDataError, false, "No authorization")
common.ResponseWithCodeData(c, common.CodeAuthenticationError, false, "No authorization.")
return
}
common.ErrorWithCode(c, common.CodeDataError, err.Error())
@@ -330,7 +330,7 @@ func (h *ChatHandler) GetChat(c *gin.Context) {
errMsg := err.Error()
// Check if it's an authorization error
if errMsg == "no authorization" {
common.ResponseWithCodeData(c, common.CodeDataError, false, "No authorization")
common.ResponseWithCodeData(c, common.CodeAuthenticationError, false, "No authorization.")
return
}
// Not found error
@@ -413,7 +413,7 @@ func (h *ChatHandler) updateChatByMethod(c *gin.Context, patch bool) {
}
if err != nil {
if err.Error() == "no authorization" {
common.ResponseWithCodeData(c, common.CodeDataError, false, "No authorization")
common.ResponseWithCodeData(c, common.CodeAuthenticationError, false, "No authorization.")
return
}
common.ResponseWithCodeData(c, common.CodeDataError, nil, err.Error())

View File

@@ -25,7 +25,7 @@ func setupChatHandlerTestDB(t *testing.T) *gorm.DB {
t.Fatalf("failed to open sqlite: %v", err)
}
if err := db.AutoMigrate(&entity.Chat{}, &entity.Tenant{}); err != nil {
if err := db.AutoMigrate(&entity.Chat{}, &entity.Tenant{}, &entity.UserTenant{}); err != nil {
t.Fatalf("failed to migrate test schema: %v", err)
}
@@ -199,3 +199,58 @@ func TestUpdateChatHandlerRejectsNonOwner(t *testing.T) {
t.Fatalf("unexpected message: %v", resp["message"])
}
}
func TestGetChatHandlerRejectsNonOwner(t *testing.T) {
db := setupChatHandlerTestDB(t)
createChatHandlerTestChat(t, db, "chat-1", "tenant-2")
h := NewChatHandler(service.NewChatService(), service.NewUserService())
c, w := setupGinContextWithUser("GET", "/api/v1/chats/chat-1", "")
c.Params = []gin.Param{{Key: "chat_id", Value: "chat-1"}}
h.GetChat(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp["code"] != float64(common.CodeAuthenticationError) {
t.Fatalf("expected auth error code 109, got %v", resp["code"])
}
if resp["data"] != false {
t.Fatalf("expected data=false, got %v", resp["data"])
}
if resp["message"] != "No authorization." {
t.Fatalf("unexpected message: %v", resp["message"])
}
}
func TestDeleteChatHandlerRejectsNonOwner(t *testing.T) {
db := setupChatHandlerTestDB(t)
createChatHandlerTestChat(t, db, "chat-1", "tenant-2")
h := NewChatHandler(service.NewChatService(), service.NewUserService())
c, w := setupGinContextWithUser("DELETE", "/api/v1/chats/chat-1", "")
c.Params = []gin.Param{{Key: "chat_id", Value: "chat-1"}}
h.DeleteChat(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp["code"] != float64(common.CodeAuthenticationError) {
t.Fatalf("expected auth error code 109, got %v", resp["code"])
}
if resp["message"] != "No authorization." {
t.Fatalf("unexpected message: %v", resp["message"])
}
}

View File

@@ -201,12 +201,25 @@ func (h *DatasetsHandler) UpdateDataset(c *gin.Context) {
return
}
var req service.UpdateDatasetRequest
if err := c.ShouldBindJSON(&req); err != nil {
bodyBytes, err := c.GetRawData()
if err != nil {
common.ResponseWithCodeData(c, common.CodeDataError, nil, err.Error())
return
}
var req service.UpdateDatasetRequest
if err := json.Unmarshal(bodyBytes, &req); err != nil {
common.ResponseWithCodeData(c, common.CodeDataError, nil, err.Error())
return
}
// Detect an explicitly provided parser_config key (even {} or null) so it is not
// rejected as "No properties were modified", mirroring the Python contract.
var providedFields map[string]json.RawMessage
if err := json.Unmarshal(bodyBytes, &providedFields); err == nil {
_, req.ParserConfigProvided = providedFields["parser_config"]
}
result, code, err := h.datasetsService.UpdateDataset(datasetID, userID, req)
if err != nil {
common.ErrorWithCode(c, code, err.Error())

View File

@@ -402,11 +402,11 @@ func (h *DocumentHandler) DeleteDocuments(c *gin.Context) {
ids = *req.IDs
}
if len(ids) > 0 && req.DeleteAll {
common.ResponseWithCodeData(c, common.CodeArgumentError, nil, "should not provide both ids and delete_all")
common.ResponseWithCodeData(c, common.CodeDataError, nil, "should not provide both ids and delete_all")
return
}
if len(ids) == 0 && !req.DeleteAll {
common.ResponseWithCodeData(c, common.CodeArgumentError, nil, "should either provide doc ids or set delete_all(true)")
common.ResponseWithCodeData(c, common.CodeDataError, nil, "should either provide doc ids or set delete_all(true)")
return
}
@@ -498,7 +498,7 @@ func (h *DocumentHandler) ListDocuments(c *gin.Context) {
userID := c.GetString("user_id")
if !h.datasetService.Accessible(datasetID, userID) {
common.ResponseWithCodeData(c, common.CodeAuthenticationError, nil, "No authorization to access the dataset.")
common.ResponseWithCodeData(c, common.CodeDataError, nil, fmt.Sprintf("You don't own the dataset %s.", datasetID))
return
}
@@ -530,7 +530,9 @@ func (h *DocumentHandler) ListDocuments(c *gin.Context) {
return
}
// Use kbID to filter documents
// Use kbID to filter documents.
// Note: create_time_from / create_time_to are applied post-query (not at DB level)
// so that total reflects the unfiltered count, matching the Python API contract.
documents, total, err := h.documentService.ListDocumentsByDatasetIDWithOptions(opts, page, pageSize)
if err != nil {
common.ResponseWithCodeData(c, 1, map[string]interface{}{"total": 0, "docs": []interface{}{}}, "failed to get documents")
@@ -539,6 +541,12 @@ func (h *DocumentHandler) ListDocuments(c *gin.Context) {
docs := make([]map[string]interface{}, 0, len(documents))
for _, doc := range documents {
if opts.CreateTimeFrom > 0 && doc.CreateTime != nil && *doc.CreateTime < opts.CreateTimeFrom {
continue
}
if opts.CreateTimeTo > 0 && doc.CreateTime != nil && *doc.CreateTime > opts.CreateTimeTo {
continue
}
metaFields, err := h.documentService.GetDocumentMetadataByID(doc.ID)
if err != nil {
metaFields = make(map[string]interface{})
@@ -1387,31 +1395,38 @@ func (h *DocumentHandler) ListIngestionTasks(c *gin.Context) {
}
type StartParseDocumentsRequest struct {
DatasetID string `json:"dataset_id"`
Documents []string `json:"documents" binding:"required"`
DocumentIDs []string `json:"document_ids" binding:"required"`
}
func (h *DocumentHandler) StartIngestionTask(c *gin.Context) {
datasetID := c.Param("dataset_id")
var req StartParseDocumentsRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.ErrorWithCode(c, common.CodeBadRequest, err.Error())
common.ResponseWithCodeData(c, common.CodeDataError, nil, "`document_ids` is required")
return
}
userID := c.GetString("user_id")
if !h.datasetService.Accessible(req.DatasetID, userID) {
common.ResponseWithCodeData(c, common.CodeAuthenticationError, nil, "No authorization to access the dataset.")
if !h.datasetService.Accessible(datasetID, userID) {
common.ResponseWithCodeData(c, common.CodeDataError, nil, fmt.Sprintf("You don't own the dataset %s.", datasetID))
return
}
parseResult, err := h.documentService.IngestDocuments(req.DatasetID, userID, req.Documents)
parseResult, err := h.documentService.IngestDocuments(datasetID, userID, req.DocumentIDs)
if err != nil {
common.ResponseWithCodeData(c, common.CodeExceptionError, nil, err.Error())
return
}
common.SuccessWithData(c, parseResult, "success")
successCount := 0
for _, r := range parseResult {
if strings.HasPrefix(r.Result, "task_id:") {
successCount++
}
}
common.SuccessWithData(c, map[string]interface{}{"success_count": successCount}, "success")
}
type StopIngestionsRequest struct {
@@ -1510,7 +1525,7 @@ func (h *DocumentHandler) StopParseDocuments(c *gin.Context) {
userID := c.GetString("user_id")
if !h.datasetService.Accessible(datasetID, userID) {
common.ResponseWithCodeData(c, common.CodeAuthenticationError, nil, "You don't own the dataset.")
common.ResponseWithCodeData(c, common.CodeDataError, nil, fmt.Sprintf("You don't own the dataset %s.", datasetID))
return
}
@@ -1684,19 +1699,32 @@ func (h *DocumentHandler) handleBatchUpdateDocumentMetadatas(c *gin.Context) {
return
}
var req documentMetadataBatchRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.ResponseWithCodeData(c, common.CodeDataError, nil, err.Error())
var rawBody map[string]interface{}
if err := c.ShouldBindJSON(&rawBody); err != nil {
common.ResponseWithCodeData(c, common.CodeDataError, nil, "Invalid request payload: expected object, got "+inferJSONType(err))
return
}
if req.Selector == nil {
req.Selector = &document.DocumentMetadataSelector{}
selector, errMsg := parseMetadataSelector(rawBody["selector"])
if errMsg != "" {
common.ResponseWithCodeData(c, common.CodeDataError, nil, errMsg)
return
}
if req.Updates == nil {
req.Updates = []document.DocumentMetadataUpdate{}
updates, errMsg := parseMetadataUpdates(rawBody["updates"])
if errMsg != "" {
common.ResponseWithCodeData(c, common.CodeDataError, nil, errMsg)
return
}
if req.Deletes == nil {
req.Deletes = []document.DocumentMetadataDelete{}
deletes, errMsg := parseMetadataDeletes(rawBody["deletes"])
if errMsg != "" {
common.ResponseWithCodeData(c, common.CodeDataError, nil, errMsg)
return
}
req := documentMetadataBatchRequest{
Selector: selector,
Updates: updates,
Deletes: deletes,
}
resp, code, err := h.documentService.BatchUpdateDocumentMetadatas(datasetID, req.Selector, req.Updates, req.Deletes)
@@ -1706,3 +1734,95 @@ func (h *DocumentHandler) handleBatchUpdateDocumentMetadatas(c *gin.Context) {
}
common.SuccessWithData(c, resp, "success")
}
func inferJSONType(err error) string {
s := err.Error()
if strings.Contains(s, "array") {
return "array"
}
if strings.Contains(s, "number") {
return "number"
}
if strings.Contains(s, "string") {
return "string"
}
if strings.Contains(s, "bool") {
return "bool"
}
return "unknown"
}
func parseMetadataSelector(raw interface{}) (*document.DocumentMetadataSelector, string) {
if raw == nil {
return &document.DocumentMetadataSelector{}, ""
}
m, ok := raw.(map[string]interface{})
if !ok {
return nil, "selector must be an object."
}
selector := &document.DocumentMetadataSelector{}
if v, ok := m["document_ids"]; ok && v != nil {
ids, ok := v.([]interface{})
if !ok {
return nil, "document_ids must be a list."
}
for _, id := range ids {
selector.DocumentIDs = append(selector.DocumentIDs, id.(string))
}
}
if v, ok := m["metadata_condition"]; ok && v != nil {
mc, ok := v.(map[string]interface{})
if !ok {
return nil, "metadata_condition must be an object."
}
selector.MetadataCondition = mc
}
return selector, ""
}
func parseMetadataUpdates(raw interface{}) ([]document.DocumentMetadataUpdate, string) {
if raw == nil {
return []document.DocumentMetadataUpdate{}, ""
}
arr, ok := raw.([]interface{})
if !ok {
return nil, "updates and deletes must be lists."
}
updates := make([]document.DocumentMetadataUpdate, 0, len(arr))
for _, item := range arr {
m, ok := item.(map[string]interface{})
if !ok {
return nil, "Each update requires key and value."
}
key, _ := m["key"].(string)
if key == "" {
return nil, "Each update requires key and value."
}
value := m["value"]
updates = append(updates, document.DocumentMetadataUpdate{Key: key, Value: value})
}
return updates, ""
}
func parseMetadataDeletes(raw interface{}) ([]document.DocumentMetadataDelete, string) {
if raw == nil {
return []document.DocumentMetadataDelete{}, ""
}
arr, ok := raw.([]interface{})
if !ok {
return nil, "updates and deletes must be lists."
}
deletes := make([]document.DocumentMetadataDelete, 0, len(arr))
for _, item := range arr {
m, ok := item.(map[string]interface{})
if !ok {
return nil, "Each delete requires key."
}
key, _ := m["key"].(string)
if key == "" {
return nil, "Each delete requires key."
}
deletes = append(deletes, document.DocumentMetadataDelete{Key: key})
}
return deletes, ""
}

View File

@@ -148,8 +148,8 @@ func (h *SearchHandler) ListSearches(c *gin.Context) {
// @Router /api/v1/searches [post]
type CreateSearchRequest struct {
Name string `json:"name" binding:"required"` // required field, max 255 bytes
Description *string `json:"description,omitempty"` // optional description
Name string `json:"name"` // required, validated via common.ValidateName (max 255 bytes)
Description *string `json:"description,omitempty"` // optional description
}
func (h *SearchHandler) CreateSearch(c *gin.Context) {
@@ -329,7 +329,7 @@ func (h *SearchHandler) UpdateSearch(c *gin.Context) {
errMsg := err.Error()
switch errMsg {
case "no authorization":
common.ResponseWithCodeData(c, common.CodeDataError, false, "No authorization")
common.ResponseWithCodeData(c, common.CodeAuthenticationError, false, "No authorization.")
case "duplicated search name":
common.ResponseWithCodeData(c, common.CodeDataError, nil, "Duplicated search name.")
default:

View File

@@ -0,0 +1,100 @@
//
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
package handler
import (
"encoding/json"
"net/http"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"ragflow/internal/common"
"ragflow/internal/dao"
"ragflow/internal/entity"
"ragflow/internal/service"
)
func setupSearchHandlerTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
TranslateError: true,
})
if err != nil {
t.Fatalf("failed to open sqlite: %v", err)
}
if err := db.AutoMigrate(&entity.Search{}, &entity.UserTenant{}); err != nil {
t.Fatalf("failed to migrate test schema: %v", err)
}
origDB := dao.DB
dao.DB = db
t.Cleanup(func() { dao.DB = origDB })
return db
}
func TestSearchHandlerCreateRejectsEmptyName(t *testing.T) {
setupSearchHandlerTestDB(t)
h := NewSearchHandler(service.NewSearchService(), service.NewUserService())
c, w := setupGinContextWithUser("POST", "/api/v1/searches", `{"name": " "}`)
h.CreateSearch(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp["code"] != float64(common.CodeDataError) {
t.Fatalf("expected code 102, got %v", resp["code"])
}
if !strings.Contains(resp["message"].(string), "empty") {
t.Fatalf("expected message containing 'empty', got %v", resp["message"])
}
}
func TestSearchHandlerUpdateRejectsInvalidSearchID(t *testing.T) {
setupSearchHandlerTestDB(t)
h := NewSearchHandler(service.NewSearchService(), service.NewUserService())
c, w := setupGinContextWithUser("PUT", "/api/v1/searches/invalid_search_id", `{"name": "invalid", "search_config": {}}`)
c.Params = []gin.Param{{Key: "search_id", Value: "invalid_search_id"}}
h.UpdateSearch(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp["code"] != float64(common.CodeAuthenticationError) {
t.Fatalf("expected code 109, got %v", resp["code"])
}
if !strings.Contains(resp["message"].(string), "No authorization") {
t.Fatalf("expected 'No authorization' in message, got %v", resp["message"])
}
}