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
574 lines
23 KiB
Go
574 lines
23 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"
|
|
"code.gitea.io/gitea/models/organization"
|
|
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 TestAPIOrgProjects(t *testing.T) {
|
|
defer tests.PrepareTestEnv(t)()
|
|
t.Run("ListProjects", testAPIOrgListProjects)
|
|
t.Run("GetProject", testAPIOrgGetProject)
|
|
t.Run("CreateProject", testAPIOrgCreateProject)
|
|
t.Run("UpdateProject", testAPIOrgUpdateProject)
|
|
t.Run("ChangeProjectStatus", testAPIOrgChangeProjectStatus)
|
|
t.Run("DeleteProject", testAPIOrgDeleteProject)
|
|
t.Run("ListProjectColumns", testAPIOrgListProjectColumns)
|
|
t.Run("CreateProjectColumn", testAPIOrgCreateProjectColumn)
|
|
t.Run("UpdateProjectColumn", testAPIOrgUpdateProjectColumn)
|
|
t.Run("DeleteProjectColumn", testAPIOrgDeleteProjectColumn)
|
|
t.Run("AddIssueToProjectColumn", testAPIOrgAddIssueToProjectColumn)
|
|
t.Run("RemoveIssueFromProjectColumn", testAPIOrgRemoveIssueFromProjectColumn)
|
|
t.Run("ListProjectColumnIssues", testAPIOrgListProjectColumnIssues)
|
|
t.Run("MoveProjectIssue", testAPIOrgMoveProjectIssue)
|
|
t.Run("Permissions", testAPIOrgProjectPermissions)
|
|
}
|
|
|
|
// makeOrgProject creates a TypeOrganization project owned by the named org.
|
|
// org3 (id=3) is used throughout these tests. Per fixtures/org_user.yml,
|
|
// user2 (id=2) and user4 (id=4) are members; user5 is not.
|
|
func makeOrgProject(t *testing.T, orgName string, creatorID int64) *project_model.Project {
|
|
t.Helper()
|
|
org, err := organization.GetOrgByName(t.Context(), orgName)
|
|
assert.NoError(t, err)
|
|
p := &project_model.Project{
|
|
OwnerID: org.ID,
|
|
Title: "Test Org Project",
|
|
Description: "created by test helper",
|
|
CreatorID: creatorID,
|
|
Type: project_model.TypeOrganization,
|
|
TemplateType: project_model.TemplateTypeNone,
|
|
}
|
|
assert.NoError(t, project_model.NewProject(t.Context(), p))
|
|
return p
|
|
}
|
|
|
|
func testAPIOrgListProjects(t *testing.T) {
|
|
member := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
|
token := getUserToken(t, member.Name, auth_model.AccessTokenScopeReadIssue)
|
|
|
|
req := NewRequest(t, "GET", "/api/v1/orgs/org3/projects").AddTokenAuth(token)
|
|
resp := MakeRequest(t, req, http.StatusOK)
|
|
var projects []*api.Project
|
|
DecodeJSON(t, resp, &projects)
|
|
|
|
req = NewRequest(t, "GET", "/api/v1/orgs/org3/projects?state=open").AddTokenAuth(token)
|
|
resp = MakeRequest(t, req, http.StatusOK)
|
|
DecodeJSON(t, resp, &projects)
|
|
for _, p := range projects {
|
|
assert.Equal(t, api.StateOpen, p.State)
|
|
}
|
|
|
|
req = NewRequest(t, "GET", "/api/v1/orgs/org3/projects?state=all").AddTokenAuth(token)
|
|
MakeRequest(t, req, http.StatusOK)
|
|
}
|
|
|
|
func testAPIOrgGetProject(t *testing.T) {
|
|
member := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
|
p := makeOrgProject(t, "org3", member.ID)
|
|
token := getUserToken(t, member.Name, auth_model.AccessTokenScopeReadIssue)
|
|
|
|
req := NewRequestf(t, "GET", "/api/v1/orgs/org3/projects/%d", 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, "organization", got.Type)
|
|
|
|
req = NewRequest(t, "GET", "/api/v1/orgs/org3/projects/99999").AddTokenAuth(token)
|
|
MakeRequest(t, req, http.StatusNotFound)
|
|
}
|
|
|
|
func testAPIOrgCreateProject(t *testing.T) {
|
|
member := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
|
token := getUserToken(t, member.Name, auth_model.AccessTokenScopeWriteIssue)
|
|
|
|
req := NewRequestWithJSON(t, "POST", "/api/v1/orgs/org3/projects", &api.CreateProjectOption{
|
|
Title: "Org API Project",
|
|
Description: "desc",
|
|
}).AddTokenAuth(token)
|
|
resp := MakeRequest(t, req, http.StatusCreated)
|
|
var got api.Project
|
|
DecodeJSON(t, resp, &got)
|
|
assert.Equal(t, "Org API Project", got.Title)
|
|
assert.Equal(t, "organization", got.Type)
|
|
|
|
// unauthenticated
|
|
req = NewRequestWithJSON(t, "POST", "/api/v1/orgs/org3/projects", &api.CreateProjectOption{Title: "x"})
|
|
MakeRequest(t, req, http.StatusUnauthorized)
|
|
|
|
// empty title
|
|
req = NewRequestWithJSON(t, "POST", "/api/v1/orgs/org3/projects", &api.CreateProjectOption{Title: ""}).AddTokenAuth(token)
|
|
MakeRequest(t, req, http.StatusUnprocessableEntity)
|
|
|
|
// non-member must be forbidden (user5 is not in org3)
|
|
nonMember := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user5"})
|
|
nmToken := getUserToken(t, nonMember.Name, auth_model.AccessTokenScopeWriteIssue)
|
|
req = NewRequestWithJSON(t, "POST", "/api/v1/orgs/org3/projects", &api.CreateProjectOption{Title: "x"}).AddTokenAuth(nmToken)
|
|
MakeRequest(t, req, http.StatusForbidden)
|
|
}
|
|
|
|
func testAPIOrgUpdateProject(t *testing.T) {
|
|
member := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
|
p := makeOrgProject(t, "org3", member.ID)
|
|
token := getUserToken(t, member.Name, auth_model.AccessTokenScopeWriteIssue)
|
|
|
|
newTitle := "Updated Org Project"
|
|
newDesc := "Updated desc"
|
|
req := NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/orgs/org3/projects/%d", 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)
|
|
|
|
// non-existent project
|
|
req = NewRequestWithJSON(t, "PATCH", "/api/v1/orgs/org3/projects/99999", &api.EditProjectOption{
|
|
Title: &newTitle,
|
|
}).AddTokenAuth(token)
|
|
MakeRequest(t, req, http.StatusNotFound)
|
|
}
|
|
|
|
func testAPIOrgChangeProjectStatus(t *testing.T) {
|
|
member := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
|
p := makeOrgProject(t, "org3", member.ID)
|
|
token := getUserToken(t, member.Name, auth_model.AccessTokenScopeWriteIssue)
|
|
|
|
closed := api.StateClosed
|
|
req := NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/orgs/org3/projects/%d", 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/orgs/org3/projects/%d", 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/orgs/org3/projects/%d", p.ID), &api.EditProjectOption{
|
|
State: &bogus,
|
|
}).AddTokenAuth(token)
|
|
MakeRequest(t, req, http.StatusUnprocessableEntity)
|
|
}
|
|
|
|
func testAPIOrgDeleteProject(t *testing.T) {
|
|
member := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
|
p := makeOrgProject(t, "org3", member.ID)
|
|
token := getUserToken(t, member.Name, auth_model.AccessTokenScopeWriteIssue)
|
|
|
|
req := NewRequestf(t, "DELETE", "/api/v1/orgs/org3/projects/%d", p.ID).AddTokenAuth(token)
|
|
MakeRequest(t, req, http.StatusNoContent)
|
|
|
|
req = NewRequestf(t, "DELETE", "/api/v1/orgs/org3/projects/%d", p.ID).AddTokenAuth(token)
|
|
MakeRequest(t, req, http.StatusNotFound)
|
|
}
|
|
|
|
func testAPIOrgListProjectColumns(t *testing.T) {
|
|
member := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
|
p := makeOrgProject(t, "org3", member.ID)
|
|
token := getUserToken(t, member.Name, auth_model.AccessTokenScopeReadIssue)
|
|
|
|
for i := 1; i <= 3; i++ {
|
|
col := &project_model.Column{
|
|
Title: fmt.Sprintf("OrgCol%d", i),
|
|
ProjectID: p.ID,
|
|
CreatorID: member.ID,
|
|
}
|
|
assert.NoError(t, project_model.NewColumn(t.Context(), col))
|
|
}
|
|
|
|
req := NewRequestf(t, "GET", "/api/v1/orgs/org3/projects/%d/columns", 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/orgs/org3/projects/%d/columns?page=1&limit=2", 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/orgs/org3/projects/%d/columns?page=2&limit=2", 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"))
|
|
|
|
// non-existent project
|
|
req = NewRequestf(t, "GET", "/api/v1/orgs/org3/projects/99999/columns").AddTokenAuth(token)
|
|
MakeRequest(t, req, http.StatusNotFound)
|
|
}
|
|
|
|
func testAPIOrgCreateProjectColumn(t *testing.T) {
|
|
member := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
|
p := makeOrgProject(t, "org3", member.ID)
|
|
token := getUserToken(t, member.Name, auth_model.AccessTokenScopeWriteIssue)
|
|
|
|
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/orgs/org3/projects/%d/columns", p.ID), &api.CreateProjectColumnOption{
|
|
Title: "OrgCol",
|
|
Color: "#123456",
|
|
}).AddTokenAuth(token)
|
|
resp := MakeRequest(t, req, http.StatusCreated)
|
|
var col api.ProjectColumn
|
|
DecodeJSON(t, resp, &col)
|
|
assert.Equal(t, "OrgCol", col.Title)
|
|
assert.Equal(t, "#123456", col.Color)
|
|
assert.Equal(t, p.ID, col.ProjectID)
|
|
|
|
// no color
|
|
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/orgs/org3/projects/%d/columns", p.ID), &api.CreateProjectColumnOption{
|
|
Title: "Plain",
|
|
}).AddTokenAuth(token)
|
|
resp = MakeRequest(t, req, http.StatusCreated)
|
|
DecodeJSON(t, resp, &col)
|
|
assert.Equal(t, "Plain", col.Title)
|
|
|
|
// empty title
|
|
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/orgs/org3/projects/%d/columns", p.ID), &api.CreateProjectColumnOption{
|
|
Title: "",
|
|
}).AddTokenAuth(token)
|
|
MakeRequest(t, req, http.StatusUnprocessableEntity)
|
|
|
|
// non-existent project
|
|
req = NewRequestWithJSON(t, "POST", "/api/v1/orgs/org3/projects/99999/columns", &api.CreateProjectColumnOption{
|
|
Title: "Orphan",
|
|
}).AddTokenAuth(token)
|
|
MakeRequest(t, req, http.StatusNotFound)
|
|
}
|
|
|
|
func testAPIOrgUpdateProjectColumn(t *testing.T) {
|
|
member := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
|
p := makeOrgProject(t, "org3", member.ID)
|
|
col := &project_model.Column{
|
|
Title: "Orig",
|
|
ProjectID: p.ID,
|
|
CreatorID: member.ID,
|
|
Color: "#000000",
|
|
}
|
|
assert.NoError(t, project_model.NewColumn(t.Context(), col))
|
|
token := getUserToken(t, member.Name, auth_model.AccessTokenScopeWriteIssue)
|
|
|
|
newTitle := "Changed"
|
|
req := NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/orgs/org3/projects/%d/columns/%d", 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/orgs/org3/projects/%d/columns/%d", 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/orgs/org3/projects/%d/columns/99999", p.ID), &api.EditProjectColumnOption{
|
|
Title: &newTitle,
|
|
}).AddTokenAuth(token)
|
|
MakeRequest(t, req, http.StatusNotFound)
|
|
}
|
|
|
|
func testAPIOrgDeleteProjectColumn(t *testing.T) {
|
|
member := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
|
p := makeOrgProject(t, "org3", member.ID)
|
|
col := &project_model.Column{
|
|
Title: "ToDelete",
|
|
ProjectID: p.ID,
|
|
CreatorID: member.ID,
|
|
}
|
|
assert.NoError(t, project_model.NewColumn(t.Context(), col))
|
|
token := getUserToken(t, member.Name, auth_model.AccessTokenScopeWriteIssue)
|
|
|
|
req := NewRequestf(t, "DELETE", "/api/v1/orgs/org3/projects/%d/columns/%d", p.ID, col.ID).AddTokenAuth(token)
|
|
MakeRequest(t, req, http.StatusNoContent)
|
|
|
|
req = NewRequestf(t, "DELETE", "/api/v1/orgs/org3/projects/%d/columns/%d", p.ID, col.ID).AddTokenAuth(token)
|
|
MakeRequest(t, req, http.StatusNotFound)
|
|
}
|
|
|
|
func testAPIOrgAddIssueToProjectColumn(t *testing.T) {
|
|
member := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
|
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 6})
|
|
p := makeOrgProject(t, "org3", member.ID)
|
|
|
|
col1 := &project_model.Column{Title: "Column 1", ProjectID: p.ID, CreatorID: member.ID}
|
|
assert.NoError(t, project_model.NewColumn(t.Context(), col1))
|
|
col2 := &project_model.Column{Title: "Column 2", ProjectID: p.ID, CreatorID: member.ID}
|
|
assert.NoError(t, project_model.NewColumn(t.Context(), col2))
|
|
|
|
token := getUserToken(t, member.Name, auth_model.AccessTokenScopeWriteIssue)
|
|
|
|
// add to col1
|
|
req := NewRequestWithJSON(t, "POST",
|
|
fmt.Sprintf("/api/v1/orgs/org3/projects/%d/columns/%d/issues/%d", 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/orgs/org3/projects/%d/columns/%d/issues/%d", 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/orgs/org3/projects/%d/columns/%d/issues/%d", 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/orgs/org3/projects/%d/columns/%d/issues/99999", p.ID, col1.ID), nil,
|
|
).AddTokenAuth(token)
|
|
MakeRequest(t, req, http.StatusNotFound)
|
|
|
|
// non-existent column
|
|
req = NewRequestWithJSON(t, "POST",
|
|
fmt.Sprintf("/api/v1/orgs/org3/projects/%d/columns/99999/issues/%d", p.ID, issue.ID), nil,
|
|
).AddTokenAuth(token)
|
|
MakeRequest(t, req, http.StatusNotFound)
|
|
}
|
|
|
|
func testAPIOrgRemoveIssueFromProjectColumn(t *testing.T) {
|
|
member := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
|
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 6})
|
|
p := makeOrgProject(t, "org3", member.ID)
|
|
|
|
col := &project_model.Column{Title: "Col", ProjectID: p.ID, CreatorID: member.ID}
|
|
assert.NoError(t, project_model.NewColumn(t.Context(), col))
|
|
otherCol := &project_model.Column{Title: "Other", ProjectID: p.ID, CreatorID: member.ID}
|
|
assert.NoError(t, project_model.NewColumn(t.Context(), otherCol))
|
|
|
|
assert.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issue, member, []int64{p.ID}))
|
|
assert.NoError(t, projects_service.MoveIssuesOnProjectColumn(t.Context(), member, col, map[int64]int64{0: issue.ID}))
|
|
|
|
token := getUserToken(t, member.Name, auth_model.AccessTokenScopeWriteIssue)
|
|
|
|
// removing via the wrong column must 404 and not detach the issue
|
|
req := NewRequestWithJSON(t, "DELETE",
|
|
fmt.Sprintf("/api/v1/orgs/org3/projects/%d/columns/%d/issues/%d", 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/orgs/org3/projects/%d/columns/%d/issues/%d", 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 testAPIOrgListProjectColumnIssues(t *testing.T) {
|
|
member := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
|
issueA := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 6})
|
|
issueB := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 12})
|
|
p := makeOrgProject(t, "org3", member.ID)
|
|
|
|
colA := &project_model.Column{Title: "ColA", ProjectID: p.ID, CreatorID: member.ID}
|
|
assert.NoError(t, project_model.NewColumn(t.Context(), colA))
|
|
colB := &project_model.Column{Title: "ColB", ProjectID: p.ID, CreatorID: member.ID}
|
|
assert.NoError(t, project_model.NewColumn(t.Context(), colB))
|
|
|
|
assert.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issueA, member, []int64{p.ID}))
|
|
assert.NoError(t, projects_service.MoveIssuesOnProjectColumn(t.Context(), member, colA, map[int64]int64{0: issueA.ID}))
|
|
assert.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issueB, member, []int64{p.ID}))
|
|
assert.NoError(t, projects_service.MoveIssuesOnProjectColumn(t.Context(), member, colB, map[int64]int64{0: issueB.ID}))
|
|
|
|
token := getUserToken(t, member.Name, auth_model.AccessTokenScopeReadIssue)
|
|
|
|
// colA contains only issueA
|
|
req := NewRequestf(t, "GET", "/api/v1/orgs/org3/projects/%d/columns/%d/issues", 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/orgs/org3/projects/%d/columns/%d/issues", 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, member, ""))
|
|
|
|
// default (state omitted) -> open only -> colA returns nothing
|
|
req = NewRequestf(t, "GET", "/api/v1/orgs/org3/projects/%d/columns/%d/issues", 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/orgs/org3/projects/%d/columns/%d/issues?state=closed", 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/orgs/org3/projects/%d/columns/%d/issues?state=all", 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 populates num_issues / num_open_issues / num_closed_issues (issue #5).
|
|
req = NewRequestf(t, "GET", "/api/v1/orgs/org3/projects/%d/columns", 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 testAPIOrgMoveProjectIssue(t *testing.T) {
|
|
member := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
|
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 6})
|
|
p := makeOrgProject(t, "org3", member.ID)
|
|
|
|
colA := &project_model.Column{Title: "A", ProjectID: p.ID, CreatorID: member.ID}
|
|
assert.NoError(t, project_model.NewColumn(t.Context(), colA))
|
|
colB := &project_model.Column{Title: "B", ProjectID: p.ID, CreatorID: member.ID}
|
|
assert.NoError(t, project_model.NewColumn(t.Context(), colB))
|
|
|
|
assert.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issue, member, []int64{p.ID}))
|
|
assert.NoError(t, projects_service.MoveIssuesOnProjectColumn(t.Context(), member, colA, map[int64]int64{0: issue.ID}))
|
|
|
|
token := getUserToken(t, member.Name, auth_model.AccessTokenScopeWriteIssue)
|
|
|
|
// move to colB
|
|
req := NewRequestWithJSON(t, "POST",
|
|
fmt.Sprintf("/api/v1/orgs/org3/projects/%d/issues/%d/move", 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/orgs/org3/projects/%d/issues/%d/move", 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: 15})
|
|
req = NewRequestWithJSON(t, "POST",
|
|
fmt.Sprintf("/api/v1/orgs/org3/projects/%d/issues/%d/move", p.ID, otherIssue.ID),
|
|
&api.MoveProjectIssueOption{ColumnID: colA.ID},
|
|
).AddTokenAuth(token)
|
|
MakeRequest(t, req, http.StatusNotFound)
|
|
}
|
|
|
|
func testAPIOrgProjectPermissions(t *testing.T) {
|
|
// org3 members per fixtures: user2 (id=2), user4 (id=4). Non-member: user5.
|
|
member := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
|
nonMember := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user5"})
|
|
admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
|
|
|
p := makeOrgProject(t, "org3", member.ID)
|
|
memberToken := getUserToken(t, member.Name, auth_model.AccessTokenScopeWriteIssue)
|
|
nmToken := getUserToken(t, nonMember.Name, auth_model.AccessTokenScopeWriteIssue)
|
|
adminToken := getUserToken(t, admin.Name, auth_model.AccessTokenScopeWriteIssue)
|
|
|
|
// anon can list
|
|
req := NewRequest(t, "GET", "/api/v1/orgs/org3/projects")
|
|
MakeRequest(t, req, http.StatusOK)
|
|
|
|
// member can read
|
|
req = NewRequestf(t, "GET", "/api/v1/orgs/org3/projects/%d", p.ID).AddTokenAuth(memberToken)
|
|
MakeRequest(t, req, http.StatusOK)
|
|
|
|
// non-member cannot write
|
|
newTitle := "By NonMember"
|
|
req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/orgs/org3/projects/%d", p.ID), &api.EditProjectOption{
|
|
Title: &newTitle,
|
|
}).AddTokenAuth(nmToken)
|
|
MakeRequest(t, req, http.StatusForbidden)
|
|
|
|
// non-member cannot delete
|
|
req = NewRequestf(t, "DELETE", "/api/v1/orgs/org3/projects/%d", p.ID).AddTokenAuth(nmToken)
|
|
MakeRequest(t, req, http.StatusForbidden)
|
|
|
|
// non-member cannot create column
|
|
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/orgs/org3/projects/%d/columns", p.ID), &api.CreateProjectColumnOption{
|
|
Title: "By NonMember",
|
|
}).AddTokenAuth(nmToken)
|
|
MakeRequest(t, req, http.StatusForbidden)
|
|
|
|
// member can write
|
|
newTitle = "By Member"
|
|
req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/orgs/org3/projects/%d", p.ID), &api.EditProjectOption{
|
|
Title: &newTitle,
|
|
}).AddTokenAuth(memberToken)
|
|
MakeRequest(t, req, http.StatusOK)
|
|
|
|
// admin can write
|
|
newTitle = "By Admin"
|
|
req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/orgs/org3/projects/%d", p.ID), &api.EditProjectOption{
|
|
Title: &newTitle,
|
|
}).AddTokenAuth(adminToken)
|
|
MakeRequest(t, req, http.StatusOK)
|
|
}
|