From 3e89e2d46ad5276a2ca7ea5c9760c4e6c425fd59 Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Mon, 6 Oct 2025 10:17:07 +0200 Subject: [PATCH 1/8] fix - unsubsribtion of project logs --- .../src/models_library/rabbitmq_messages.py | 17 +++++++ .../_rabbitmq_exclusive_queue_consumers.py | 47 +++++++++++++++++++ .../projects/_controller/projects_slot.py | 4 +- .../_controller/projects_states_rest.py | 4 +- .../projects/_projects_service.py | 36 ++++++++++---- 5 files changed, 96 insertions(+), 12 deletions(-) diff --git a/packages/models-library/src/models_library/rabbitmq_messages.py b/packages/models-library/src/models_library/rabbitmq_messages.py index d6c8135eb99d..afc983ea4925 100644 --- a/packages/models-library/src/models_library/rabbitmq_messages.py +++ b/packages/models-library/src/models_library/rabbitmq_messages.py @@ -77,6 +77,23 @@ def routing_key(self) -> str | None: return None +class WebserverInternalEventRabbitMessageAction(str, Enum): + UNSUBSCRIBE_FROM_PROJECT_LOGS_RABBIT_QUEUE = ( + "UNSUBSCRIBE_FROM_PROJECT_LOGS_RABBIT_QUEUE" + ) + + +class WebserverInternalEventRabbitMessage(RabbitMessageBase): + channel_name: Literal["simcore.services.webserver_internal_events"] = ( + "simcore.services.webserver_internal_events" + ) + action: WebserverInternalEventRabbitMessageAction + data: dict[str, Any] = Field(default_factory=dict) + + def routing_key(self) -> str | None: + return None + + class ProgressType(StrAutoEnum): COMPUTATION_RUNNING = auto() # NOTE: this is the original only progress report diff --git a/services/web/server/src/simcore_service_webserver/notifications/_rabbitmq_exclusive_queue_consumers.py b/services/web/server/src/simcore_service_webserver/notifications/_rabbitmq_exclusive_queue_consumers.py index b53a04d30397..0facb6c8943e 100644 --- a/services/web/server/src/simcore_service_webserver/notifications/_rabbitmq_exclusive_queue_consumers.py +++ b/services/web/server/src/simcore_service_webserver/notifications/_rabbitmq_exclusive_queue_consumers.py @@ -6,6 +6,7 @@ from aiohttp import web from models_library.groups import GroupID +from models_library.projects import ProjectID from models_library.projects_state import RUNNING_STATE_COMPLETED_STATES from models_library.rabbitmq_messages import ( ComputationalPipelineStatusMessage, @@ -15,6 +16,8 @@ ProgressRabbitMessageProject, ProgressType, WalletCreditsMessage, + WebserverInternalEventRabbitMessage, + WebserverInternalEventRabbitMessageAction, ) from models_library.socketio import SocketMessageDict from pydantic import TypeAdapter @@ -34,6 +37,7 @@ ) from ..socketio.models import WebSocketNodeProgress, WebSocketProjectProgress from ..wallets import api as wallets_service +from . import project_logs from ._rabbitmq_consumers_common import SubcribeArgumentsTuple, subscribe_to_rabbitmq _logger = logging.getLogger(__name__) @@ -151,6 +155,44 @@ async def _events_message_parser(app: web.Application, data: bytes) -> bool: return True +async def _webserver_internal_events_message_parser( + app: web.Application, data: bytes +) -> bool: + """ + Handles internal webserver events that need to be propagated to other webserver replicas + + Ex. Log unsubscription is triggered by user closing a project, which is a REST API call + that can reach any webserver replica. Then this event is propagated to all replicas + so that the one holding the websocket connection can unsubscribe from the logs queue. + """ + + rabbit_message = WebserverInternalEventRabbitMessage.model_validate_json(data) + + if ( + rabbit_message.action + == WebserverInternalEventRabbitMessageAction.UNSUBSCRIBE_FROM_PROJECT_LOGS_RABBIT_QUEUE + ): + _project_id = rabbit_message.data.get("project_id") + + if _project_id: + _logger.debug( + "Received UNSUBSCRIBE_FROM_PROJECT_LOGS_RABBIT_QUEUE event for project %s", + _project_id, + ) + await project_logs.unsubscribe(app, ProjectID(_project_id)) + else: + _logger.exception( + "Missing project_id in UNSUBSCRIBE_FROM_LOGS_RABBIT_QUEUE event, this should never happen, investigate!" + ) + + else: + _logger.warning( + "Unknown webserver internal event message action %s", rabbit_message.action + ) + + return True + + async def _osparc_credits_message_parser(app: web.Application, data: bytes) -> bool: rabbit_message = TypeAdapter(WalletCreditsMessage).validate_json(data) wallet_groups = await wallets_service.list_wallet_groups_with_read_access_by_wallet( @@ -191,6 +233,11 @@ async def _osparc_credits_message_parser(app: web.Application, data: bytes) -> b _events_message_parser, {}, ), + SubcribeArgumentsTuple( + WebserverInternalEventRabbitMessage.get_channel_name(), + _webserver_internal_events_message_parser, + {}, + ), SubcribeArgumentsTuple( WalletCreditsMessage.get_channel_name(), _osparc_credits_message_parser, diff --git a/services/web/server/src/simcore_service_webserver/projects/_controller/projects_slot.py b/services/web/server/src/simcore_service_webserver/projects/_controller/projects_slot.py index a1a8411a8f9d..6d55df236ec7 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_controller/projects_slot.py +++ b/services/web/server/src/simcore_service_webserver/projects/_controller/projects_slot.py @@ -20,7 +20,7 @@ managed_resource, ) from .._projects_service import ( - conditionally_unsubscribe_from_project_logs, + conditionally_unsubscribe_project_logs_across_replicas, retrieve_and_notify_project_locked_state, ) @@ -83,7 +83,7 @@ async def _on_user_disconnected( ) for _project_id in projects: # At the moment, only 1 is expected - await conditionally_unsubscribe_from_project_logs( + await conditionally_unsubscribe_project_logs_across_replicas( app, ProjectID(_project_id), user_id ) diff --git a/services/web/server/src/simcore_service_webserver/projects/_controller/projects_states_rest.py b/services/web/server/src/simcore_service_webserver/projects/_controller/projects_states_rest.py index bec0a0017f11..f5e7a31047db 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_controller/projects_states_rest.py +++ b/services/web/server/src/simcore_service_webserver/projects/_controller/projects_states_rest.py @@ -34,7 +34,7 @@ from ...users import users_service from ...utils_aiohttp import envelope_json_response, get_api_base_url from .. import _projects_service, projects_wallets_service -from .._projects_service import conditionally_unsubscribe_from_project_logs +from .._projects_service import conditionally_unsubscribe_project_logs_across_replicas from ..exceptions import ProjectStartsTooManyDynamicNodesError from ._rest_exceptions import handle_plugin_requests_exceptions from ._rest_schemas import AuthenticatedRequestContext, ProjectPathParams @@ -223,7 +223,7 @@ async def close_project(request: web.Request) -> web.Response: ), ) - await conditionally_unsubscribe_from_project_logs( + await conditionally_unsubscribe_project_logs_across_replicas( request.app, path_params.project_id, req_ctx.user_id ) diff --git a/services/web/server/src/simcore_service_webserver/projects/_projects_service.py b/services/web/server/src/simcore_service_webserver/projects/_projects_service.py index 72711e7b7dd7..086ffe6be3ae 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_projects_service.py +++ b/services/web/server/src/simcore_service_webserver/projects/_projects_service.py @@ -62,6 +62,10 @@ ProjectStatus, RunningState, ) +from models_library.rabbitmq_messages import ( + WebserverInternalEventRabbitMessage, + WebserverInternalEventRabbitMessageAction, +) from models_library.resource_tracker import ( HardwareInfo, PricingAndHardwareInfoTuple, @@ -115,9 +119,8 @@ from ..director_v2 import director_v2_service from ..dynamic_scheduler import api as dynamic_scheduler_service from ..models import ClientSessionID -from ..notifications import project_logs from ..products import products_web -from ..rabbitmq import get_rabbitmq_rpc_client +from ..rabbitmq import get_rabbitmq_client, get_rabbitmq_rpc_client from ..redis import get_redis_lock_manager_client_sdk from ..resource_manager.models import UserSession from ..resource_manager.registry import get_registry @@ -187,19 +190,26 @@ _logger = logging.getLogger(__name__) -async def conditionally_unsubscribe_from_project_logs( +async def conditionally_unsubscribe_project_logs_across_replicas( app: web.Application, project_id: ProjectID, user_id: UserID ) -> None: """ Unsubscribes from project logs only if no active socket connections remain for the project. This function checks for actual socket connections rather than just user sessions, - ensuring logs are only unsubscribed when truly no users are connected. + ensuring logs are only unsubscribed when truly no users are connected. When no active + connections are detected, an unsubscribe event is sent via RabbitMQ to all webserver + replicas to coordinate the unsubscription across the entire cluster. + + Note: With multiple webserver replicas, this ensures we don't unsubscribe until + the last socket is closed. However, another replica may still maintain an active + subscription even if no users are connected to it, as the RabbitMQ event ensures + all replicas receive the unsubscription notification. Args: app: The web application instance - project_id: The project ID to check - user_id: Optional user ID to use for the resource session (defaults to 0 if None) + project_id: The project ID to check for active connections + user_id: User ID to use for the resource session management """ redis_resource_registry = get_registry(app) with managed_resource(user_id, None, app) as user_session: @@ -221,7 +231,17 @@ async def conditionally_unsubscribe_from_project_logs( # the last socket is closed, though another replica may still maintain an active # subscription even if no users are connected to it. if actually_used_sockets_on_project == 0: - await project_logs.unsubscribe(app, project_id) + rabbitmq_client = get_rabbitmq_client(app) + message = WebserverInternalEventRabbitMessage( + action=WebserverInternalEventRabbitMessageAction.UNSUBSCRIBE_FROM_PROJECT_LOGS_RABBIT_QUEUE, + data={"project_id": f"{project_id}"}, + ) + _logger.debug( + "No active socket connections detected for project %s by user %s. Sending unsubscribe event to all replicas.", + project_id, + user_id, + ) + await rabbitmq_client.publish(message.channel_name, message) async def patch_project_and_notify_users( @@ -1569,7 +1589,7 @@ async def _leave_project_room( sio = get_socket_server(app) await sio.leave_room(socket_id, SocketIORoomStr.from_project_id(project_uuid)) else: - _logger.error( + _logger.warning( "User %s/%s has no socket_id, cannot leave project room %s", user_id, client_session_id, From 82d1e50686ce1af11f54e10fe1a03a903dc9dc1c Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Mon, 6 Oct 2025 13:35:43 +0200 Subject: [PATCH 2/8] fix failing tests --- .../projects/_controller/projects_states_rest.py | 5 ----- .../projects/_projects_service.py | 4 ++++ .../with_dbs/02/test_projects_states_handlers.py | 8 ++++++-- services/web/server/tests/unit/with_dbs/conftest.py | 12 ++++++++++++ 4 files changed, 22 insertions(+), 7 deletions(-) diff --git a/services/web/server/src/simcore_service_webserver/projects/_controller/projects_states_rest.py b/services/web/server/src/simcore_service_webserver/projects/_controller/projects_states_rest.py index f5e7a31047db..24dbacc3e1e8 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_controller/projects_states_rest.py +++ b/services/web/server/src/simcore_service_webserver/projects/_controller/projects_states_rest.py @@ -34,7 +34,6 @@ from ...users import users_service from ...utils_aiohttp import envelope_json_response, get_api_base_url from .. import _projects_service, projects_wallets_service -from .._projects_service import conditionally_unsubscribe_project_logs_across_replicas from ..exceptions import ProjectStartsTooManyDynamicNodesError from ._rest_exceptions import handle_plugin_requests_exceptions from ._rest_schemas import AuthenticatedRequestContext, ProjectPathParams @@ -223,10 +222,6 @@ async def close_project(request: web.Request) -> web.Response: ), ) - await conditionally_unsubscribe_project_logs_across_replicas( - request.app, path_params.project_id, req_ctx.user_id - ) - return web.json_response(status=status.HTTP_204_NO_CONTENT) diff --git a/services/web/server/src/simcore_service_webserver/projects/_projects_service.py b/services/web/server/src/simcore_service_webserver/projects/_projects_service.py index 086ffe6be3ae..d6f90917cacf 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_projects_service.py +++ b/services/web/server/src/simcore_service_webserver/projects/_projects_service.py @@ -1807,6 +1807,10 @@ async def close_project_for_user( ) await notify_project_state_update(app, project) + await conditionally_unsubscribe_project_logs_across_replicas( + app, project_uuid, user_id + ) + async def _get_project_share_state( user_id: int, diff --git a/services/web/server/tests/unit/with_dbs/02/test_projects_states_handlers.py b/services/web/server/tests/unit/with_dbs/02/test_projects_states_handlers.py index cfa61a8e5df4..4e25648bcae1 100644 --- a/services/web/server/tests/unit/with_dbs/02/test_projects_states_handlers.py +++ b/services/web/server/tests/unit/with_dbs/02/test_projects_states_handlers.py @@ -49,6 +49,7 @@ ServiceResourcesDict, ServiceResourcesDictHelpers, ) +from models_library.users import UserID from models_library.utils.fastapi_encoders import jsonable_encoder from pydantic import TypeAdapter from pytest_mock import MockerFixture @@ -1072,6 +1073,7 @@ async def test_close_project( fake_services: Callable[..., Awaitable[list[DynamicServiceGet]]], mock_dynamic_scheduler_rabbitmq: None, mocked_notifications_plugin: dict[str, mock.Mock], + mocked_conditionally_unsubscribe_project_logs: mock.Mock, ): # POST /v0/projects/{project_id}:close fake_dynamic_services = await fake_services(number_services=5) @@ -1109,8 +1111,10 @@ async def test_close_project( await assert_status(resp, expected.no_content) if resp.status == status.HTTP_204_NO_CONTENT: - mocked_notifications_plugin["unsubscribe"].assert_called_once_with( - client.app, ProjectID(user_project["uuid"]) + mocked_conditionally_unsubscribe_project_logs.assert_called_once_with( + client.app, + project_id=ProjectID(user_project["uuid"]), + user_id=UserID(user_id), ) # These checks are after a fire&forget, so we wait a moment await asyncio.sleep(2) diff --git a/services/web/server/tests/unit/with_dbs/conftest.py b/services/web/server/tests/unit/with_dbs/conftest.py index b5040eb6455e..d6d9c8f76cd3 100644 --- a/services/web/server/tests/unit/with_dbs/conftest.py +++ b/services/web/server/tests/unit/with_dbs/conftest.py @@ -707,6 +707,18 @@ def mocked_notifications_plugin(mocker: MockerFixture) -> dict[str, mock.Mock]: return {"subscribe": mocked_subscribe, "unsubscribe": mocked_unsubscribe} +@pytest.fixture +def mocked_conditionally_unsubscribe_project_logs( + mocker: MockerFixture, +) -> dict[str, mock.Mock]: + mocked_unsubscribe = mocker.patch( + "simcore_service_webserver.projects._projects_service.conditionally_unsubscribe_project_logs_across_replicas", + autospec=True, + ) + + return mocked_unsubscribe + + @pytest.fixture async def user_project( client: TestClient, From c2a9e9e8d957101dc024524d966a2569c4a28460 Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Tue, 7 Oct 2025 11:54:16 +0200 Subject: [PATCH 3/8] review @pcrespov --- .../models-library/src/models_library/rabbitmq_messages.py | 6 ++---- .../simcore_service_webserver/projects/_projects_service.py | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/models-library/src/models_library/rabbitmq_messages.py b/packages/models-library/src/models_library/rabbitmq_messages.py index afc983ea4925..a95f8487506a 100644 --- a/packages/models-library/src/models_library/rabbitmq_messages.py +++ b/packages/models-library/src/models_library/rabbitmq_messages.py @@ -77,10 +77,8 @@ def routing_key(self) -> str | None: return None -class WebserverInternalEventRabbitMessageAction(str, Enum): - UNSUBSCRIBE_FROM_PROJECT_LOGS_RABBIT_QUEUE = ( - "UNSUBSCRIBE_FROM_PROJECT_LOGS_RABBIT_QUEUE" - ) +class WebserverInternalEventRabbitMessageAction(StrAutoEnum): + UNSUBSCRIBE_FROM_PROJECT_LOGS_RABBIT_QUEUE = auto() class WebserverInternalEventRabbitMessage(RabbitMessageBase): diff --git a/services/web/server/src/simcore_service_webserver/projects/_projects_service.py b/services/web/server/src/simcore_service_webserver/projects/_projects_service.py index d6f90917cacf..bf9808407475 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_projects_service.py +++ b/services/web/server/src/simcore_service_webserver/projects/_projects_service.py @@ -194,7 +194,7 @@ async def conditionally_unsubscribe_project_logs_across_replicas( app: web.Application, project_id: ProjectID, user_id: UserID ) -> None: """ - Unsubscribes from project logs only if no active socket connections remain for the project. + Unsubscribes from project logs only if no active socket connections remain for the project (across all users). This function checks for actual socket connections rather than just user sessions, ensuring logs are only unsubscribed when truly no users are connected. When no active From a1061bf26f1c61c0587bf83965c867dd9a19c566 Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Tue, 7 Oct 2025 12:47:21 +0200 Subject: [PATCH 4/8] review @pcrespov --- .../models-library/src/models_library/rabbitmq_messages.py | 5 +++-- .../notifications/_rabbitmq_exclusive_queue_consumers.py | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/models-library/src/models_library/rabbitmq_messages.py b/packages/models-library/src/models_library/rabbitmq_messages.py index a95f8487506a..8ea54f2e9e55 100644 --- a/packages/models-library/src/models_library/rabbitmq_messages.py +++ b/packages/models-library/src/models_library/rabbitmq_messages.py @@ -3,9 +3,10 @@ from abc import abstractmethod from decimal import Decimal from enum import Enum, IntEnum, auto -from typing import Any, Literal, TypeAlias +from typing import Annotated, Any, Literal, TypeAlias import arrow +from common_library.basic_types import DEFAULT_FACTORY from pydantic import BaseModel, Field from .products import ProductName @@ -86,7 +87,7 @@ class WebserverInternalEventRabbitMessage(RabbitMessageBase): "simcore.services.webserver_internal_events" ) action: WebserverInternalEventRabbitMessageAction - data: dict[str, Any] = Field(default_factory=dict) + data: Annotated[dict[str, Any], Field(default_factory=dict)] = DEFAULT_FACTORY def routing_key(self) -> str | None: return None diff --git a/services/web/server/src/simcore_service_webserver/notifications/_rabbitmq_exclusive_queue_consumers.py b/services/web/server/src/simcore_service_webserver/notifications/_rabbitmq_exclusive_queue_consumers.py index 0facb6c8943e..467fba7e7079 100644 --- a/services/web/server/src/simcore_service_webserver/notifications/_rabbitmq_exclusive_queue_consumers.py +++ b/services/web/server/src/simcore_service_webserver/notifications/_rabbitmq_exclusive_queue_consumers.py @@ -181,8 +181,8 @@ async def _webserver_internal_events_message_parser( ) await project_logs.unsubscribe(app, ProjectID(_project_id)) else: - _logger.exception( - "Missing project_id in UNSUBSCRIBE_FROM_LOGS_RABBIT_QUEUE event, this should never happen, investigate!" + _logger.error( + "Missing project_id in UNSUBSCRIBE_FROM_PROJECT_LOGS_RABBIT_QUEUE event, this should never happen, investigate!" ) else: From 0bf949e0278a173172a01bb0b0a2806aa0cb72bc Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Wed, 8 Oct 2025 11:05:11 +0200 Subject: [PATCH 5/8] add tests --- .../projects/_projects_service.py | 29 +++-- .../server/tests/unit/with_dbs/02/common.py | 6 + .../server/tests/unit/with_dbs/02/conftest.py | 74 +++++++++++- ...itionally_unsubscribe_from_project_logs.py | 109 +++++++++++++++++ .../02/test_projects_states_handlers.py | 111 ++++-------------- 5 files changed, 226 insertions(+), 103 deletions(-) create mode 100644 services/web/server/tests/unit/with_dbs/02/common.py create mode 100644 services/web/server/tests/unit/with_dbs/02/test_projects_service_conditionally_unsubscribe_from_project_logs.py diff --git a/services/web/server/src/simcore_service_webserver/projects/_projects_service.py b/services/web/server/src/simcore_service_webserver/projects/_projects_service.py index bf9808407475..9bd620d3d23b 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_projects_service.py +++ b/services/web/server/src/simcore_service_webserver/projects/_projects_service.py @@ -190,6 +190,23 @@ _logger = logging.getLogger(__name__) +async def _publish_unsubscribe_from_project_logs_event( + app: web.Application, project_id: ProjectID, user_id: UserID +) -> None: + """Publishes an unsubscribe event for project logs to all webserver replicas.""" + rabbitmq_client = get_rabbitmq_client(app) + message = WebserverInternalEventRabbitMessage( + action=WebserverInternalEventRabbitMessageAction.UNSUBSCRIBE_FROM_PROJECT_LOGS_RABBIT_QUEUE, + data={"project_id": f"{project_id}"}, + ) + _logger.debug( + "No active socket connections detected for project %s by user %s. Sending unsubscribe event to all replicas.", + project_id, + user_id, + ) + await rabbitmq_client.publish(message.channel_name, message) + + async def conditionally_unsubscribe_project_logs_across_replicas( app: web.Application, project_id: ProjectID, user_id: UserID ) -> None: @@ -231,17 +248,7 @@ async def conditionally_unsubscribe_project_logs_across_replicas( # the last socket is closed, though another replica may still maintain an active # subscription even if no users are connected to it. if actually_used_sockets_on_project == 0: - rabbitmq_client = get_rabbitmq_client(app) - message = WebserverInternalEventRabbitMessage( - action=WebserverInternalEventRabbitMessageAction.UNSUBSCRIBE_FROM_PROJECT_LOGS_RABBIT_QUEUE, - data={"project_id": f"{project_id}"}, - ) - _logger.debug( - "No active socket connections detected for project %s by user %s. Sending unsubscribe event to all replicas.", - project_id, - user_id, - ) - await rabbitmq_client.publish(message.channel_name, message) + await _publish_unsubscribe_from_project_logs_event(app, project_id, user_id) async def patch_project_and_notify_users( diff --git a/services/web/server/tests/unit/with_dbs/02/common.py b/services/web/server/tests/unit/with_dbs/02/common.py new file mode 100644 index 000000000000..8ad7606fc04f --- /dev/null +++ b/services/web/server/tests/unit/with_dbs/02/common.py @@ -0,0 +1,6 @@ +from typing import TypedDict +from unittest import mock + + +class SocketHandlers(TypedDict): + SOCKET_IO_PROJECT_UPDATED_EVENT: mock.Mock diff --git a/services/web/server/tests/unit/with_dbs/02/conftest.py b/services/web/server/tests/unit/with_dbs/02/conftest.py index 714614c9d952..81f16f944899 100644 --- a/services/web/server/tests/unit/with_dbs/02/conftest.py +++ b/services/web/server/tests/unit/with_dbs/02/conftest.py @@ -3,6 +3,7 @@ # pylint: disable=unused-argument # pylint: disable=unused-variable +import asyncio import contextlib import re from collections.abc import AsyncIterator, Awaitable, Callable @@ -14,8 +15,10 @@ from unittest import mock import pytest -from aiohttp.test_utils import TestClient +import socketio +from aiohttp.test_utils import TestClient, TestServer from aioresponses import aioresponses +from common import SocketHandlers from common_library.json_serialization import json_dumps from faker import Faker from models_library.api_schemas_directorv2.dynamic_services import DynamicServiceGet @@ -36,6 +39,7 @@ from simcore_service_webserver.application_settings import get_application_settings from simcore_service_webserver.catalog.settings import get_plugin_settings from simcore_service_webserver.projects.models import ProjectDict +from simcore_service_webserver.socketio.messages import SOCKET_IO_PROJECT_UPDATED_EVENT @pytest.fixture @@ -533,3 +537,71 @@ def with_enabled_rtc_collaboration_limited_to_1_user( ) }, ) + + +@pytest.fixture +async def client_on_running_server_factory( + client: TestClient, +) -> AsyncIterator[Callable[[], TestClient]]: + # Creates clients connected to the same server as the reference client + # + # Implemented as aihttp_client but creates a client using a running server, + # i.e. avoid client.start_server + + assert isinstance(client.server, TestServer) + + clients = [] + + def go() -> TestClient: + cli = TestClient(client.server, loop=asyncio.get_event_loop()) + assert client.server.started + # AVOIDS client.start_server + clients.append(cli) + return cli + + yield go + + async def close_client_but_not_server(cli: TestClient) -> None: + # pylint: disable=protected-access + if not cli._closed: # noqa: SLF001 + for resp in cli._responses: # noqa: SLF001 + resp.close() + for ws in cli._websockets: # noqa: SLF001 + await ws.close() + await cli._session.close() # noqa: SLF001 + cli._closed = True # noqa: SLF001 + + async def finalize(): + while clients: + await close_client_but_not_server(clients.pop()) + + await finalize() + + +@pytest.fixture +async def create_socketio_connection_with_handlers( + create_socketio_connection: Callable[ + [str | None, TestClient | None], Awaitable[tuple[socketio.AsyncClient, str]] + ], + mocker: MockerFixture, +) -> Callable[ + [str | None, TestClient], + Awaitable[tuple[socketio.AsyncClient, str, SocketHandlers]], +]: + async def _( + client_session_id: str | None, client: TestClient + ) -> tuple[socketio.AsyncClient, str, SocketHandlers]: + sio, received_client_id = await create_socketio_connection( + client_session_id, client + ) + assert sio.sid + + event_handlers = SocketHandlers( + **{SOCKET_IO_PROJECT_UPDATED_EVENT: mocker.Mock()} + ) + + for event, handler in event_handlers.items(): + sio.on(event, handler=handler) + return sio, received_client_id, event_handlers + + return _ diff --git a/services/web/server/tests/unit/with_dbs/02/test_projects_service_conditionally_unsubscribe_from_project_logs.py b/services/web/server/tests/unit/with_dbs/02/test_projects_service_conditionally_unsubscribe_from_project_logs.py new file mode 100644 index 000000000000..63374aa268eb --- /dev/null +++ b/services/web/server/tests/unit/with_dbs/02/test_projects_service_conditionally_unsubscribe_from_project_logs.py @@ -0,0 +1,109 @@ +# pylint: disable=redefined-outer-name +# pylint: disable=too-many-arguments +# pylint: disable=too-many-positional-arguments +# pylint: disable=too-many-statements +# pylint: disable=unused-argument +# pylint: disable=unused-variable + +import contextlib +from collections.abc import Awaitable, Callable +from unittest import mock + +import pytest +import socketio +from aiohttp.test_utils import TestClient +from common import SocketHandlers +from pytest_mock import MockerFixture +from pytest_simcore.helpers.webserver_login import log_client_in +from pytest_simcore.helpers.webserver_parametrizations import ( + ExpectedResponse, +) +from servicelib.aiohttp import status +from simcore_service_webserver.db.models import UserRole +from test_projects_states_handlers import _close_project, _open_project + + +@pytest.fixture +def max_number_of_user_sessions() -> int: + return 3 + + +@pytest.fixture +def mock_publish_unsubscribe_from_project_logs_event(mocker: MockerFixture) -> None: + return mocker.patch( + "simcore_service_webserver.projects._projects_service._publish_unsubscribe_from_project_logs_event", + autospec=True, + ) + + +@pytest.mark.parametrize( + "user_role,expected", + [ + (UserRole.USER, status.HTTP_200_OK), + ], +) +async def test_conditionally_unsubscribe_from_project_logs( + max_number_of_user_sessions: int, + with_enabled_rtc_collaboration: None, + client: TestClient, + client_on_running_server_factory: Callable[[], TestClient], + logged_user: dict, + shared_project: dict, + user_role: UserRole, + expected: ExpectedResponse, + exit_stack: contextlib.AsyncExitStack, + create_socketio_connection_with_handlers: Callable[ + [str | None, TestClient], + Awaitable[tuple[socketio.AsyncClient, str, SocketHandlers]], + ], + mocked_dynamic_services_interface: dict[str, mock.Mock], + mock_publish_unsubscribe_from_project_logs_event: mock.Mock, + mock_catalog_api: dict[str, mock.Mock], + mocker: MockerFixture, +): + # Use-case: 2 users open the same shared project, then close it + # Only when the last user closes the project, the unsubscribe from project logs is done + # (this is important to avoid loosing logs when multiple users are working on the same project) + + client_1 = client + client_2 = client_on_running_server_factory() + + # 1. user 1 opens project + sio1, client_id1, sio1_handlers = await create_socketio_connection_with_handlers( + None, client_1 + ) + await _open_project( + client_1, + client_id1, + shared_project, + status.HTTP_200_OK, + ) + + # 2. create a separate client now and log in user2, open the same shared project + user_2 = await log_client_in( + client_2, + {"role": user_role.name}, + enable_check=True, + exit_stack=exit_stack, + ) + sio2, client_id2, sio2_handlers = await create_socketio_connection_with_handlers( + None, client_2 + ) + await _open_project( + client_2, + client_id2, + shared_project, + status.HTTP_200_OK, + ) + + # 3. user 1 closes the project (As user 2 is still connected, no unsubscribe should happen) + await _close_project( + client_1, client_id1, shared_project, status.HTTP_204_NO_CONTENT + ) + assert mock_publish_unsubscribe_from_project_logs_event.assert_not_called + + # 4. user 2 closes the project (now unsubscribe should happen) + await _close_project( + client_2, client_id2, shared_project, status.HTTP_204_NO_CONTENT + ) + assert mock_publish_unsubscribe_from_project_logs_event.assert_called_once diff --git a/services/web/server/tests/unit/with_dbs/02/test_projects_states_handlers.py b/services/web/server/tests/unit/with_dbs/02/test_projects_states_handlers.py index 4e25648bcae1..60531c8d08d5 100644 --- a/services/web/server/tests/unit/with_dbs/02/test_projects_states_handlers.py +++ b/services/web/server/tests/unit/with_dbs/02/test_projects_states_handlers.py @@ -8,12 +8,12 @@ import asyncio import contextlib import logging -from collections.abc import AsyncIterator, Awaitable, Callable, Iterator +from collections.abc import Awaitable, Callable, Iterator from copy import deepcopy from datetime import UTC, datetime, timedelta from decimal import Decimal from http import HTTPStatus -from typing import Any, TypedDict +from typing import Any from unittest import mock from unittest.mock import call @@ -21,7 +21,8 @@ import socketio import sqlalchemy as sa from aiohttp import ClientResponse -from aiohttp.test_utils import TestClient, TestServer +from aiohttp.test_utils import TestClient +from common import SocketHandlers from deepdiff import DeepDiff # type: ignore[attr-defined] from faker import Faker from models_library.api_schemas_directorv2.dynamic_services import DynamicServiceGet @@ -152,39 +153,6 @@ async def _replace_project( return data -class _SocketHandlers(TypedDict): - SOCKET_IO_PROJECT_UPDATED_EVENT: mock.Mock - - -@pytest.fixture -async def create_socketio_connection_with_handlers( - create_socketio_connection: Callable[ - [str | None, TestClient | None], Awaitable[tuple[socketio.AsyncClient, str]] - ], - mocker: MockerFixture, -) -> Callable[ - [str | None, TestClient], - Awaitable[tuple[socketio.AsyncClient, str, _SocketHandlers]], -]: - async def _( - client_session_id: str | None, client: TestClient - ) -> tuple[socketio.AsyncClient, str, _SocketHandlers]: - sio, received_client_id = await create_socketio_connection( - client_session_id, client - ) - assert sio.sid - - event_handlers = _SocketHandlers( - **{SOCKET_IO_PROJECT_UPDATED_EVENT: mocker.Mock()} - ) - - for event, handler in event_handlers.items(): - sio.on(event, handler=handler) - return sio, received_client_id, event_handlers - - return _ - - async def _open_project( client: TestClient, client_id: str, @@ -472,7 +440,7 @@ async def test_open_project( user_project: ProjectDict, create_socketio_connection_with_handlers: Callable[ [str | None, TestClient], - Awaitable[tuple[socketio.AsyncClient, str, _SocketHandlers]], + Awaitable[tuple[socketio.AsyncClient, str, SocketHandlers]], ], expected: HTTPStatus, save_state: bool, @@ -565,7 +533,7 @@ async def test_open_project__in_debt( user_project: ProjectDict, create_socketio_connection_with_handlers: Callable[ [str | None, TestClient], - Awaitable[tuple[socketio.AsyncClient, str, _SocketHandlers]], + Awaitable[tuple[socketio.AsyncClient, str, SocketHandlers]], ], expected: HTTPStatus, mocked_dynamic_services_interface: dict[str, mock.Mock], @@ -629,7 +597,7 @@ async def test_open_template_project_for_edition( create_template_project: Callable[..., Awaitable[ProjectDict]], create_socketio_connection_with_handlers: Callable[ [str | None, TestClient], - Awaitable[tuple[socketio.AsyncClient, str, _SocketHandlers]], + Awaitable[tuple[socketio.AsyncClient, str, SocketHandlers]], ], expected: HTTPStatus, save_state: bool, @@ -716,7 +684,7 @@ async def test_open_template_project_for_edition_with_missing_write_rights( create_template_project: Callable[..., Awaitable[ProjectDict]], create_socketio_connection_with_handlers: Callable[ [str | None, TestClient], - Awaitable[tuple[socketio.AsyncClient, str, _SocketHandlers]], + Awaitable[tuple[socketio.AsyncClient, str, SocketHandlers]], ], expected: HTTPStatus, mocked_dynamic_services_interface: dict[str, mock.Mock], @@ -750,7 +718,7 @@ async def test_open_project_with_small_amount_of_dynamic_services_starts_them_au user_project_with_num_dynamic_services: Callable[[int], Awaitable[ProjectDict]], create_socketio_connection_with_handlers: Callable[ [str | None, TestClient], - Awaitable[tuple[socketio.AsyncClient, str, _SocketHandlers]], + Awaitable[tuple[socketio.AsyncClient, str, SocketHandlers]], ], expected: ExpectedResponse, mocked_dynamic_services_interface: dict[str, mock.Mock], @@ -803,7 +771,7 @@ async def test_open_project_with_disable_service_auto_start_set_overrides_behavi user_project_with_num_dynamic_services: Callable[[int], Awaitable[ProjectDict]], create_socketio_connection_with_handlers: Callable[ [str | None, TestClient], - Awaitable[tuple[socketio.AsyncClient, str, _SocketHandlers]], + Awaitable[tuple[socketio.AsyncClient, str, SocketHandlers]], ], expected: ExpectedResponse, mocked_dynamic_services_interface: dict[str, mock.Mock], @@ -856,7 +824,7 @@ async def test_open_project_with_large_amount_of_dynamic_services_does_not_start user_project_with_num_dynamic_services: Callable[[int], Awaitable[ProjectDict]], create_socketio_connection_with_handlers: Callable[ [str | None, TestClient], - Awaitable[tuple[socketio.AsyncClient, str, _SocketHandlers]], + Awaitable[tuple[socketio.AsyncClient, str, SocketHandlers]], ], expected: ExpectedResponse, mocked_dynamic_services_interface: dict[str, mock.Mock], @@ -910,7 +878,7 @@ async def test_open_project_with_large_amount_of_dynamic_services_starts_them_if user_project_with_num_dynamic_services: Callable[[int], Awaitable[ProjectDict]], create_socketio_connection_with_handlers: Callable[ [str | None, TestClient], - Awaitable[tuple[socketio.AsyncClient, str, _SocketHandlers]], + Awaitable[tuple[socketio.AsyncClient, str, SocketHandlers]], ], expected: ExpectedResponse, mocked_dynamic_services_interface: dict[str, mock.Mock], @@ -965,7 +933,7 @@ async def test_open_project_with_deprecated_services_ok_but_does_not_start_dynam user_project, create_socketio_connection_with_handlers: Callable[ [str | None, TestClient], - Awaitable[tuple[socketio.AsyncClient, str, _SocketHandlers]], + Awaitable[tuple[socketio.AsyncClient, str, SocketHandlers]], ], expected: ExpectedResponse, mocked_dynamic_services_interface: dict[str, mock.Mock], @@ -1024,7 +992,7 @@ async def test_open_project_more_than_limitation_of_max_studies_open_per_user( logged_user, create_socketio_connection_with_handlers: Callable[ [str | None, TestClient], - Awaitable[tuple[socketio.AsyncClient, str, _SocketHandlers]], + Awaitable[tuple[socketio.AsyncClient, str, SocketHandlers]], ], user_project: ProjectDict, shared_project: ProjectDict, @@ -1412,45 +1380,6 @@ async def test_project_node_lifetime( # noqa: PLR0915 mock_storage_api_delete_data_folders_of_project_node.assert_not_called() -@pytest.fixture -async def client_on_running_server_factory( - client: TestClient, -) -> AsyncIterator[Callable[[], TestClient]]: - # Creates clients connected to the same server as the reference client - # - # Implemented as aihttp_client but creates a client using a running server, - # i.e. avoid client.start_server - - assert isinstance(client.server, TestServer) - - clients = [] - - def go() -> TestClient: - cli = TestClient(client.server, loop=asyncio.get_event_loop()) - assert client.server.started - # AVOIDS client.start_server - clients.append(cli) - return cli - - yield go - - async def close_client_but_not_server(cli: TestClient) -> None: - # pylint: disable=protected-access - if not cli._closed: # noqa: SLF001 - for resp in cli._responses: # noqa: SLF001 - resp.close() - for ws in cli._websockets: # noqa: SLF001 - await ws.close() - await cli._session.close() # noqa: SLF001 - cli._closed = True # noqa: SLF001 - - async def finalize(): - while clients: - await close_client_but_not_server(clients.pop()) - - await finalize() - - @pytest.fixture def clean_redis_table(redis_client) -> None: """this just ensures the redis table is cleaned up between test runs""" @@ -1469,7 +1398,7 @@ async def test_open_shared_project_multiple_users( exit_stack: contextlib.AsyncExitStack, create_socketio_connection_with_handlers: Callable[ [str | None, TestClient], - Awaitable[tuple[socketio.AsyncClient, str, _SocketHandlers]], + Awaitable[tuple[socketio.AsyncClient, str, SocketHandlers]], ], mocked_dynamic_services_interface: dict[str, mock.Mock], mock_catalog_api: dict[str, mock.Mock], @@ -1510,7 +1439,7 @@ async def test_open_shared_project_multiple_users( # now we create more users and open the same project until we reach the maximum number of user sessions other_users: list[ - tuple[UserInfoDict, TestClient, str, socketio.AsyncClient, _SocketHandlers] + tuple[UserInfoDict, TestClient, str, socketio.AsyncClient, SocketHandlers] ] = [] for user_session in range(1, max_number_of_user_sessions): client_i = client_on_running_server_factory() @@ -1639,7 +1568,7 @@ async def test_refreshing_tab_of_opened_project_multiple_users( expected: ExpectedResponse, create_socketio_connection_with_handlers: Callable[ [str | None, TestClient], - Awaitable[tuple[socketio.AsyncClient, str, _SocketHandlers]], + Awaitable[tuple[socketio.AsyncClient, str, SocketHandlers]], ], mocked_dynamic_services_interface: dict[str, mock.Mock], mock_catalog_api: dict[str, mock.Mock], @@ -1733,7 +1662,7 @@ async def test_closing_and_reopening_tab_of_opened_project_multiple_users( exit_stack: contextlib.AsyncExitStack, create_socketio_connection_with_handlers: Callable[ [str | None, TestClient], - Awaitable[tuple[socketio.AsyncClient, str, _SocketHandlers]], + Awaitable[tuple[socketio.AsyncClient, str, SocketHandlers]], ], mocked_dynamic_services_interface: dict[str, mock.Mock], mock_catalog_api: dict[str, mock.Mock], @@ -1823,7 +1752,7 @@ async def test_open_shared_project_2_users_locked_remove_once_rtc_collaboration_ exit_stack: contextlib.AsyncExitStack, create_socketio_connection_with_handlers: Callable[ [str | None, TestClient], - Awaitable[tuple[socketio.AsyncClient, str, _SocketHandlers]], + Awaitable[tuple[socketio.AsyncClient, str, SocketHandlers]], ], ): # Use-case: user 1 opens a shared project, user 2 tries to open it as well @@ -2064,7 +1993,7 @@ async def test_open_shared_project_at_same_time( exit_stack: contextlib.AsyncExitStack, create_socketio_connection_with_handlers: Callable[ [str | None, TestClient], - Awaitable[tuple[socketio.AsyncClient, str, _SocketHandlers]], + Awaitable[tuple[socketio.AsyncClient, str, SocketHandlers]], ], ): NUMBER_OF_ADDITIONAL_CLIENTS = 10 From 154a8534f4f24e9727b129bc5210434d437ac961 Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Thu, 9 Oct 2025 09:14:10 +0200 Subject: [PATCH 6/8] review @pcrespov --- .../helpers/webserver_parametrizations.py | 6 ++++- .../server/tests/unit/with_dbs/02/common.py | 6 ----- .../server/tests/unit/with_dbs/02/conftest.py | 2 +- ...itionally_unsubscribe_from_project_logs.py | 26 +++++++++++-------- .../02/test_projects_states_handlers.py | 2 +- .../server/tests/unit/with_dbs/conftest.py | 13 +++++----- 6 files changed, 29 insertions(+), 26 deletions(-) delete mode 100644 services/web/server/tests/unit/with_dbs/02/common.py diff --git a/packages/pytest-simcore/src/pytest_simcore/helpers/webserver_parametrizations.py b/packages/pytest-simcore/src/pytest_simcore/helpers/webserver_parametrizations.py index 6422122f4f4c..1a2c6367e862 100644 --- a/packages/pytest-simcore/src/pytest_simcore/helpers/webserver_parametrizations.py +++ b/packages/pytest-simcore/src/pytest_simcore/helpers/webserver_parametrizations.py @@ -1,4 +1,4 @@ -from typing import NamedTuple +from typing import NamedTuple, TypedDict from unittest import mock from servicelib.aiohttp import status @@ -120,3 +120,7 @@ class MockedStorageSubsystem(NamedTuple): delete_project: mock.MagicMock delete_node: mock.MagicMock get_project_total_size_simcore_s3: mock.MagicMock + + +class SocketHandlers(TypedDict): + SOCKET_IO_PROJECT_UPDATED_EVENT: mock.Mock diff --git a/services/web/server/tests/unit/with_dbs/02/common.py b/services/web/server/tests/unit/with_dbs/02/common.py deleted file mode 100644 index 8ad7606fc04f..000000000000 --- a/services/web/server/tests/unit/with_dbs/02/common.py +++ /dev/null @@ -1,6 +0,0 @@ -from typing import TypedDict -from unittest import mock - - -class SocketHandlers(TypedDict): - SOCKET_IO_PROJECT_UPDATED_EVENT: mock.Mock diff --git a/services/web/server/tests/unit/with_dbs/02/conftest.py b/services/web/server/tests/unit/with_dbs/02/conftest.py index 81f16f944899..a7503be096c4 100644 --- a/services/web/server/tests/unit/with_dbs/02/conftest.py +++ b/services/web/server/tests/unit/with_dbs/02/conftest.py @@ -18,7 +18,6 @@ import socketio from aiohttp.test_utils import TestClient, TestServer from aioresponses import aioresponses -from common import SocketHandlers from common_library.json_serialization import json_dumps from faker import Faker from models_library.api_schemas_directorv2.dynamic_services import DynamicServiceGet @@ -33,6 +32,7 @@ from pytest_simcore.helpers.assert_checks import assert_status from pytest_simcore.helpers.monkeypatch_envs import setenvs_from_dict from pytest_simcore.helpers.typing_env import EnvVarsDict +from pytest_simcore.helpers.webserver_parametrizations import SocketHandlers from pytest_simcore.helpers.webserver_projects import NewProject, delete_all_projects from pytest_simcore.helpers.webserver_users import UserInfoDict from settings_library.catalog import CatalogSettings diff --git a/services/web/server/tests/unit/with_dbs/02/test_projects_service_conditionally_unsubscribe_from_project_logs.py b/services/web/server/tests/unit/with_dbs/02/test_projects_service_conditionally_unsubscribe_from_project_logs.py index 63374aa268eb..2e049ac32a96 100644 --- a/services/web/server/tests/unit/with_dbs/02/test_projects_service_conditionally_unsubscribe_from_project_logs.py +++ b/services/web/server/tests/unit/with_dbs/02/test_projects_service_conditionally_unsubscribe_from_project_logs.py @@ -7,16 +7,15 @@ import contextlib from collections.abc import Awaitable, Callable -from unittest import mock import pytest import socketio from aiohttp.test_utils import TestClient -from common import SocketHandlers -from pytest_mock import MockerFixture +from pytest_mock import MockerFixture, MockType from pytest_simcore.helpers.webserver_login import log_client_in from pytest_simcore.helpers.webserver_parametrizations import ( ExpectedResponse, + SocketHandlers, ) from servicelib.aiohttp import status from simcore_service_webserver.db.models import UserRole @@ -29,9 +28,14 @@ def max_number_of_user_sessions() -> int: @pytest.fixture -def mock_publish_unsubscribe_from_project_logs_event(mocker: MockerFixture) -> None: - return mocker.patch( - "simcore_service_webserver.projects._projects_service._publish_unsubscribe_from_project_logs_event", +def mocked_publish_unsubscribe_from_project_logs_event( + mocker: MockerFixture, +) -> MockType: + import simcore_service_webserver.projects._projects_service + + return mocker.patch.object( + simcore_service_webserver.projects._projects_service, + "_publish_unsubscribe_from_project_logs_event", # noqa: SLF001 autospec=True, ) @@ -56,9 +60,9 @@ async def test_conditionally_unsubscribe_from_project_logs( [str | None, TestClient], Awaitable[tuple[socketio.AsyncClient, str, SocketHandlers]], ], - mocked_dynamic_services_interface: dict[str, mock.Mock], - mock_publish_unsubscribe_from_project_logs_event: mock.Mock, - mock_catalog_api: dict[str, mock.Mock], + mocked_dynamic_services_interface: dict[str, MockType], + mocked_publish_unsubscribe_from_project_logs_event: MockType, + mock_catalog_api: dict[str, MockType], mocker: MockerFixture, ): # Use-case: 2 users open the same shared project, then close it @@ -100,10 +104,10 @@ async def test_conditionally_unsubscribe_from_project_logs( await _close_project( client_1, client_id1, shared_project, status.HTTP_204_NO_CONTENT ) - assert mock_publish_unsubscribe_from_project_logs_event.assert_not_called + assert not mocked_publish_unsubscribe_from_project_logs_event.called # 4. user 2 closes the project (now unsubscribe should happen) await _close_project( client_2, client_id2, shared_project, status.HTTP_204_NO_CONTENT ) - assert mock_publish_unsubscribe_from_project_logs_event.assert_called_once + assert mocked_publish_unsubscribe_from_project_logs_event.called diff --git a/services/web/server/tests/unit/with_dbs/02/test_projects_states_handlers.py b/services/web/server/tests/unit/with_dbs/02/test_projects_states_handlers.py index 60531c8d08d5..0f4f4e0405ef 100644 --- a/services/web/server/tests/unit/with_dbs/02/test_projects_states_handlers.py +++ b/services/web/server/tests/unit/with_dbs/02/test_projects_states_handlers.py @@ -22,7 +22,6 @@ import sqlalchemy as sa from aiohttp import ClientResponse from aiohttp.test_utils import TestClient -from common import SocketHandlers from deepdiff import DeepDiff # type: ignore[attr-defined] from faker import Faker from models_library.api_schemas_directorv2.dynamic_services import DynamicServiceGet @@ -59,6 +58,7 @@ from pytest_simcore.helpers.webserver_login import LoggedUser, log_client_in from pytest_simcore.helpers.webserver_parametrizations import ( ExpectedResponse, + SocketHandlers, standard_role_response, standard_user_role_response, ) diff --git a/services/web/server/tests/unit/with_dbs/conftest.py b/services/web/server/tests/unit/with_dbs/conftest.py index d6d9c8f76cd3..83c66ac55146 100644 --- a/services/web/server/tests/unit/with_dbs/conftest.py +++ b/services/web/server/tests/unit/with_dbs/conftest.py @@ -44,7 +44,7 @@ from models_library.users import UserID from pydantic import ByteSize, TypeAdapter from pytest_docker.plugin import Services -from pytest_mock import MockerFixture +from pytest_mock import MockerFixture, MockType from pytest_simcore.helpers import postgres_tools from pytest_simcore.helpers.faker_factories import random_product from pytest_simcore.helpers.monkeypatch_envs import setenvs_from_dict @@ -710,14 +710,15 @@ def mocked_notifications_plugin(mocker: MockerFixture) -> dict[str, mock.Mock]: @pytest.fixture def mocked_conditionally_unsubscribe_project_logs( mocker: MockerFixture, -) -> dict[str, mock.Mock]: - mocked_unsubscribe = mocker.patch( - "simcore_service_webserver.projects._projects_service.conditionally_unsubscribe_project_logs_across_replicas", +) -> dict[str, MockType]: + import simcore_service_webserver.projects._projects_service + + return mocker.patch.object( + simcore_service_webserver.projects._projects_service, + "conditionally_unsubscribe_project_logs_across_replicas", autospec=True, ) - return mocked_unsubscribe - @pytest.fixture async def user_project( From ddf6228fdac5fb053125b97adaf46f807f9819ff Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Thu, 9 Oct 2025 10:14:00 +0200 Subject: [PATCH 7/8] review @pcrespov --- ...s_service_conditionally_unsubscribe_from_project_logs.py | 6 +++--- services/web/server/tests/unit/with_dbs/conftest.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/services/web/server/tests/unit/with_dbs/02/test_projects_service_conditionally_unsubscribe_from_project_logs.py b/services/web/server/tests/unit/with_dbs/02/test_projects_service_conditionally_unsubscribe_from_project_logs.py index 2e049ac32a96..9923e617faa3 100644 --- a/services/web/server/tests/unit/with_dbs/02/test_projects_service_conditionally_unsubscribe_from_project_logs.py +++ b/services/web/server/tests/unit/with_dbs/02/test_projects_service_conditionally_unsubscribe_from_project_logs.py @@ -31,11 +31,11 @@ def max_number_of_user_sessions() -> int: def mocked_publish_unsubscribe_from_project_logs_event( mocker: MockerFixture, ) -> MockType: - import simcore_service_webserver.projects._projects_service + import simcore_service_webserver.projects._projects_service # noqa: PLC0415 return mocker.patch.object( - simcore_service_webserver.projects._projects_service, - "_publish_unsubscribe_from_project_logs_event", # noqa: SLF001 + simcore_service_webserver.projects._projects_service, # noqa: SLF001 + "_publish_unsubscribe_from_project_logs_event", autospec=True, ) diff --git a/services/web/server/tests/unit/with_dbs/conftest.py b/services/web/server/tests/unit/with_dbs/conftest.py index 83c66ac55146..7dd4c67c5661 100644 --- a/services/web/server/tests/unit/with_dbs/conftest.py +++ b/services/web/server/tests/unit/with_dbs/conftest.py @@ -710,11 +710,11 @@ def mocked_notifications_plugin(mocker: MockerFixture) -> dict[str, mock.Mock]: @pytest.fixture def mocked_conditionally_unsubscribe_project_logs( mocker: MockerFixture, -) -> dict[str, MockType]: - import simcore_service_webserver.projects._projects_service +) -> MockType: + import simcore_service_webserver.projects._projects_service # noqa: PLC0415 return mocker.patch.object( - simcore_service_webserver.projects._projects_service, + simcore_service_webserver.projects._projects_service, # noqa: SLF001 "conditionally_unsubscribe_project_logs_across_replicas", autospec=True, ) From 78575ee23bf45b8564a77c8ac57f3b03c237a97a Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Thu, 9 Oct 2025 10:56:46 +0200 Subject: [PATCH 8/8] fix pylint --- ...jects_service_conditionally_unsubscribe_from_project_logs.py | 2 ++ services/web/server/tests/unit/with_dbs/conftest.py | 1 + 2 files changed, 3 insertions(+) diff --git a/services/web/server/tests/unit/with_dbs/02/test_projects_service_conditionally_unsubscribe_from_project_logs.py b/services/web/server/tests/unit/with_dbs/02/test_projects_service_conditionally_unsubscribe_from_project_logs.py index 9923e617faa3..460a55ca3cec 100644 --- a/services/web/server/tests/unit/with_dbs/02/test_projects_service_conditionally_unsubscribe_from_project_logs.py +++ b/services/web/server/tests/unit/with_dbs/02/test_projects_service_conditionally_unsubscribe_from_project_logs.py @@ -4,6 +4,8 @@ # pylint: disable=too-many-statements # pylint: disable=unused-argument # pylint: disable=unused-variable +# pylint: disable=protected-access + import contextlib from collections.abc import Awaitable, Callable diff --git a/services/web/server/tests/unit/with_dbs/conftest.py b/services/web/server/tests/unit/with_dbs/conftest.py index 7dd4c67c5661..085f8ea02eb4 100644 --- a/services/web/server/tests/unit/with_dbs/conftest.py +++ b/services/web/server/tests/unit/with_dbs/conftest.py @@ -2,6 +2,7 @@ # pylint: disable=unused-argument # pylint: disable=unused-variable # pylint: disable=too-many-arguments +# pylint: disable=protected-access import random import sys