diff --git a/services/dynamic-sidecar/tests/unit/test_modules_outputs_event_handler.py b/services/dynamic-sidecar/tests/unit/test_modules_outputs_event_handler.py index c3387c01550..5f02a500a4d 100644 --- a/services/dynamic-sidecar/tests/unit/test_modules_outputs_event_handler.py +++ b/services/dynamic-sidecar/tests/unit/test_modules_outputs_event_handler.py @@ -4,7 +4,7 @@ import asyncio from pathlib import Path from typing import AsyncIterable -from unittest.mock import AsyncMock +from unittest.mock import Mock import aioprocessing import pytest @@ -46,7 +46,7 @@ async def outputs_manager( ) await outputs_manager.start() - outputs_manager.set_all_ports_for_upload = AsyncMock() + outputs_manager.set_all_ports_for_upload = Mock() yield outputs_manager await outputs_manager.shutdown() diff --git a/services/dynamic-sidecar/tests/unit/test_modules_outputs_watchdog_extensions.py b/services/dynamic-sidecar/tests/unit/test_modules_outputs_watchdog_extensions.py index 27682a53699..7456d5d77e8 100644 --- a/services/dynamic-sidecar/tests/unit/test_modules_outputs_watchdog_extensions.py +++ b/services/dynamic-sidecar/tests/unit/test_modules_outputs_watchdog_extensions.py @@ -2,7 +2,7 @@ import asyncio from pathlib import Path -from unittest.mock import AsyncMock +from unittest.mock import Mock from uuid import uuid4 import pytest @@ -34,7 +34,7 @@ async def test_regression_watchdog_blocks_on_handler_error( path_to_observe: Path, fail_once: bool ): raised_error = False - event_handler = AsyncMock() + event_handler = Mock() class MockedEventHandler(FileSystemEventHandler): def on_any_event(self, event: FileSystemEvent) -> None: @@ -43,7 +43,8 @@ def on_any_event(self, event: FileSystemEvent) -> None: nonlocal raised_error if not raised_error and fail_once: raised_error = True - raise RuntimeError("raised as expected") + msg = "raised as expected" + raise RuntimeError(msg) observer = ExtendedInotifyObserver() observer.schedule( @@ -75,7 +76,8 @@ async def test_safe_file_system_event_handler( class MockedEventHandler(SafeFileSystemEventHandler): def event_handler(self, _: FileSystemEvent) -> None: if user_code_raises_error: - raise RuntimeError("error was raised") + msg = "error was raised" + raise RuntimeError(msg) mocked_handler = MockedEventHandler() mocked_handler.on_any_event(mocked_file_system_event) diff --git a/services/web/server/src/simcore_service_webserver/projects/_nodes_api.py b/services/web/server/src/simcore_service_webserver/projects/_nodes_api.py index b2d3882bd75..ddcc4a50fc6 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_nodes_api.py +++ b/services/web/server/src/simcore_service_webserver/projects/_nodes_api.py @@ -2,10 +2,12 @@ import logging import mimetypes import urllib.parse -from typing import Final +from pathlib import Path +from typing import Final, NamedTuple from aiohttp import web from aiohttp.client import ClientError +from models_library.api_schemas_storage import FileMetaDataGet from models_library.projects import ProjectID from models_library.projects_nodes import Node, NodeID from models_library.projects_nodes_io import SimCoreFileLink @@ -20,15 +22,21 @@ parse_obj_as, root_validator, ) +from servicelib.utils import logged_gather -from .._constants import APP_SETTINGS_KEY, RQT_USERID_KEY from ..application_settings import get_settings -from ..storage.api import get_download_link +from ..storage.api import get_download_link, get_files_in_node_folder from .exceptions import ProjectStartsTooManyDynamicNodesError _logger = logging.getLogger(__name__) + _NODE_START_INTERVAL_S: Final[datetime.timedelta] = datetime.timedelta(seconds=15) +_SUPPORTED_THUMBNAIL_EXTENSIONS: set[str] = {".png", ".jpeg", ".jpg"} +_SUPPORTED_PREVIEW_FILE_EXTENSIONS: set[str] = {".gltf", ".png", ".jpeg", ".jpg"} + +ASSETS_FOLDER: Final[str] = "assets" + def get_service_start_lock_key(user_id: UserID, project_uuid: ProjectID) -> str: return f"lock_service_start_limit.{user_id}.{project_uuid}" @@ -68,6 +76,19 @@ def get_total_project_dynamic_nodes_creation_interval( # +def _guess_mimetype_from_name(name: str) -> str | None: + """Tries to guess the mimetype provided a name + + Arguments: + name -- path of a file or the file url + """ + _type, _ = mimetypes.guess_type(name) + # NOTE: mimetypes.guess_type works differently in our image, therefore made it nullable if + # cannot guess type + # SEE https://github.com/ITISFoundation/osparc-simcore/issues/4385 + return _type + + class NodeScreenshot(BaseModel): thumbnail_url: HttpUrl file_url: HttpUrl @@ -86,24 +107,113 @@ def guess_mimetype_if_undefined(cls, values): file_url = values["file_url"] assert file_url # nosec - _type, _encoding = mimetypes.guess_type(file_url) - # NOTE: mimetypes.guess_type works differently in our image, therefore made it nullable if - # cannot guess type - # SEE https://github.com/ITISFoundation/osparc-simcore/issues/4385 - values["mimetype"] = _type + values["mimetype"] = _guess_mimetype_from_name(file_url) return values -async def fake_screenshots_factory( - request: web.Request, node_id: NodeID, node: Node +def __get_search_key(meta_data: FileMetaDataGet) -> str: + return f"{meta_data.file_id}".lower() + + +class _FileWithThumbnail(NamedTuple): + file: FileMetaDataGet + thumbnail: FileMetaDataGet + + +def _get_files_with_thumbnails( + assets_files: list[FileMetaDataGet], +) -> list[_FileWithThumbnail]: + """returns a list of tuples where the second entry is the thumbnails""" + + selected_file_entries: dict[str, FileMetaDataGet] = { + __get_search_key(f): f + for f in assets_files + if not f.file_id.endswith(".hidden_do_not_remove") + and Path(f.file_id).suffix.lower() in _SUPPORTED_PREVIEW_FILE_EXTENSIONS + } + + with_thumbnail_image: list[_FileWithThumbnail] = [] + + for selected_file in set(selected_file_entries.keys()): + # search for thumbnail + thumbnail: FileMetaDataGet | None = None + for extension in _SUPPORTED_THUMBNAIL_EXTENSIONS: + thumbnail_search_file_name = f"{selected_file}{extension}" + if thumbnail_search_file_name in selected_file_entries: + thumbnail = selected_file_entries[thumbnail_search_file_name] + break + if not thumbnail: + continue + + # since there is a thumbnail it can be associated to a file + with_thumbnail_image.append( + _FileWithThumbnail( + file=selected_file_entries[selected_file], + thumbnail=thumbnail, + ) + ) + # remove entries which have been used + selected_file_entries.pop( + __get_search_key(selected_file_entries[selected_file]), None + ) + selected_file_entries.pop(__get_search_key(thumbnail), None) + + no_thumbnail_image: list[_FileWithThumbnail] = [ + _FileWithThumbnail(file=x, thumbnail=x) for x in selected_file_entries.values() + ] + + return with_thumbnail_image + no_thumbnail_image + + +async def __get_link( + app: web.Application, user_id: UserID, file_meta_data: FileMetaDataGet +) -> tuple[str, HttpUrl]: + return __get_search_key(file_meta_data), await get_download_link( + app, + user_id, + parse_obj_as(SimCoreFileLink, {"store": "0", "path": file_meta_data.file_id}), + ) + + +async def _get_node_screenshots( + app: web.Application, + user_id: UserID, + files_with_thumbnails: list[_FileWithThumbnail], ) -> list[NodeScreenshot]: - """ - ONLY for testing purposes + """resolves links concurrently before returning all the NodeScreenshots""" - """ - assert request.app[APP_SETTINGS_KEY].WEBSERVER_DEV_FEATURES_ENABLED # nosec - screenshots = [] + search_map: dict[str, FileMetaDataGet] = {} + + for entry in files_with_thumbnails: + search_map[__get_search_key(entry.file)] = entry.file + search_map[__get_search_key(entry.thumbnail)] = entry.thumbnail + + resolved_links: list[tuple[str, HttpUrl]] = await logged_gather( + *[__get_link(app, user_id, x) for x in search_map.values()], + max_concurrency=10, + ) + + mapped_http_url: dict[str, HttpUrl] = dict(resolved_links) + + return [ + NodeScreenshot( + mimetype=_guess_mimetype_from_name(e.file.file_id), + file_url=mapped_http_url[__get_search_key(e.file)], + thumbnail_url=mapped_http_url[__get_search_key(e.thumbnail)], + ) + for e in files_with_thumbnails + ] + + +async def get_node_screenshots( + app: web.Application, + user_id: UserID, + project_id: ProjectID, + node_id: NodeID, + node: Node, +) -> list[NodeScreenshot]: + screenshots: list[NodeScreenshot] = [] if ( "file-picker" in node.key @@ -113,14 +223,13 @@ async def fake_screenshots_factory( # Example of file that can be added in file-picker: # Example https://github.com/Ybalrid/Ogre_glTF/raw/6a59adf2f04253a3afb9459549803ab297932e8d/Media/Monster.glb try: - user_id = request[RQT_USERID_KEY] text = urllib.parse.quote(node.label) assert node.outputs is not None # nosec filelink = parse_obj_as(SimCoreFileLink, node.outputs["outFile"]) - file_url = await get_download_link(request.app, user_id, filelink) + file_url = await get_download_link(app, user_id, filelink) screenshots.append( NodeScreenshot( thumbnail_url=f"https://placehold.co/170x120?text={text}", # type: ignore[arg-type] @@ -135,33 +244,22 @@ async def fake_screenshots_factory( ) elif node.key.startswith("simcore/services/dynamic"): - # For dynamic services, just create fake images - - # References: - # - https://github.com/Ybalrid/Ogre_glTF - # - https://placehold.co/ - # - https://picsum.photos/ - # - count = int(str(node_id.int)[0]) - text = urllib.parse.quote(node.label) - - screenshots = [ - *( - NodeScreenshot( - thumbnail_url=f"https://picsum.photos/seed/{node_id.int + n}/170/120", # type: ignore[arg-type] - file_url=f"https://picsum.photos/seed/{node_id.int + n}/500", # type: ignore[arg-type] - mimetype="image/jpeg", - ) - for n in range(count) - ), - *( - NodeScreenshot( - thumbnail_url=f"https://placehold.co/170x120?text={text}", # type: ignore[arg-type] - file_url=f"https://placehold.co/500x500?text={text}", # type: ignore[arg-type] - mimetype="image/svg+xml", - ) - for n in range(count) - ), - ] + # when dealing with dynamic service scan the assets directory and + # pull in all the assets that have been dropped in there + + assets_files: list[FileMetaDataGet] = await get_files_in_node_folder( + app=app, + user_id=user_id, + project_id=project_id, + node_id=node_id, + folder_name=ASSETS_FOLDER, + ) + + resolved_screenshots: list[NodeScreenshot] = await _get_node_screenshots( + app=app, + user_id=user_id, + files_with_thumbnails=_get_files_with_thumbnails(assets_files), + ) + screenshots.extend(resolved_screenshots) return screenshots diff --git a/services/web/server/src/simcore_service_webserver/projects/_nodes_handlers.py b/services/web/server/src/simcore_service_webserver/projects/_nodes_handlers.py index e3e274719f0..aa0ab1fd1b9 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_nodes_handlers.py +++ b/services/web/server/src/simcore_service_webserver/projects/_nodes_handlers.py @@ -39,7 +39,6 @@ from servicelib.mimetype_constants import MIMETYPE_APPLICATION_JSON from simcore_postgres_database.models.users import UserRole -from .._constants import APP_SETTINGS_KEY, MSG_UNDER_DEVELOPMENT from .._meta import API_VTAG as VTAG from ..catalog import client as catalog_client from ..director_v2 import api @@ -50,7 +49,7 @@ from ..utils_aiohttp import envelope_json_response from . import projects_api from ._common_models import ProjectPathParams, RequestContext -from ._nodes_api import NodeScreenshot, fake_screenshots_factory +from ._nodes_api import NodeScreenshot, get_node_screenshots from .db import ProjectDBAPI from .exceptions import ( NodeNotFoundError, @@ -512,9 +511,6 @@ async def list_project_nodes_previews(request: web.Request) -> web.Response: path_params = parse_request_path_parameters_as(ProjectPathParams, request) assert req_ctx # nosec - if not request.app[APP_SETTINGS_KEY].WEBSERVER_DEV_FEATURES_ENABLED: - raise NotImplementedError(MSG_UNDER_DEVELOPMENT) - nodes_previews: list[_ProjectNodePreview] = [] project_data = await projects_api.get_project_for_user( request.app, @@ -524,7 +520,13 @@ async def list_project_nodes_previews(request: web.Request) -> web.Response: project = Project.parse_obj(project_data) for node_id, node in project.workbench.items(): - screenshots = await fake_screenshots_factory(request, NodeID(node_id), node) + screenshots = await get_node_screenshots( + app=request.app, + user_id=req_ctx.user_id, + project_id=path_params.project_id, + node_id=NodeID(node_id), + node=node, + ) if screenshots: nodes_previews.append( _ProjectNodePreview( @@ -549,9 +551,6 @@ async def get_project_node_preview(request: web.Request) -> web.Response: path_params = parse_request_path_parameters_as(_NodePathParams, request) assert req_ctx # nosec - if not request.app[APP_SETTINGS_KEY].WEBSERVER_DEV_FEATURES_ENABLED: - raise NotImplementedError(MSG_UNDER_DEVELOPMENT) - project_data = await projects_api.get_project_for_user( request.app, project_uuid=f"{path_params.project_id}", @@ -567,14 +566,15 @@ async def get_project_node_preview(request: web.Request) -> web.Response: node_uuid=f"{path_params.node_id}", ) - # NOTE: keep until is not a dev-feature - # raise HTTPNotFound( - # reason=f"Node '{path_params.project_id}/{path_params.node_id}' has no preview" - # ) - # node_preview = _ProjectNodePreview( project_id=project.uuid, node_id=path_params.node_id, - screenshots=await fake_screenshots_factory(request, path_params.node_id, node), + screenshots=await get_node_screenshots( + app=request.app, + user_id=req_ctx.user_id, + project_id=path_params.project_id, + node_id=path_params.node_id, + node=node, + ), ) return envelope_json_response(node_preview) diff --git a/services/web/server/src/simcore_service_webserver/storage/api.py b/services/web/server/src/simcore_service_webserver/storage/api.py index a954e9dc1a6..1b92184f837 100644 --- a/services/web/server/src/simcore_service_webserver/storage/api.py +++ b/services/web/server/src/simcore_service_webserver/storage/api.py @@ -5,7 +5,7 @@ import logging import urllib.parse from collections.abc import AsyncGenerator -from typing import Any +from typing import Any, Final from aiohttp import ClientError, ClientSession, ClientTimeout, web from models_library.api_schemas_storage import ( @@ -16,7 +16,7 @@ ) from models_library.generics import Envelope from models_library.projects import ProjectID -from models_library.projects_nodes_io import SimCoreFileLink +from models_library.projects_nodes_io import LocationID, NodeID, SimCoreFileLink from models_library.users import UserID from models_library.utils.fastapi_encoders import jsonable_encoder from pydantic import ByteSize, HttpUrl, parse_obj_as @@ -34,7 +34,8 @@ _logger = logging.getLogger(__name__) -_TOTAL_TIMEOUT_TO_COPY_DATA_SECS = 60 * 60 +_TOTAL_TIMEOUT_TO_COPY_DATA_SECS: Final[int] = 60 * 60 +_SIMCORE_LOCATION: Final[LocationID] = 0 def _get_storage_client(app: web.Application) -> tuple[ClientSession, URL]: @@ -204,3 +205,27 @@ async def get_download_link( ) link: HttpUrl = parse_obj_as(HttpUrl, download.link) return link + + +async def get_files_in_node_folder( + app: web.Application, + user_id: UserID, + project_id: ProjectID, + node_id: NodeID, + folder_name: str, +) -> list[FileMetaDataGet]: + session, api_endpoint = _get_storage_client(app) + + s3_folder_path = f"{project_id}/{node_id}/{folder_name}" + files_metadata_url = ( + api_endpoint / "locations" / f"{_SIMCORE_LOCATION}" / "files" / "metadata" + ).with_query(user_id=user_id, uuid_filter=s3_folder_path, expand_dirs="true") + + async with session.get(f"{files_metadata_url}") as response: + response.raise_for_status() + list_of_files_enveloped = Envelope[list[FileMetaDataGet]].parse_obj( + await response.json() + ) + assert list_of_files_enveloped.data is not None # nosec + result: list[FileMetaDataGet] = list_of_files_enveloped.data + return result diff --git a/services/web/server/tests/unit/isolated/test_projects__nodes_api.py b/services/web/server/tests/unit/isolated/test_projects__nodes_api.py new file mode 100644 index 00000000000..ef58b4b2451 --- /dev/null +++ b/services/web/server/tests/unit/isolated/test_projects__nodes_api.py @@ -0,0 +1,120 @@ +import datetime +from uuid import uuid4 + +import pytest +from models_library.api_schemas_storage import FileMetaDataGet +from pydantic import parse_obj_as +from simcore_service_webserver.projects._nodes_api import ( + _SUPPORTED_PREVIEW_FILE_EXTENSIONS, + _FileWithThumbnail, + _get_files_with_thumbnails, +) + +_PROJECT_ID = uuid4() +_NODE_ID = uuid4() +_UTC_NOW = datetime.datetime.now(tz=datetime.timezone.utc) + + +def _c(file_name: str) -> FileMetaDataGet: + """simple converter utility""" + return parse_obj_as( + FileMetaDataGet, + { + "file_uuid": f"{_PROJECT_ID}/{_NODE_ID}/{file_name}", + "location_id": 0, + "file_name": file_name, + "file_id": f"{_PROJECT_ID}/{_NODE_ID}/{file_name}", + "created_at": _UTC_NOW, + "last_modified": _UTC_NOW, + }, + ) + + +def _get_comparable(entries: list[_FileWithThumbnail]) -> set[tuple[str, str]]: + return {(x.file.file_id, x.thumbnail.file_id) for x in entries} + + +@pytest.mark.parametrize( + "assets_files, expected_result", + [ + pytest.param( + [_c("f.gltf"), _c("f.gltf.png")], + [_FileWithThumbnail(_c("f.gltf"), _c("f.gltf.png"))], + id="test_extension_gltf", + ), + pytest.param( + [_c("f.gltf"), _c("f.gltf.jpg")], + [_FileWithThumbnail(_c("f.gltf"), _c("f.gltf.jpg"))], + id="test_extension_jpg", + ), + pytest.param( + [_c("f.gltf"), _c("f.gltf.jpeg")], + [_FileWithThumbnail(_c("f.gltf"), _c("f.gltf.jpeg"))], + id="test_extension_jpeg", + ), + pytest.param( + [_c("f.gltf"), _c("f.gltf.pNg")], + [_FileWithThumbnail(_c("f.gltf"), _c("f.gltf.pNg"))], + id="test_extension_png_case_insensitivity", + ), + pytest.param( + [_c("f.gltf"), _c("f.gltf.pNg")], + [_FileWithThumbnail(_c("f.gltf"), _c("f.gltf.pNg"))], + id="test_extension_case_insensitivity", + ), + pytest.param( + [_c("a.png")], + [_FileWithThumbnail(_c("a.png"), _c("a.png"))], + id="one_to_one_same_extension", + ), + pytest.param( + [_c("a.gltf")], + [_FileWithThumbnail(_c("a.gltf"), _c("a.gltf"))], + id="one_to_one_other_files", + ), + pytest.param( + [_c("a.gltf.png"), _c("a.gltf")], + [_FileWithThumbnail(_c("a.gltf"), _c("a.gltf.png"))], + id="with_thumb", + ), + pytest.param( + reversed( + [_c("a.gltf.png"), _c("a.gltf")], + ), + [_FileWithThumbnail(_c("a.gltf"), _c("a.gltf.png"))], + id="with_thumb_order_does_not_matter", + ), + pytest.param( + [_c("C.gltf"), _c("a.gltf"), _c("b.gltf")], + [ + _FileWithThumbnail(_c("a.gltf"), _c("a.gltf")), + _FileWithThumbnail(_c("b.gltf"), _c("b.gltf")), + _FileWithThumbnail(_c("C.gltf"), _c("C.gltf")), + ], + id="one_to_one_multiple_entries", + ), + pytest.param( + [_c("C.gltf"), _c("a.gltf"), _c("a.gltf.jpeg"), _c("b.gltf")], + [ + _FileWithThumbnail(_c("a.gltf"), _c("a.gltf.jpeg")), + _FileWithThumbnail(_c("b.gltf"), _c("b.gltf")), + _FileWithThumbnail(_c("C.gltf"), _c("C.gltf")), + ], + id="one_to_one_multiple_entries_some_have_thumbnails", + ), + pytest.param( + [_c(f"a{x}") for x in _SUPPORTED_PREVIEW_FILE_EXTENSIONS], + [ + _FileWithThumbnail(_c(f"a{x}"), _c(f"a{x}")) + for x in _SUPPORTED_PREVIEW_FILE_EXTENSIONS + ], + id="all_supported_extensions_detected", + ), + ], +) +def test_associate_thumbnails( + assets_files: list[FileMetaDataGet], + expected_result: list[_FileWithThumbnail], +): + results = _get_files_with_thumbnails(assets_files) + assert _get_comparable(results) == _get_comparable(expected_result) 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 de3a7cb23d4..58177c4975f 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 @@ -4,10 +4,13 @@ # pylint: disable=unused-variable import asyncio +import re +from collections.abc import Awaitable, Callable from dataclasses import dataclass, field from datetime import datetime, timedelta +from pathlib import Path from random import choice -from typing import Any, Awaitable, Callable, Final +from typing import Any, Final from unittest import mock from uuid import uuid4 @@ -15,14 +18,17 @@ import sqlalchemy as sa from aiohttp import web from aiohttp.test_utils import TestClient +from aioresponses import aioresponses as AioResponsesMock from faker import Faker +from models_library.api_schemas_storage import FileMetaDataGet, PresignedLink +from models_library.generics import Envelope from models_library.services_resources import ( DEFAULT_SINGLE_SERVICE_NAME, ServiceResourcesDict, ServiceResourcesDictHelpers, ) +from models_library.utils.fastapi_encoders import jsonable_encoder from pydantic import NonNegativeFloat, NonNegativeInt, parse_obj_as -from pytest import MonkeyPatch from pytest_simcore.helpers.utils_assert import assert_status from pytest_simcore.helpers.utils_envs import setenvs_from_dict from pytest_simcore.helpers.utils_webserver_unit_with_db import ( @@ -828,7 +834,7 @@ async def test_stop_node( all_service_uuids = list(project["workbench"]) # start the node, shall work as expected url = client.app.router["stop_node"].url_for( - project_id=project["uuid"], node_id=choice(all_service_uuids) + project_id=project["uuid"], node_id=choice(all_service_uuids) # noqa: S311 ) response = await client.post(f"{url}") data, error = await assert_status( @@ -847,15 +853,59 @@ async def test_stop_node( @pytest.fixture def app_environment( - app_environment: dict[str, str], monkeypatch: MonkeyPatch + app_environment: dict[str, str], monkeypatch: pytest.MonkeyPatch ) -> dict[str, str]: # test_read_project_nodes_previews needs WEBSERVER_DEV_FEATURES_ENABLED=1 new_envs = setenvs_from_dict(monkeypatch, {"WEBSERVER_DEV_FEATURES_ENABLED": "1"}) return app_environment | new_envs -@pytest.mark.parametrize("user_role", (UserRole.USER,)) +@pytest.fixture +def mock_storage_calls(aioresponses_mocker: AioResponsesMock, faker: Faker) -> None: + _get_files_in_node_folder = re.compile( + r"^http://[a-z\-_]*:[0-9]+/v[0-9]/locations/[0-9]+/files/metadata.+$" + ) + + _get_download_link = re.compile( + r"^http://[a-z\-_]*:[0-9]+/v[0-9]/locations/[0-9]+/files.+$" + ) + + file_uuid = f"{uuid4()}/{uuid4()}/assets/some_file.png" + aioresponses_mocker.get( + _get_files_in_node_folder, + payload=jsonable_encoder( + Envelope[list[FileMetaDataGet]]( + data=[ + parse_obj_as( + FileMetaDataGet, + { + "file_uuid": file_uuid, + "location_id": 0, + "file_name": Path(file_uuid).name, + "file_id": file_uuid, + "created_at": "2020-06-17 12:28:55.705340", + "last_modified": "2020-06-17 12:28:55.705340", + }, + ) + ] + ) + ), + repeat=True, + ) + + aioresponses_mocker.get( + _get_download_link, + status=web.HTTPOk.status_code, + payload=jsonable_encoder( + Envelope[PresignedLink](data=PresignedLink(link=faker.image_url())) + ), + repeat=True, + ) + + +@pytest.mark.parametrize("user_role", [UserRole.USER]) async def test_read_project_nodes_previews( + mock_storage_calls: None, client: TestClient, user_project_with_num_dynamic_services: Callable[[int], Awaitable[ProjectDict]], user_role: UserRole,