feat(api/v2): add project duplication on /api/v2
This commit is contained in:
parent
d5bcbe39b4
commit
1aa9493bc3
|
|
@ -0,0 +1,63 @@
|
|||
// Vikunja is a to-do list application to facilitate your life.
|
||||
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package apiv2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"code.vikunja.io/api/pkg/models"
|
||||
"code.vikunja.io/api/pkg/web/handler"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
)
|
||||
|
||||
// RegisterProjectDuplicateRoutes wires the project-duplicate action onto the Huma API.
|
||||
//
|
||||
// ProjectDuplicate is a CRUDable Create, so the handler reuses handler.DoCreate
|
||||
// (its CanCreate enforces access); the only custom part is taking ProjectID from
|
||||
// the path rather than the request body.
|
||||
func RegisterProjectDuplicateRoutes(api huma.API) {
|
||||
tags := []string{"projects"}
|
||||
|
||||
Register(api, huma.Operation{
|
||||
OperationID: "projects-duplicate",
|
||||
Summary: "Duplicate a project",
|
||||
Description: "Deep-copies a project — its tasks, files, kanban data, assignees, comments, attachments, labels, relations, backgrounds and user/team/link shares — into a new project owned by the authenticated user. The user needs read access to the source project, plus write access to the parent project when one is given. The copy is placed under parent_project_id (top level if omitted). Returns the duplicate in duplicated_project.",
|
||||
Method: http.MethodPost,
|
||||
Path: "/projects/{projectid}/duplicate",
|
||||
Tags: tags,
|
||||
}, projectsDuplicate)
|
||||
}
|
||||
|
||||
func init() { AddRouteRegistrar(RegisterProjectDuplicateRoutes) }
|
||||
|
||||
func projectsDuplicate(ctx context.Context, in *struct {
|
||||
ProjectID int64 `path:"projectid" doc:"The numeric id of the project to duplicate."`
|
||||
Body models.ProjectDuplicate
|
||||
}) (*singleBody[models.ProjectDuplicate], error) {
|
||||
a, err := authFromCtx(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pd := &in.Body
|
||||
pd.ProjectID = in.ProjectID
|
||||
if err := handler.DoCreate(ctx, pd, a); err != nil {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
return &singleBody[models.ProjectDuplicate]{Body: pd}, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
// Vikunja is a to-do list application to facilitate your life.
|
||||
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package webtests
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"code.vikunja.io/api/pkg/files"
|
||||
"code.vikunja.io/api/pkg/models"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestProjectDuplicateV2 covers POST /projects/{projectid}/duplicate. It drives
|
||||
// the Echo+Huma stack directly (humaRequest/humaTokenFor) because
|
||||
// webHandlerTestV2's buildURL only models base[/{id}] paths, not action sub-paths.
|
||||
func TestProjectDuplicateV2(t *testing.T) {
|
||||
t.Run("duplicates an accessible project to the top level", func(t *testing.T) {
|
||||
e, err := setupTestEnv()
|
||||
require.NoError(t, err)
|
||||
// Duplicating copies the source project's task attachments, so the
|
||||
// referenced fixture file must exist in the (memory) file store.
|
||||
files.InitTestFileFixtures(t)
|
||||
token := humaTokenFor(t, &testuser1)
|
||||
|
||||
// Project 1 is owned by testuser1.
|
||||
const sourceProjectID int64 = 1
|
||||
rec := humaRequest(t, e, http.MethodPost, "/api/v2/projects/1/duplicate", `{}`, token, "")
|
||||
require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String())
|
||||
assert.Contains(t, rec.Body.String(), `"duplicated_project"`)
|
||||
|
||||
var resp struct {
|
||||
DuplicatedProject struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
ParentProjectID int64 `json:"parent_project_id"`
|
||||
} `json:"duplicated_project"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||
assert.NotZero(t, resp.DuplicatedProject.ID, "duplicated project should have an id")
|
||||
assert.NotEqual(t, sourceProjectID, resp.DuplicatedProject.ID, "duplicated project must have a new id, not the source project's")
|
||||
assert.Contains(t, resp.DuplicatedProject.Title, "duplicate")
|
||||
assert.Zero(t, resp.DuplicatedProject.ParentProjectID, "top-level duplicate must have no parent")
|
||||
})
|
||||
|
||||
t.Run("places the duplicate under parent_project_id from the body", func(t *testing.T) {
|
||||
e, err := setupTestEnv()
|
||||
require.NoError(t, err)
|
||||
files.InitTestFileFixtures(t)
|
||||
token := humaTokenFor(t, &testuser1)
|
||||
|
||||
// testuser1 owns project 1, so it may both read the source and create
|
||||
// the copy underneath it.
|
||||
rec := humaRequest(t, e, http.MethodPost, "/api/v2/projects/1/duplicate", `{"parent_project_id":1}`, token, "")
|
||||
require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String())
|
||||
|
||||
var resp struct {
|
||||
DuplicatedProject struct {
|
||||
ID int64 `json:"id"`
|
||||
ParentProjectID int64 `json:"parent_project_id"`
|
||||
} `json:"duplicated_project"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||
assert.NotZero(t, resp.DuplicatedProject.ID)
|
||||
assert.Equal(t, int64(1), resp.DuplicatedProject.ParentProjectID, "duplicate must land under the requested parent")
|
||||
})
|
||||
|
||||
t.Run("nonexistent source project", func(t *testing.T) {
|
||||
e, err := setupTestEnv()
|
||||
require.NoError(t, err)
|
||||
token := humaTokenFor(t, &testuser1)
|
||||
|
||||
rec := humaRequest(t, e, http.MethodPost, "/api/v2/projects/99999/duplicate", `{}`, token, "")
|
||||
// CanCreate loads the source via CanRead, which surfaces
|
||||
// ErrProjectDoesNotExist (404) for a missing project rather than a 403.
|
||||
require.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String())
|
||||
assert.Contains(t, rec.Body.String(), fmt.Sprintf(`"code":%d`, models.ErrCodeProjectDoesNotExist), "body must surface ErrCodeProjectDoesNotExist; body: %s", rec.Body.String())
|
||||
})
|
||||
|
||||
t.Run("no read on source project is forbidden", func(t *testing.T) {
|
||||
e, err := setupTestEnv()
|
||||
require.NoError(t, err)
|
||||
// testuser15 cannot read project 1 (owned by testuser1, no share).
|
||||
token := humaTokenFor(t, &testuser15)
|
||||
|
||||
rec := humaRequest(t, e, http.MethodPost, "/api/v2/projects/1/duplicate", `{}`, token, "")
|
||||
require.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String())
|
||||
})
|
||||
}
|
||||
Loading…
Reference in New Issue