// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package integration

import (
	"fmt"
	"net/http"
	"testing"

	auth_model "code.gitea.io/gitea/models/auth"
	issues_model "code.gitea.io/gitea/models/issues"
	project_model "code.gitea.io/gitea/models/project"
	"code.gitea.io/gitea/models/unittest"
	user_model "code.gitea.io/gitea/models/user"
	api "code.gitea.io/gitea/modules/structs"
	issue_service "code.gitea.io/gitea/services/issue"
	projects_service "code.gitea.io/gitea/services/projects"
	"code.gitea.io/gitea/tests"

	"github.com/stretchr/testify/assert"
)

func TestAPIUserProjects(t *testing.T) {
	defer tests.PrepareTestEnv(t)()
	t.Run("ListProjects", testAPIUserListProjects)
	t.Run("GetProject", testAPIUserGetProject)
	t.Run("CreateProject", testAPIUserCreateProject)
	t.Run("UpdateProject", testAPIUserUpdateProject)
	t.Run("ChangeProjectStatus", testAPIUserChangeProjectStatus)
	t.Run("DeleteProject", testAPIUserDeleteProject)
	t.Run("ListProjectColumns", testAPIUserListProjectColumns)
	t.Run("CreateProjectColumn", testAPIUserCreateProjectColumn)
	t.Run("UpdateProjectColumn", testAPIUserUpdateProjectColumn)
	t.Run("DeleteProjectColumn", testAPIUserDeleteProjectColumn)
	t.Run("AddIssueToProjectColumn", testAPIUserAddIssueToProjectColumn)
	t.Run("RemoveIssueFromProjectColumn", testAPIUserRemoveIssueFromProjectColumn)
	t.Run("ListProjectColumnIssues", testAPIUserListProjectColumnIssues)
	t.Run("MoveProjectIssue", testAPIUserMoveProjectIssue)
	t.Run("Permissions", testAPIUserProjectPermissions)
}

func makeUserProject(t *testing.T, owner *user_model.User) *project_model.Project {
	t.Helper()
	p := &project_model.Project{
		OwnerID:      owner.ID,
		Title:        "Test User Project",
		Description:  "created by test helper",
		CreatorID:    owner.ID,
		Type:         project_model.TypeIndividual,
		TemplateType: project_model.TemplateTypeNone,
	}
	err := project_model.NewProject(t.Context(), p)
	assert.NoError(t, err)
	return p
}

func testAPIUserListProjects(t *testing.T) {
	owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
	token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeReadIssue)

	req := NewRequestf(t, "GET", "/api/v1/users/%s/projects", owner.Name).AddTokenAuth(token)
	resp := MakeRequest(t, req, http.StatusOK)
	var projects []*api.Project
	DecodeJSON(t, resp, &projects)

	req = NewRequestf(t, "GET", "/api/v1/users/%s/projects?state=open", owner.Name).AddTokenAuth(token)
	resp = MakeRequest(t, req, http.StatusOK)
	DecodeJSON(t, resp, &projects)
	for _, p := range projects {
		assert.Equal(t, api.StateOpen, p.State)
	}

	req = NewRequestf(t, "GET", "/api/v1/users/%s/projects?state=all", owner.Name).AddTokenAuth(token)
	MakeRequest(t, req, http.StatusOK)
}

func testAPIUserGetProject(t *testing.T) {
	owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
	p := makeUserProject(t, owner)
	token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeReadIssue)

	req := NewRequestf(t, "GET", "/api/v1/users/%s/projects/%d", owner.Name, p.ID).AddTokenAuth(token)
	resp := MakeRequest(t, req, http.StatusOK)

	var got api.Project
	DecodeJSON(t, resp, &got)
	assert.Equal(t, p.ID, got.ID)
	assert.Equal(t, p.Title, got.Title)
	assert.Equal(t, "individual", got.Type)

	req = NewRequestf(t, "GET", "/api/v1/users/%s/projects/99999", owner.Name).AddTokenAuth(token)
	MakeRequest(t, req, http.StatusNotFound)
}

func testAPIUserCreateProject(t *testing.T) {
	owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
	token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)

	req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/users/%s/projects", owner.Name), &api.CreateProjectOption{
		Title:       "Created via API",
		Description: "desc",
	}).AddTokenAuth(token)
	resp := MakeRequest(t, req, http.StatusCreated)

	var got api.Project
	DecodeJSON(t, resp, &got)
	assert.Equal(t, "Created via API", got.Title)
	assert.Equal(t, "individual", got.Type)

	// unauthenticated
	req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/users/%s/projects", owner.Name), &api.CreateProjectOption{Title: "x"})
	MakeRequest(t, req, http.StatusUnauthorized)

	// empty title
	req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/users/%s/projects", owner.Name), &api.CreateProjectOption{Title: ""}).AddTokenAuth(token)
	MakeRequest(t, req, http.StatusUnprocessableEntity)
}

func testAPIUserUpdateProject(t *testing.T) {
	owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
	p := makeUserProject(t, owner)
	token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)

	newTitle := "Updated Title"
	newDesc := "Updated desc"
	req := NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/users/%s/projects/%d", owner.Name, p.ID), &api.EditProjectOption{
		Title:       &newTitle,
		Description: &newDesc,
	}).AddTokenAuth(token)
	resp := MakeRequest(t, req, http.StatusOK)

	var got api.Project
	DecodeJSON(t, resp, &got)
	assert.Equal(t, newTitle, got.Title)
	assert.Equal(t, newDesc, got.Description)
}

func testAPIUserChangeProjectStatus(t *testing.T) {
	owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
	p := makeUserProject(t, owner)
	token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)

	closed := api.StateClosed
	req := NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/users/%s/projects/%d", owner.Name, p.ID), &api.EditProjectOption{
		State: &closed,
	}).AddTokenAuth(token)
	resp := MakeRequest(t, req, http.StatusOK)
	var got api.Project
	DecodeJSON(t, resp, &got)
	assert.Equal(t, api.StateClosed, got.State)
	assert.NotNil(t, got.ClosedAt)

	open := api.StateOpen
	req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/users/%s/projects/%d", owner.Name, p.ID), &api.EditProjectOption{
		State: &open,
	}).AddTokenAuth(token)
	resp = MakeRequest(t, req, http.StatusOK)
	DecodeJSON(t, resp, &got)
	assert.Equal(t, api.StateOpen, got.State)

	bogus := api.StateType("reopen")
	req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/users/%s/projects/%d", owner.Name, p.ID), &api.EditProjectOption{
		State: &bogus,
	}).AddTokenAuth(token)
	MakeRequest(t, req, http.StatusUnprocessableEntity)
}

func testAPIUserDeleteProject(t *testing.T) {
	owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
	p := makeUserProject(t, owner)
	token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)

	req := NewRequestf(t, "DELETE", "/api/v1/users/%s/projects/%d", owner.Name, p.ID).AddTokenAuth(token)
	MakeRequest(t, req, http.StatusNoContent)

	req = NewRequestf(t, "DELETE", "/api/v1/users/%s/projects/%d", owner.Name, p.ID).AddTokenAuth(token)
	MakeRequest(t, req, http.StatusNotFound)
}

func testAPIUserListProjectColumns(t *testing.T) {
	owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
	p := makeUserProject(t, owner)
	token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeReadIssue)

	for i := 1; i <= 3; i++ {
		col := &project_model.Column{
			Title:     fmt.Sprintf("Col%d", i),
			ProjectID: p.ID,
			CreatorID: owner.ID,
		}
		assert.NoError(t, project_model.NewColumn(t.Context(), col))
	}

	req := NewRequestf(t, "GET", "/api/v1/users/%s/projects/%d/columns", owner.Name, p.ID).AddTokenAuth(token)
	resp := MakeRequest(t, req, http.StatusOK)
	var cols []*api.ProjectColumn
	DecodeJSON(t, resp, &cols)
	assert.Len(t, cols, 3)
	assert.Equal(t, "3", resp.Header().Get("X-Total-Count"))

	req = NewRequestf(t, "GET", "/api/v1/users/%s/projects/%d/columns?page=1&limit=2", owner.Name, p.ID).AddTokenAuth(token)
	resp = MakeRequest(t, req, http.StatusOK)
	DecodeJSON(t, resp, &cols)
	assert.Len(t, cols, 2)
	assert.Equal(t, "3", resp.Header().Get("X-Total-Count"))

	req = NewRequestf(t, "GET", "/api/v1/users/%s/projects/%d/columns?page=2&limit=2", owner.Name, p.ID).AddTokenAuth(token)
	resp = MakeRequest(t, req, http.StatusOK)
	DecodeJSON(t, resp, &cols)
	assert.Len(t, cols, 1)
	assert.Equal(t, "3", resp.Header().Get("X-Total-Count"))
}

func testAPIUserCreateProjectColumn(t *testing.T) {
	owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
	p := makeUserProject(t, owner)
	token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)

	req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/users/%s/projects/%d/columns", owner.Name, p.ID), &api.CreateProjectColumnOption{
		Title: "New Column",
		Color: "#FF5733",
	}).AddTokenAuth(token)
	resp := MakeRequest(t, req, http.StatusCreated)
	var col api.ProjectColumn
	DecodeJSON(t, resp, &col)
	assert.Equal(t, "New Column", col.Title)
	assert.Equal(t, "#FF5733", col.Color)
	assert.Equal(t, p.ID, col.ProjectID)

	req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/users/%s/projects/%d/columns", owner.Name, p.ID), &api.CreateProjectColumnOption{
		Title: "Simple Column",
	}).AddTokenAuth(token)
	resp = MakeRequest(t, req, http.StatusCreated)
	DecodeJSON(t, resp, &col)
	assert.Equal(t, "Simple Column", col.Title)

	// empty title
	req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/users/%s/projects/%d/columns", owner.Name, p.ID), &api.CreateProjectColumnOption{
		Title: "",
	}).AddTokenAuth(token)
	MakeRequest(t, req, http.StatusUnprocessableEntity)

	// non-existent project
	req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/users/%s/projects/99999/columns", owner.Name), &api.CreateProjectColumnOption{
		Title: "Orphan",
	}).AddTokenAuth(token)
	MakeRequest(t, req, http.StatusNotFound)
}

func testAPIUserUpdateProjectColumn(t *testing.T) {
	owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
	p := makeUserProject(t, owner)
	col := &project_model.Column{
		Title:     "Original",
		ProjectID: p.ID,
		CreatorID: owner.ID,
		Color:     "#000000",
	}
	assert.NoError(t, project_model.NewColumn(t.Context(), col))
	token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)

	newTitle := "Updated Column"
	req := NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/users/%s/projects/%d/columns/%d", owner.Name, p.ID, col.ID), &api.EditProjectColumnOption{
		Title: &newTitle,
	}).AddTokenAuth(token)
	resp := MakeRequest(t, req, http.StatusOK)
	var got api.ProjectColumn
	DecodeJSON(t, resp, &got)
	assert.Equal(t, newTitle, got.Title)

	newColor := "#FF0000"
	req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/users/%s/projects/%d/columns/%d", owner.Name, p.ID, col.ID), &api.EditProjectColumnOption{
		Color: &newColor,
	}).AddTokenAuth(token)
	resp = MakeRequest(t, req, http.StatusOK)
	DecodeJSON(t, resp, &got)
	assert.Equal(t, newColor, got.Color)

	// non-existent column
	req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/users/%s/projects/%d/columns/99999", owner.Name, p.ID), &api.EditProjectColumnOption{
		Title: &newTitle,
	}).AddTokenAuth(token)
	MakeRequest(t, req, http.StatusNotFound)
}

func testAPIUserDeleteProjectColumn(t *testing.T) {
	owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
	p := makeUserProject(t, owner)
	col := &project_model.Column{
		Title:     "ToDelete",
		ProjectID: p.ID,
		CreatorID: owner.ID,
	}
	assert.NoError(t, project_model.NewColumn(t.Context(), col))
	token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)

	req := NewRequestf(t, "DELETE", "/api/v1/users/%s/projects/%d/columns/%d", owner.Name, p.ID, col.ID).AddTokenAuth(token)
	MakeRequest(t, req, http.StatusNoContent)

	req = NewRequestf(t, "DELETE", "/api/v1/users/%s/projects/%d/columns/%d", owner.Name, p.ID, col.ID).AddTokenAuth(token)
	MakeRequest(t, req, http.StatusNotFound)
}

func testAPIUserAddIssueToProjectColumn(t *testing.T) {
	owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
	issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
	p := makeUserProject(t, owner)

	col1 := &project_model.Column{Title: "Column 1", ProjectID: p.ID, CreatorID: owner.ID}
	assert.NoError(t, project_model.NewColumn(t.Context(), col1))
	col2 := &project_model.Column{Title: "Column 2", ProjectID: p.ID, CreatorID: owner.ID}
	assert.NoError(t, project_model.NewColumn(t.Context(), col2))

	token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)

	// add to col1
	req := NewRequestWithJSON(t, "POST",
		fmt.Sprintf("/api/v1/users/%s/projects/%d/columns/%d/issues/%d", owner.Name, p.ID, col1.ID, issue.ID), nil,
	).AddTokenAuth(token)
	MakeRequest(t, req, http.StatusCreated)

	pi := unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{ProjectID: p.ID, IssueID: issue.ID})
	assert.Equal(t, col1.ID, pi.ProjectColumnID)

	// move to col2 via POST
	req = NewRequestWithJSON(t, "POST",
		fmt.Sprintf("/api/v1/users/%s/projects/%d/columns/%d/issues/%d", owner.Name, p.ID, col2.ID, issue.ID), nil,
	).AddTokenAuth(token)
	MakeRequest(t, req, http.StatusCreated)

	pi = unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{ProjectID: p.ID, IssueID: issue.ID})
	assert.Equal(t, col2.ID, pi.ProjectColumnID)

	// idempotent: add to same column again
	req = NewRequestWithJSON(t, "POST",
		fmt.Sprintf("/api/v1/users/%s/projects/%d/columns/%d/issues/%d", owner.Name, p.ID, col2.ID, issue.ID), nil,
	).AddTokenAuth(token)
	MakeRequest(t, req, http.StatusCreated)

	// non-existent issue
	req = NewRequestWithJSON(t, "POST",
		fmt.Sprintf("/api/v1/users/%s/projects/%d/columns/%d/issues/99999", owner.Name, p.ID, col1.ID), nil,
	).AddTokenAuth(token)
	MakeRequest(t, req, http.StatusNotFound)

	// non-existent column
	req = NewRequestWithJSON(t, "POST",
		fmt.Sprintf("/api/v1/users/%s/projects/%d/columns/99999/issues/%d", owner.Name, p.ID, issue.ID), nil,
	).AddTokenAuth(token)
	MakeRequest(t, req, http.StatusNotFound)
}

func testAPIUserRemoveIssueFromProjectColumn(t *testing.T) {
	owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
	issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
	p := makeUserProject(t, owner)

	col := &project_model.Column{Title: "Col", ProjectID: p.ID, CreatorID: owner.ID}
	assert.NoError(t, project_model.NewColumn(t.Context(), col))
	otherCol := &project_model.Column{Title: "Other", ProjectID: p.ID, CreatorID: owner.ID}
	assert.NoError(t, project_model.NewColumn(t.Context(), otherCol))

	assert.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issue, owner, []int64{p.ID}))
	assert.NoError(t, projects_service.MoveIssuesOnProjectColumn(t.Context(), owner, col, map[int64]int64{0: issue.ID}))

	token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)

	// removing via the wrong column must 404 and not detach the issue
	req := NewRequestWithJSON(t, "DELETE",
		fmt.Sprintf("/api/v1/users/%s/projects/%d/columns/%d/issues/%d", owner.Name, p.ID, otherCol.ID, issue.ID), nil,
	).AddTokenAuth(token)
	MakeRequest(t, req, http.StatusNotFound)
	unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{ProjectID: p.ID, IssueID: issue.ID})

	// correct column fully detaches the issue from the project
	req = NewRequestWithJSON(t, "DELETE",
		fmt.Sprintf("/api/v1/users/%s/projects/%d/columns/%d/issues/%d", owner.Name, p.ID, col.ID, issue.ID), nil,
	).AddTokenAuth(token)
	MakeRequest(t, req, http.StatusNoContent)
	unittest.AssertNotExistsBean(t, &project_model.ProjectIssue{ProjectID: p.ID, IssueID: issue.ID})
}

func testAPIUserListProjectColumnIssues(t *testing.T) {
	owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
	issueA := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
	issueB := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 2})
	p := makeUserProject(t, owner)

	colA := &project_model.Column{Title: "ColA", ProjectID: p.ID, CreatorID: owner.ID}
	assert.NoError(t, project_model.NewColumn(t.Context(), colA))
	colB := &project_model.Column{Title: "ColB", ProjectID: p.ID, CreatorID: owner.ID}
	assert.NoError(t, project_model.NewColumn(t.Context(), colB))

	assert.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issueA, owner, []int64{p.ID}))
	assert.NoError(t, projects_service.MoveIssuesOnProjectColumn(t.Context(), owner, colA, map[int64]int64{0: issueA.ID}))
	assert.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issueB, owner, []int64{p.ID}))
	assert.NoError(t, projects_service.MoveIssuesOnProjectColumn(t.Context(), owner, colB, map[int64]int64{0: issueB.ID}))

	token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeReadIssue)

	// colA contains only issueA
	req := NewRequestf(t, "GET", "/api/v1/users/%s/projects/%d/columns/%d/issues", owner.Name, p.ID, colA.ID).AddTokenAuth(token)
	resp := MakeRequest(t, req, http.StatusOK)
	var gotA []api.Issue
	DecodeJSON(t, resp, &gotA)
	assert.Len(t, gotA, 1)
	assert.Equal(t, issueA.ID, gotA[0].ID)

	// colB contains only issueB
	req = NewRequestf(t, "GET", "/api/v1/users/%s/projects/%d/columns/%d/issues", owner.Name, p.ID, colB.ID).AddTokenAuth(token)
	resp = MakeRequest(t, req, http.StatusOK)
	var gotB []api.Issue
	DecodeJSON(t, resp, &gotB)
	assert.Len(t, gotB, 1)
	assert.Equal(t, issueB.ID, gotB[0].ID)

	// Close issueA, then exercise the state filter (issue #4).
	assert.NoError(t, issue_service.CloseIssue(t.Context(), issueA, owner, ""))

	// default (state omitted) -> open only -> colA has nothing
	req = NewRequestf(t, "GET", "/api/v1/users/%s/projects/%d/columns/%d/issues", owner.Name, p.ID, colA.ID).AddTokenAuth(token)
	resp = MakeRequest(t, req, http.StatusOK)
	var openOnly []api.Issue
	DecodeJSON(t, resp, &openOnly)
	assert.Empty(t, openOnly)

	// state=closed -> colA returns issueA
	req = NewRequestf(t, "GET", "/api/v1/users/%s/projects/%d/columns/%d/issues?state=closed", owner.Name, p.ID, colA.ID).AddTokenAuth(token)
	resp = MakeRequest(t, req, http.StatusOK)
	var closedOnly []api.Issue
	DecodeJSON(t, resp, &closedOnly)
	assert.Len(t, closedOnly, 1)
	assert.Equal(t, issueA.ID, closedOnly[0].ID)

	// state=all -> colA returns issueA
	req = NewRequestf(t, "GET", "/api/v1/users/%s/projects/%d/columns/%d/issues?state=all", owner.Name, p.ID, colA.ID).AddTokenAuth(token)
	resp = MakeRequest(t, req, http.StatusOK)
	var all []api.Issue
	DecodeJSON(t, resp, &all)
	assert.Len(t, all, 1)

	// Columns endpoint must populate num_issues / num_open_issues / num_closed_issues (issue #5).
	req = NewRequestf(t, "GET", "/api/v1/users/%s/projects/%d/columns", owner.Name, p.ID).AddTokenAuth(token)
	resp = MakeRequest(t, req, http.StatusOK)
	var listed []*api.ProjectColumn
	DecodeJSON(t, resp, &listed)
	byID := map[int64]*api.ProjectColumn{}
	for _, c := range listed {
		byID[c.ID] = c
	}
	if assert.NotNil(t, byID[colA.ID]) {
		assert.EqualValues(t, 1, byID[colA.ID].NumIssues)
		assert.EqualValues(t, 0, byID[colA.ID].NumOpenIssues)
		assert.EqualValues(t, 1, byID[colA.ID].NumClosedIssues)
	}
	if assert.NotNil(t, byID[colB.ID]) {
		assert.EqualValues(t, 1, byID[colB.ID].NumIssues)
		assert.EqualValues(t, 1, byID[colB.ID].NumOpenIssues)
		assert.EqualValues(t, 0, byID[colB.ID].NumClosedIssues)
	}
}

func testAPIUserMoveProjectIssue(t *testing.T) {
	owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
	issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
	p := makeUserProject(t, owner)

	colA := &project_model.Column{Title: "A", ProjectID: p.ID, CreatorID: owner.ID}
	assert.NoError(t, project_model.NewColumn(t.Context(), colA))
	colB := &project_model.Column{Title: "B", ProjectID: p.ID, CreatorID: owner.ID}
	assert.NoError(t, project_model.NewColumn(t.Context(), colB))

	assert.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issue, owner, []int64{p.ID}))
	assert.NoError(t, projects_service.MoveIssuesOnProjectColumn(t.Context(), owner, colA, map[int64]int64{0: issue.ID}))

	token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)

	// move to colB
	req := NewRequestWithJSON(t, "POST",
		fmt.Sprintf("/api/v1/users/%s/projects/%d/issues/%d/move", owner.Name, p.ID, issue.ID),
		&api.MoveProjectIssueOption{ColumnID: colB.ID},
	).AddTokenAuth(token)
	MakeRequest(t, req, http.StatusNoContent)

	pi := unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{ProjectID: p.ID, IssueID: issue.ID})
	assert.Equal(t, colB.ID, pi.ProjectColumnID)

	// non-existent target column -> 422
	req = NewRequestWithJSON(t, "POST",
		fmt.Sprintf("/api/v1/users/%s/projects/%d/issues/%d/move", owner.Name, p.ID, issue.ID),
		&api.MoveProjectIssueOption{ColumnID: 99999},
	).AddTokenAuth(token)
	MakeRequest(t, req, http.StatusUnprocessableEntity)

	// issue not in project -> 404
	otherIssue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 4})
	req = NewRequestWithJSON(t, "POST",
		fmt.Sprintf("/api/v1/users/%s/projects/%d/issues/%d/move", owner.Name, p.ID, otherIssue.ID),
		&api.MoveProjectIssueOption{ColumnID: colA.ID},
	).AddTokenAuth(token)
	MakeRequest(t, req, http.StatusNotFound)
}

func testAPIUserProjectPermissions(t *testing.T) {
	owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
	other := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user5"})
	admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})

	p := makeUserProject(t, owner)
	ownerToken := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)
	otherToken := getUserToken(t, other.Name, auth_model.AccessTokenScopeWriteIssue)
	adminToken := getUserToken(t, admin.Name, auth_model.AccessTokenScopeWriteIssue)

	// anon can read
	req := NewRequestf(t, "GET", "/api/v1/users/%s/projects/%d", owner.Name, p.ID)
	MakeRequest(t, req, http.StatusOK)

	// owner can write
	newTitle := "By Owner"
	req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/users/%s/projects/%d", owner.Name, p.ID), &api.EditProjectOption{
		Title: &newTitle,
	}).AddTokenAuth(ownerToken)
	MakeRequest(t, req, http.StatusOK)

	// other user cannot write
	newTitle = "By Other"
	req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/users/%s/projects/%d", owner.Name, p.ID), &api.EditProjectOption{
		Title: &newTitle,
	}).AddTokenAuth(otherToken)
	MakeRequest(t, req, http.StatusForbidden)

	// other user cannot delete
	req = NewRequestf(t, "DELETE", "/api/v1/users/%s/projects/%d", owner.Name, p.ID).AddTokenAuth(otherToken)
	MakeRequest(t, req, http.StatusForbidden)

	// admin can write
	newTitle = "By Admin"
	req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/users/%s/projects/%d", owner.Name, p.ID), &api.EditProjectOption{
		Title: &newTitle,
	}).AddTokenAuth(adminToken)
	MakeRequest(t, req, http.StatusOK)
}
