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