From 554926631d5c7fe235cd1a3f12b3e9f98829ba21 Mon Sep 17 00:00:00 2001 From: Mike Cousins Date: Wed, 7 Jul 2021 13:46:21 -0400 Subject: [PATCH] refactor(robot-server): split apart experimental sessions router (#8057) Co-authored-by: Sanniti Pimpley --- .../robot_server/sessions/router/__init__.py | 14 + .../sessions/router/actions_router.py | 89 ++ .../{router.py => router/base_router.py} | 181 +---- .../sessions/router/commands_router.py | 93 +++ robot-server/tests/sessions/conftest.py | 39 +- .../tests/sessions/router/__init__.py | 1 + .../tests/sessions/router/conftest.py | 87 ++ .../sessions/router/test_actions_router.py | 176 ++++ .../tests/sessions/router/test_base_router.py | 380 +++++++++ .../sessions/router/test_commands_router.py | 128 +++ .../tests/sessions/test_sessions_router.py | 762 ------------------ 11 files changed, 985 insertions(+), 965 deletions(-) create mode 100644 robot-server/robot_server/sessions/router/__init__.py create mode 100644 robot-server/robot_server/sessions/router/actions_router.py rename robot-server/robot_server/sessions/{router.py => router/base_router.py} (51%) create mode 100644 robot-server/robot_server/sessions/router/commands_router.py create mode 100644 robot-server/tests/sessions/router/__init__.py create mode 100644 robot-server/tests/sessions/router/conftest.py create mode 100644 robot-server/tests/sessions/router/test_actions_router.py create mode 100644 robot-server/tests/sessions/router/test_base_router.py create mode 100644 robot-server/tests/sessions/router/test_commands_router.py delete mode 100644 robot-server/tests/sessions/test_sessions_router.py diff --git a/robot-server/robot_server/sessions/router/__init__.py b/robot-server/robot_server/sessions/router/__init__.py new file mode 100644 index 00000000000..69e83b3439d --- /dev/null +++ b/robot-server/robot_server/sessions/router/__init__.py @@ -0,0 +1,14 @@ +"""Sessions router.""" +from fastapi import APIRouter + +from .base_router import base_router +from .commands_router import commands_router +from .actions_router import actions_router + +sessions_router = APIRouter() + +sessions_router.include_router(base_router) +sessions_router.include_router(commands_router) +sessions_router.include_router(actions_router) + +__all__ = ["sessions_router"] diff --git a/robot-server/robot_server/sessions/router/actions_router.py b/robot-server/robot_server/sessions/router/actions_router.py new file mode 100644 index 00000000000..99dcb1f33a7 --- /dev/null +++ b/robot-server/robot_server/sessions/router/actions_router.py @@ -0,0 +1,89 @@ +"""Router for /sessions actions endpoints.""" +from fastapi import APIRouter, Depends, status +from datetime import datetime +from typing_extensions import Literal + +from robot_server.errors import ErrorDetails, ErrorResponse +from robot_server.service.dependencies import get_current_time, get_unique_id +from robot_server.service.task_runner import TaskRunner +from robot_server.service.json_api import RequestModel, ResponseModel + +from ..session_store import SessionStore, SessionNotFoundError +from ..session_view import SessionView +from ..action_models import SessionAction, SessionActionCreateData +from ..engine_store import EngineStore, EngineMissingError +from ..dependencies import get_session_store, get_engine_store +from .base_router import SessionNotFound + +actions_router = APIRouter() + + +class SessionActionNotAllowed(ErrorDetails): + """An error if one tries to issue an unsupported session action.""" + + id: Literal["SessionActionNotAllowed"] = "SessionActionNotAllowed" + title: str = "Session Action Not Allowed" + + +@actions_router.post( + path="/sessions/{sessionId}/actions", + summary="Issue a control action to the session", + description=( + "Provide an action to the session in order to control execution of the run." + ), + status_code=status.HTTP_201_CREATED, + response_model=ResponseModel[SessionAction], + responses={ + status.HTTP_400_BAD_REQUEST: {"model": ErrorResponse[SessionActionNotAllowed]}, + status.HTTP_404_NOT_FOUND: {"model": ErrorResponse[SessionNotFound]}, + }, +) +async def create_session_action( + sessionId: str, + request_body: RequestModel[SessionActionCreateData], + session_view: SessionView = Depends(SessionView), + session_store: SessionStore = Depends(get_session_store), + engine_store: EngineStore = Depends(get_engine_store), + action_id: str = Depends(get_unique_id), + created_at: datetime = Depends(get_current_time), + task_runner: TaskRunner = Depends(TaskRunner), +) -> ResponseModel[SessionAction]: + """Create a session control action. + + Arguments: + sessionId: Session ID pulled from the URL. + request_body: Input payload from the request body. + session_view: Resource model builder. + session_store: Session storage interface. + engine_store: Protocol engine and runner storage. + action_id: Generated ID to assign to the control action. + created_at: Timestamp to attach to the control action. + task_runner: Background task runner. + """ + try: + prev_session = session_store.get(session_id=sessionId) + + action, next_session = session_view.with_action( + session=prev_session, + action_id=action_id, + action_data=request_body.data, + created_at=created_at, + ) + + # TODO(mc, 2021-06-11): support actions other than `start` + # TODO(mc, 2021-06-24): ensure the engine homes pipette plungers + # before starting the protocol run + # TODO(mc, 2021-06-30): capture errors (e.g. uncaught Python raise) + # and place them in the session response + task_runner.run(engine_store.runner.run) + + except SessionNotFoundError as e: + raise SessionNotFound(detail=str(e)).as_error(status.HTTP_404_NOT_FOUND) + except EngineMissingError as e: + raise SessionActionNotAllowed(detail=str(e)).as_error( + status.HTTP_400_BAD_REQUEST + ) + + session_store.upsert(session=next_session) + + return ResponseModel(data=action) diff --git a/robot-server/robot_server/sessions/router.py b/robot-server/robot_server/sessions/router/base_router.py similarity index 51% rename from robot-server/robot_server/sessions/router.py rename to robot-server/robot_server/sessions/router/base_router.py index c8ac5441aab..9b6a4134e7f 100644 --- a/robot-server/robot_server/sessions/router.py +++ b/robot-server/robot_server/sessions/router/base_router.py @@ -1,16 +1,15 @@ -"""Router for /sessions endpoints.""" +"""Base router for /sessions endpoints. + +Contains routes dealing primarily with `Session` models. +""" from fastapi import APIRouter, Depends, status from datetime import datetime -from typing import Optional, Union +from typing import Optional from typing_extensions import Literal -from opentrons.protocol_engine import commands as pe_commands, errors as pe_errors - from robot_server.errors import ErrorDetails, ErrorResponse from robot_server.service.dependencies import get_current_time, get_unique_id -from robot_server.service.task_runner import TaskRunner from robot_server.service.json_api import ( - RequestModel, ResponseModel, EmptyResponseModel, MultiResponseModel, @@ -23,15 +22,14 @@ get_protocol_store, ) -from .session_store import SessionStore, SessionNotFoundError -from .session_view import SessionView -from .session_models import Session, SessionCommandSummary, ProtocolSessionCreateData -from .action_models import SessionAction, SessionActionCreateData -from .schema_models import CreateSessionRequest, SessionResponse, SessionCommandResponse -from .engine_store import EngineStore, EngineConflictError, EngineMissingError -from .dependencies import get_session_store, get_engine_store +from ..session_store import SessionStore, SessionNotFoundError +from ..session_view import SessionView +from ..session_models import Session, ProtocolSessionCreateData +from ..schema_models import CreateSessionRequest, SessionResponse +from ..engine_store import EngineStore, EngineConflictError +from ..dependencies import get_session_store, get_engine_store -sessions_router = APIRouter() +base_router = APIRouter() class SessionNotFound(ErrorDetails): @@ -41,13 +39,6 @@ class SessionNotFound(ErrorDetails): title: str = "Session Not Found" -class CommandNotFound(ErrorDetails): - """An error if a given session command is not found.""" - - id: Literal["CommandNotFound"] = "CommandNotFound" - title: str = "Session Command Not Found" - - # TODO(mc, 2021-05-28): evaluate multi-session logic class SessionAlreadyActive(ErrorDetails): """An error if one tries to create a new session while one is already active.""" @@ -56,14 +47,7 @@ class SessionAlreadyActive(ErrorDetails): title: str = "Session Already Active" -class SessionActionNotAllowed(ErrorDetails): - """An error if one tries to issue an unsupported session action.""" - - id: Literal["SessionActionNotAllowed"] = "SessionActionNotAllowed" - title: str = "Session Action Not Allowed" - - -@sessions_router.post( +@base_router.post( path="/sessions", summary="Create a session", description="Create a new session to track robot interaction.", @@ -124,7 +108,7 @@ async def create_session( return ResponseModel(data=data) -@sessions_router.get( +@base_router.get( path="/sessions", summary="Get all sessions", description="Get a list of all active and inactive sessions.", @@ -153,7 +137,7 @@ async def get_sessions( return MultiResponseModel(data=data) -@sessions_router.get( +@base_router.get( path="/sessions/{sessionId}", summary="Get a session", description="Get a specific session by its unique identifier.", @@ -188,7 +172,7 @@ async def get_session( return ResponseModel(data=data) -@sessions_router.delete( +@base_router.delete( path="/sessions/{sessionId}", summary="Delete a session", description="Delete a specific session by its unique identifier.", @@ -215,136 +199,3 @@ async def remove_session_by_id( raise SessionNotFound(detail=str(e)).as_error(status.HTTP_404_NOT_FOUND) return EmptyResponseModel() - - -@sessions_router.post( - path="/sessions/{sessionId}/actions", - summary="Issue a control action to the session", - description=( - "Provide an action to the session in order to control execution of the run." - ), - status_code=status.HTTP_201_CREATED, - response_model=ResponseModel[SessionAction], - responses={ - status.HTTP_400_BAD_REQUEST: {"model": ErrorResponse[SessionActionNotAllowed]}, - status.HTTP_404_NOT_FOUND: {"model": ErrorResponse[SessionNotFound]}, - }, -) -async def create_session_action( - sessionId: str, - request_body: RequestModel[SessionActionCreateData], - session_view: SessionView = Depends(SessionView), - session_store: SessionStore = Depends(get_session_store), - engine_store: EngineStore = Depends(get_engine_store), - action_id: str = Depends(get_unique_id), - created_at: datetime = Depends(get_current_time), - task_runner: TaskRunner = Depends(TaskRunner), -) -> ResponseModel[SessionAction]: - """Create a session control action. - - Arguments: - sessionId: Session ID pulled from the URL. - request_body: Input payload from the request body. - session_view: Resource model builder. - session_store: Session storage interface. - engine_store: Protocol engine and runner storage. - action_id: Generated ID to assign to the control action. - created_at: Timestamp to attach to the control action. - task_runner: Background task runner. - """ - try: - prev_session = session_store.get(session_id=sessionId) - - action, next_session = session_view.with_action( - session=prev_session, - action_id=action_id, - action_data=request_body.data, - created_at=created_at, - ) - - # TODO(mc, 2021-06-11): support actions other than `start` - # TODO(mc, 2021-06-24): ensure the engine homes pipette plungers - # before starting the protocol run - # TODO(mc, 2021-06-30): capture errors (e.g. uncaught Python raise) - # and place them in the session response - task_runner.run(engine_store.runner.run) - - except SessionNotFoundError as e: - raise SessionNotFound(detail=str(e)).as_error(status.HTTP_404_NOT_FOUND) - except EngineMissingError as e: - raise SessionActionNotAllowed(detail=str(e)).as_error( - status.HTTP_400_BAD_REQUEST - ) - - session_store.upsert(session=next_session) - - return ResponseModel(data=action) - - -@sessions_router.get( - path="/sessions/{sessionId}/commands", - summary="Get a list of all protocol commands in the session", - description=( - "Get a list of all commands in the session and their statuses. " - "This endpoint returns command summaries. Use " - "`GET /sessions/{sessionId}/commands/{commandId}` to get all " - "information available for a given command." - ), - status_code=status.HTTP_200_OK, - response_model=MultiResponseModel[SessionCommandSummary], - responses={ - status.HTTP_404_NOT_FOUND: {"model": ErrorResponse[SessionNotFound]}, - }, -) -async def get_session_commands( - session: ResponseModel[Session] = Depends(get_session), -) -> MultiResponseModel[SessionCommandSummary]: - """Get a summary of all commands in a session. - - Arguments: - session: Session response model, provided by the route handler for - `GET /session/{sessionId}` - """ - return MultiResponseModel(data=session.data.commands) - - -@sessions_router.get( - path="/sessions/{sessionId}/commands/{commandId}", - summary="Get full details about a specific command in the session", - description=( - "Get a command along with any associated payload, result, and " - "execution information." - ), - status_code=status.HTTP_200_OK, - # TODO(mc, 2021-06-23): mypy >= 0.780 broke Unions as `response_model` - # see https://github.com/tiangolo/fastapi/issues/2279 - response_model=SessionCommandResponse, # type: ignore[arg-type] - responses={ - status.HTTP_404_NOT_FOUND: { - "model": Union[ - ErrorResponse[SessionNotFound], - ErrorResponse[CommandNotFound], - ] - }, - }, -) -async def get_session_command( - commandId: str, - engine_store: EngineStore = Depends(get_engine_store), - session: ResponseModel[Session] = Depends(get_session), -) -> ResponseModel[pe_commands.Command]: - """Get a specific command from a session. - - Arguments: - commandId: Command identifier, pulled from route parameter. - engine_store: Protocol engine and runner storage. - session: Session response model, provided by the route handler for - `GET /session/{sessionId}`. Present to ensure 404 if session - not found. - """ - try: - command = engine_store.engine.state_view.commands.get(commandId) - except pe_errors.CommandDoesNotExistError as e: - raise CommandNotFound(detail=str(e)).as_error(status.HTTP_404_NOT_FOUND) - - return ResponseModel(data=command) diff --git a/robot-server/robot_server/sessions/router/commands_router.py b/robot-server/robot_server/sessions/router/commands_router.py new file mode 100644 index 00000000000..831a3c36ded --- /dev/null +++ b/robot-server/robot_server/sessions/router/commands_router.py @@ -0,0 +1,93 @@ +"""Router for /sessions commands endpoints.""" +from fastapi import APIRouter, Depends, status +from typing import Union +from typing_extensions import Literal + +from opentrons.protocol_engine import commands as pe_commands, errors as pe_errors + +from robot_server.errors import ErrorDetails, ErrorResponse +from robot_server.service.json_api import ResponseModel, MultiResponseModel + +from ..session_models import Session, SessionCommandSummary +from ..schema_models import SessionCommandResponse +from ..engine_store import EngineStore +from ..dependencies import get_engine_store +from .base_router import SessionNotFound, get_session + +commands_router = APIRouter() + + +class CommandNotFound(ErrorDetails): + """An error if a given session command is not found.""" + + id: Literal["CommandNotFound"] = "CommandNotFound" + title: str = "Session Command Not Found" + + +@commands_router.get( + path="/sessions/{sessionId}/commands", + summary="Get a list of all protocol commands in the session", + description=( + "Get a list of all commands in the session and their statuses. " + "This endpoint returns command summaries. Use " + "`GET /sessions/{sessionId}/commands/{commandId}` to get all " + "information available for a given command." + ), + status_code=status.HTTP_200_OK, + response_model=MultiResponseModel[SessionCommandSummary], + responses={ + status.HTTP_404_NOT_FOUND: {"model": ErrorResponse[SessionNotFound]}, + }, +) +async def get_session_commands( + session: ResponseModel[Session] = Depends(get_session), +) -> MultiResponseModel[SessionCommandSummary]: + """Get a summary of all commands in a session. + + Arguments: + session: Session response model, provided by the route handler for + `GET /session/{sessionId}` + """ + return MultiResponseModel(data=session.data.commands) + + +@commands_router.get( + path="/sessions/{sessionId}/commands/{commandId}", + summary="Get full details about a specific command in the session", + description=( + "Get a command along with any associated payload, result, and " + "execution information." + ), + status_code=status.HTTP_200_OK, + # TODO(mc, 2021-06-23): mypy >= 0.780 broke Unions as `response_model` + # see https://github.com/tiangolo/fastapi/issues/2279 + response_model=SessionCommandResponse, # type: ignore[arg-type] + responses={ + status.HTTP_404_NOT_FOUND: { + "model": Union[ + ErrorResponse[SessionNotFound], + ErrorResponse[CommandNotFound], + ] + }, + }, +) +async def get_session_command( + commandId: str, + engine_store: EngineStore = Depends(get_engine_store), + session: ResponseModel[Session] = Depends(get_session), +) -> ResponseModel[pe_commands.Command]: + """Get a specific command from a session. + + Arguments: + commandId: Command identifier, pulled from route parameter. + engine_store: Protocol engine and runner storage. + session: Session response model, provided by the route handler for + `GET /session/{sessionId}`. Present to ensure 404 if session + not found. + """ + try: + command = engine_store.engine.state_view.commands.get(commandId) + except pe_errors.CommandDoesNotExistError as e: + raise CommandNotFound(detail=str(e)).as_error(status.HTTP_404_NOT_FOUND) + + return ResponseModel(data=command) diff --git a/robot-server/tests/sessions/conftest.py b/robot-server/tests/sessions/conftest.py index 3fc81300728..4e1bed606b0 100644 --- a/robot-server/tests/sessions/conftest.py +++ b/robot-server/tests/sessions/conftest.py @@ -1,45 +1,8 @@ -"""Common test fixtures for sessions route tests.""" +"""Common test fixtures for sessions module tests.""" import pytest import json import textwrap from pathlib import Path -from decoy import Decoy - -from robot_server.service.task_runner import TaskRunner -from robot_server.protocols import ProtocolStore -from robot_server.sessions.session_view import SessionView -from robot_server.sessions.session_store import SessionStore -from robot_server.sessions.engine_store import EngineStore - - -@pytest.fixture -def task_runner(decoy: Decoy) -> TaskRunner: - """Get a mock background TaskRunner.""" - return decoy.create_decoy(spec=TaskRunner) - - -@pytest.fixture -def protocol_store(decoy: Decoy) -> ProtocolStore: - """Get a mock ProtocolStore interface.""" - return decoy.create_decoy(spec=ProtocolStore) - - -@pytest.fixture -def session_store(decoy: Decoy) -> SessionStore: - """Get a mock SessionStore interface.""" - return decoy.create_decoy(spec=SessionStore) - - -@pytest.fixture -def session_view(decoy: Decoy) -> SessionView: - """Get a mock SessionView interface.""" - return decoy.create_decoy(spec=SessionView) - - -@pytest.fixture -def engine_store(decoy: Decoy) -> EngineStore: - """Get a mock EngineStore interface.""" - return decoy.create_decoy(spec=EngineStore) # TODO(mc, 2021-06-28): these fixtures are duplicated with fixtures in diff --git a/robot-server/tests/sessions/router/__init__.py b/robot-server/tests/sessions/router/__init__.py new file mode 100644 index 00000000000..915e93ecc8d --- /dev/null +++ b/robot-server/tests/sessions/router/__init__.py @@ -0,0 +1 @@ +"""Tests for /sessions routers.""" diff --git a/robot-server/tests/sessions/router/conftest.py b/robot-server/tests/sessions/router/conftest.py new file mode 100644 index 00000000000..d5b45aafab0 --- /dev/null +++ b/robot-server/tests/sessions/router/conftest.py @@ -0,0 +1,87 @@ +"""Common test fixtures for sessions route tests.""" +import pytest +import asyncio +from datetime import datetime +from decoy import Decoy +from fastapi import FastAPI +from fastapi.testclient import TestClient +from httpx import AsyncClient +from typing import AsyncIterator + +from robot_server.errors import exception_handlers +from robot_server.service.dependencies import get_current_time, get_unique_id +from robot_server.service.task_runner import TaskRunner +from robot_server.protocols import ProtocolStore, get_protocol_store +from robot_server.sessions.session_view import SessionView +from robot_server.sessions.session_store import SessionStore +from robot_server.sessions.engine_store import EngineStore +from robot_server.sessions.dependencies import get_session_store, get_engine_store + + +@pytest.fixture +def task_runner(decoy: Decoy) -> TaskRunner: + """Get a mock background TaskRunner.""" + return decoy.create_decoy(spec=TaskRunner) + + +@pytest.fixture +def protocol_store(decoy: Decoy) -> ProtocolStore: + """Get a mock ProtocolStore interface.""" + return decoy.create_decoy(spec=ProtocolStore) + + +@pytest.fixture +def session_store(decoy: Decoy) -> SessionStore: + """Get a mock SessionStore interface.""" + return decoy.create_decoy(spec=SessionStore) + + +@pytest.fixture +def session_view(decoy: Decoy) -> SessionView: + """Get a mock SessionView interface.""" + return decoy.create_decoy(spec=SessionView) + + +@pytest.fixture +def engine_store(decoy: Decoy) -> EngineStore: + """Get a mock EngineStore interface.""" + return decoy.create_decoy(spec=EngineStore) + + +@pytest.fixture +def app( + task_runner: TaskRunner, + session_store: SessionStore, + session_view: SessionView, + engine_store: EngineStore, + protocol_store: ProtocolStore, + unique_id: str, + current_time: datetime, +) -> FastAPI: + """Get a FastAPI app with mocked-out dependencies.""" + app = FastAPI(exception_handlers=exception_handlers) + app.dependency_overrides[TaskRunner] = lambda: task_runner + app.dependency_overrides[SessionView] = lambda: session_view + app.dependency_overrides[get_session_store] = lambda: session_store + app.dependency_overrides[get_engine_store] = lambda: engine_store + app.dependency_overrides[get_protocol_store] = lambda: protocol_store + app.dependency_overrides[get_unique_id] = lambda: unique_id + app.dependency_overrides[get_current_time] = lambda: current_time + + return app + + +@pytest.fixture +def client(app: FastAPI) -> TestClient: + """Get an TestClient for /sessions route testing.""" + return TestClient(app) + + +@pytest.fixture +async def async_client( + loop: asyncio.AbstractEventLoop, + app: FastAPI, +) -> AsyncIterator[AsyncClient]: + """Get an asynchronous client for /sessions route testing.""" + async with AsyncClient(app=app, base_url="http://test") as client: + yield client diff --git a/robot-server/tests/sessions/router/test_actions_router.py b/robot-server/tests/sessions/router/test_actions_router.py new file mode 100644 index 00000000000..5369f8eb59d --- /dev/null +++ b/robot-server/tests/sessions/router/test_actions_router.py @@ -0,0 +1,176 @@ +"""Tests for the /sessions router.""" +import pytest +from datetime import datetime +from decoy import Decoy +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from tests.helpers import verify_response +from robot_server.service.task_runner import TaskRunner +from robot_server.sessions.session_view import SessionView, BasicSessionCreateData +from robot_server.sessions.engine_store import EngineStore + +from robot_server.sessions.session_store import ( + SessionStore, + SessionNotFoundError, + SessionResource, +) + +from robot_server.sessions.action_models import ( + SessionAction, + SessionActionType, + SessionActionCreateData, +) + +from robot_server.sessions.router.base_router import SessionNotFound + +from robot_server.sessions.router.actions_router import ( + actions_router, + SessionActionNotAllowed, +) + + +@pytest.fixture(autouse=True) +def setup_app(app: FastAPI) -> None: + """Setup the FastAPI app with actions routes.""" + app.include_router(actions_router) + + +def test_create_session_action( + decoy: Decoy, + task_runner: TaskRunner, + session_view: SessionView, + session_store: SessionStore, + engine_store: EngineStore, + unique_id: str, + current_time: datetime, + client: TestClient, +) -> None: + """It should handle a start action.""" + session_created_at = datetime.now() + + actions = SessionAction( + actionType=SessionActionType.START, + createdAt=current_time, + id=unique_id, + ) + + prev_session = SessionResource( + session_id="unique-id", + create_data=BasicSessionCreateData(), + created_at=session_created_at, + actions=[], + ) + + next_session = SessionResource( + session_id="unique-id", + create_data=BasicSessionCreateData(), + created_at=session_created_at, + actions=[actions], + ) + + decoy.when(session_store.get(session_id="session-id")).then_return(prev_session) + + decoy.when( + session_view.with_action( + session=prev_session, + action_id=unique_id, + action_data=SessionActionCreateData(actionType=SessionActionType.START), + created_at=current_time, + ), + ).then_return((actions, next_session)) + + response = client.post( + "/sessions/session-id/actions", + json={"data": {"actionType": "start"}}, + ) + + verify_response(response, expected_status=201, expected_data=actions) + decoy.verify(task_runner.run(engine_store.runner.run)) + + +def test_create_session_action_with_missing_id( + decoy: Decoy, + session_store: SessionStore, + unique_id: str, + current_time: datetime, + client: TestClient, +) -> None: + """It should 404 if the session ID does not exist.""" + not_found_error = SessionNotFoundError(session_id="session-id") + + decoy.when(session_store.get(session_id="session-id")).then_raise(not_found_error) + + response = client.post( + "/sessions/session-id/actions", + json={"data": {"actionType": "start"}}, + ) + + verify_response( + response, + expected_status=404, + expected_errors=SessionNotFound(detail=str(not_found_error)), + ) + + +@pytest.mark.xfail(strict=True) +def test_create_session_action_without_runner( + decoy: Decoy, + session_view: SessionView, + session_store: SessionStore, + engine_store: EngineStore, + unique_id: str, + current_time: datetime, + client: TestClient, +) -> None: + """It should 400 if the runner is not able to handle the action.""" + session_created_at = datetime.now() + + actions = SessionAction( + actionType=SessionActionType.START, + createdAt=current_time, + id=unique_id, + ) + + prev_session = SessionResource( + session_id="unique-id", + create_data=BasicSessionCreateData(), + created_at=session_created_at, + actions=[], + ) + + next_session = SessionResource( + session_id="unique-id", + create_data=BasicSessionCreateData(), + created_at=session_created_at, + actions=[actions], + ) + + decoy.when(session_store.get(session_id="session-id")).then_return(prev_session) + + decoy.when( + session_view.with_action( + session=prev_session, + action_id=unique_id, + action_data=SessionActionCreateData(actionType=SessionActionType.START), + created_at=current_time, + ), + ).then_return((actions, next_session)) + + # TODO(mc, 2021-07-06): in reality, it will be the engine_store.runner + # property access that triggers this raise. Explore adding property access + # rehearsals to decoy + # decoy.when( + # await engine_store.runner.run() + # ).then_raise(EngineMissingError("oh no")) + + response = client.post( + "/sessions/session-id/actions", + json={"data": {"actionType": "start"}}, + ) + + verify_response( + response, + expected_status=400, + expected_errors=SessionActionNotAllowed(detail="oh no"), + ) diff --git a/robot-server/tests/sessions/router/test_base_router.py b/robot-server/tests/sessions/router/test_base_router.py new file mode 100644 index 00000000000..468b3eaa4ef --- /dev/null +++ b/robot-server/tests/sessions/router/test_base_router.py @@ -0,0 +1,380 @@ +"""Tests for base /sessions routes.""" +import pytest +from datetime import datetime +from decoy import Decoy +from fastapi import FastAPI +from fastapi.testclient import TestClient +from httpx import AsyncClient + +from tests.helpers import verify_response + +from robot_server.protocols import ( + ProtocolStore, + ProtocolResource, + ProtocolFileType, + ProtocolNotFoundError, + ProtocolNotFound, +) + +from robot_server.sessions.session_view import SessionView, BasicSessionCreateData + +from robot_server.sessions.session_models import ( + BasicSession, + ProtocolSession, + ProtocolSessionCreateData, + ProtocolSessionCreateParams, +) + +from robot_server.sessions.engine_store import EngineStore, EngineConflictError + +from robot_server.sessions.session_store import ( + SessionStore, + SessionNotFoundError, + SessionResource, +) + +from robot_server.sessions.router.base_router import ( + base_router, + SessionNotFound, + SessionAlreadyActive, +) + + +@pytest.fixture(autouse=True) +def setup_app(app: FastAPI) -> None: + """Setup the FastAPI app with /sessions routes.""" + app.include_router(base_router) + + +async def test_create_session( + decoy: Decoy, + session_view: SessionView, + session_store: SessionStore, + engine_store: EngineStore, + unique_id: str, + current_time: datetime, + async_client: AsyncClient, +) -> None: + """It should be able to create a basic session.""" + session = SessionResource( + session_id=unique_id, + created_at=current_time, + create_data=BasicSessionCreateData(), + actions=[], + ) + expected_response = BasicSession( + id=unique_id, + createdAt=current_time, + actions=[], + commands=[], + ) + + decoy.when(engine_store.engine.state_view.commands.get_all()).then_return([]) + + decoy.when( + session_view.as_resource( + create_data=BasicSessionCreateData(), + session_id=unique_id, + created_at=current_time, + ) + ).then_return(session) + + decoy.when( + session_view.as_response(session=session, commands=[]), + ).then_return(expected_response) + + response = await async_client.post( + "/sessions", + json={"data": {"sessionType": "basic"}}, + ) + + verify_response(response, expected_status=201, expected_data=expected_response) + + # TODO(mc, 2021-05-27): spec the initialize method to return actual data + decoy.verify( + await engine_store.create(protocol=None), + session_store.upsert(session=session), + ) + + +async def test_create_protocol_session( + decoy: Decoy, + session_view: SessionView, + session_store: SessionStore, + protocol_store: ProtocolStore, + engine_store: EngineStore, + unique_id: str, + current_time: datetime, + async_client: AsyncClient, +) -> None: + """It should be able to create a protocol session.""" + session = SessionResource( + session_id=unique_id, + created_at=current_time, + create_data=ProtocolSessionCreateData( + createParams=ProtocolSessionCreateParams(protocolId="protocol-id") + ), + actions=[], + ) + protocol = ProtocolResource( + protocol_id="protocol-id", + protocol_type=ProtocolFileType.JSON, + created_at=datetime.now(), + files=[], + ) + expected_response = ProtocolSession( + id=unique_id, + createdAt=current_time, + createParams=ProtocolSessionCreateParams(protocolId="protocol-id"), + actions=[], + commands=[], + ) + + decoy.when(protocol_store.get(protocol_id="protocol-id")).then_return(protocol) + + decoy.when( + session_view.as_resource( + create_data=ProtocolSessionCreateData( + createParams=ProtocolSessionCreateParams(protocolId="protocol-id") + ), + session_id=unique_id, + created_at=current_time, + ) + ).then_return(session) + + decoy.when(engine_store.engine.state_view.commands.get_all()).then_return([]) + + decoy.when( + session_view.as_response(session=session, commands=[]), + ).then_return(expected_response) + + response = await async_client.post( + "/sessions", + json={ + "data": { + "sessionType": "protocol", + "createParams": {"protocolId": "protocol-id"}, + } + }, + ) + + verify_response(response, expected_status=201, expected_data=expected_response) + + # TODO(mc, 2021-05-27): spec the initialize method to return actual data + decoy.verify( + await engine_store.create(protocol=protocol), + session_store.upsert(session=session), + ) + + +async def test_create_protocol_session_missing_protocol( + decoy: Decoy, + session_view: SessionView, + session_store: SessionStore, + protocol_store: ProtocolStore, + engine_store: EngineStore, + unique_id: str, + current_time: datetime, + async_client: AsyncClient, +) -> None: + """It should 404 if a protocol for a session does not exist.""" + error = ProtocolNotFoundError("protocol-id") + + decoy.when(protocol_store.get(protocol_id="protocol-id")).then_raise(error) + + response = await async_client.post( + "/sessions", + json={ + "data": { + "sessionType": "protocol", + "createParams": {"protocolId": "protocol-id"}, + } + }, + ) + + verify_response( + response, + expected_status=404, + expected_errors=ProtocolNotFound(detail=str(error)), + ) + + +async def test_create_session_conflict( + decoy: Decoy, + session_view: SessionView, + session_store: SessionStore, + engine_store: EngineStore, + unique_id: str, + current_time: datetime, + async_client: AsyncClient, +) -> None: + """It should respond with a conflict error if multiple engines are created.""" + session = SessionResource( + session_id=unique_id, + create_data=BasicSessionCreateData(), + created_at=current_time, + actions=[], + ) + + decoy.when( + session_view.as_resource( + create_data=None, + session_id=unique_id, + created_at=current_time, + ) + ).then_return(session) + + decoy.when(await engine_store.create(protocol=None)).then_raise( + EngineConflictError("oh no") + ) + + response = await async_client.post("/sessions") + + verify_response( + response, + expected_status=409, + expected_errors=SessionAlreadyActive(detail="oh no"), + ) + + +def test_get_session( + decoy: Decoy, + session_view: SessionView, + session_store: SessionStore, + engine_store: EngineStore, + client: TestClient, +) -> None: + """It should be able to get a session by ID.""" + created_at = datetime.now() + create_data = BasicSessionCreateData() + session = SessionResource( + session_id="session-id", + create_data=create_data, + created_at=created_at, + actions=[], + ) + expected_response = BasicSession( + id="session-id", + createdAt=created_at, + actions=[], + commands=[], + ) + + decoy.when(session_store.get(session_id="session-id")).then_return(session) + + decoy.when(engine_store.engine.state_view.commands.get_all()).then_return([]) + + decoy.when( + session_view.as_response(session=session, commands=[]), + ).then_return(expected_response) + + response = client.get("/sessions/session-id") + + verify_response(response, expected_status=200, expected_data=expected_response) + + +def test_get_session_with_missing_id( + decoy: Decoy, + session_store: SessionStore, + client: TestClient, +) -> None: + """It should 404 if the session ID does not exist.""" + not_found_error = SessionNotFoundError(session_id="session-id") + + decoy.when(session_store.get(session_id="session-id")).then_raise(not_found_error) + + response = client.get("/sessions/session-id") + + verify_response( + response, + expected_status=404, + expected_errors=SessionNotFound(detail=str(not_found_error)), + ) + + +def test_get_sessions_empty( + decoy: Decoy, + session_store: SessionStore, + client: TestClient, +) -> None: + """It should return an empty collection response when no sessions exist.""" + decoy.when(session_store.get_all()).then_return([]) + + response = client.get("/sessions") + + verify_response(response, expected_status=200, expected_data=[]) + + +def test_get_sessions_not_empty( + decoy: Decoy, + session_view: SessionView, + session_store: SessionStore, + engine_store: EngineStore, + client: TestClient, +) -> None: + """It should return a collection response when a session exists.""" + # TODO(mc, 2021-06-23): add actual multi-session support + created_at_1 = datetime.now() + + session_1 = SessionResource( + session_id="unique-id-1", + create_data=BasicSessionCreateData(), + created_at=created_at_1, + actions=[], + ) + + response_1 = BasicSession( + id="unique-id-1", + createdAt=created_at_1, + actions=[], + commands=[], + ) + + decoy.when(session_store.get_all()).then_return([session_1]) + + decoy.when(engine_store.engine.state_view.commands.get_all()).then_return([]) + + decoy.when( + session_view.as_response(session=session_1, commands=[]), + ).then_return(response_1) + + response = client.get("/sessions") + + verify_response(response, expected_status=200, expected_data=[response_1]) + + +def test_delete_session_by_id( + decoy: Decoy, + session_store: SessionStore, + engine_store: EngineStore, + client: TestClient, +) -> None: + """It should be able to remove a session by ID.""" + response = client.delete("/sessions/unique-id") + + decoy.verify( + engine_store.clear(), + session_store.remove(session_id="unique-id"), + ) + + assert response.status_code == 200 + assert response.json()["data"] is None + + +def test_delete_session_with_bad_id( + decoy: Decoy, + session_store: SessionStore, + client: TestClient, +) -> None: + """It should 404 if the session ID does not exist.""" + key_error = SessionNotFoundError(session_id="session-id") + + decoy.when(session_store.remove(session_id="session-id")).then_raise(key_error) + + response = client.delete("/sessions/session-id") + + verify_response( + response, + expected_status=404, + expected_errors=SessionNotFound(detail=str(key_error)), + ) diff --git a/robot-server/tests/sessions/router/test_commands_router.py b/robot-server/tests/sessions/router/test_commands_router.py new file mode 100644 index 00000000000..3d40907e8e9 --- /dev/null +++ b/robot-server/tests/sessions/router/test_commands_router.py @@ -0,0 +1,128 @@ +"""Tests for the /sessions/.../commands routes.""" +import pytest +import inspect + +from datetime import datetime +from decoy import Decoy, matchers +from fastapi import FastAPI +from fastapi.testclient import TestClient +from httpx import AsyncClient +from typing import Callable, Awaitable + +from tests.helpers import verify_response + +from opentrons.protocol_engine import ( + CommandStatus, + commands as pe_commands, + errors as pe_errors, +) + +from robot_server.service.json_api import ResponseModel +from robot_server.sessions.session_models import BasicSession, SessionCommandSummary +from robot_server.sessions.engine_store import EngineStore +from robot_server.sessions.router.base_router import get_session as real_get_session +from robot_server.sessions.router.commands_router import ( + commands_router, + CommandNotFound, +) + + +@pytest.fixture +def get_session(decoy: Decoy) -> Callable[..., Awaitable[ResponseModel]]: + """Get a mock version of the get_session route handler.""" + get_session: Callable[..., Awaitable[ResponseModel]] = decoy.create_decoy_func( + spec=real_get_session, + ) + # TODO(mc, 2021-07-06): add signature support in decoy + get_session.__signature__ = inspect.signature( # type: ignore[attr-defined] + real_get_session + ) + return get_session + + +@pytest.fixture(autouse=True) +def setup_app( + get_session: Callable[..., Awaitable[ResponseModel]], + app: FastAPI, +) -> None: + """Setup the FastAPI app with commands routes and dependencies.""" + app.dependency_overrides[real_get_session] = get_session + app.include_router(commands_router) + + +async def test_get_session_commands( + decoy: Decoy, + get_session: Callable[..., Awaitable[ResponseModel]], + async_client: AsyncClient, +) -> None: + """It should return a list of all commands in a session.""" + command_summary = SessionCommandSummary( + id="command-id", + commandType="moveToWell", + status=CommandStatus.RUNNING, + ) + + session_response = BasicSession( + id="session-id", + createdAt=datetime(year=2021, month=1, day=1), + actions=[], + commands=[command_summary], + ) + + decoy.when( + await get_session( + sessionId="session-id", + session_view=matchers.Anything(), + session_store=matchers.Anything(), + engine_store=matchers.Anything(), + ), + ).then_return( + ResponseModel(data=session_response) # type: ignore[arg-type] + ) + + response = await async_client.get("/sessions/session-id/commands") + + verify_response(response, expected_status=200, expected_data=[command_summary]) + + +def test_get_session_command_by_id( + decoy: Decoy, + engine_store: EngineStore, + client: TestClient, +) -> None: + """It should return full details about a command by ID.""" + command = pe_commands.MoveToWell( + id="command-id", + status=CommandStatus.RUNNING, + createdAt=datetime(year=2022, month=2, day=2), + data=pe_commands.MoveToWellData(pipetteId="a", labwareId="b", wellName="c"), + ) + + decoy.when(engine_store.engine.state_view.commands.get("command-id")).then_return( + command + ) + + response = client.get("/sessions/session-id/commands/command-id") + + verify_response(response, expected_status=200, expected_data=command) + + +def test_get_session_command_missing_command( + decoy: Decoy, + engine_store: EngineStore, + client: TestClient, +) -> None: + """It should 404 if you attempt to get a non-existent command.""" + key_error = pe_errors.CommandDoesNotExistError("oh no") + + decoy.when(engine_store.engine.state_view.commands.get("command-id")).then_raise( + key_error + ) + + response = client.get("/sessions/session-id/commands/command-id") + + verify_response( + response, + expected_status=404, + expected_errors=CommandNotFound(detail=str(key_error)), + ) diff --git a/robot-server/tests/sessions/test_sessions_router.py b/robot-server/tests/sessions/test_sessions_router.py deleted file mode 100644 index 6a1f02f136a..00000000000 --- a/robot-server/tests/sessions/test_sessions_router.py +++ /dev/null @@ -1,762 +0,0 @@ -"""Tests for the /sessions router.""" -import pytest -from asyncio import AbstractEventLoop -from datetime import datetime -from decoy import Decoy -from fastapi import FastAPI -from fastapi.testclient import TestClient -from httpx import AsyncClient -from typing import AsyncIterator - -from opentrons.protocol_engine import ( - CommandStatus, - commands as pe_commands, - errors as pe_errors, -) - -from robot_server.errors import exception_handlers - -from robot_server.service.task_runner import TaskRunner - -from robot_server.protocols import ( - ProtocolStore, - ProtocolResource, - ProtocolFileType, - ProtocolNotFoundError, - ProtocolNotFound, -) -from robot_server.sessions.session_view import ( - SessionView, - BasicSessionCreateData, -) -from robot_server.sessions.session_models import ( - BasicSession, - ProtocolSession, - ProtocolSessionCreateData, - ProtocolSessionCreateParams, - SessionCommandSummary, -) - -from robot_server.sessions.engine_store import EngineStore, EngineConflictError - -from robot_server.sessions.session_store import ( - SessionStore, - SessionNotFoundError, - SessionResource, -) - -from robot_server.sessions.action_models import ( - SessionAction, - SessionActionType, - SessionActionCreateData, -) - -from robot_server.sessions.router import ( - sessions_router, - SessionNotFound, - SessionAlreadyActive, - SessionActionNotAllowed, - CommandNotFound, - get_session_store, - get_engine_store, - get_protocol_store, - get_unique_id, - get_current_time, -) - -from ..helpers import verify_response - - -@pytest.fixture -def app( - task_runner: TaskRunner, - session_store: SessionStore, - session_view: SessionView, - engine_store: EngineStore, - protocol_store: ProtocolStore, - unique_id: str, - current_time: datetime, -) -> FastAPI: - """Get a FastAPI app with /sessions routes and mocked-out dependencies.""" - app = FastAPI(exception_handlers=exception_handlers) - app.dependency_overrides[TaskRunner] = lambda: task_runner - app.dependency_overrides[SessionView] = lambda: session_view - app.dependency_overrides[get_session_store] = lambda: session_store - app.dependency_overrides[get_engine_store] = lambda: engine_store - app.dependency_overrides[get_protocol_store] = lambda: protocol_store - app.dependency_overrides[get_unique_id] = lambda: unique_id - app.dependency_overrides[get_current_time] = lambda: current_time - app.include_router(sessions_router) - - return app - - -@pytest.fixture -def client(app: FastAPI) -> TestClient: - """Get an TestClient for /sessions route testing.""" - return TestClient(app) - - -@pytest.fixture -async def async_client( - loop: AbstractEventLoop, - app: FastAPI, -) -> AsyncIterator[AsyncClient]: - """Get an asynchronous client for /sessions route testing.""" - async with AsyncClient(app=app, base_url="http://test") as client: - yield client - - -async def test_create_session( - decoy: Decoy, - session_view: SessionView, - session_store: SessionStore, - engine_store: EngineStore, - unique_id: str, - current_time: datetime, - async_client: AsyncClient, -) -> None: - """It should be able to create a basic session.""" - session = SessionResource( - session_id=unique_id, - created_at=current_time, - create_data=BasicSessionCreateData(), - actions=[], - ) - expected_response = BasicSession( - id=unique_id, - createdAt=current_time, - actions=[], - commands=[], - ) - - decoy.when(engine_store.engine.state_view.commands.get_all()).then_return([]) - - decoy.when( - session_view.as_resource( - create_data=BasicSessionCreateData(), - session_id=unique_id, - created_at=current_time, - ) - ).then_return(session) - - decoy.when( - session_view.as_response(session=session, commands=[]), - ).then_return(expected_response) - - response = await async_client.post( - "/sessions", - json={"data": {"sessionType": "basic"}}, - ) - - verify_response(response, expected_status=201, expected_data=expected_response) - - # TODO(mc, 2021-05-27): spec the initialize method to return actual data - decoy.verify( - await engine_store.create(protocol=None), - session_store.upsert(session=session), - ) - - -async def test_create_protocol_session( - decoy: Decoy, - session_view: SessionView, - session_store: SessionStore, - protocol_store: ProtocolStore, - engine_store: EngineStore, - unique_id: str, - current_time: datetime, - async_client: AsyncClient, -) -> None: - """It should be able to create a protocol session.""" - session = SessionResource( - session_id=unique_id, - created_at=current_time, - create_data=ProtocolSessionCreateData( - createParams=ProtocolSessionCreateParams(protocolId="protocol-id") - ), - actions=[], - ) - protocol = ProtocolResource( - protocol_id="protocol-id", - protocol_type=ProtocolFileType.JSON, - created_at=datetime.now(), - files=[], - ) - expected_response = ProtocolSession( - id=unique_id, - createdAt=current_time, - createParams=ProtocolSessionCreateParams(protocolId="protocol-id"), - actions=[], - commands=[], - ) - - decoy.when(protocol_store.get(protocol_id="protocol-id")).then_return(protocol) - - decoy.when( - session_view.as_resource( - create_data=ProtocolSessionCreateData( - createParams=ProtocolSessionCreateParams(protocolId="protocol-id") - ), - session_id=unique_id, - created_at=current_time, - ) - ).then_return(session) - - decoy.when(engine_store.engine.state_view.commands.get_all()).then_return([]) - - decoy.when( - session_view.as_response(session=session, commands=[]), - ).then_return(expected_response) - - response = await async_client.post( - "/sessions", - json={ - "data": { - "sessionType": "protocol", - "createParams": {"protocolId": "protocol-id"}, - } - }, - ) - - verify_response(response, expected_status=201, expected_data=expected_response) - - # TODO(mc, 2021-05-27): spec the initialize method to return actual data - decoy.verify( - await engine_store.create(protocol=protocol), - session_store.upsert(session=session), - ) - - -async def test_create_protocol_session_missing_protocol( - decoy: Decoy, - session_view: SessionView, - session_store: SessionStore, - protocol_store: ProtocolStore, - engine_store: EngineStore, - unique_id: str, - current_time: datetime, - async_client: AsyncClient, -) -> None: - """It should 404 if a protocol for a session does not exist.""" - error = ProtocolNotFoundError("protocol-id") - - decoy.when(protocol_store.get(protocol_id="protocol-id")).then_raise(error) - - response = await async_client.post( - "/sessions", - json={ - "data": { - "sessionType": "protocol", - "createParams": {"protocolId": "protocol-id"}, - } - }, - ) - - verify_response( - response, - expected_status=404, - expected_errors=ProtocolNotFound(detail=str(error)), - ) - - -async def test_create_session_conflict( - decoy: Decoy, - session_view: SessionView, - session_store: SessionStore, - engine_store: EngineStore, - unique_id: str, - current_time: datetime, - async_client: AsyncClient, -) -> None: - """It should respond with a conflict error if multiple engines are created.""" - session = SessionResource( - session_id=unique_id, - create_data=BasicSessionCreateData(), - created_at=current_time, - actions=[], - ) - - decoy.when( - session_view.as_resource( - create_data=None, - session_id=unique_id, - created_at=current_time, - ) - ).then_return(session) - - decoy.when(await engine_store.create(protocol=None)).then_raise( - EngineConflictError("oh no") - ) - - response = await async_client.post("/sessions") - - verify_response( - response, - expected_status=409, - expected_errors=SessionAlreadyActive(detail="oh no"), - ) - - -def test_get_session( - decoy: Decoy, - session_view: SessionView, - session_store: SessionStore, - engine_store: EngineStore, - client: TestClient, -) -> None: - """It should be able to get a session by ID.""" - created_at = datetime.now() - create_data = BasicSessionCreateData() - session = SessionResource( - session_id="session-id", - create_data=create_data, - created_at=created_at, - actions=[], - ) - expected_response = BasicSession( - id="session-id", - createdAt=created_at, - actions=[], - commands=[], - ) - - decoy.when(session_store.get(session_id="session-id")).then_return(session) - - decoy.when(engine_store.engine.state_view.commands.get_all()).then_return([]) - - decoy.when( - session_view.as_response(session=session, commands=[]), - ).then_return(expected_response) - - response = client.get("/sessions/session-id") - - verify_response(response, expected_status=200, expected_data=expected_response) - - -def test_get_session_with_missing_id( - decoy: Decoy, - session_store: SessionStore, - client: TestClient, -) -> None: - """It should 404 if the session ID does not exist.""" - not_found_error = SessionNotFoundError(session_id="session-id") - - decoy.when(session_store.get(session_id="session-id")).then_raise(not_found_error) - - response = client.get("/sessions/session-id") - - verify_response( - response, - expected_status=404, - expected_errors=SessionNotFound(detail=str(not_found_error)), - ) - - -def test_get_sessions_empty( - decoy: Decoy, - session_store: SessionStore, - client: TestClient, -) -> None: - """It should return an empty collection response when no sessions exist.""" - decoy.when(session_store.get_all()).then_return([]) - - response = client.get("/sessions") - - verify_response(response, expected_status=200, expected_data=[]) - - -def test_get_sessions_not_empty( - decoy: Decoy, - session_view: SessionView, - session_store: SessionStore, - engine_store: EngineStore, - client: TestClient, -) -> None: - """It should return a collection response when a session exists.""" - # TODO(mc, 2021-06-23): add actual multi-session support - created_at_1 = datetime.now() - - session_1 = SessionResource( - session_id="unique-id-1", - create_data=BasicSessionCreateData(), - created_at=created_at_1, - actions=[], - ) - - response_1 = BasicSession( - id="unique-id-1", - createdAt=created_at_1, - actions=[], - commands=[], - ) - - decoy.when(session_store.get_all()).then_return([session_1]) - - decoy.when(engine_store.engine.state_view.commands.get_all()).then_return([]) - - decoy.when( - session_view.as_response(session=session_1, commands=[]), - ).then_return(response_1) - - response = client.get("/sessions") - - verify_response(response, expected_status=200, expected_data=[response_1]) - - -def test_delete_session_by_id( - decoy: Decoy, - session_store: SessionStore, - engine_store: EngineStore, - client: TestClient, -) -> None: - """It should be able to remove a session by ID.""" - response = client.delete("/sessions/unique-id") - - decoy.verify( - engine_store.clear(), - session_store.remove(session_id="unique-id"), - ) - - assert response.status_code == 200 - assert response.json()["data"] is None - - -def test_delete_session_with_bad_id( - decoy: Decoy, - session_store: SessionStore, - client: TestClient, -) -> None: - """It should 404 if the session ID does not exist.""" - key_error = SessionNotFoundError(session_id="session-id") - - decoy.when(session_store.remove(session_id="session-id")).then_raise(key_error) - - response = client.delete("/sessions/session-id") - - verify_response( - response, - expected_status=404, - expected_errors=SessionNotFound(detail=str(key_error)), - ) - - -def test_create_session_action( - decoy: Decoy, - task_runner: TaskRunner, - session_view: SessionView, - session_store: SessionStore, - engine_store: EngineStore, - unique_id: str, - current_time: datetime, - client: TestClient, -) -> None: - """It should handle a start input.""" - session_created_at = datetime.now() - - actions = SessionAction( - actionType=SessionActionType.START, - createdAt=current_time, - id=unique_id, - ) - - prev_session = SessionResource( - session_id="unique-id", - create_data=BasicSessionCreateData(), - created_at=session_created_at, - actions=[], - ) - - next_session = SessionResource( - session_id="unique-id", - create_data=BasicSessionCreateData(), - created_at=session_created_at, - actions=[actions], - ) - - decoy.when(session_store.get(session_id="session-id")).then_return(prev_session) - - decoy.when( - session_view.with_action( - session=prev_session, - action_id=unique_id, - action_data=SessionActionCreateData(actionType=SessionActionType.START), - created_at=current_time, - ), - ).then_return((actions, next_session)) - - response = client.post( - "/sessions/session-id/actions", - json={"data": {"actionType": "start"}}, - ) - - verify_response(response, expected_status=201, expected_data=actions) - decoy.verify(task_runner.run(engine_store.runner.run)) - - -def test_create_session_action_with_missing_id( - decoy: Decoy, - session_store: SessionStore, - unique_id: str, - current_time: datetime, - client: TestClient, -) -> None: - """It should 404 if the session ID does not exist.""" - not_found_error = SessionNotFoundError(session_id="session-id") - - decoy.when(session_store.get(session_id="session-id")).then_raise(not_found_error) - - response = client.post( - "/sessions/session-id/actions", - json={"data": {"actionType": "start"}}, - ) - - verify_response( - response, - expected_status=404, - expected_errors=SessionNotFound(detail=str(not_found_error)), - ) - - -@pytest.mark.xfail(strict=True) -def test_create_session_action_without_runner( - decoy: Decoy, - session_view: SessionView, - session_store: SessionStore, - engine_store: EngineStore, - unique_id: str, - current_time: datetime, - client: TestClient, -) -> None: - """It should handle a start input.""" - session_created_at = datetime.now() - - actions = SessionAction( - actionType=SessionActionType.START, - createdAt=current_time, - id=unique_id, - ) - - prev_session = SessionResource( - session_id="unique-id", - create_data=BasicSessionCreateData(), - created_at=session_created_at, - actions=[], - ) - - next_session = SessionResource( - session_id="unique-id", - create_data=BasicSessionCreateData(), - created_at=session_created_at, - actions=[actions], - ) - - decoy.when(session_store.get(session_id="session-id")).then_return(prev_session) - - decoy.when( - session_view.with_action( - session=prev_session, - action_id=unique_id, - action_data=SessionActionCreateData(actionType=SessionActionType.START), - created_at=current_time, - ), - ).then_return((actions, next_session)) - - # TODO(mc, 2021-07-06): in reality, it will be the engine_store.runner - # property access that triggers this raise. Explore adding property access - # rehearsals to decoy - # decoy.when( - # await engine_store.runner.run() - # ).then_raise(EngineMissingError("oh no")) - - response = client.post( - "/sessions/session-id/actions", - json={"data": {"actionType": "start"}}, - ) - - verify_response( - response, - expected_status=400, - expected_errors=SessionActionNotAllowed(detail="oh no"), - ) - - -def test_get_session_commands( - decoy: Decoy, - session_view: SessionView, - session_store: SessionStore, - engine_store: EngineStore, - client: TestClient, -) -> None: - """It should return a list of all commands in a session.""" - session = SessionResource( - session_id="session-id", - create_data=BasicSessionCreateData(), - created_at=datetime(year=2021, month=1, day=1), - actions=[], - ) - - command = pe_commands.MoveToWell( - id="command-id", - status=CommandStatus.RUNNING, - createdAt=datetime(year=2022, month=2, day=2), - data=pe_commands.MoveToWellData(pipetteId="a", labwareId="b", wellName="c"), - ) - - command_summary = SessionCommandSummary( - id="command-id", - commandType="moveToWell", - status=CommandStatus.RUNNING, - ) - - session_response = BasicSession( - id="session-id", - createdAt=datetime(year=2021, month=1, day=1), - actions=[], - commands=[command_summary], - ) - - decoy.when(session_store.get(session_id="session-id")).then_return(session) - - decoy.when(engine_store.engine.state_view.commands.get_all()).then_return([command]) - - decoy.when( - session_view.as_response(session=session, commands=[command]), - ).then_return(session_response) - - response = client.get("/sessions/session-id/commands") - - verify_response(response, expected_status=200, expected_data=[command_summary]) - - -def test_get_session_commands_missing_session( - decoy: Decoy, - session_view: SessionView, - session_store: SessionStore, - engine_store: EngineStore, - client: TestClient, -) -> None: - """It should 404 if you attempt to get the commands for a non-existent session.""" - key_error = SessionNotFoundError(session_id="session-id") - - decoy.when(session_store.get(session_id="session-id")).then_raise(key_error) - - response = client.get("/sessions/session-id/commands") - - verify_response( - response, - expected_status=404, - expected_errors=SessionNotFound(detail=str(key_error)), - ) - - -def test_get_session_command_by_id( - decoy: Decoy, - session_view: SessionView, - session_store: SessionStore, - engine_store: EngineStore, - client: TestClient, -) -> None: - """It should return full details about a command by ID.""" - session = SessionResource( - session_id="session-id", - create_data=BasicSessionCreateData(), - created_at=datetime(year=2021, month=1, day=1), - actions=[], - ) - - session_response = BasicSession( - id="session-id", - createdAt=datetime(year=2021, month=1, day=1), - actions=[], - commands=[], - ) - - command = pe_commands.MoveToWell( - id="command-id", - status=CommandStatus.RUNNING, - createdAt=datetime(year=2022, month=2, day=2), - data=pe_commands.MoveToWellData(pipetteId="a", labwareId="b", wellName="c"), - ) - - decoy.when(session_store.get(session_id="session-id")).then_return(session) - - decoy.when(engine_store.engine.state_view.commands.get_all()).then_return([command]) - - decoy.when( - session_view.as_response(session=session, commands=[command]), - ).then_return(session_response) - - decoy.when(engine_store.engine.state_view.commands.get("command-id")).then_return( - command - ) - - response = client.get("/sessions/session-id/commands/command-id") - - verify_response(response, expected_status=200, expected_data=command) - - -def test_get_session_command_missing_session( - decoy: Decoy, - session_view: SessionView, - session_store: SessionStore, - engine_store: EngineStore, - client: TestClient, -) -> None: - """It should 404 if you attempt to get a command for a non-existent session.""" - key_error = SessionNotFoundError(session_id="session-id") - - decoy.when(session_store.get(session_id="session-id")).then_raise(key_error) - - response = client.get("/sessions/session-id/commands/command-id") - - verify_response( - response, - expected_status=404, - expected_errors=SessionNotFound(detail=str(key_error)), - ) - - -def test_get_session_command_missing_command( - decoy: Decoy, - session_view: SessionView, - session_store: SessionStore, - engine_store: EngineStore, - client: TestClient, -) -> None: - """It should 404 if you attempt to get a non-existent command.""" - session = SessionResource( - session_id="session-id", - create_data=BasicSessionCreateData(), - created_at=datetime(year=2021, month=1, day=1), - actions=[], - ) - - session_response = BasicSession( - id="session-id", - createdAt=datetime(year=2021, month=1, day=1), - actions=[], - commands=[], - ) - - key_error = pe_errors.CommandDoesNotExistError("oh no") - - decoy.when(session_store.get(session_id="session-id")).then_return(session) - - decoy.when(engine_store.engine.state_view.commands.get_all()).then_return([]) - - decoy.when( - session_view.as_response(session=session, commands=[]), - ).then_return(session_response) - - decoy.when(engine_store.engine.state_view.commands.get("command-id")).then_raise( - key_error - ) - - response = client.get("/sessions/session-id/commands/command-id") - - verify_response( - response, - expected_status=404, - expected_errors=CommandNotFound(detail=str(key_error)), - )