Skip to content
49 changes: 49 additions & 0 deletions services/api-server/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -4459,6 +4459,16 @@
"title": "Study Id"
}
},
{
"name": "hidden",
"in": "query",
"required": false,
"schema": {
"type": "boolean",
"default": false,
"title": "Hidden"
}
},
{
"name": "x-simcore-parent-project-uuid",
"in": "header",
Expand Down Expand Up @@ -4494,6 +4504,15 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Body_clone_study_v0_studies__study_id__clone_post"
}
}
}
},
"responses": {
"201": {
"description": "Successful Response",
Expand Down Expand Up @@ -7762,6 +7781,36 @@
],
"title": "Body_abort_multipart_upload_v0_files__file_id__abort_post"
},
"Body_clone_study_v0_studies__study_id__clone_post": {
"properties": {
"title": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Title",
"empty": true
},
"description": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Description",
"empty": true
}
},
"type": "object",
"title": "Body_clone_study_v0_studies__study_id__clone_post"
},
"Body_complete_multipart_upload_v0_files__file_id__complete_post": {
"properties": {
"client_file": {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import logging
from typing import Annotated, Final

from fastapi import APIRouter, Depends, Header, status
from fastapi import APIRouter, Body, Depends, Header, Query, status
from fastapi_pagination.api import create_page
from models_library.api_schemas_webserver.projects import ProjectGet
from models_library.api_schemas_webserver.projects import ProjectGet, ProjectPatch
from models_library.basic_types import LongTruncatedStr, ShortTruncatedStr
from models_library.projects import ProjectID
from models_library.projects_nodes_io import NodeID

Expand Down Expand Up @@ -94,13 +95,24 @@ async def clone_study(
webserver_api: Annotated[AuthSession, Depends(get_webserver_session)],
x_simcore_parent_project_uuid: Annotated[ProjectID | None, Header()] = None,
x_simcore_parent_node_id: Annotated[NodeID | None, Header()] = None,
hidden: Annotated[bool, Query()] = False,
title: Annotated[ShortTruncatedStr | None, Body(empty=True)] = None,
description: Annotated[LongTruncatedStr | None, Body(empty=True)] = None,
):
project: ProjectGet = await webserver_api.clone_project(
project_id=study_id,
hidden=False,
hidden=hidden,
parent_project_uuid=x_simcore_parent_project_uuid,
parent_node_id=x_simcore_parent_node_id,
)
if title or description:
patch_params = ProjectPatch(
name=title,
description=description,
)
await webserver_api.patch_project(
project_id=study_id, patch_params=patch_params
)
return _create_study_from_project(project)


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
# pylint: disable=unused-variable


import json
from collections.abc import Callable
from pathlib import Path
from typing import Any, TypedDict
Expand Down Expand Up @@ -188,6 +189,7 @@ def clone_project_side_effect(request: httpx.Request):
_headers[X_SIMCORE_PARENT_PROJECT_UUID] = f"{parent_project_id}"
if parent_node_id is not None:
_headers[X_SIMCORE_PARENT_NODE_ID] = f"{parent_node_id}"

resp = await client.post(
f"/{API_VTAG}/studies/{study_id}:clone", headers=_headers, auth=auth
)
Expand All @@ -197,6 +199,97 @@ def clone_project_side_effect(request: httpx.Request):
assert resp.status_code == status.HTTP_201_CREATED


# string length limits: https://github.com/ITISFoundation/osparc-simcore/blob/master/packages/models-library/src/models_library/api_schemas_webserver/projects.py#L242
@pytest.mark.parametrize("hidden", [True, False, None])
@pytest.mark.parametrize(
"title, description, expected_status_code",
[
(
_faker.text(max_nb_chars=600),
_faker.text(max_nb_chars=65536),
status.HTTP_201_CREATED,
),
("a" * 999, "b" * 99999, status.HTTP_201_CREATED),
(None, None, status.HTTP_201_CREATED),
],
ids=[
"valid_title_and_description",
"very_long_title_and_description",
"no_title_or_description",
],
)
async def test_clone_study_with_title(
client: httpx.AsyncClient,
auth: httpx.BasicAuth,
study_id: StudyID,
mocked_webserver_rest_api_base: MockRouter,
patch_webserver_long_running_project_tasks: Callable[[MockRouter], MockRouter],
mock_webserver_patch_project: Callable[
[
MockRouter,
],
MockRouter,
],
hidden: bool | None,
title: str | None,
description: str | None,
expected_status_code: int,
):
# Mocks /projects
patch_webserver_long_running_project_tasks(mocked_webserver_rest_api_base)
mock_webserver_patch_project(mocked_webserver_rest_api_base)

create_callback = mocked_webserver_rest_api_base["create_projects"].side_effect
assert create_callback is not None
patch_callback = mocked_webserver_rest_api_base["project_patch"].side_effect
assert patch_callback is not None

def clone_project_side_effect(request: httpx.Request):
if hidden is not None:
_hidden = request.url.params.get("hidden")
assert _hidden == str(hidden).lower()
return create_callback(request)

def patch_project_side_effect(request: httpx.Request, *args, **kwargs):
body = json.loads(request.content.decode("utf-8"))
if title is not None:
_name = body.get("name")
assert _name is not None and _name in title
if description is not None:
_description = body.get("description")
assert _description is not None and _description in description
return patch_callback(request, *args, **kwargs)

mocked_webserver_rest_api_base["create_projects"].side_effect = (
clone_project_side_effect
)
mocked_webserver_rest_api_base["project_patch"].side_effect = (
patch_project_side_effect
)

query = dict()
if hidden is not None:
query["hidden"] = str(hidden).lower()

body = dict()
if hidden is not None:
body["hidden"] = hidden
if title is not None:
body["title"] = title
if description is not None:
body["description"] = description

resp = await client.post(
f"/{API_VTAG}/studies/{study_id}:clone", auth=auth, json=body, params=query
)

assert mocked_webserver_rest_api_base["create_projects"].called
if title or description:
assert mocked_webserver_rest_api_base["project_patch"].called

assert resp.status_code == expected_status_code


async def test_clone_study_not_found(
client: httpx.AsyncClient,
auth: httpx.BasicAuth,
Expand Down
21 changes: 21 additions & 0 deletions services/api-server/tests/unit/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -715,6 +715,27 @@ def _mock(webserver_mock_router: MockRouter) -> MockRouter:
return _mock


@pytest.fixture
def mock_webserver_patch_project(
app: FastAPI, faker: Faker, services_mocks_enabled: bool
) -> Callable[[MockRouter], MockRouter]:
settings: ApplicationSettings = app.state.settings
assert settings.API_SERVER_WEBSERVER is not None

def _mock(webserver_mock_router: MockRouter) -> MockRouter:
def _patch_project(request: httpx.Request, *args, **kwargs):
return httpx.Response(status.HTTP_200_OK)

if services_mocks_enabled:
webserver_mock_router.patch(
path__regex=r"/projects/(?P<project_id>[\w-]+)$",
name="project_patch",
).mock(side_effect=_patch_project)
return webserver_mock_router

return _mock


@pytest.fixture
def openapi_dev_specs(project_slug_dir: Path) -> dict[str, Any]:
openapi_file = (project_slug_dir / "openapi-dev.json").resolve()
Expand Down
Loading