Add git-like file commit API (#15978)

### What problem does this PR solve?

| # | Method | Endpoint | Description | Git Equivalent |
|---|--------|----------|-------------|----------------|
| 1 | `POST` | `/api/v1/{prefix}/{folder_id}/commits` | Create a
snapshot commit with file changes (add/modify/delete/rename) | `git add`
+ `git commit` |
| 2 | `GET` | `/api/v1/{prefix}/{folder_id}/commits` | List commit
history (paginated) | `git log` |
| 3 | `GET` | `/api/v1/{prefix}/{folder_id}/commits/{commit_id}` | Get
commit detail with file changes | `git show` |
| 4 | `GET` | `/api/v1/{prefix}/{folder_id}/commits/{commit_id}/files` |
List file changes in a commit | `git show --name-status` |
| 5 | `GET` |
`/api/v1/{prefix}/{folder_id}/commits/diff?from=...&to=...` | Compare
two commits and return differences | `git diff` |
| 6 | `GET` | `/api/v1/{prefix}/{folder_id}/changes` | Get uncommitted
changes (add/modify/delete) | `git status` |
| 7 | `GET` | `/api/v1/{prefix}/{folder_id}/commits/{commit_id}/tree` |
Get the folder tree snapshot at commit time | `git ls-tree` |
| 8 | `GET` |
`/api/v1/{prefix}/{folder_id}/commits/{commit_id}/files/{file_id}/content`
| Get a file's content as it existed in a specific commit | `git show
HEAD:file` |
| 9 | `GET` | `/api/v1/{prefix}/{file_id}/versions` | Get version
history for a specific file across all commits | `git log -- file` |

Where `{prefix}/{id}` can be:
- `folders/{folder_id}` — direct folder access
- `workspaces/{workspace_id}` — alias of `folders/{folder_id}`
- `datasets/{dataset_id}` — resolves to the dataset's folder
- `memories/{memory_id}` — resolves to the memory's folder
- `skills/{skill_id}` — resolves to the skill's folder

### Type of change

- [x] New Feature (non-breaking change which adds functionality)
- [x] Documentation Update
This commit is contained in:
Yingfeng
2026-06-15 11:19:56 +08:00
committed by GitHub
parent 83e2180e80
commit b5bea72e4b
15 changed files with 4244 additions and 7 deletions

View File

@@ -0,0 +1,584 @@
//
// 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 (
"fmt"
"net/http"
"ragflow/internal/common"
"ragflow/internal/dao"
"ragflow/internal/entity"
"strconv"
"github.com/gin-gonic/gin"
)
// fileCommitService is the consumer-side interface for FileCommitHandler's service dependency.
type fileCommitService interface {
CreateCommit(folderID, authorID, message string, changes []entity.FileChange) (*entity.FileCommit, error)
ListCommits(folderID string, page, pageSize int, orderBy string, desc bool) ([]*entity.FileCommit, int64, error)
GetCommit(commitID string) (*entity.FileCommit, error)
ListCommitFiles(commitID string) ([]*entity.FileCommitItem, error)
DiffCommits(fromID, toID string) ([]entity.DiffEntry, error)
GetUncommittedChanges(folderID string) ([]entity.DiffEntry, error)
GetCommitTree(commitID string) (map[string]interface{}, error)
GetCommitFileContent(folderID, commitID, fileID string) ([]byte, error)
GetFileVersionHistory(fileID string) ([]entity.VersionEntry, error)
}
// FileCommitHandler file commit handler
type FileCommitHandler struct {
commitService fileCommitService
kbDAO *dao.KnowledgebaseDAO
fileDAO *dao.FileDAO
}
// NewFileCommitHandler create file commit handler
func NewFileCommitHandler(commitService fileCommitService) *FileCommitHandler {
return &FileCommitHandler{
commitService: commitService,
kbDAO: dao.NewKnowledgebaseDAO(),
fileDAO: dao.NewFileDAO(),
}
}
// ResolveFolderID resolves a resource ID (dataset/memory/skill) to its folder_id.
// entityType is the plural resource name (e.g. "datasets", "memories", "skills").
func (h *FileCommitHandler) ResolveFolderID(entityType, entityID string) (string, error) {
switch entityType {
case "datasets":
return h.resolveDatasetFolderID(entityID)
default:
return "", fmt.Errorf("unsupported entity type: %s", entityType)
}
}
// CommitFolderResolver returns a Gin middleware that resolves an entity ID
// (e.g. dataset_id, memory_id, skill_id) to a folder_id and injects it into
// c.Params so that the standard commit handlers can read it via c.Param("folder_id").
//
// entityType must match the key used in ResolveFolderID (e.g. "datasets").
// urlParam is the gin URL param name (e.g. "dataset_id").
func CommitFolderResolver(h *FileCommitHandler, entityType, urlParam string) gin.HandlerFunc {
return func(c *gin.Context) {
id := c.Param(urlParam)
if id == "" {
jsonError(c, common.CodeParamError, fmt.Sprintf("%s is required", urlParam))
c.Abort()
return
}
folderID, err := h.ResolveFolderID(entityType, id)
if err != nil {
jsonError(c, common.CodeNotFound, fmt.Sprintf("%s folder not found", entityType))
c.Abort()
return
}
c.Params = append(c.Params, gin.Param{Key: "folder_id", Value: folderID})
c.Next()
}
}
func (h *FileCommitHandler) resolveDatasetFolderID(datasetID string) (string, error) {
kb, err := h.kbDAO.GetByID(datasetID)
if err != nil {
return "", err
}
files := h.fileDAO.Query(kb.Name, "")
for _, f := range files {
if f.SourceType == string(entity.FileSourceKnowledgebase) && f.Type == "folder" && f.TenantID == kb.TenantID {
return f.ID, nil
}
}
return "", common.ErrNotFound
}
// CreateCommitRequest represents the request body for creating a commit
type CreateCommitRequest struct {
Message string `json:"message" binding:"required"`
Files []entity.FileChange `json:"files" binding:"required"`
}
// CreateCommit creates a new commit for a workspace folder
// @Summary Create Commit
// @Description Create a new commit with file changes for a workspace folder
// @Tags file_commit
// @Accept json
// @Produce json
// @Param folder_id path string true "workspace folder ID"
// @Param body body CreateCommitRequest true "commit request"
// @Success 200 {object} map[string]interface{}
// @Router /api/v1/workspaces/{folder_id}/commits [post]
func (h *FileCommitHandler) CreateCommit(c *gin.Context) {
user, errorCode, errorMessage := GetUser(c)
if errorCode != common.CodeSuccess {
jsonError(c, errorCode, errorMessage)
return
}
folderID := c.Param("folder_id")
if folderID == "" {
jsonError(c, common.CodeParamError, "folder_id is required")
return
}
var req CreateCommitRequest
if err := c.ShouldBindJSON(&req); err != nil {
jsonError(c, common.CodeBadRequest, err.Error())
return
}
commit, err := h.commitService.CreateCommit(folderID, user.ID, req.Message, req.Files)
if err != nil {
jsonInternalError(c, err)
return
}
ct := int64(0)
if commit.CreateTime != nil {
ct = *commit.CreateTime
}
c.JSON(http.StatusOK, gin.H{
"code": common.CodeSuccess,
"data": entity.CommitResponse{
ID: commit.ID,
FolderID: commit.FolderID,
ParentID: commit.ParentID,
Message: commit.Message,
AuthorID: commit.AuthorID,
FileCount: commit.FileCount,
TreeState: commit.TreeState,
CreateTime: &ct,
},
"message": common.CodeSuccess.Message(),
})
}
// ListCommits lists commits for a workspace folder
// @Summary List Commits
// @Description List all commits for a workspace folder with pagination
// @Tags file_commit
// @Accept json
// @Produce json
// @Param folder_id path string true "workspace folder ID"
// @Param page query int false "page number"
// @Param page_size query int false "items per page"
// @Param order_by query string false "order by field"
// @Param desc query bool false "descending order"
// @Success 200 {object} map[string]interface{}
// @Router /api/v1/workspaces/{folder_id}/commits [get]
func (h *FileCommitHandler) ListCommits(c *gin.Context) {
_, errorCode, errorMessage := GetUser(c)
if errorCode != common.CodeSuccess {
jsonError(c, errorCode, errorMessage)
return
}
folderID := c.Param("folder_id")
if folderID == "" {
jsonError(c, common.CodeParamError, "folder_id is required")
return
}
page := 1
if pageStr := c.Query("page"); pageStr != "" {
if p, err := strconv.Atoi(pageStr); err == nil && p >= 1 {
page = p
}
}
pageSize := 15
if psStr := c.Query("page_size"); psStr != "" {
if ps, err := strconv.Atoi(psStr); err == nil {
if ps < 1 {
ps = 15
}
pageSize = ps
}
}
orderBy := c.DefaultQuery("order_by", "create_time")
desc := true
if descStr := c.Query("desc"); descStr != "" {
desc = descStr != "false"
}
commits, total, err := h.commitService.ListCommits(folderID, page, pageSize, orderBy, desc)
if err != nil {
jsonInternalError(c, err)
return
}
var commitList []entity.CommitResponse
for _, commit := range commits {
var ct int64
if commit.CreateTime != nil {
ct = *commit.CreateTime
}
commitList = append(commitList, entity.CommitResponse{
ID: commit.ID,
FolderID: commit.FolderID,
ParentID: commit.ParentID,
Message: commit.Message,
AuthorID: commit.AuthorID,
FileCount: commit.FileCount,
CreateTime: &ct,
})
}
c.JSON(http.StatusOK, gin.H{
"code": common.CodeSuccess,
"data": gin.H{
"total": total,
"page": page,
"page_size": pageSize,
"commits": commitList,
},
"message": common.CodeSuccess.Message(),
})
}
// GetCommit gets details of a single commit
// @Summary Get Commit
// @Description Get details of a single commit including file changes
// @Tags file_commit
// @Accept json
// @Produce json
// @Param folder_id path string true "workspace folder ID"
// @Param commit_id path string true "commit ID"
// @Success 200 {object} map[string]interface{}
// @Router /api/v1/workspaces/{folder_id}/commits/{commit_id} [get]
func (h *FileCommitHandler) GetCommit(c *gin.Context) {
_, errorCode, errorMessage := GetUser(c)
if errorCode != common.CodeSuccess {
jsonError(c, errorCode, errorMessage)
return
}
folderID := c.Param("folder_id")
commitID := c.Param("commit_id")
if commitID == "" {
jsonError(c, common.CodeParamError, "commit_id is required")
return
}
commit, err := h.commitService.GetCommit(commitID)
if err != nil {
jsonError(c, common.CodeNotFound, "Commit not found")
return
}
if commit.FolderID != folderID {
jsonError(c, common.CodeNotFound, "Commit not found in workspace")
return
}
items, err := h.commitService.ListCommitFiles(commitID)
if err != nil {
items = []*entity.FileCommitItem{}
}
var ct int64
if commit.CreateTime != nil {
ct = *commit.CreateTime
}
c.JSON(http.StatusOK, gin.H{
"code": common.CodeSuccess,
"data": gin.H{
"id": commit.ID,
"folder_id": commit.FolderID,
"parent_id": commit.ParentID,
"message": commit.Message,
"author_id": commit.AuthorID,
"file_count": commit.FileCount,
"create_time": ct,
"files": items,
},
"message": common.CodeSuccess.Message(),
})
}
// ListCommitFiles lists all file changes in a commit
// @Summary List Commit Files
// @Description List all file changes in a given commit
// @Tags file_commit
// @Accept json
// @Produce json
// @Param folder_id path string true "workspace folder ID"
// @Param commit_id path string true "commit ID"
// @Success 200 {object} map[string]interface{}
// @Router /api/v1/workspaces/{folder_id}/commits/{commit_id}/files [get]
func (h *FileCommitHandler) ListCommitFiles(c *gin.Context) {
_, errorCode, errorMessage := GetUser(c)
if errorCode != common.CodeSuccess {
jsonError(c, errorCode, errorMessage)
return
}
folderID := c.Param("folder_id")
commitID := c.Param("commit_id")
if commitID == "" {
jsonError(c, common.CodeParamError, "commit_id is required")
return
}
commit, err := h.commitService.GetCommit(commitID)
if err != nil {
jsonError(c, common.CodeNotFound, "Commit not found")
return
}
if commit.FolderID != folderID {
jsonError(c, common.CodeNotFound, "Commit not found in workspace")
return
}
items, err := h.commitService.ListCommitFiles(commitID)
if err != nil {
jsonInternalError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"code": common.CodeSuccess,
"data": items,
"message": common.CodeSuccess.Message(),
})
}
// DiffCommits compares two commits
// @Summary Diff Commits
// @Description Compare two commits and return the diff
// @Tags file_commit
// @Accept json
// @Produce json
// @Param folder_id path string true "workspace folder ID"
// @Param from query string true "from commit ID"
// @Param to query string true "to commit ID"
// @Success 200 {object} map[string]interface{}
// @Router /api/v1/workspaces/{folder_id}/commits/diff [get]
func (h *FileCommitHandler) DiffCommits(c *gin.Context) {
_, errorCode, errorMessage := GetUser(c)
if errorCode != common.CodeSuccess {
jsonError(c, errorCode, errorMessage)
return
}
folderID := c.Param("folder_id")
fromID := c.Query("from")
toID := c.Query("to")
if fromID == "" || toID == "" {
jsonError(c, common.CodeParamError, "'from' and 'to' query parameters are required")
return
}
fromCommit, err := h.commitService.GetCommit(fromID)
if err != nil {
jsonError(c, common.CodeNotFound, "Commit not found")
return
}
toCommit, err := h.commitService.GetCommit(toID)
if err != nil {
jsonError(c, common.CodeNotFound, "Commit not found")
return
}
if fromCommit.FolderID != folderID || toCommit.FolderID != folderID {
jsonError(c, common.CodeNotFound, "Commit not found in workspace")
return
}
diff, err := h.commitService.DiffCommits(fromID, toID)
if err != nil {
jsonInternalError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"code": common.CodeSuccess,
"data": diff,
"message": common.CodeSuccess.Message(),
})
}
// GetUncommittedChanges gets uncommitted changes
// @Summary Get Uncommitted Changes
// @Description Get uncommitted changes for a workspace folder (like git status)
// @Tags file_commit
// @Accept json
// @Produce json
// @Param folder_id path string true "workspace folder ID"
// @Success 200 {object} map[string]interface{}
// @Router /api/v1/workspaces/{folder_id}/changes [get]
func (h *FileCommitHandler) GetUncommittedChanges(c *gin.Context) {
_, errorCode, errorMessage := GetUser(c)
if errorCode != common.CodeSuccess {
jsonError(c, errorCode, errorMessage)
return
}
folderID := c.Param("folder_id")
if folderID == "" {
jsonError(c, common.CodeParamError, "folder_id is required")
return
}
changes, err := h.commitService.GetUncommittedChanges(folderID)
if err != nil {
jsonInternalError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"code": common.CodeSuccess,
"data": changes,
"message": common.CodeSuccess.Message(),
})
}
// GetCommitTree gets the folder tree snapshot for a commit
// @Summary Get Commit Tree
// @Description Get the folder tree snapshot for a given commit
// @Tags file_commit
// @Accept json
// @Produce json
// @Param folder_id path string true "workspace folder ID"
// @Param commit_id path string true "commit ID"
// @Success 200 {object} map[string]interface{}
// @Router /api/v1/workspaces/{folder_id}/commits/{commit_id}/tree [get]
func (h *FileCommitHandler) GetCommitTree(c *gin.Context) {
_, errorCode, errorMessage := GetUser(c)
if errorCode != common.CodeSuccess {
jsonError(c, errorCode, errorMessage)
return
}
folderID := c.Param("folder_id")
commitID := c.Param("commit_id")
if commitID == "" {
jsonError(c, common.CodeParamError, "commit_id is required")
return
}
commit, err := h.commitService.GetCommit(commitID)
if err != nil {
jsonError(c, common.CodeNotFound, "Commit not found")
return
}
if commit.FolderID != folderID {
jsonError(c, common.CodeNotFound, "Commit not found in workspace")
return
}
tree, err := h.commitService.GetCommitTree(commitID)
if err != nil {
jsonInternalError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"code": common.CodeSuccess,
"data": tree,
"message": common.CodeSuccess.Message(),
})
}
// GetCommitFileContent gets file content as it existed in a given commit
// @Summary Get Commit File Content
// @Description Get file content as it existed in a specific commit
// @Tags file_commit
// @Accept json
// @Produce json
// @Param folder_id path string true "workspace folder ID"
// @Param commit_id path string true "commit ID"
// @Param file_id path string true "file ID"
// @Success 200 {object} map[string]interface{}
// @Router /api/v1/workspaces/{folder_id}/commits/{commit_id}/files/{file_id}/content [get]
func (h *FileCommitHandler) GetCommitFileContent(c *gin.Context) {
_, errorCode, errorMessage := GetUser(c)
if errorCode != common.CodeSuccess {
jsonError(c, errorCode, errorMessage)
return
}
folderID := c.Param("folder_id")
commitID := c.Param("commit_id")
fileID := c.Param("file_id")
if folderID == "" || commitID == "" || fileID == "" {
jsonError(c, common.CodeParamError, "folder_id, commit_id, and file_id are required")
return
}
commit, err := h.commitService.GetCommit(commitID)
if err != nil {
jsonError(c, common.CodeNotFound, "Commit not found")
return
}
if commit.FolderID != folderID {
jsonError(c, common.CodeNotFound, "Commit not found in workspace")
return
}
content, err := h.commitService.GetCommitFileContent(folderID, commitID, fileID)
if err != nil {
jsonError(c, common.CodeNotFound, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{
"code": common.CodeSuccess,
"data": gin.H{
"content": string(content),
},
"message": common.CodeSuccess.Message(),
})
}
// GetFileVersionHistory gets version history for a specific file
// @Summary Get File Version History
// @Description Get version history for a specific file across all commits
// @Tags file_commit
// @Accept json
// @Produce json
// @Param file_id path string true "file ID"
// @Success 200 {object} map[string]interface{}
// @Router /api/v1/files/{file_id}/versions [get]
func (h *FileCommitHandler) GetFileVersionHistory(c *gin.Context) {
_, errorCode, errorMessage := GetUser(c)
if errorCode != common.CodeSuccess {
jsonError(c, errorCode, errorMessage)
return
}
fileID := c.Param("id")
if fileID == "" {
jsonError(c, common.CodeParamError, "file_id is required")
return
}
versions, err := h.commitService.GetFileVersionHistory(fileID)
if err != nil {
jsonInternalError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"code": common.CodeSuccess,
"data": versions,
"message": common.CodeSuccess.Message(),
})
}

View File

@@ -0,0 +1,413 @@
//
// 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"
"net/http/httptest"
"strings"
"testing"
"ragflow/internal/common"
"ragflow/internal/entity"
"github.com/gin-gonic/gin"
)
// mockFileCommitSvc implements FileCommitServiceInterface for testing
type mockFileCommitSvc struct {
createCommitFn func(folderID, authorID, message string, changes []entity.FileChange) (*entity.FileCommit, error)
listCommitsFn func(folderID string, page, pageSize int, orderBy string, desc bool) ([]*entity.FileCommit, int64, error)
getCommitFn func(commitID string) (*entity.FileCommit, error)
listCommitFilesFn func(commitID string) ([]*entity.FileCommitItem, error)
diffCommitsFn func(fromID, toID string) ([]entity.DiffEntry, error)
getUncommittedChangesFn func(folderID string) ([]entity.DiffEntry, error)
getCommitTreeFn func(commitID string) (map[string]interface{}, error)
getCommitFileContentFn func(folderID, commitID, fileID string) ([]byte, error)
getFileVersionHistoryFn func(fileID string) ([]entity.VersionEntry, error)
}
func (m *mockFileCommitSvc) CreateCommit(folderID, authorID, message string, changes []entity.FileChange) (*entity.FileCommit, error) {
if m.createCommitFn != nil {
return m.createCommitFn(folderID, authorID, message, changes)
}
return &entity.FileCommit{
ID: "commit-1",
FolderID: folderID,
Message: message,
AuthorID: authorID,
FileCount: len(changes),
}, nil
}
func (m *mockFileCommitSvc) ListCommits(folderID string, page, pageSize int, orderBy string, desc bool) ([]*entity.FileCommit, int64, error) {
if m.listCommitsFn != nil {
return m.listCommitsFn(folderID, page, pageSize, orderBy, desc)
}
now := int64(1718200000000)
return []*entity.FileCommit{
{ID: "c2", FolderID: folderID, Message: "second", AuthorID: "u1", FileCount: 1, BaseModel: entity.BaseModel{CreateTime: &now}},
{ID: "c1", FolderID: folderID, Message: "first", AuthorID: "u1", FileCount: 2, BaseModel: entity.BaseModel{CreateTime: &now}},
}, 2, nil
}
func (m *mockFileCommitSvc) GetCommit(commitID string) (*entity.FileCommit, error) {
if m.getCommitFn != nil {
return m.getCommitFn(commitID)
}
return &entity.FileCommit{ID: commitID, FolderID: "folder-1", Message: "test commit", AuthorID: "u1", FileCount: 1}, nil
}
func (m *mockFileCommitSvc) ListCommitFiles(commitID string) ([]*entity.FileCommitItem, error) {
if m.listCommitFilesFn != nil {
return m.listCommitFilesFn(commitID)
}
return []*entity.FileCommitItem{
{ID: "i1", CommitID: commitID, FileID: "f1", Operation: "add"},
}, nil
}
func (m *mockFileCommitSvc) DiffCommits(fromID, toID string) ([]entity.DiffEntry, error) {
if m.diffCommitsFn != nil {
return m.diffCommitsFn(fromID, toID)
}
return []entity.DiffEntry{
{FileID: "f1", FileName: "file.txt", Operation: "modify"},
}, nil
}
func (m *mockFileCommitSvc) GetUncommittedChanges(folderID string) ([]entity.DiffEntry, error) {
if m.getUncommittedChangesFn != nil {
return m.getUncommittedChangesFn(folderID)
}
return []entity.DiffEntry{
{FileID: "f1", FileName: "new.txt", Operation: "add"},
}, nil
}
func (m *mockFileCommitSvc) GetCommitTree(commitID string) (map[string]interface{}, error) {
if m.getCommitTreeFn != nil {
return m.getCommitTreeFn(commitID)
}
return map[string]interface{}{
"f1": map[string]interface{}{"name": "file.txt", "hash": "abc123", "size": 100, "status": "1"},
}, nil
}
func (m *mockFileCommitSvc) GetCommitFileContent(folderID, commitID, fileID string) ([]byte, error) {
if m.getCommitFileContentFn != nil {
return m.getCommitFileContentFn(folderID, commitID, fileID)
}
return []byte("file content"), nil
}
func (m *mockFileCommitSvc) GetFileVersionHistory(fileID string) ([]entity.VersionEntry, error) {
if m.getFileVersionHistoryFn != nil {
return m.getFileVersionHistoryFn(fileID)
}
now := int64(1718200000000)
return []entity.VersionEntry{
{CommitID: "c2", Operation: "modify", Hash: "def456", CreateTime: &now, Message: "updated"},
{CommitID: "c1", Operation: "add", Hash: "abc123", CreateTime: &now, Message: "initial"},
}, nil
}
func setupFileCommitTest(userID string) (*gin.Engine, *mockFileCommitSvc) {
mock := &mockFileCommitSvc{}
h := &FileCommitHandler{commitService: mock}
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(func(c *gin.Context) {
c.Set("user", &entity.User{ID: userID})
})
r.POST("/api/v1/folders/:folder_id/commits", h.CreateCommit)
r.GET("/api/v1/folders/:folder_id/commits", h.ListCommits)
r.GET("/api/v1/folders/:folder_id/commits/:commit_id", h.GetCommit)
r.GET("/api/v1/folders/:folder_id/commits/:commit_id/files", h.ListCommitFiles)
r.GET("/api/v1/folders/:folder_id/commits/diff", h.DiffCommits)
r.GET("/api/v1/folders/:folder_id/changes", h.GetUncommittedChanges)
r.GET("/api/v1/folders/:folder_id/commits/:commit_id/tree", h.GetCommitTree)
r.GET("/api/v1/folders/:folder_id/commits/:commit_id/files/:file_id/content", h.GetCommitFileContent)
r.GET("/api/v1/files/:id/versions", h.GetFileVersionHistory)
return r, mock
}
func setupFileCommitTestNoAuth() *gin.Engine {
h := &FileCommitHandler{}
gin.SetMode(gin.TestMode)
r := gin.New()
r.POST("/api/v1/folders/:folder_id/commits", h.CreateCommit)
return r
}
// ── Tests ────────────────────────────────────────────────────────────────
func TestFileCommit_CreateCommit_Success(t *testing.T) {
r, mock := setupFileCommitTest("user-1")
mock.createCommitFn = func(folderID, authorID, message string, changes []entity.FileChange) (*entity.FileCommit, error) {
if folderID != "folder-1" {
t.Errorf("expected folder-1, got %s", folderID)
}
if authorID != "user-1" {
t.Errorf("expected user-1, got %s", authorID)
}
if message != "initial commit" {
t.Errorf("expected 'initial commit', got %s", message)
}
if len(changes) != 1 || changes[0].FileID != "f1" {
t.Errorf("unexpected changes: %+v", changes)
}
now := int64(1718200000000)
return &entity.FileCommit{
ID: "commit-1", FolderID: folderID, Message: message,
AuthorID: authorID, FileCount: len(changes),
BaseModel: entity.BaseModel{CreateTime: &now},
}, nil
}
body := `{"message": "initial commit", "files": [{"file_id": "f1", "file_name": "test.txt", "operation": "add", "content": "hello"}]}`
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/folders/folder-1/commits", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
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.Fatal(err)
}
if resp["code"] != float64(common.CodeSuccess) {
t.Errorf("expected code 0, got %v", resp["code"])
}
data, ok := resp["data"].(map[string]interface{})
if !ok {
t.Fatalf("expected data to be object, got %T", resp["data"])
}
if data["message"] != "initial commit" {
t.Errorf("expected 'initial commit', got %v", data["message"])
}
}
func TestFileCommit_CreateCommit_NoAuth(t *testing.T) {
r := setupFileCommitTestNoAuth()
body := `{"message": "test", "files": [{"file_id": "f1", "file_name": "t.txt", "operation": "add"}]}`
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/folders/folder-1/commits", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
// No auth middleware → code 401
if code, _ := resp["code"].(float64); code != float64(common.CodeUnauthorized) {
t.Errorf("expected unauthorized, got code %v", code)
}
}
func TestFileCommit_CreateCommit_InvalidJSON(t *testing.T) {
r, _ := setupFileCommitTest("user-1")
body := `{invalid json`
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/folders/folder-1/commits", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
if code, _ := resp["code"].(float64); code != float64(common.CodeBadRequest) {
t.Errorf("expected bad request, got code %v", code)
}
}
func TestFileCommit_ListCommits_Success(t *testing.T) {
r, mock := setupFileCommitTest("user-1")
mock.listCommitsFn = func(folderID string, page, pageSize int, orderBy string, desc bool) ([]*entity.FileCommit, int64, error) {
if folderID != "folder-1" {
t.Errorf("expected folder-1, got %s", folderID)
}
return []*entity.FileCommit{
{ID: "c2", FolderID: folderID, Message: "second", AuthorID: "u1", FileCount: 1},
{ID: "c1", FolderID: folderID, Message: "first", AuthorID: "u1", FileCount: 2},
}, 2, nil
}
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/folders/folder-1/commits?page=1&page_size=10", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
if resp["code"] != float64(common.CodeSuccess) {
t.Errorf("expected code 0, got %v", resp["code"])
}
data, _ := resp["data"].(map[string]interface{})
if total, _ := data["total"].(float64); total != 2 {
t.Errorf("expected total 2, got %v", total)
}
}
func TestFileCommit_GetCommit_Success(t *testing.T) {
r, _ := setupFileCommitTest("user-1")
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/folders/folder-1/commits/commit-1", nil)
r.ServeHTTP(w, req)
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
if resp["code"] != float64(common.CodeSuccess) {
t.Errorf("expected success, got code %v: %s", resp["code"], resp["message"])
}
data, _ := resp["data"].(map[string]interface{})
if data["id"] != "commit-1" {
t.Errorf("expected commit-1, got %v", data["id"])
}
}
func TestFileCommit_GetCommit_NotFound(t *testing.T) {
r, mock := setupFileCommitTest("user-1")
mock.getCommitFn = func(commitID string) (*entity.FileCommit, error) {
return nil, common.ErrNotFound
}
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/folders/folder-1/commits/missing", nil)
r.ServeHTTP(w, req)
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
if code, _ := resp["code"].(float64); code != float64(common.CodeNotFound) {
t.Errorf("expected 404, got code %v", code)
}
}
func TestFileCommit_ListCommitFiles_Success(t *testing.T) {
r, _ := setupFileCommitTest("user-1")
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/folders/folder-1/commits/commit-1/files", nil)
r.ServeHTTP(w, req)
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
if resp["code"] != float64(common.CodeSuccess) {
t.Errorf("expected success, got code %v", resp["code"])
}
}
func TestFileCommit_DiffCommits_Success(t *testing.T) {
r, _ := setupFileCommitTest("user-1")
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/folders/folder-1/commits/diff?from=c1&to=c2", nil)
r.ServeHTTP(w, req)
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
if resp["code"] != float64(common.CodeSuccess) {
t.Errorf("expected success, got code %v", resp["code"])
}
}
func TestFileCommit_DiffCommits_MissingParams(t *testing.T) {
r, _ := setupFileCommitTest("user-1")
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/folders/folder-1/commits/diff", nil)
r.ServeHTTP(w, req)
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
if code, _ := resp["code"].(float64); code != float64(common.CodeParamError) {
t.Errorf("expected param error, got code %v", code)
}
}
func TestFileCommit_GetUncommittedChanges_Success(t *testing.T) {
r, _ := setupFileCommitTest("user-1")
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/folders/folder-1/changes", nil)
r.ServeHTTP(w, req)
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
if resp["code"] != float64(common.CodeSuccess) {
t.Errorf("expected success, got code %v", resp["code"])
}
}
func TestFileCommit_GetCommitTree_Success(t *testing.T) {
r, _ := setupFileCommitTest("user-1")
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/folders/folder-1/commits/commit-1/tree", nil)
r.ServeHTTP(w, req)
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
if resp["code"] != float64(common.CodeSuccess) {
t.Errorf("expected success, got code %v", resp["code"])
}
}
func TestFileCommit_GetCommitFileContent_Success(t *testing.T) {
r, mock := setupFileCommitTest("user-1")
mock.getCommitFileContentFn = func(folderID, commitID, fileID string) ([]byte, error) {
return []byte("hello world"), nil
}
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/folders/folder-1/commits/commit-1/files/f1/content", nil)
r.ServeHTTP(w, req)
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
if resp["code"] != float64(common.CodeSuccess) {
t.Errorf("expected success, got code %v", resp["code"])
}
data, _ := resp["data"].(map[string]interface{})
if content, _ := data["content"].(string); content != "hello world" {
t.Errorf("expected 'hello world', got %q", content)
}
}
func TestFileCommit_GetFileVersionHistory_Success(t *testing.T) {
r, _ := setupFileCommitTest("user-1")
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/files/f1/versions", nil)
r.ServeHTTP(w, req)
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
if resp["code"] != float64(common.CodeSuccess) {
t.Errorf("expected success, got code %v", resp["code"])
}
}