Files
gitea/tests/integration/api_user_project_test.go
T
Oleks 1cd81ff925 feat(api): state filter + populated num_issues on project columns
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
2026-05-15 21:54:35 +03:00

544 lines
22 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"
"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 TestAPIUserProjects(t *testing.T) {
defer tests.PrepareTestEnv(t)()
t.Run("ListProjects", testAPIUserListProjects)
t.Run("GetProject", testAPIUserGetProject)
t.Run("CreateProject", testAPIUserCreateProject)
t.Run("UpdateProject", testAPIUserUpdateProject)
t.Run("ChangeProjectStatus", testAPIUserChangeProjectStatus)
t.Run("DeleteProject", testAPIUserDeleteProject)
t.Run("ListProjectColumns", testAPIUserListProjectColumns)
t.Run("CreateProjectColumn", testAPIUserCreateProjectColumn)
t.Run("UpdateProjectColumn", testAPIUserUpdateProjectColumn)
t.Run("DeleteProjectColumn", testAPIUserDeleteProjectColumn)
t.Run("AddIssueToProjectColumn", testAPIUserAddIssueToProjectColumn)
t.Run("RemoveIssueFromProjectColumn", testAPIUserRemoveIssueFromProjectColumn)
t.Run("ListProjectColumnIssues", testAPIUserListProjectColumnIssues)
t.Run("MoveProjectIssue", testAPIUserMoveProjectIssue)
t.Run("Permissions", testAPIUserProjectPermissions)
}
func makeUserProject(t *testing.T, owner *user_model.User) *project_model.Project {
t.Helper()
p := &project_model.Project{
OwnerID: owner.ID,
Title: "Test User Project",
Description: "created by test helper",
CreatorID: owner.ID,
Type: project_model.TypeIndividual,
TemplateType: project_model.TemplateTypeNone,
}
err := project_model.NewProject(t.Context(), p)
assert.NoError(t, err)
return p
}
func testAPIUserListProjects(t *testing.T) {
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeReadIssue)
req := NewRequestf(t, "GET", "/api/v1/users/%s/projects", owner.Name).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
var projects []*api.Project
DecodeJSON(t, resp, &projects)
req = NewRequestf(t, "GET", "/api/v1/users/%s/projects?state=open", owner.Name).AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &projects)
for _, p := range projects {
assert.Equal(t, api.StateOpen, p.State)
}
req = NewRequestf(t, "GET", "/api/v1/users/%s/projects?state=all", owner.Name).AddTokenAuth(token)
MakeRequest(t, req, http.StatusOK)
}
func testAPIUserGetProject(t *testing.T) {
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
p := makeUserProject(t, owner)
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeReadIssue)
req := NewRequestf(t, "GET", "/api/v1/users/%s/projects/%d", owner.Name, p.ID).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
var got api.Project
DecodeJSON(t, resp, &got)
assert.Equal(t, p.ID, got.ID)
assert.Equal(t, p.Title, got.Title)
assert.Equal(t, "individual", got.Type)
req = NewRequestf(t, "GET", "/api/v1/users/%s/projects/99999", owner.Name).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
}
func testAPIUserCreateProject(t *testing.T) {
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/users/%s/projects", owner.Name), &api.CreateProjectOption{
Title: "Created via API",
Description: "desc",
}).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusCreated)
var got api.Project
DecodeJSON(t, resp, &got)
assert.Equal(t, "Created via API", got.Title)
assert.Equal(t, "individual", got.Type)
// unauthenticated
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/users/%s/projects", owner.Name), &api.CreateProjectOption{Title: "x"})
MakeRequest(t, req, http.StatusUnauthorized)
// empty title
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/users/%s/projects", owner.Name), &api.CreateProjectOption{Title: ""}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusUnprocessableEntity)
}
func testAPIUserUpdateProject(t *testing.T) {
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
p := makeUserProject(t, owner)
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)
newTitle := "Updated Title"
newDesc := "Updated desc"
req := NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/users/%s/projects/%d", owner.Name, p.ID), &api.EditProjectOption{
Title: &newTitle,
Description: &newDesc,
}).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
var got api.Project
DecodeJSON(t, resp, &got)
assert.Equal(t, newTitle, got.Title)
assert.Equal(t, newDesc, got.Description)
}
func testAPIUserChangeProjectStatus(t *testing.T) {
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
p := makeUserProject(t, owner)
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)
closed := api.StateClosed
req := NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/users/%s/projects/%d", owner.Name, p.ID), &api.EditProjectOption{
State: &closed,
}).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
var got api.Project
DecodeJSON(t, resp, &got)
assert.Equal(t, api.StateClosed, got.State)
assert.NotNil(t, got.ClosedAt)
open := api.StateOpen
req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/users/%s/projects/%d", owner.Name, p.ID), &api.EditProjectOption{
State: &open,
}).AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &got)
assert.Equal(t, api.StateOpen, got.State)
bogus := api.StateType("reopen")
req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/users/%s/projects/%d", owner.Name, p.ID), &api.EditProjectOption{
State: &bogus,
}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusUnprocessableEntity)
}
func testAPIUserDeleteProject(t *testing.T) {
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
p := makeUserProject(t, owner)
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)
req := NewRequestf(t, "DELETE", "/api/v1/users/%s/projects/%d", owner.Name, p.ID).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNoContent)
req = NewRequestf(t, "DELETE", "/api/v1/users/%s/projects/%d", owner.Name, p.ID).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
}
func testAPIUserListProjectColumns(t *testing.T) {
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
p := makeUserProject(t, owner)
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeReadIssue)
for i := 1; i <= 3; i++ {
col := &project_model.Column{
Title: fmt.Sprintf("Col%d", i),
ProjectID: p.ID,
CreatorID: owner.ID,
}
assert.NoError(t, project_model.NewColumn(t.Context(), col))
}
req := NewRequestf(t, "GET", "/api/v1/users/%s/projects/%d/columns", owner.Name, p.ID).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
var cols []*api.ProjectColumn
DecodeJSON(t, resp, &cols)
assert.Len(t, cols, 3)
assert.Equal(t, "3", resp.Header().Get("X-Total-Count"))
req = NewRequestf(t, "GET", "/api/v1/users/%s/projects/%d/columns?page=1&limit=2", owner.Name, p.ID).AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &cols)
assert.Len(t, cols, 2)
assert.Equal(t, "3", resp.Header().Get("X-Total-Count"))
req = NewRequestf(t, "GET", "/api/v1/users/%s/projects/%d/columns?page=2&limit=2", owner.Name, p.ID).AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &cols)
assert.Len(t, cols, 1)
assert.Equal(t, "3", resp.Header().Get("X-Total-Count"))
}
func testAPIUserCreateProjectColumn(t *testing.T) {
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
p := makeUserProject(t, owner)
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/users/%s/projects/%d/columns", owner.Name, p.ID), &api.CreateProjectColumnOption{
Title: "New Column",
Color: "#FF5733",
}).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusCreated)
var col api.ProjectColumn
DecodeJSON(t, resp, &col)
assert.Equal(t, "New Column", col.Title)
assert.Equal(t, "#FF5733", col.Color)
assert.Equal(t, p.ID, col.ProjectID)
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/users/%s/projects/%d/columns", owner.Name, p.ID), &api.CreateProjectColumnOption{
Title: "Simple Column",
}).AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusCreated)
DecodeJSON(t, resp, &col)
assert.Equal(t, "Simple Column", col.Title)
// empty title
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/users/%s/projects/%d/columns", owner.Name, p.ID), &api.CreateProjectColumnOption{
Title: "",
}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusUnprocessableEntity)
// non-existent project
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/users/%s/projects/99999/columns", owner.Name), &api.CreateProjectColumnOption{
Title: "Orphan",
}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
}
func testAPIUserUpdateProjectColumn(t *testing.T) {
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
p := makeUserProject(t, owner)
col := &project_model.Column{
Title: "Original",
ProjectID: p.ID,
CreatorID: owner.ID,
Color: "#000000",
}
assert.NoError(t, project_model.NewColumn(t.Context(), col))
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)
newTitle := "Updated Column"
req := NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/users/%s/projects/%d/columns/%d", owner.Name, p.ID, col.ID), &api.EditProjectColumnOption{
Title: &newTitle,
}).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
var got api.ProjectColumn
DecodeJSON(t, resp, &got)
assert.Equal(t, newTitle, got.Title)
newColor := "#FF0000"
req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/users/%s/projects/%d/columns/%d", owner.Name, p.ID, col.ID), &api.EditProjectColumnOption{
Color: &newColor,
}).AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &got)
assert.Equal(t, newColor, got.Color)
// non-existent column
req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/users/%s/projects/%d/columns/99999", owner.Name, p.ID), &api.EditProjectColumnOption{
Title: &newTitle,
}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
}
func testAPIUserDeleteProjectColumn(t *testing.T) {
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
p := makeUserProject(t, owner)
col := &project_model.Column{
Title: "ToDelete",
ProjectID: p.ID,
CreatorID: owner.ID,
}
assert.NoError(t, project_model.NewColumn(t.Context(), col))
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)
req := NewRequestf(t, "DELETE", "/api/v1/users/%s/projects/%d/columns/%d", owner.Name, p.ID, col.ID).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNoContent)
req = NewRequestf(t, "DELETE", "/api/v1/users/%s/projects/%d/columns/%d", owner.Name, p.ID, col.ID).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
}
func testAPIUserAddIssueToProjectColumn(t *testing.T) {
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
p := makeUserProject(t, owner)
col1 := &project_model.Column{Title: "Column 1", ProjectID: p.ID, CreatorID: owner.ID}
assert.NoError(t, project_model.NewColumn(t.Context(), col1))
col2 := &project_model.Column{Title: "Column 2", ProjectID: p.ID, CreatorID: owner.ID}
assert.NoError(t, project_model.NewColumn(t.Context(), col2))
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)
// add to col1
req := NewRequestWithJSON(t, "POST",
fmt.Sprintf("/api/v1/users/%s/projects/%d/columns/%d/issues/%d", owner.Name, p.ID, col1.ID, issue.ID), nil,
).AddTokenAuth(token)
MakeRequest(t, req, http.StatusCreated)
pi := unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{ProjectID: p.ID, IssueID: issue.ID})
assert.Equal(t, col1.ID, pi.ProjectColumnID)
// move to col2 via POST
req = NewRequestWithJSON(t, "POST",
fmt.Sprintf("/api/v1/users/%s/projects/%d/columns/%d/issues/%d", owner.Name, p.ID, col2.ID, issue.ID), nil,
).AddTokenAuth(token)
MakeRequest(t, req, http.StatusCreated)
pi = unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{ProjectID: p.ID, IssueID: issue.ID})
assert.Equal(t, col2.ID, pi.ProjectColumnID)
// idempotent: add to same column again
req = NewRequestWithJSON(t, "POST",
fmt.Sprintf("/api/v1/users/%s/projects/%d/columns/%d/issues/%d", owner.Name, p.ID, col2.ID, issue.ID), nil,
).AddTokenAuth(token)
MakeRequest(t, req, http.StatusCreated)
// non-existent issue
req = NewRequestWithJSON(t, "POST",
fmt.Sprintf("/api/v1/users/%s/projects/%d/columns/%d/issues/99999", owner.Name, p.ID, col1.ID), nil,
).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
// non-existent column
req = NewRequestWithJSON(t, "POST",
fmt.Sprintf("/api/v1/users/%s/projects/%d/columns/99999/issues/%d", owner.Name, p.ID, issue.ID), nil,
).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
}
func testAPIUserRemoveIssueFromProjectColumn(t *testing.T) {
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
p := makeUserProject(t, owner)
col := &project_model.Column{Title: "Col", ProjectID: p.ID, CreatorID: owner.ID}
assert.NoError(t, project_model.NewColumn(t.Context(), col))
otherCol := &project_model.Column{Title: "Other", ProjectID: p.ID, CreatorID: owner.ID}
assert.NoError(t, project_model.NewColumn(t.Context(), otherCol))
assert.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issue, owner, []int64{p.ID}))
assert.NoError(t, projects_service.MoveIssuesOnProjectColumn(t.Context(), owner, col, map[int64]int64{0: issue.ID}))
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)
// removing via the wrong column must 404 and not detach the issue
req := NewRequestWithJSON(t, "DELETE",
fmt.Sprintf("/api/v1/users/%s/projects/%d/columns/%d/issues/%d", owner.Name, p.ID, otherCol.ID, issue.ID), nil,
).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{ProjectID: p.ID, IssueID: issue.ID})
// correct column fully detaches the issue from the project
req = NewRequestWithJSON(t, "DELETE",
fmt.Sprintf("/api/v1/users/%s/projects/%d/columns/%d/issues/%d", owner.Name, p.ID, col.ID, issue.ID), nil,
).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNoContent)
unittest.AssertNotExistsBean(t, &project_model.ProjectIssue{ProjectID: p.ID, IssueID: issue.ID})
}
func testAPIUserListProjectColumnIssues(t *testing.T) {
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
issueA := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
issueB := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 2})
p := makeUserProject(t, owner)
colA := &project_model.Column{Title: "ColA", ProjectID: p.ID, CreatorID: owner.ID}
assert.NoError(t, project_model.NewColumn(t.Context(), colA))
colB := &project_model.Column{Title: "ColB", ProjectID: p.ID, CreatorID: owner.ID}
assert.NoError(t, project_model.NewColumn(t.Context(), colB))
assert.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issueA, owner, []int64{p.ID}))
assert.NoError(t, projects_service.MoveIssuesOnProjectColumn(t.Context(), owner, colA, map[int64]int64{0: issueA.ID}))
assert.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issueB, owner, []int64{p.ID}))
assert.NoError(t, projects_service.MoveIssuesOnProjectColumn(t.Context(), owner, colB, map[int64]int64{0: issueB.ID}))
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeReadIssue)
// colA contains only issueA
req := NewRequestf(t, "GET", "/api/v1/users/%s/projects/%d/columns/%d/issues", owner.Name, p.ID, colA.ID).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
var gotA []api.Issue
DecodeJSON(t, resp, &gotA)
assert.Len(t, gotA, 1)
assert.Equal(t, issueA.ID, gotA[0].ID)
// colB contains only issueB
req = NewRequestf(t, "GET", "/api/v1/users/%s/projects/%d/columns/%d/issues", owner.Name, p.ID, colB.ID).AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
var gotB []api.Issue
DecodeJSON(t, resp, &gotB)
assert.Len(t, gotB, 1)
assert.Equal(t, issueB.ID, gotB[0].ID)
// Close issueA, then exercise the state filter (issue #4).
assert.NoError(t, issue_service.CloseIssue(t.Context(), issueA, owner, ""))
// default (state omitted) -> open only -> colA has nothing
req = NewRequestf(t, "GET", "/api/v1/users/%s/projects/%d/columns/%d/issues", owner.Name, p.ID, colA.ID).AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
var openOnly []api.Issue
DecodeJSON(t, resp, &openOnly)
assert.Empty(t, openOnly)
// state=closed -> colA returns issueA
req = NewRequestf(t, "GET", "/api/v1/users/%s/projects/%d/columns/%d/issues?state=closed", owner.Name, p.ID, colA.ID).AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
var closedOnly []api.Issue
DecodeJSON(t, resp, &closedOnly)
assert.Len(t, closedOnly, 1)
assert.Equal(t, issueA.ID, closedOnly[0].ID)
// state=all -> colA returns issueA
req = NewRequestf(t, "GET", "/api/v1/users/%s/projects/%d/columns/%d/issues?state=all", owner.Name, p.ID, colA.ID).AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
var all []api.Issue
DecodeJSON(t, resp, &all)
assert.Len(t, all, 1)
// Columns endpoint must populate num_issues / num_open_issues / num_closed_issues (issue #5).
req = NewRequestf(t, "GET", "/api/v1/users/%s/projects/%d/columns", owner.Name, p.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[colA.ID]) {
assert.EqualValues(t, 1, byID[colA.ID].NumIssues)
assert.EqualValues(t, 0, byID[colA.ID].NumOpenIssues)
assert.EqualValues(t, 1, byID[colA.ID].NumClosedIssues)
}
if assert.NotNil(t, byID[colB.ID]) {
assert.EqualValues(t, 1, byID[colB.ID].NumIssues)
assert.EqualValues(t, 1, byID[colB.ID].NumOpenIssues)
assert.EqualValues(t, 0, byID[colB.ID].NumClosedIssues)
}
}
func testAPIUserMoveProjectIssue(t *testing.T) {
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
p := makeUserProject(t, owner)
colA := &project_model.Column{Title: "A", ProjectID: p.ID, CreatorID: owner.ID}
assert.NoError(t, project_model.NewColumn(t.Context(), colA))
colB := &project_model.Column{Title: "B", ProjectID: p.ID, CreatorID: owner.ID}
assert.NoError(t, project_model.NewColumn(t.Context(), colB))
assert.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issue, owner, []int64{p.ID}))
assert.NoError(t, projects_service.MoveIssuesOnProjectColumn(t.Context(), owner, colA, map[int64]int64{0: issue.ID}))
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)
// move to colB
req := NewRequestWithJSON(t, "POST",
fmt.Sprintf("/api/v1/users/%s/projects/%d/issues/%d/move", owner.Name, p.ID, issue.ID),
&api.MoveProjectIssueOption{ColumnID: colB.ID},
).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNoContent)
pi := unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{ProjectID: p.ID, IssueID: issue.ID})
assert.Equal(t, colB.ID, pi.ProjectColumnID)
// non-existent target column -> 422
req = NewRequestWithJSON(t, "POST",
fmt.Sprintf("/api/v1/users/%s/projects/%d/issues/%d/move", owner.Name, p.ID, issue.ID),
&api.MoveProjectIssueOption{ColumnID: 99999},
).AddTokenAuth(token)
MakeRequest(t, req, http.StatusUnprocessableEntity)
// issue not in project -> 404
otherIssue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 4})
req = NewRequestWithJSON(t, "POST",
fmt.Sprintf("/api/v1/users/%s/projects/%d/issues/%d/move", owner.Name, p.ID, otherIssue.ID),
&api.MoveProjectIssueOption{ColumnID: colA.ID},
).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
}
func testAPIUserProjectPermissions(t *testing.T) {
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
other := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user5"})
admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
p := makeUserProject(t, owner)
ownerToken := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)
otherToken := getUserToken(t, other.Name, auth_model.AccessTokenScopeWriteIssue)
adminToken := getUserToken(t, admin.Name, auth_model.AccessTokenScopeWriteIssue)
// anon can read
req := NewRequestf(t, "GET", "/api/v1/users/%s/projects/%d", owner.Name, p.ID)
MakeRequest(t, req, http.StatusOK)
// owner can write
newTitle := "By Owner"
req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/users/%s/projects/%d", owner.Name, p.ID), &api.EditProjectOption{
Title: &newTitle,
}).AddTokenAuth(ownerToken)
MakeRequest(t, req, http.StatusOK)
// other user cannot write
newTitle = "By Other"
req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/users/%s/projects/%d", owner.Name, p.ID), &api.EditProjectOption{
Title: &newTitle,
}).AddTokenAuth(otherToken)
MakeRequest(t, req, http.StatusForbidden)
// other user cannot delete
req = NewRequestf(t, "DELETE", "/api/v1/users/%s/projects/%d", owner.Name, p.ID).AddTokenAuth(otherToken)
MakeRequest(t, req, http.StatusForbidden)
// admin can write
newTitle = "By Admin"
req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/users/%s/projects/%d", owner.Name, p.ID), &api.EditProjectOption{
Title: &newTitle,
}).AddTokenAuth(adminToken)
MakeRequest(t, req, http.StatusOK)
}