078459c497
MoveIssuesOnProjectColumn updated `project_issue` with a WHERE clause on issue_id only. An issue assigned to several projects has one project_issue row per project, so moving it within one project rewrote project_board_id for every project the issue belonged to, detaching it from all the others. Scope the UPDATE to (issue_id, project_id) so only the target project's row changes. Mirrors the fix already present in upstream/main. Adds an integration regression test asserting an issue in two user projects keeps its column in the other project after a move. Fixes #17.
583 lines
24 KiB
Go
583 lines
24 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("MoveProjectIssueMultiProjectIsolation", testAPIUserMoveProjectIssueMultiProjectIsolation)
|
|
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)
|
|
}
|
|
|
|
// Regression for #17: moving an issue's column in one user project must not
|
|
// rewrite its column in other projects the issue also belongs to.
|
|
func testAPIUserMoveProjectIssueMultiProjectIsolation(t *testing.T) {
|
|
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
|
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
|
|
|
|
p1 := makeUserProject(t, owner)
|
|
p2 := makeUserProject(t, owner)
|
|
|
|
p1ColA := &project_model.Column{Title: "p1-A", ProjectID: p1.ID, CreatorID: owner.ID}
|
|
assert.NoError(t, project_model.NewColumn(t.Context(), p1ColA))
|
|
p1ColB := &project_model.Column{Title: "p1-B", ProjectID: p1.ID, CreatorID: owner.ID}
|
|
assert.NoError(t, project_model.NewColumn(t.Context(), p1ColB))
|
|
p2Col := &project_model.Column{Title: "p2", ProjectID: p2.ID, CreatorID: owner.ID}
|
|
assert.NoError(t, project_model.NewColumn(t.Context(), p2Col))
|
|
|
|
assert.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issue, owner, []int64{p1.ID, p2.ID}))
|
|
assert.NoError(t, projects_service.MoveIssuesOnProjectColumn(t.Context(), owner, p1ColA, map[int64]int64{0: issue.ID}))
|
|
assert.NoError(t, projects_service.MoveIssuesOnProjectColumn(t.Context(), owner, p2Col, map[int64]int64{0: issue.ID}))
|
|
|
|
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)
|
|
|
|
// move the issue inside p1 only
|
|
req := NewRequestWithJSON(t, "POST",
|
|
fmt.Sprintf("/api/v1/users/%s/projects/%d/issues/%d/move", owner.Name, p1.ID, issue.ID),
|
|
&api.MoveProjectIssueOption{ColumnID: p1ColB.ID},
|
|
).AddTokenAuth(token)
|
|
MakeRequest(t, req, http.StatusNoContent)
|
|
|
|
// p1 updated as requested
|
|
pi1 := unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{ProjectID: p1.ID, IssueID: issue.ID})
|
|
assert.Equal(t, p1ColB.ID, pi1.ProjectColumnID)
|
|
|
|
// p2 must be untouched
|
|
pi2 := unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{ProjectID: p2.ID, IssueID: issue.ID})
|
|
assert.Equal(t, p2Col.ID, pi2.ProjectColumnID, "issue must remain in its original column in other projects")
|
|
}
|
|
|
|
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)
|
|
}
|