1cd81ff925
Adds two improvements to the user/org/repo project-board REST API: * state filter on column-issues endpoints (issue #4) GET /api/v1/{users,orgs}/{name}/-/projects/{id}/columns/{col}/issues GET /api/v1/repos/{owner}/{repo}/projects/{id}/columns/{col}/issues Now accept ?state=open|closed|all (default open), matching the convention on the project-list endpoint and on /repos/.../issues. Applied at the IssuesOptions layer so all three scopes inherit the filter. * populated num_issues / num_open_issues / num_closed_issues on column-list (issue #5) ColumnList.LoadIssueCounts runs two grouped queries against project_issue joined with issue (one open, one closed). All three List*Columns handlers call it before converting, so num_issues stops being null and consumers can render a kanban summary in a single round trip instead of N+1. Tests: * unit: empty-input fast path on ColumnList.LoadIssueCounts. * integration: extended testAPIListProjectColumnIssues / -User / -Org to close an issue, then verify default=open hides it, state=closed and state=all return it, and the column-list response carries the correct open/closed/total split. Closes #4, closes #5
810 lines
31 KiB
Go
810 lines
31 KiB
Go
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package integration
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"testing"
|
|
|
|
auth_model "code.gitea.io/gitea/models/auth"
|
|
issues_model "code.gitea.io/gitea/models/issues"
|
|
project_model "code.gitea.io/gitea/models/project"
|
|
repo_model "code.gitea.io/gitea/models/repo"
|
|
"code.gitea.io/gitea/models/unittest"
|
|
user_model "code.gitea.io/gitea/models/user"
|
|
api "code.gitea.io/gitea/modules/structs"
|
|
issue_service "code.gitea.io/gitea/services/issue"
|
|
projects_service "code.gitea.io/gitea/services/projects"
|
|
"code.gitea.io/gitea/tests"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
)
|
|
|
|
func TestAPIProjects(t *testing.T) {
|
|
defer tests.PrepareTestEnv(t)()
|
|
t.Run("ListProjects", testAPIListProjects)
|
|
t.Run("GetProject", testAPIGetProject)
|
|
t.Run("CreateProject", testAPICreateProject)
|
|
t.Run("UpdateProject", testAPIUpdateProject)
|
|
t.Run("ChangeProjectStatus", testAPIChangeProjectStatus)
|
|
t.Run("DeleteProject", testAPIDeleteProject)
|
|
t.Run("ListProjectColumns", testAPIListProjectColumns)
|
|
t.Run("CreateProjectColumn", testAPICreateProjectColumn)
|
|
t.Run("UpdateProjectColumn", testAPIUpdateProjectColumn)
|
|
t.Run("DeleteProjectColumn", testAPIDeleteProjectColumn)
|
|
t.Run("AddIssueToProjectColumn", testAPIAddIssueToProjectColumn)
|
|
t.Run("RemoveIssueFromProjectColumn", testAPIRemoveIssueFromProjectColumn)
|
|
t.Run("ListProjectColumnIssues", testAPIListProjectColumnIssues)
|
|
t.Run("MoveProjectIssue", testAPIMoveProjectIssue)
|
|
t.Run("Permissions", testAPIProjectPermissions)
|
|
}
|
|
|
|
func testAPIMoveProjectIssue(t *testing.T) {
|
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
|
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
|
issueA := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
|
|
|
|
project := &project_model.Project{
|
|
Title: "Project for MoveIssue",
|
|
RepoID: repo.ID,
|
|
Type: project_model.TypeRepository,
|
|
CreatorID: owner.ID,
|
|
TemplateType: project_model.TemplateTypeNone,
|
|
}
|
|
err := project_model.NewProject(t.Context(), project)
|
|
assert.NoError(t, err)
|
|
|
|
colA := &project_model.Column{Title: "A", ProjectID: project.ID, CreatorID: owner.ID}
|
|
err = project_model.NewColumn(t.Context(), colA)
|
|
assert.NoError(t, err)
|
|
colB := &project_model.Column{Title: "B", ProjectID: project.ID, CreatorID: owner.ID}
|
|
err = project_model.NewColumn(t.Context(), colB)
|
|
assert.NoError(t, err)
|
|
|
|
err = issues_model.IssueAssignOrRemoveProject(t.Context(), issueA, owner, []int64{project.ID})
|
|
assert.NoError(t, err)
|
|
err = projects_service.MoveIssuesOnProjectColumn(t.Context(), owner, colA, map[int64]int64{0: issueA.ID})
|
|
assert.NoError(t, err)
|
|
|
|
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)
|
|
|
|
req := NewRequestWithJSON(t, "POST",
|
|
fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/issues/%d/move", owner.Name, repo.Name, project.ID, issueA.ID),
|
|
&api.MoveProjectIssueOption{ColumnID: colB.ID},
|
|
).AddTokenAuth(token)
|
|
MakeRequest(t, req, http.StatusNoContent)
|
|
|
|
pi := unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{ProjectID: project.ID, IssueID: issueA.ID})
|
|
assert.Equal(t, colB.ID, pi.ProjectColumnID)
|
|
|
|
req = NewRequestWithJSON(t, "POST",
|
|
fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/issues/%d/move", owner.Name, repo.Name, project.ID, issueA.ID),
|
|
&api.MoveProjectIssueOption{ColumnID: 99999},
|
|
).AddTokenAuth(token)
|
|
MakeRequest(t, req, http.StatusUnprocessableEntity)
|
|
|
|
issueB := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 4})
|
|
req = NewRequestWithJSON(t, "POST",
|
|
fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/issues/%d/move", owner.Name, repo.Name, project.ID, issueB.ID),
|
|
&api.MoveProjectIssueOption{ColumnID: colA.ID},
|
|
).AddTokenAuth(token)
|
|
MakeRequest(t, req, http.StatusNotFound)
|
|
}
|
|
|
|
func testAPIListProjects(t *testing.T) {
|
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
|
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
|
|
|
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeReadIssue)
|
|
|
|
// Test listing all projects
|
|
req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects", owner.Name, repo.Name).
|
|
AddTokenAuth(token)
|
|
resp := MakeRequest(t, req, http.StatusOK)
|
|
|
|
var projects []*api.Project
|
|
DecodeJSON(t, resp, &projects)
|
|
|
|
// Test state filter - open
|
|
req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects?state=open", owner.Name, repo.Name).
|
|
AddTokenAuth(token)
|
|
resp = MakeRequest(t, req, http.StatusOK)
|
|
DecodeJSON(t, resp, &projects)
|
|
for _, project := range projects {
|
|
assert.Equal(t, api.StateOpen, project.State, "Project should be open")
|
|
}
|
|
|
|
// Test state filter - all
|
|
req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects?state=all", owner.Name, repo.Name).
|
|
AddTokenAuth(token)
|
|
resp = MakeRequest(t, req, http.StatusOK)
|
|
DecodeJSON(t, resp, &projects)
|
|
|
|
// Test pagination
|
|
req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects?page=1&limit=5", owner.Name, repo.Name).
|
|
AddTokenAuth(token)
|
|
MakeRequest(t, req, http.StatusOK)
|
|
}
|
|
|
|
func testAPIGetProject(t *testing.T) {
|
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
|
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
|
|
|
// Create a test project
|
|
project := &project_model.Project{
|
|
Title: "Test Project for API",
|
|
RepoID: repo.ID,
|
|
Type: project_model.TypeRepository,
|
|
CreatorID: owner.ID,
|
|
TemplateType: project_model.TemplateTypeNone,
|
|
}
|
|
err := project_model.NewProject(t.Context(), project)
|
|
assert.NoError(t, err)
|
|
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeReadIssue)
|
|
|
|
// Test getting the project
|
|
req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/%d", owner.Name, repo.Name, project.ID).
|
|
AddTokenAuth(token)
|
|
resp := MakeRequest(t, req, http.StatusOK)
|
|
|
|
var apiProject api.Project
|
|
DecodeJSON(t, resp, &apiProject)
|
|
assert.Equal(t, project.Title, apiProject.Title)
|
|
assert.Equal(t, project.ID, apiProject.ID)
|
|
assert.Equal(t, repo.ID, apiProject.RepoID)
|
|
assert.NotEmpty(t, apiProject.HTMLURL)
|
|
|
|
// Test getting non-existent project
|
|
req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/99999", owner.Name, repo.Name).
|
|
AddTokenAuth(token)
|
|
MakeRequest(t, req, http.StatusNotFound)
|
|
}
|
|
|
|
func testAPICreateProject(t *testing.T) {
|
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
|
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
|
|
|
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)
|
|
|
|
// Test creating a project
|
|
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects", owner.Name, repo.Name), &api.CreateProjectOption{
|
|
Title: "API Created Project",
|
|
Description: "This is a test project created via API",
|
|
TemplateType: "basic_kanban",
|
|
CardType: "images_and_text",
|
|
}).AddTokenAuth(token)
|
|
resp := MakeRequest(t, req, http.StatusCreated)
|
|
|
|
var project api.Project
|
|
DecodeJSON(t, resp, &project)
|
|
assert.Equal(t, "API Created Project", project.Title)
|
|
assert.Equal(t, "This is a test project created via API", project.Description)
|
|
assert.Equal(t, "basic_kanban", project.TemplateType)
|
|
assert.Equal(t, "images_and_text", project.CardType)
|
|
assert.Equal(t, api.StateOpen, project.State)
|
|
|
|
// Test creating with minimal data
|
|
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects", owner.Name, repo.Name), &api.CreateProjectOption{
|
|
Title: "Minimal Project",
|
|
}).AddTokenAuth(token)
|
|
resp = MakeRequest(t, req, http.StatusCreated)
|
|
|
|
var minimalProject api.Project
|
|
DecodeJSON(t, resp, &minimalProject)
|
|
assert.Equal(t, "Minimal Project", minimalProject.Title)
|
|
|
|
// Test creating without authentication
|
|
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects", owner.Name, repo.Name), &api.CreateProjectOption{
|
|
Title: "Unauthorized Project",
|
|
})
|
|
MakeRequest(t, req, http.StatusUnauthorized)
|
|
|
|
// Test creating with invalid data (empty title)
|
|
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects", owner.Name, repo.Name), &api.CreateProjectOption{
|
|
Title: "",
|
|
}).AddTokenAuth(token)
|
|
MakeRequest(t, req, http.StatusUnprocessableEntity)
|
|
}
|
|
|
|
func testAPIUpdateProject(t *testing.T) {
|
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
|
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
|
|
|
// Create a test project
|
|
project := &project_model.Project{
|
|
Title: "Project to Update",
|
|
Description: "Original description",
|
|
RepoID: repo.ID,
|
|
Type: project_model.TypeRepository,
|
|
CreatorID: owner.ID,
|
|
TemplateType: project_model.TemplateTypeNone,
|
|
}
|
|
err := project_model.NewProject(t.Context(), project)
|
|
assert.NoError(t, err)
|
|
|
|
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)
|
|
|
|
// Test updating project title and description
|
|
newTitle := "Updated Project Title"
|
|
newDesc := "Updated description"
|
|
req := NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d", owner.Name, repo.Name, project.ID), &api.EditProjectOption{
|
|
Title: &newTitle,
|
|
Description: &newDesc,
|
|
}).AddTokenAuth(token)
|
|
resp := MakeRequest(t, req, http.StatusOK)
|
|
|
|
var updatedProject api.Project
|
|
DecodeJSON(t, resp, &updatedProject)
|
|
assert.Equal(t, newTitle, updatedProject.Title)
|
|
assert.Equal(t, newDesc, updatedProject.Description)
|
|
|
|
// Test updating non-existent project
|
|
req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/projects/99999", owner.Name, repo.Name), &api.EditProjectOption{
|
|
Title: &newTitle,
|
|
}).AddTokenAuth(token)
|
|
MakeRequest(t, req, http.StatusNotFound)
|
|
}
|
|
|
|
func testAPIChangeProjectStatus(t *testing.T) {
|
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
|
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
|
|
|
project := &project_model.Project{
|
|
Title: "Project to Close",
|
|
Description: "Project to close and reopen",
|
|
RepoID: repo.ID,
|
|
Type: project_model.TypeRepository,
|
|
CreatorID: owner.ID,
|
|
TemplateType: project_model.TemplateTypeNone,
|
|
}
|
|
err := project_model.NewProject(t.Context(), project)
|
|
assert.NoError(t, err)
|
|
|
|
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)
|
|
|
|
closed := api.StateClosed
|
|
req := NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d", owner.Name, repo.Name, project.ID), &api.EditProjectOption{
|
|
State: &closed,
|
|
}).AddTokenAuth(token)
|
|
resp := MakeRequest(t, req, http.StatusOK)
|
|
|
|
var updatedProject api.Project
|
|
DecodeJSON(t, resp, &updatedProject)
|
|
assert.Equal(t, api.StateClosed, updatedProject.State)
|
|
assert.NotNil(t, updatedProject.ClosedAt)
|
|
|
|
open := api.StateOpen
|
|
req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d", owner.Name, repo.Name, project.ID), &api.EditProjectOption{
|
|
State: &open,
|
|
}).AddTokenAuth(token)
|
|
resp = MakeRequest(t, req, http.StatusOK)
|
|
|
|
DecodeJSON(t, resp, &updatedProject)
|
|
assert.Equal(t, api.StateOpen, updatedProject.State)
|
|
|
|
bogus := api.StateType("reopen")
|
|
req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d", owner.Name, repo.Name, project.ID), &api.EditProjectOption{
|
|
State: &bogus,
|
|
}).AddTokenAuth(token)
|
|
MakeRequest(t, req, http.StatusUnprocessableEntity)
|
|
}
|
|
|
|
func testAPIDeleteProject(t *testing.T) {
|
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
|
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
|
|
|
// Create a test project
|
|
project := &project_model.Project{
|
|
Title: "Project to Delete",
|
|
RepoID: repo.ID,
|
|
Type: project_model.TypeRepository,
|
|
CreatorID: owner.ID,
|
|
TemplateType: project_model.TemplateTypeNone,
|
|
}
|
|
err := project_model.NewProject(t.Context(), project)
|
|
assert.NoError(t, err)
|
|
|
|
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)
|
|
|
|
// Test deleting the project
|
|
req := NewRequestf(t, "DELETE", "/api/v1/repos/%s/%s/projects/%d", owner.Name, repo.Name, project.ID).
|
|
AddTokenAuth(token)
|
|
MakeRequest(t, req, http.StatusNoContent)
|
|
|
|
// Test deleting non-existent project (including the one we just deleted)
|
|
req = NewRequestf(t, "DELETE", "/api/v1/repos/%s/%s/projects/%d", owner.Name, repo.Name, project.ID).
|
|
AddTokenAuth(token)
|
|
MakeRequest(t, req, http.StatusNotFound)
|
|
}
|
|
|
|
func testAPIListProjectColumns(t *testing.T) {
|
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
|
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
|
|
|
// Create a test project
|
|
project := &project_model.Project{
|
|
Title: "Project for Columns Test",
|
|
RepoID: repo.ID,
|
|
Type: project_model.TypeRepository,
|
|
CreatorID: owner.ID,
|
|
TemplateType: project_model.TemplateTypeNone,
|
|
}
|
|
err := project_model.NewProject(t.Context(), project)
|
|
assert.NoError(t, err)
|
|
|
|
// Create test columns
|
|
for i := 1; i <= 3; i++ {
|
|
column := &project_model.Column{
|
|
Title: fmt.Sprintf("Column %d", i),
|
|
ProjectID: project.ID,
|
|
CreatorID: owner.ID,
|
|
}
|
|
err = project_model.NewColumn(t.Context(), column)
|
|
assert.NoError(t, err)
|
|
}
|
|
|
|
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeReadIssue)
|
|
|
|
// Test listing all columns — X-Total-Count must equal 3
|
|
req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/%d/columns", owner.Name, repo.Name, project.ID).
|
|
AddTokenAuth(token)
|
|
resp := MakeRequest(t, req, http.StatusOK)
|
|
|
|
var columns []*api.ProjectColumn
|
|
DecodeJSON(t, resp, &columns)
|
|
assert.Len(t, columns, 3)
|
|
assert.Equal(t, "Column 1", columns[0].Title)
|
|
assert.Equal(t, "Column 2", columns[1].Title)
|
|
assert.Equal(t, "Column 3", columns[2].Title)
|
|
assert.Equal(t, "3", resp.Header().Get("X-Total-Count"))
|
|
|
|
// Test pagination: page 1 with limit 2 returns first 2 columns, total count still 3
|
|
req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/%d/columns?page=1&limit=2", owner.Name, repo.Name, project.ID).
|
|
AddTokenAuth(token)
|
|
resp = MakeRequest(t, req, http.StatusOK)
|
|
DecodeJSON(t, resp, &columns)
|
|
assert.Len(t, columns, 2)
|
|
assert.Equal(t, "Column 1", columns[0].Title)
|
|
assert.Equal(t, "Column 2", columns[1].Title)
|
|
assert.Equal(t, "3", resp.Header().Get("X-Total-Count"))
|
|
|
|
// Test pagination: page 2 with limit 2 returns remaining column
|
|
req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/%d/columns?page=2&limit=2", owner.Name, repo.Name, project.ID).
|
|
AddTokenAuth(token)
|
|
resp = MakeRequest(t, req, http.StatusOK)
|
|
DecodeJSON(t, resp, &columns)
|
|
assert.Len(t, columns, 1)
|
|
assert.Equal(t, "Column 3", columns[0].Title)
|
|
assert.Equal(t, "3", resp.Header().Get("X-Total-Count"))
|
|
|
|
// Test listing columns for non-existent project
|
|
req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/99999/columns", owner.Name, repo.Name).
|
|
AddTokenAuth(token)
|
|
MakeRequest(t, req, http.StatusNotFound)
|
|
}
|
|
|
|
func testAPICreateProjectColumn(t *testing.T) {
|
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
|
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
|
|
|
// Create a test project
|
|
project := &project_model.Project{
|
|
Title: "Project for Column Creation",
|
|
RepoID: repo.ID,
|
|
Type: project_model.TypeRepository,
|
|
CreatorID: owner.ID,
|
|
TemplateType: project_model.TemplateTypeNone,
|
|
}
|
|
err := project_model.NewProject(t.Context(), project)
|
|
assert.NoError(t, err)
|
|
|
|
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)
|
|
|
|
// Test creating a column with color
|
|
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns", owner.Name, repo.Name, project.ID), &api.CreateProjectColumnOption{
|
|
Title: "New Column",
|
|
Color: "#FF5733",
|
|
}).AddTokenAuth(token)
|
|
resp := MakeRequest(t, req, http.StatusCreated)
|
|
|
|
var column api.ProjectColumn
|
|
DecodeJSON(t, resp, &column)
|
|
assert.Equal(t, "New Column", column.Title)
|
|
assert.Equal(t, "#FF5733", column.Color)
|
|
assert.Equal(t, project.ID, column.ProjectID)
|
|
|
|
// Test creating a column without color
|
|
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns", owner.Name, repo.Name, project.ID), &api.CreateProjectColumnOption{
|
|
Title: "Simple Column",
|
|
}).AddTokenAuth(token)
|
|
resp = MakeRequest(t, req, http.StatusCreated)
|
|
|
|
DecodeJSON(t, resp, &column)
|
|
assert.Equal(t, "Simple Column", column.Title)
|
|
|
|
// Test creating with empty title
|
|
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns", owner.Name, repo.Name, project.ID), &api.CreateProjectColumnOption{
|
|
Title: "",
|
|
}).AddTokenAuth(token)
|
|
MakeRequest(t, req, http.StatusUnprocessableEntity)
|
|
|
|
// Test creating for non-existent project
|
|
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects/99999/columns", owner.Name, repo.Name), &api.CreateProjectColumnOption{
|
|
Title: "Orphan Column",
|
|
}).AddTokenAuth(token)
|
|
MakeRequest(t, req, http.StatusNotFound)
|
|
}
|
|
|
|
func testAPIUpdateProjectColumn(t *testing.T) {
|
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
|
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
|
|
|
// Create a test project and column
|
|
project := &project_model.Project{
|
|
Title: "Project for Column Update",
|
|
RepoID: repo.ID,
|
|
Type: project_model.TypeRepository,
|
|
CreatorID: owner.ID,
|
|
TemplateType: project_model.TemplateTypeNone,
|
|
}
|
|
err := project_model.NewProject(t.Context(), project)
|
|
assert.NoError(t, err)
|
|
column := &project_model.Column{
|
|
Title: "Original Column",
|
|
ProjectID: project.ID,
|
|
CreatorID: owner.ID,
|
|
Color: "#000000",
|
|
}
|
|
err = project_model.NewColumn(t.Context(), column)
|
|
assert.NoError(t, err)
|
|
|
|
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)
|
|
|
|
// Test updating column title
|
|
newTitle := "Updated Column"
|
|
req := NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/%d", owner.Name, repo.Name, project.ID, column.ID), &api.EditProjectColumnOption{
|
|
Title: &newTitle,
|
|
}).AddTokenAuth(token)
|
|
resp := MakeRequest(t, req, http.StatusOK)
|
|
|
|
var updatedColumn api.ProjectColumn
|
|
DecodeJSON(t, resp, &updatedColumn)
|
|
assert.Equal(t, newTitle, updatedColumn.Title)
|
|
|
|
// Test updating column color
|
|
newColor := "#FF0000"
|
|
req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/%d", owner.Name, repo.Name, project.ID, column.ID), &api.EditProjectColumnOption{
|
|
Color: &newColor,
|
|
}).AddTokenAuth(token)
|
|
resp = MakeRequest(t, req, http.StatusOK)
|
|
|
|
DecodeJSON(t, resp, &updatedColumn)
|
|
assert.Equal(t, newColor, updatedColumn.Color)
|
|
|
|
// Test updating non-existent column
|
|
req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/99999", owner.Name, repo.Name, project.ID), &api.EditProjectColumnOption{
|
|
Title: &newTitle,
|
|
}).AddTokenAuth(token)
|
|
MakeRequest(t, req, http.StatusNotFound)
|
|
}
|
|
|
|
func testAPIDeleteProjectColumn(t *testing.T) {
|
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
|
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
|
|
|
// Create a test project and column
|
|
project := &project_model.Project{
|
|
Title: "Project for Column Deletion",
|
|
RepoID: repo.ID,
|
|
Type: project_model.TypeRepository,
|
|
CreatorID: owner.ID,
|
|
TemplateType: project_model.TemplateTypeNone,
|
|
}
|
|
err := project_model.NewProject(t.Context(), project)
|
|
assert.NoError(t, err)
|
|
column := &project_model.Column{
|
|
Title: "Column to Delete",
|
|
ProjectID: project.ID,
|
|
CreatorID: owner.ID,
|
|
}
|
|
err = project_model.NewColumn(t.Context(), column)
|
|
assert.NoError(t, err)
|
|
|
|
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)
|
|
|
|
// Test deleting the column
|
|
req := NewRequestf(t, "DELETE", "/api/v1/repos/%s/%s/projects/%d/columns/%d", owner.Name, repo.Name, project.ID, column.ID).
|
|
AddTokenAuth(token)
|
|
MakeRequest(t, req, http.StatusNoContent)
|
|
|
|
// Test deleting non-existent column (including the one we just deleted)
|
|
req = NewRequestf(t, "DELETE", "/api/v1/repos/%s/%s/projects/%d/columns/%d", owner.Name, repo.Name, project.ID, column.ID).
|
|
AddTokenAuth(token)
|
|
MakeRequest(t, req, http.StatusNotFound)
|
|
}
|
|
|
|
func testAPIAddIssueToProjectColumn(t *testing.T) {
|
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
|
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
|
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: repo.ID})
|
|
|
|
// Create a test project and column
|
|
project := &project_model.Project{
|
|
Title: "Project for Issue Assignment",
|
|
RepoID: repo.ID,
|
|
Type: project_model.TypeRepository,
|
|
CreatorID: owner.ID,
|
|
TemplateType: project_model.TemplateTypeNone,
|
|
}
|
|
err := project_model.NewProject(t.Context(), project)
|
|
assert.NoError(t, err)
|
|
|
|
column1 := &project_model.Column{
|
|
Title: "Column 1",
|
|
ProjectID: project.ID,
|
|
CreatorID: owner.ID,
|
|
}
|
|
err = project_model.NewColumn(t.Context(), column1)
|
|
assert.NoError(t, err)
|
|
|
|
column2 := &project_model.Column{
|
|
Title: "Column 2",
|
|
ProjectID: project.ID,
|
|
CreatorID: owner.ID,
|
|
}
|
|
err = project_model.NewColumn(t.Context(), column2)
|
|
assert.NoError(t, err)
|
|
|
|
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)
|
|
|
|
// Test adding issue to column
|
|
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/%d/issues/%d", owner.Name, repo.Name, project.ID, column1.ID, issue.ID), nil).AddTokenAuth(token)
|
|
MakeRequest(t, req, http.StatusCreated)
|
|
|
|
// Verify issue is in the column
|
|
projectIssue := unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{
|
|
ProjectID: project.ID,
|
|
IssueID: issue.ID,
|
|
})
|
|
assert.Equal(t, column1.ID, projectIssue.ProjectColumnID)
|
|
|
|
// Test moving issue to another column
|
|
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/%d/issues/%d", owner.Name, repo.Name, project.ID, column2.ID, issue.ID), nil).AddTokenAuth(token)
|
|
MakeRequest(t, req, http.StatusCreated)
|
|
|
|
// Verify issue moved to new column
|
|
projectIssue = unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{
|
|
ProjectID: project.ID,
|
|
IssueID: issue.ID,
|
|
})
|
|
assert.Equal(t, column2.ID, projectIssue.ProjectColumnID)
|
|
|
|
// Test adding same issue to same column (should be idempotent)
|
|
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/%d/issues/%d", owner.Name, repo.Name, project.ID, column2.ID, issue.ID), nil).AddTokenAuth(token)
|
|
MakeRequest(t, req, http.StatusCreated)
|
|
|
|
// Test adding non-existent issue
|
|
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/%d/issues/%d", owner.Name, repo.Name, project.ID, column1.ID, 99999), nil).AddTokenAuth(token)
|
|
MakeRequest(t, req, http.StatusNotFound)
|
|
|
|
// Test adding to non-existent column
|
|
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/99999/issues/%d", owner.Name, repo.Name, project.ID, issue.ID), nil).AddTokenAuth(token)
|
|
MakeRequest(t, req, http.StatusNotFound)
|
|
}
|
|
|
|
func testAPIListProjectColumnIssues(t *testing.T) {
|
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
|
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
|
issueA := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
|
|
issueB := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 2})
|
|
|
|
project := &project_model.Project{
|
|
Title: "Project for Column Issues",
|
|
RepoID: repo.ID,
|
|
Type: project_model.TypeRepository,
|
|
CreatorID: owner.ID,
|
|
TemplateType: project_model.TemplateTypeNone,
|
|
}
|
|
err := project_model.NewProject(t.Context(), project)
|
|
assert.NoError(t, err)
|
|
|
|
columnA := &project_model.Column{
|
|
Title: "Column A",
|
|
ProjectID: project.ID,
|
|
CreatorID: owner.ID,
|
|
}
|
|
err = project_model.NewColumn(t.Context(), columnA)
|
|
assert.NoError(t, err)
|
|
|
|
columnB := &project_model.Column{
|
|
Title: "Column B",
|
|
ProjectID: project.ID,
|
|
CreatorID: owner.ID,
|
|
}
|
|
err = project_model.NewColumn(t.Context(), columnB)
|
|
assert.NoError(t, err)
|
|
|
|
// Place issueA in columnA, issueB in columnB.
|
|
err = issues_model.IssueAssignOrRemoveProject(t.Context(), issueA, owner, []int64{project.ID})
|
|
assert.NoError(t, err)
|
|
err = projects_service.MoveIssuesOnProjectColumn(t.Context(), owner, columnA, map[int64]int64{0: issueA.ID})
|
|
assert.NoError(t, err)
|
|
|
|
err = issues_model.IssueAssignOrRemoveProject(t.Context(), issueB, owner, []int64{project.ID})
|
|
assert.NoError(t, err)
|
|
err = projects_service.MoveIssuesOnProjectColumn(t.Context(), owner, columnB, map[int64]int64{0: issueB.ID})
|
|
assert.NoError(t, err)
|
|
|
|
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeReadIssue)
|
|
|
|
// Column A should contain only issueA.
|
|
req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/%d/columns/%d/issues", owner.Name, repo.Name, project.ID, columnA.ID).
|
|
AddTokenAuth(token)
|
|
resp := MakeRequest(t, req, http.StatusOK)
|
|
var issuesA []api.Issue
|
|
DecodeJSON(t, resp, &issuesA)
|
|
assert.Len(t, issuesA, 1)
|
|
assert.Equal(t, issueA.ID, issuesA[0].ID)
|
|
|
|
// Column B should contain only issueB.
|
|
req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/%d/columns/%d/issues", owner.Name, repo.Name, project.ID, columnB.ID).
|
|
AddTokenAuth(token)
|
|
resp = MakeRequest(t, req, http.StatusOK)
|
|
var issuesB []api.Issue
|
|
DecodeJSON(t, resp, &issuesB)
|
|
assert.Len(t, issuesB, 1)
|
|
assert.Equal(t, issueB.ID, issuesB[0].ID)
|
|
|
|
// Close issueA, then exercise the new state= query parameter.
|
|
assert.NoError(t, issue_service.CloseIssue(t.Context(), issueA, owner, ""))
|
|
|
|
// Default (state omitted) -> open only -> columnA returns nothing.
|
|
req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/%d/columns/%d/issues", owner.Name, repo.Name, project.ID, columnA.ID).
|
|
AddTokenAuth(token)
|
|
resp = MakeRequest(t, req, http.StatusOK)
|
|
var gotOpenOnly []api.Issue
|
|
DecodeJSON(t, resp, &gotOpenOnly)
|
|
assert.Empty(t, gotOpenOnly, "default state=open must hide the closed issueA")
|
|
|
|
// state=closed -> returns issueA.
|
|
req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/%d/columns/%d/issues?state=closed", owner.Name, repo.Name, project.ID, columnA.ID).
|
|
AddTokenAuth(token)
|
|
resp = MakeRequest(t, req, http.StatusOK)
|
|
var gotClosed []api.Issue
|
|
DecodeJSON(t, resp, &gotClosed)
|
|
assert.Len(t, gotClosed, 1)
|
|
assert.Equal(t, issueA.ID, gotClosed[0].ID)
|
|
|
|
// state=all -> returns issueA.
|
|
req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/%d/columns/%d/issues?state=all", owner.Name, repo.Name, project.ID, columnA.ID).
|
|
AddTokenAuth(token)
|
|
resp = MakeRequest(t, req, http.StatusOK)
|
|
var gotAll []api.Issue
|
|
DecodeJSON(t, resp, &gotAll)
|
|
assert.Len(t, gotAll, 1)
|
|
|
|
// And the columns endpoint must populate num_issues / num_open_issues /
|
|
// num_closed_issues — issue #5. columnA has 1 closed; columnB has 1 open.
|
|
req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/%d/columns", owner.Name, repo.Name, project.ID).
|
|
AddTokenAuth(token)
|
|
resp = MakeRequest(t, req, http.StatusOK)
|
|
var listed []*api.ProjectColumn
|
|
DecodeJSON(t, resp, &listed)
|
|
|
|
byID := map[int64]*api.ProjectColumn{}
|
|
for _, c := range listed {
|
|
byID[c.ID] = c
|
|
}
|
|
if assert.NotNil(t, byID[columnA.ID]) {
|
|
assert.EqualValues(t, 1, byID[columnA.ID].NumIssues, "columnA total")
|
|
assert.EqualValues(t, 0, byID[columnA.ID].NumOpenIssues, "columnA open")
|
|
assert.EqualValues(t, 1, byID[columnA.ID].NumClosedIssues, "columnA closed")
|
|
}
|
|
if assert.NotNil(t, byID[columnB.ID]) {
|
|
assert.EqualValues(t, 1, byID[columnB.ID].NumIssues, "columnB total")
|
|
assert.EqualValues(t, 1, byID[columnB.ID].NumOpenIssues, "columnB open")
|
|
assert.EqualValues(t, 0, byID[columnB.ID].NumClosedIssues, "columnB closed")
|
|
}
|
|
}
|
|
|
|
func testAPIRemoveIssueFromProjectColumn(t *testing.T) {
|
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
|
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
|
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: repo.ID})
|
|
|
|
project := &project_model.Project{
|
|
Title: "Project for Issue Removal",
|
|
RepoID: repo.ID,
|
|
Type: project_model.TypeRepository,
|
|
CreatorID: owner.ID,
|
|
TemplateType: project_model.TemplateTypeNone,
|
|
}
|
|
err := project_model.NewProject(t.Context(), project)
|
|
assert.NoError(t, err)
|
|
|
|
column := &project_model.Column{
|
|
Title: "Column for Issue Removal",
|
|
ProjectID: project.ID,
|
|
CreatorID: owner.ID,
|
|
}
|
|
err = project_model.NewColumn(t.Context(), column)
|
|
assert.NoError(t, err)
|
|
|
|
otherColumn := &project_model.Column{
|
|
Title: "Other Column",
|
|
ProjectID: project.ID,
|
|
CreatorID: owner.ID,
|
|
}
|
|
err = project_model.NewColumn(t.Context(), otherColumn)
|
|
assert.NoError(t, err)
|
|
|
|
err = issues_model.IssueAssignOrRemoveProject(t.Context(), issue, owner, []int64{project.ID})
|
|
assert.NoError(t, err)
|
|
|
|
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)
|
|
|
|
// Removing via a column the issue does not live in must 404 and not detach the issue
|
|
req := NewRequestWithJSON(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/%d/issues/%d", owner.Name, repo.Name, project.ID, otherColumn.ID, issue.ID), nil).
|
|
AddTokenAuth(token)
|
|
MakeRequest(t, req, http.StatusNotFound)
|
|
unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{
|
|
ProjectID: project.ID,
|
|
IssueID: issue.ID,
|
|
})
|
|
|
|
req = NewRequestWithJSON(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d/columns/%d/issues/%d", owner.Name, repo.Name, project.ID, column.ID, issue.ID), nil).
|
|
AddTokenAuth(token)
|
|
MakeRequest(t, req, http.StatusNoContent)
|
|
|
|
unittest.AssertNotExistsBean(t, &project_model.ProjectIssue{
|
|
ProjectID: project.ID,
|
|
IssueID: issue.ID,
|
|
})
|
|
}
|
|
|
|
func testAPIProjectPermissions(t *testing.T) {
|
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
|
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
|
nonCollaborator := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user5"})
|
|
|
|
// Create a test project
|
|
project := &project_model.Project{
|
|
Title: "Permission Test Project",
|
|
RepoID: repo.ID,
|
|
Type: project_model.TypeRepository,
|
|
CreatorID: owner.ID,
|
|
TemplateType: project_model.TemplateTypeNone,
|
|
}
|
|
err := project_model.NewProject(t.Context(), project)
|
|
assert.NoError(t, err)
|
|
|
|
ownerToken := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)
|
|
nonCollaboratorToken := getUserToken(t, nonCollaborator.Name, auth_model.AccessTokenScopeWriteIssue)
|
|
|
|
// Owner should be able to read
|
|
req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/%d", owner.Name, repo.Name, project.ID).
|
|
AddTokenAuth(ownerToken)
|
|
MakeRequest(t, req, http.StatusOK)
|
|
|
|
// Owner should be able to update
|
|
newTitle := "Updated by Owner"
|
|
req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d", owner.Name, repo.Name, project.ID), &api.EditProjectOption{
|
|
Title: &newTitle,
|
|
}).AddTokenAuth(ownerToken)
|
|
MakeRequest(t, req, http.StatusOK)
|
|
|
|
// Non-collaborator should not be able to update
|
|
anotherTitle := "Updated by Non-collaborator"
|
|
req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/projects/%d", owner.Name, repo.Name, project.ID), &api.EditProjectOption{
|
|
Title: &anotherTitle,
|
|
}).AddTokenAuth(nonCollaboratorToken)
|
|
MakeRequest(t, req, http.StatusForbidden)
|
|
|
|
// Non-collaborator should not be able to delete
|
|
req = NewRequestf(t, "DELETE", "/api/v1/repos/%s/%s/projects/%d", owner.Name, repo.Name, project.ID).
|
|
AddTokenAuth(nonCollaboratorToken)
|
|
MakeRequest(t, req, http.StatusForbidden)
|
|
}
|