Files
gitea/tests/integration/api_repo_project_test.go
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

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)
}