diff --git a/.codecov.yml b/.codecov.yml index f575cb82adc..2b5040ee14b 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -16,21 +16,25 @@ coverage: - api - packages - services + carryforward: true api: informational: true threshold: 1% paths: - api + carryforward: true packages: informational: true threshold: 1% paths: - packages + carryforward: true services: informational: true threshold: 1% paths: - services + carryforward: true patch: default: diff --git a/services/web/server/src/simcore_service_webserver/projects/projects_api.py b/services/web/server/src/simcore_service_webserver/projects/projects_api.py index 6f9560f4307..aa0f7e52ba7 100644 --- a/services/web/server/src/simcore_service_webserver/projects/projects_api.py +++ b/services/web/server/src/simcore_service_webserver/projects/projects_api.py @@ -194,25 +194,22 @@ async def add_project_node( node_uuid = service_id if service_id else str(uuid4()) # ensure the project is up-to-date in the database prior to start any potential service - project_workbench = project.get("workbench", {}) - assert node_uuid not in project_workbench # nosec - project_workbench[node_uuid] = jsonable_encoder( - Node.parse_obj( - { - "key": service_key, - "version": service_version, - "label": service_key.split("/")[-1], - } + partial_workbench_data: dict[str, Any] = { + node_uuid: jsonable_encoder( + Node.parse_obj( + { + "key": service_key, + "version": service_version, + "label": service_key.split("/")[-1], + } + ), + exclude_unset=True, ), - exclude_unset=True, - ) + } db: ProjectDBAPI = request.app[APP_PROJECT_DBAPI] assert db # nosec - await db.replace_user_project( - new_project_data=project, - user_id=user_id, - product_name=product_name, - project_uuid=project["uuid"], + await db.patch_user_project_workbench( + partial_workbench_data, user_id, project["uuid"], product_name ) # also ensure the project is updated by director-v2 since services # are due to access comp_tasks at some point see [https://github.com/ITISFoundation/osparc-simcore/issues/3216] @@ -248,35 +245,40 @@ async def add_project_node( async def delete_project_node( - request: web.Request, project_uuid: str, user_id: int, node_uuid: str + request: web.Request, project_uuid: ProjectID, user_id: UserID, node_uuid: str ) -> None: log.debug( "deleting node %s in project %s for user %s", node_uuid, project_uuid, user_id ) - list_of_services = await director_v2_api.get_dynamic_services( - request.app, project_id=project_uuid, user_id=user_id + list_running_dynamic_services = await director_v2_api.get_dynamic_services( + request.app, project_id=f"{project_uuid}", user_id=user_id ) - # stop the service if it is running - for service in list_of_services: - if service["service_uuid"] == node_uuid: - log.info( - "Stopping dynamic %s in prj/node=%s", - f"{service}", - f"{project_uuid}/{node_uuid}", - ) - # no need to save the state of the node when deleting it - await director_v2_api.stop_dynamic_service( - request.app, - node_uuid, - save_state=False, - ) - break - # remove its data if any + if any(s["service_uuid"] == node_uuid for s in list_running_dynamic_services): + # no need to save the state of the node when deleting it + await director_v2_api.stop_dynamic_service( + request.app, + node_uuid, + save_state=False, + ) + + # remove the node's data if any await storage_api.delete_data_folders_of_project_node( - request.app, project_uuid, node_uuid, user_id + request.app, f"{project_uuid}", node_uuid, user_id ) + # remove the node from the db + partial_workbench_data: dict[str, Any] = { + node_uuid: None, + } + db: ProjectDBAPI = request.app[APP_PROJECT_DBAPI] + assert db # nosec + await db.patch_user_project_workbench( + partial_workbench_data, user_id, f"{project_uuid}" + ) + # also ensure the project is updated by director-v2 since services + await director_v2_api.create_or_update_pipeline(request.app, user_id, project_uuid) + async def update_project_linked_product( app: web.Application, project_id: ProjectID, product_name: str diff --git a/services/web/server/src/simcore_service_webserver/projects/projects_db.py b/services/web/server/src/simcore_service_webserver/projects/projects_db.py index 0e3f98f8740..a4929561d94 100644 --- a/services/web/server/src/simcore_service_webserver/projects/projects_db.py +++ b/services/web/server/src/simcore_service_webserver/projects/projects_db.py @@ -22,12 +22,14 @@ from aiopg.sa.connection import SAConnection from aiopg.sa.result import RowProxy from models_library.projects import ProjectAtDB, ProjectID, ProjectIDStr +from models_library.projects_nodes import Node from models_library.users import UserID from models_library.utils.change_case import camel_to_snake, snake_to_camel from pydantic import ValidationError from pydantic.types import PositiveInt from servicelib.aiohttp.application_keys import APP_DB_ENGINE_KEY from servicelib.json_serialization import json_dumps +from servicelib.logging_utils import log_context from simcore_postgres_database.models.projects_to_products import projects_to_products from simcore_postgres_database.webserver_models import ProjectType, projects from sqlalchemy import desc, func, literal_column @@ -551,14 +553,25 @@ async def get_template_project( return template_prj async def patch_user_project_workbench( - self, partial_workbench_data: dict[str, Any], user_id: int, project_uuid: str - ) -> tuple[dict[str, Any], dict[str, Any]]: + self, + partial_workbench_data: dict[str, Any], + user_id: int, + project_uuid: str, + product_name: Optional[str] = None, + ) -> tuple[ProjectDict, dict[str, Any]]: """patches an EXISTING project from a user new_project_data only contains the entries to modify + + - Example: to add a node: ```{new_node_id: {"key": node_key, "version": node_version, "label": node_label, ...}}``` + - Example: to modify a node ```{new_node_id: {"outputs": {"output_1": 2}}}``` + - Example: to remove a node ```{node_id: None}``` """ - log.info("Patching project %s for user %s", project_uuid, user_id) - async with self.engine.acquire() as conn: - async with conn.begin() as _transaction: + with log_context( + log, + logging.DEBUG, + msg=f"Patching project {project_uuid} for user {user_id}", + ): + async with self.engine.acquire() as conn, conn.begin() as _transaction: current_project: dict = await self._get_project( conn, user_id, @@ -575,30 +588,47 @@ async def patch_user_project_workbench( ) def _patch_workbench( - project: dict[str, Any], new_partial_workbench_data: dict[str, Any] + project: dict[str, Any], + new_partial_workbench_data: dict[str, Any], ) -> tuple[dict[str, Any], dict[str, Any]]: """patch the project workbench with the values in new_data and returns the changed project and changed values""" changed_entries = {} - for node_key, new_node_data in new_partial_workbench_data.items(): + for ( + node_key, + new_node_data, + ) in new_partial_workbench_data.items(): current_node_data = project.get("workbench", {}).get(node_key) if current_node_data is None: - log.debug( - "node %s is missing from project, no patch", node_key - ) - raise NodeNotFoundError(project_uuid, node_key) - # find changed keys - changed_entries.update( - { - node_key: find_changed_node_keys( - current_node_data, - new_node_data, - look_for_removed_keys=False, + # if it's a new node, let's check that it validates + try: + Node.parse_obj(new_node_data) + project["workbench"][node_key] = new_node_data + changed_entries.update({node_key: new_node_data}) + except ValidationError as err: + log.debug( + "node %s is missing from project, and %s is no new node, no patch", + node_key, + f"{new_node_data=}", ) - } - ) - # patch - current_node_data.update(new_node_data) + raise NodeNotFoundError(project_uuid, node_key) from err + elif new_node_data is None: + # remove the node + project["workbench"].pop(node_key) + changed_entries.update({node_key: None}) + else: + # find changed keys + changed_entries.update( + { + node_key: find_changed_node_keys( + current_node_data, + new_node_data, + look_for_removed_keys=False, + ) + } + ) + # patch + current_node_data.update(new_node_data) return (project, changed_entries) new_project_data, changed_entries = _patch_workbench( @@ -621,6 +651,10 @@ def _patch_workbench( ) project = await result.fetchone() assert project # nosec + if product_name: + await self.upsert_project_linked_product( + ProjectID(project_uuid), product_name, conn=conn + ) log.debug( "DB updated returned row project=%s", json_dumps(dict(project.items())), @@ -772,7 +806,7 @@ async def check_project_has_only_one_product(self, project_uuid: ProjectID) -> N .select_from(projects_to_products) .where(projects_to_products.c.project_uuid == f"{project_uuid}") ) - assert num_products_linked_to_project # nosec + assert isinstance(num_products_linked_to_project, int) # nosec if num_products_linked_to_project > 1: # NOTE: # in agreement with @odeimaiz : @@ -810,18 +844,20 @@ async def make_unique_project_uuid(self) -> str: async def _get_user_email(conn: SAConnection, user_id: Optional[int]) -> str: if not user_id: return "not_a_user@unknown.com" - email: Optional[str] = await conn.scalar( + email = await conn.scalar( sa.select([users.c.email]).where(users.c.id == user_id) ) + assert isinstance(email, str) or email is None # nosec return email or "Unknown" @staticmethod async def _get_user_primary_group_gid(conn: SAConnection, user_id: int) -> int: - primary_gid: Optional[int] = await conn.scalar( + primary_gid = await conn.scalar( sa.select([users.c.primary_gid]).where(users.c.id == str(user_id)) ) if not primary_gid: raise UserNotFoundError(uid=user_id) + assert isinstance(primary_gid, int) return primary_gid @staticmethod @@ -840,6 +876,7 @@ async def node_id_exists(self, node_id: str) -> bool: .where(projects.c.workbench.op("->>")(f"{node_id}") != None) ) assert num_entries is not None # nosec + assert isinstance(num_entries, int) # nosec return bool(num_entries > 0) async def get_node_ids_from_project(self, project_uuid: str) -> set[str]: @@ -860,7 +897,7 @@ async def list_all_projects_by_uuid_for_user(self, user_id: int) -> list[str]: async for row in conn.execute( sa.select([projects.c.uuid]).where(projects.c.prj_owner == user_id) ): - result.append(row[0]) + result.append(row[projects.c.uuid]) return list(result) async def update_project_without_checking_permissions( diff --git a/services/web/server/tests/unit/with_dbs/02/test_project_db.py b/services/web/server/tests/unit/with_dbs/02/test_project_db.py index ff54870a045..a8e6c2c6fec 100644 --- a/services/web/server/tests/unit/with_dbs/02/test_project_db.py +++ b/services/web/server/tests/unit/with_dbs/02/test_project_db.py @@ -18,6 +18,7 @@ import pytest import sqlalchemy as sa from aiohttp.test_utils import TestClient +from faker import Faker from models_library.projects import ProjectID from models_library.projects_nodes_io import NodeID from psycopg2.errors import UniqueViolation @@ -39,6 +40,10 @@ _convert_to_schema_names, _create_project_access_rights, ) +from simcore_service_webserver.projects.projects_exceptions import ( + NodeNotFoundError, + ProjectNotFoundError, +) from simcore_service_webserver.users_exceptions import UserNotFoundError from simcore_service_webserver.utils import to_datetime from sqlalchemy.engine.result import Row @@ -476,9 +481,109 @@ async def test_add_project_to_db( @pytest.mark.parametrize( "user_role", - [ - (UserRole.USER), - ], + [(UserRole.USER)], +) +async def test_patch_user_project_workbench_raises_if_project_does_not_exist( + fake_project: dict[str, Any], + logged_user: dict[str, Any], + db_api: ProjectDBAPI, + faker: Faker, +): + partial_workbench_data = { + faker.uuid4(): { + "key": "simcore/services/comp/sleepers", + "version": faker.numerify("%.#.#"), + "label": "I am a test node", + } + } + with pytest.raises(ProjectNotFoundError): + await db_api.patch_user_project_workbench( + partial_workbench_data, + logged_user["id"], + fake_project["uuid"], + ) + + +@pytest.mark.parametrize( + "user_role", + [(UserRole.USER)], +) +async def test_patch_user_project_workbench_creates_nodes( + fake_project: dict[str, Any], + logged_user: dict[str, Any], + db_api: ProjectDBAPI, + faker: Faker, + osparc_product_name: str, +): + empty_fake_project = deepcopy(fake_project) + workbench = empty_fake_project.setdefault("workbench", {}) + assert isinstance(workbench, dict) + workbench.clear() + + new_project = await db_api.add_project( + prj=empty_fake_project, + user_id=logged_user["id"], + product_name=osparc_product_name, + ) + partial_workbench_data = { + faker.uuid4(): { + "key": f"simcore/services/comp/{faker.pystr()}", + "version": faker.numerify("%.#.#"), + "label": faker.text(), + } + for _ in range(faker.pyint(min_value=5, max_value=30)) + } + patched_project, changed_entries = await db_api.patch_user_project_workbench( + partial_workbench_data, + logged_user["id"], + new_project["uuid"], + ) + for node_id in partial_workbench_data: + assert node_id in patched_project["workbench"] + assert partial_workbench_data[node_id] == patched_project["workbench"][node_id] + assert node_id in changed_entries + assert changed_entries[node_id] == partial_workbench_data[node_id] + + +@pytest.mark.parametrize( + "user_role", + [(UserRole.USER)], +) +async def test_patch_user_project_workbench_creates_nodes_raises_if_invalid_node_is_passed( + fake_project: dict[str, Any], + logged_user: dict[str, Any], + db_api: ProjectDBAPI, + faker: Faker, + osparc_product_name: str, +): + empty_fake_project = deepcopy(fake_project) + workbench = empty_fake_project.setdefault("workbench", {}) + assert isinstance(workbench, dict) + workbench.clear() + + new_project = await db_api.add_project( + prj=empty_fake_project, + user_id=logged_user["id"], + product_name=osparc_product_name, + ) + partial_workbench_data = { + faker.uuid4(): { + "version": faker.numerify("%.#.#"), + "label": faker.text(), + } + for _ in range(faker.pyint(min_value=5, max_value=30)) + } + with pytest.raises(NodeNotFoundError): + await db_api.patch_user_project_workbench( + partial_workbench_data, + logged_user["id"], + new_project["uuid"], + ) + + +@pytest.mark.parametrize( + "user_role", + [(UserRole.USER)], ) @pytest.mark.parametrize("number_of_nodes", [1, randint(250, 300)]) async def test_patch_user_project_workbench_concurrently( diff --git a/services/web/server/tests/unit/with_dbs/02/test_projects_nodes_handler.py b/services/web/server/tests/unit/with_dbs/02/test_projects_nodes_handler.py index 97df6e41b91..c0bc7d295e9 100644 --- a/services/web/server/tests/unit/with_dbs/02/test_projects_nodes_handler.py +++ b/services/web/server/tests/unit/with_dbs/02/test_projects_nodes_handler.py @@ -3,16 +3,17 @@ # pylint: disable=unused-argument # pylint: disable=unused-variable +import asyncio import re -from collections import UserDict -from copy import deepcopy +from dataclasses import dataclass, field from datetime import datetime, timedelta from typing import Any from unittest import mock -from uuid import UUID, uuid4 +from uuid import uuid4 import pytest -from _helpers import ExpectedResponse, standard_role_response +import sqlalchemy as sa +from _helpers import ExpectedResponse, MockedStorageSubsystem, standard_role_response from aiohttp import web from aiohttp.test_utils import TestClient from faker import Faker @@ -20,6 +21,7 @@ from pytest_simcore.helpers.utils_assert import assert_status from pytest_simcore.helpers.utils_login import UserInfoDict from settings_library.catalog import CatalogSettings +from simcore_postgres_database.models.projects import projects as projects_db_model from simcore_service_webserver.catalog_settings import get_plugin_settings from simcore_service_webserver.db_models import UserRole from simcore_service_webserver.projects.project_models import ProjectDict @@ -153,67 +155,6 @@ async def test_replace_node_resources( await assert_status(response, expected) -@pytest.mark.parametrize(*standard_role_response(), ids=str) -async def test_create_node_properly_upgrade_database( - client: TestClient, - logged_user: UserDict, - user_project: ProjectDict, - expected: ExpectedResponse, - faker: Faker, - mocked_director_v2_api: dict[str, mock.MagicMock], - mock_catalog_api: dict[str, mock.Mock], - catalog_subsystem_mock, - mocker: MockerFixture, -): - create_or_update_mock = mocker.patch( - "simcore_service_webserver.director_v2_api.create_or_update_pipeline", - autospec=True, - return_value=None, - ) - - assert client.app - url = client.app.router["create_node"].url_for(project_id=user_project["uuid"]) - - # Use-case 1.: not passing a service UUID will generate a new one on the fly - body = { - "service_key": f"simcore/services/frontend/{faker.pystr()}", - "service_version": f"{faker.random_int()}.{faker.random_int()}.{faker.random_int()}", - } - response = await client.post(url.path, json=body) - data, error = await assert_status(response, expected.created) - if not error: - assert data - assert "node_id" in data - assert UUID(data["node_id"]) - new_node_uuid = UUID(data["node_id"]) - expected_project_data = deepcopy(user_project) - expected_project_data["workbench"][f"{new_node_uuid}"] = { - "key": body["service_key"], - "version": body["service_version"], - } - # give access to services inside the project - catalog_subsystem_mock([expected_project_data]) - # check the project was updated - get_url = client.app.router["get_project"].url_for( - project_id=user_project["uuid"] - ) - response = await client.get(get_url.path) - prj_data, error = await assert_status(response, expected.ok) - assert prj_data - assert not error - assert "workbench" in prj_data - assert ( - f"{new_node_uuid}" in prj_data["workbench"] - ), f"node {new_node_uuid} is missing from project workbench! workbench nodes {list(prj_data['workbench'].keys())}" - - create_or_update_mock.assert_called_once_with( - mock.ANY, logged_user["id"], user_project["uuid"] - ) - - # this does not start anything in the backend since this is not a dynamic service - mocked_director_v2_api["director_v2_api.run_dynamic_service"].assert_not_called() - - @pytest.mark.parametrize(*standard_role_response(), ids=str) async def test_create_node_returns_422_if_body_is_missing( client: TestClient, @@ -252,26 +193,22 @@ async def test_create_node( mocked_director_v2_api: dict[str, mock.MagicMock], mock_catalog_api: dict[str, mock.Mock], mocker: MockerFixture, + postgres_db: sa.engine.Engine, ): - create_or_update_mock = mocker.patch( - "simcore_service_webserver.director_v2_api.create_or_update_pipeline", - autospec=True, - return_value=None, - ) - assert client.app url = client.app.router["create_node"].url_for(project_id=user_project["uuid"]) - # Use-case 1.: not passing a service UUID will generate a new one on the fly body = { "service_key": f"simcore/services/{node_class}/{faker.pystr()}", - "service_version": f"{faker.random_int()}.{faker.random_int()}.{faker.random_int()}", + "service_version": faker.numerify("%.#.#"), } response = await client.post(url.path, json=body) data, error = await assert_status(response, expected.created) if data: assert not error - create_or_update_mock.assert_called_once() + mocked_director_v2_api[ + "director_v2_api.create_or_update_pipeline" + ].assert_called_once() if expect_run_service_call: mocked_director_v2_api[ "director_v2_api.run_dynamic_service" @@ -280,10 +217,110 @@ async def test_create_node( mocked_director_v2_api[ "director_v2_api.run_dynamic_service" ].assert_not_called() + + # check database is updated + assert "node_id" in data + create_node_id = data["node_id"] + with postgres_db.connect() as conn: + result = conn.execute( + sa.select([projects_db_model.c.workbench]).where( + projects_db_model.c.uuid == user_project["uuid"] + ) + ) + assert result + workbench = result.one()[projects_db_model.c.workbench] + assert create_node_id in workbench else: assert error +def standard_user_role() -> tuple[str, tuple]: + all_roles = standard_role_response() + + return (all_roles[0], (pytest.param(*all_roles[1][2], id="standard user role"),)) + + +@pytest.mark.parametrize(*standard_user_role()) +async def test_create_and_delete_many_nodes_in_parallel( + client: TestClient, + user_project: ProjectDict, + expected: ExpectedResponse, + mocked_director_v2_api: dict[str, mock.MagicMock], + mock_catalog_api: dict[str, mock.Mock], + faker: Faker, + postgres_db: sa.engine.Engine, + storage_subsystem_mock: MockedStorageSubsystem, +): + assert client.app + + @dataclass + class _RunninServices: + running_services_uuids: list[str] = field(default_factory=list) + + def num_services(self, *args, **kwargs) -> list[dict[str, Any]]: + return [ + {"service_uuid": service_uuid} + for service_uuid in self.running_services_uuids + ] + + def inc_running_services(self, *args, **kwargs): + self.running_services_uuids.append(kwargs["service_uuid"]) + + # let's count the started services + running_services = _RunninServices() + assert running_services.running_services_uuids == [] + mocked_director_v2_api[ + "director_v2_api.get_dynamic_services" + ].side_effect = running_services.num_services + mocked_director_v2_api[ + "director_v2_api.run_dynamic_service" + ].side_effect = running_services.inc_running_services + + # let's create many nodes + num_services_in_project = len(user_project["workbench"]) + url = client.app.router["create_node"].url_for(project_id=user_project["uuid"]) + body = { + "service_key": f"simcore/services/dynamic/{faker.pystr()}", + "service_version": faker.numerify("%.#.#"), + } + NUM_DY_SERVICES = 250 + responses = await asyncio.gather( + *(client.post(f"{url}", json=body) for _ in range(NUM_DY_SERVICES)) + ) + # all shall have worked + await asyncio.gather(*(assert_status(r, expected.created) for r in responses)) + + # but only the allowed number of services should have started + assert ( + mocked_director_v2_api["director_v2_api.run_dynamic_service"].call_count + == NUM_DY_SERVICES + ) + assert len(running_services.running_services_uuids) == NUM_DY_SERVICES + # check that we do have NUM_DY_SERVICES nodes in the project + with postgres_db.connect() as conn: + result = conn.execute( + sa.select([projects_db_model.c.workbench]).where( + projects_db_model.c.uuid == user_project["uuid"] + ) + ) + assert result + workbench = result.one()[projects_db_model.c.workbench] + assert len(workbench) == NUM_DY_SERVICES + num_services_in_project + print(f"--> {NUM_DY_SERVICES} nodes were created concurrently") + # + # delete now + # + delete_node_tasks = [] + for node_id in workbench: + delete_url = client.app.router["delete_node"].url_for( + project_id=user_project["uuid"], node_id=node_id + ) + delete_node_tasks.append(client.delete(f"{delete_url}")) + responses = await asyncio.gather(*delete_node_tasks) + await asyncio.gather(*(assert_status(r, expected.no_content) for r in responses)) + print("--> deleted all nodes concurrently") + + @pytest.mark.parametrize( "node_class", [("dynamic"), ("comp"), ("frontend")], @@ -315,3 +352,70 @@ async def test_creating_deprecated_node_returns_406_not_acceptable( assert not data # this does not start anything in the backend since this node is deprecated mocked_director_v2_api["director_v2_api.run_dynamic_service"].assert_not_called() + + +@pytest.mark.parametrize( + "dy_service_running", + [ + pytest.param(True, id="dy-service-running"), + pytest.param(False, id="dy-service-NOT-running"), + ], +) +@pytest.mark.parametrize(*standard_role_response(), ids=str) +async def test_delete_node( + client: TestClient, + user_project: ProjectDict, + expected: ExpectedResponse, + mocked_director_v2_api: dict[str, mock.MagicMock], + mock_catalog_api: dict[str, mock.Mock], + storage_subsystem_mock: MockedStorageSubsystem, + dy_service_running: bool, + postgres_db: sa.engine.Engine, +): + # first create a node + assert client.app + assert "workbench" in user_project + assert isinstance(user_project["workbench"], dict) + running_dy_services = [ + service_uuid + for service_uuid, service_data in user_project["workbench"].items() + if "/dynamic/" in service_data["key"] and dy_service_running + ] + mocked_director_v2_api["director_v2_api.get_dynamic_services"].return_value = [ + {"service_uuid": service_uuid} for service_uuid in running_dy_services + ] + for node_id in user_project["workbench"]: + url = client.app.router["delete_node"].url_for( + project_id=user_project["uuid"], node_id=node_id + ) + response = await client.delete(url.path) + data, error = await assert_status(response, expected.no_content) + assert not data + if error: + continue + + mocked_director_v2_api[ + "director_v2_api.get_dynamic_services" + ].assert_called_once() + mocked_director_v2_api["director_v2_api.get_dynamic_services"].reset_mock() + + if node_id in running_dy_services: + mocked_director_v2_api[ + "director_v2_api.stop_dynamic_service" + ].assert_called_once_with(mock.ANY, node_id, save_state=False) + mocked_director_v2_api["director_v2_api.stop_dynamic_service"].reset_mock() + else: + mocked_director_v2_api[ + "director_v2_api.stop_dynamic_service" + ].assert_not_called() + + # ensure the node is gone + with postgres_db.connect() as conn: + result = conn.execute( + sa.select([projects_db_model.c.workbench]).where( + projects_db_model.c.uuid == user_project["uuid"] + ) + ) + assert result + workbench = result.one()[projects_db_model.c.workbench] + assert node_id not in workbench diff --git a/services/web/server/tests/unit/with_dbs/_helpers.py b/services/web/server/tests/unit/with_dbs/_helpers.py index 31f91a782f0..539a2dc0082 100644 --- a/services/web/server/tests/unit/with_dbs/_helpers.py +++ b/services/web/server/tests/unit/with_dbs/_helpers.py @@ -120,4 +120,5 @@ def standard_role_response() -> tuple[str, list[tuple[UserRole, ExpectedResponse class MockedStorageSubsystem(NamedTuple): copy_data_folders_from_project: mock.MagicMock delete_project: mock.MagicMock + delete_node: mock.MagicMock get_project_total_size: mock.MagicMock diff --git a/services/web/server/tests/unit/with_dbs/conftest.py b/services/web/server/tests/unit/with_dbs/conftest.py index b07873c2766..a5f8319fb61 100644 --- a/services/web/server/tests/unit/with_dbs/conftest.py +++ b/services/web/server/tests/unit/with_dbs/conftest.py @@ -264,16 +264,23 @@ async def _mock_result(): async_mock = mocker.AsyncMock(return_value="") mock1 = mocker.patch( "simcore_service_webserver.projects._delete.delete_data_folders_of_project", + autospec=True, side_effect=async_mock, ) + mock2 = mocker.patch( + "simcore_service_webserver.projects.projects_api.storage_api.delete_data_folders_of_project_node", + autospec=True, + return_value=None, + ) + mock3 = mocker.patch( "simcore_service_webserver.projects.projects_handlers_crud.get_project_total_size", autospec=True, return_value=parse_obj_as(ByteSize, "1Gib"), ) - return MockedStorageSubsystem(mock, mock1, mock3) + return MockedStorageSubsystem(mock, mock1, mock2, mock3) @pytest.fixture @@ -306,7 +313,11 @@ async def mocked_director_v2_api(mocker: MockerFixture) -> dict[str, MagicMock]: autospec=True, return_value={}, ) - + mock["director_v2_api.create_or_update_pipeline"] = mocker.patch( + "simcore_service_webserver.director_v2_api.create_or_update_pipeline", + autospec=True, + return_value=None, + ) return mock