From 66c4b1a3c1a7ff664881bd559959937cca607004 Mon Sep 17 00:00:00 2001 From: Sven Marcus Date: Fri, 10 Feb 2023 09:29:27 +0100 Subject: [PATCH] Introduce abstraction for broadway socket communication --- .gitignore | 1 + ocrd_monitor/ocrdbrowser/__init__.py | 4 ++ ocrd_monitor/ocrdbrowser/_browser.py | 17 ++++- ocrd_monitor/ocrdbrowser/_docker.py | 8 ++- ocrd_monitor/ocrdbrowser/_subprocess.py | 8 ++- ocrd_monitor/ocrdbrowser/_websocketchannel.py | 60 ++++++++++++++++ ocrd_monitor/ocrdmonitor/server/proxy.py | 63 +--------------- ocrd_monitor/ocrdmonitor/server/redirect.py | 35 +++++---- ocrd_monitor/ocrdmonitor/server/workspaces.py | 21 +++--- ocrd_monitor/requirements.dev.txt | 1 + .../tests/fakes/_backgroundprocess.py | 2 +- .../tests/ocrdbrowser/browserdoubles.py | 71 +++++++++++++++++++ ocrd_monitor/tests/ocrdbrowser/test_launch.py | 53 ++------------ .../ocrdbrowser/test_websocketchannel.py | 44 ++++++++++++ .../server/test_workspace_endpoint.py | 46 ++++-------- .../tests/ocrdmonitor/test_redirect.py | 34 +++++---- 16 files changed, 273 insertions(+), 195 deletions(-) create mode 100644 ocrd_monitor/ocrdbrowser/_websocketchannel.py create mode 100644 ocrd_monitor/tests/ocrdbrowser/browserdoubles.py create mode 100644 ocrd_monitor/tests/ocrdbrowser/test_websocketchannel.py diff --git a/.gitignore b/.gitignore index bc4888e..f23362c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ *~ authorized_keys __pycache__/ +.python-version diff --git a/ocrd_monitor/ocrdbrowser/__init__.py b/ocrd_monitor/ocrdbrowser/__init__.py index ec865cb..6b96eec 100644 --- a/ocrd_monitor/ocrdbrowser/__init__.py +++ b/ocrd_monitor/ocrdbrowser/__init__.py @@ -1,5 +1,7 @@ from . import _workspace as workspace from ._browser import ( + Channel, + ChannelClosed, OcrdBrowser, OcrdBrowserFactory, filter_owned, @@ -14,6 +16,8 @@ from ._subprocess import SubProcessOcrdBrowserFactory __all__ = [ + "Channel", + "ChannelClosed", "DockerOcrdBrowserFactory", "NoPortsAvailableError", "OcrdBrowser", diff --git a/ocrd_monitor/ocrdbrowser/_browser.py b/ocrd_monitor/ocrdbrowser/_browser.py index ce314b1..a7f4818 100644 --- a/ocrd_monitor/ocrdbrowser/_browser.py +++ b/ocrd_monitor/ocrdbrowser/_browser.py @@ -1,7 +1,19 @@ from __future__ import annotations from os import path -from typing import Protocol +from typing import AsyncContextManager, Protocol + + +class ChannelClosed(RuntimeError): + pass + + +class Channel(Protocol): + async def receive_bytes(self) -> bytes: + ... + + async def send_bytes(self, data: bytes) -> None: + ... class OcrdBrowser(Protocol): @@ -20,6 +32,9 @@ def start(self) -> None: def stop(self) -> None: ... + def open_channel(self) -> AsyncContextManager[Channel]: + ... + class OcrdBrowserFactory(Protocol): def __call__(self, owner: str, workspace_path: str) -> OcrdBrowser: diff --git a/ocrd_monitor/ocrdbrowser/_docker.py b/ocrd_monitor/ocrdbrowser/_docker.py index 40c46b7..91aeb3c 100644 --- a/ocrd_monitor/ocrdbrowser/_docker.py +++ b/ocrd_monitor/ocrdbrowser/_docker.py @@ -2,10 +2,11 @@ import os.path as path import subprocess as sp -from typing import Any +from typing import Any, AsyncContextManager -from ._browser import OcrdBrowser +from ._browser import Channel, OcrdBrowser from ._port import Port +from ._websocketchannel import WebSocketChannel _docker_run = "docker run --rm -d --name {} -v {}:/data -p {}:8085 ocrd-browser:latest" _docker_stop = "docker stop {}" @@ -48,6 +49,9 @@ def stop(self) -> None: self._port.release() self.id = None + def open_channel(self) -> AsyncContextManager[Channel]: + return WebSocketChannel(self.address() + "/socket") + def _container_name(self) -> str: workspace = path.basename(self.workspace()) return f"ocrd-browser-{self.owner()}-{workspace}" diff --git a/ocrd_monitor/ocrdbrowser/_subprocess.py b/ocrd_monitor/ocrdbrowser/_subprocess.py index e59dc46..c06c12c 100644 --- a/ocrd_monitor/ocrdbrowser/_subprocess.py +++ b/ocrd_monitor/ocrdbrowser/_subprocess.py @@ -3,10 +3,11 @@ import os import subprocess as sp from shutil import which -from typing import Optional +from typing import AsyncContextManager, Optional -from ._browser import OcrdBrowser +from ._browser import Channel, OcrdBrowser from ._port import Port +from ._websocketchannel import WebSocketChannel BROADWAY_BASE_PORT = 8080 @@ -65,6 +66,9 @@ def stop(self) -> None: self._process.terminate() self._localport.release() + def open_channel(self) -> AsyncContextManager[Channel]: + return WebSocketChannel(self.address() + "/socket") + class SubProcessOcrdBrowserFactory: def __init__(self, available_ports: set[int]) -> None: diff --git a/ocrd_monitor/ocrdbrowser/_websocketchannel.py b/ocrd_monitor/ocrdbrowser/_websocketchannel.py new file mode 100644 index 0000000..0550fe2 --- /dev/null +++ b/ocrd_monitor/ocrdbrowser/_websocketchannel.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from types import TracebackType +from typing import Type, cast + +from websockets import client +from websockets.exceptions import ConnectionClosed +from websockets.legacy.client import WebSocketClientProtocol +from websockets.typing import Subprotocol + +from ._browser import ChannelClosed + + +class WebSocketChannel: + def __init__(self, url: str) -> None: + url = url.replace("http://", "ws://").replace("https://", "wss://") + self._connection = client.connect( + url, + subprotocols=[Subprotocol("broadway")], + open_timeout=None, + ping_timeout=None, + close_timeout=None, + max_size=2**32, + ) + + self._open_connection: WebSocketClientProtocol | None = None + + async def __aenter__(self) -> "WebSocketChannel": + self._open_connection = await self._connection + return self + + async def __aexit__( + self, + exc_type: Type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + if not self._open_connection: + return + + await self._open_connection.close() + self._open_connection = None + + async def receive_bytes(self) -> bytes: + try: + if not self._open_connection: + return bytes() + + return cast(bytes, await self._open_connection.recv()) + except ConnectionClosed: + raise ChannelClosed() + + async def send_bytes(self, data: bytes) -> None: + try: + if not self._open_connection: + return + + await self._open_connection.send(data) + except ConnectionClosed: + raise ChannelClosed() diff --git a/ocrd_monitor/ocrdmonitor/server/proxy.py b/ocrd_monitor/ocrdmonitor/server/proxy.py index 2569c12..6b0589e 100644 --- a/ocrd_monitor/ocrdmonitor/server/proxy.py +++ b/ocrd_monitor/ocrdmonitor/server/proxy.py @@ -1,64 +1,15 @@ from __future__ import annotations import asyncio -from types import TracebackType -from typing import Protocol, Sequence, Type, cast from fastapi import Response +from ocrdbrowser import Channel from requests import request -from websockets import client -from websockets.legacy.client import WebSocketClientProtocol -from websockets.typing import Subprotocol -from .redirect import WorkspaceRedirect +from .redirect import BrowserRedirect -class WebSocketAdapter: - def __init__( - self, url: str, protocols: Sequence[Subprotocol] | None = None - ) -> None: - url = url.replace("http://", "ws://").replace("https://", "wss://") - self._connection = client.connect( - url, - subprotocols=protocols, - open_timeout=None, - ping_timeout=None, - close_timeout=None, - max_size=2**32, - ) - - self._open_connection: WebSocketClientProtocol | None = None - - async def __aenter__(self) -> "WebSocketAdapter": - self._open_connection = await self._connection - return self - - async def __aexit__( - self, - exc_type: Type[BaseException] | None, - exc_value: BaseException | None, - traceback: TracebackType | None, - ) -> None: - if not self._open_connection: - return - - await self._open_connection.close() - self._open_connection = None - - async def receive_bytes(self) -> bytes: - if not self._open_connection: - return bytes() - - return cast(bytes, await self._open_connection.recv()) - - async def send_bytes(self, data: bytes) -> None: - if not self._open_connection: - return - - await self._open_connection.send(data) - - -def forward(redirect: WorkspaceRedirect, url: str) -> Response: +def forward(redirect: BrowserRedirect, url: str) -> Response: redirect_url = redirect.redirect_url(url) response = request("GET", redirect_url, allow_redirects=False) return Response( @@ -68,14 +19,6 @@ def forward(redirect: WorkspaceRedirect, url: str) -> Response: ) -class Channel(Protocol): - async def receive_bytes(self) -> bytes: - ... - - async def send_bytes(self, data: bytes) -> None: - ... - - async def tunnel( source: Channel, target: Channel, diff --git a/ocrd_monitor/ocrdmonitor/server/redirect.py b/ocrd_monitor/ocrdmonitor/server/redirect.py index 781945d..226c6e6 100644 --- a/ocrd_monitor/ocrdmonitor/server/redirect.py +++ b/ocrd_monitor/ocrdmonitor/server/redirect.py @@ -1,7 +1,9 @@ from __future__ import annotations from pathlib import Path -from typing import Callable, Protocol +from typing import Callable + +from ocrdbrowser import OcrdBrowser def removeprefix(string: str, prefix: str) -> str: @@ -33,15 +35,14 @@ def __removesuffix(suffix: str) -> str: return _removesuffix(suffix) -class WorkspaceServer(Protocol): - def address(self) -> str: - ... - - -class WorkspaceRedirect: - def __init__(self, workspace: Path, server: WorkspaceServer) -> None: +class BrowserRedirect: + def __init__(self, workspace: Path, browser: OcrdBrowser) -> None: self._workspace = workspace - self._server = server + self._browser = browser + + @property + def browser(self) -> OcrdBrowser: + return self._browser @property def workspace(self) -> Path: @@ -50,7 +51,7 @@ def workspace(self) -> Path: def redirect_url(self, url: str) -> str: url = removeprefix(url, str(self._workspace)) url = removeprefix(url, "/") - address = removesuffix(self._server.address(), "/") + address = removesuffix(self._browser.address(), "/") return removesuffix(address + "/" + url, "/") def matches(self, path: str) -> bool: @@ -59,16 +60,16 @@ def matches(self, path: str) -> bool: class RedirectMap: def __init__(self) -> None: - self._redirects: dict[str, set[WorkspaceRedirect]] = {} + self._redirects: dict[str, set[BrowserRedirect]] = {} def add( - self, session_id: str, workspace: Path, server: WorkspaceServer - ) -> WorkspaceRedirect: + self, session_id: str, workspace: Path, server: OcrdBrowser + ) -> BrowserRedirect: try: redirect = self.get(session_id, workspace) return redirect except KeyError: - redirect = WorkspaceRedirect(workspace, server) + redirect = BrowserRedirect(workspace, server) self._redirects.setdefault(session_id, set()).add(redirect) return redirect @@ -76,7 +77,7 @@ def remove(self, session_id: str, workspace: Path) -> None: redirect = self.get(session_id, workspace) self._redirects[session_id].remove(redirect) - def get(self, session_id: str, workspace: Path) -> WorkspaceRedirect: + def get(self, session_id: str, workspace: Path) -> BrowserRedirect: redirect = next( ( redirect @@ -88,9 +89,7 @@ def get(self, session_id: str, workspace: Path) -> WorkspaceRedirect: return self._instance_or_raise(redirect) - def _instance_or_raise( - self, redirect: WorkspaceRedirect | None - ) -> WorkspaceRedirect: + def _instance_or_raise(self, redirect: BrowserRedirect | None) -> BrowserRedirect: if redirect is None: raise KeyError("No redirect found") diff --git a/ocrd_monitor/ocrdmonitor/server/workspaces.py b/ocrd_monitor/ocrdmonitor/server/workspaces.py index cedc8fa..65eb055 100644 --- a/ocrd_monitor/ocrdmonitor/server/workspaces.py +++ b/ocrd_monitor/ocrdmonitor/server/workspaces.py @@ -4,15 +4,14 @@ from pathlib import Path import ocrdbrowser +import ocrdmonitor.server.proxy as proxy from fastapi import APIRouter, Cookie, Request, Response, WebSocket from fastapi.templating import Jinja2Templates -from ocrdbrowser import OcrdBrowser, OcrdBrowserFactory, workspace +from ocrdbrowser import ChannelClosed, OcrdBrowser, OcrdBrowserFactory, workspace +from ocrdmonitor.server.redirect import RedirectMap from requests.exceptions import ConnectionError -from websockets.typing import Subprotocol from websockets.exceptions import ConnectionClosedError - -import ocrdmonitor.server.proxy as proxy -from ocrdmonitor.server.redirect import RedirectMap +from websockets.typing import Subprotocol def create_workspaces( @@ -74,17 +73,13 @@ async def workspace_socket_proxy( websocket: WebSocket, workspace: Path, session_id: str = Cookie(default=None) ) -> None: redirect = redirects.get(session_id, workspace) - url = redirect.redirect_url(str(workspace / "socket")) await websocket.accept(subprotocol="broadway") - - async with proxy.WebSocketAdapter( - url, [Subprotocol("broadway")] - ) as broadway_socket: + async with redirect.browser.open_channel() as channel: try: while True: - await proxy.tunnel(broadway_socket, websocket) - except ConnectionClosedError: - _stop_browsers_in_workspace(workspace, session_id) + await proxy.tunnel(channel, websocket) + except ChannelClosed: + redirect.browser.stop() def _launch_browser(session_id: str, workspace: Path) -> OcrdBrowser: browser = ocrdbrowser.launch( diff --git a/ocrd_monitor/requirements.dev.txt b/ocrd_monitor/requirements.dev.txt index bed8991..5708521 100644 --- a/ocrd_monitor/requirements.dev.txt +++ b/ocrd_monitor/requirements.dev.txt @@ -3,4 +3,5 @@ httpx>=0.23.1 mypy>=0.982 nox>=2022.11.21 pytest>=7.2.0 +pytest-asyncio>=0.20.3 testcontainers>=3.7.0 \ No newline at end of file diff --git a/ocrd_monitor/tests/fakes/_backgroundprocess.py b/ocrd_monitor/tests/fakes/_backgroundprocess.py index 07a4332..159682e 100644 --- a/ocrd_monitor/tests/fakes/_backgroundprocess.py +++ b/ocrd_monitor/tests/fakes/_backgroundprocess.py @@ -4,7 +4,7 @@ class AnyFunc(Protocol): - def __call__(self, *args, **kwargs) -> Any: + def __call__(self, *args: Any, **kwargs: Any) -> Any: ... diff --git a/ocrd_monitor/tests/ocrdbrowser/browserdoubles.py b/ocrd_monitor/tests/ocrdbrowser/browserdoubles.py new file mode 100644 index 0000000..40d05c2 --- /dev/null +++ b/ocrd_monitor/tests/ocrdbrowser/browserdoubles.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from contextlib import asynccontextmanager +from textwrap import dedent +from typing import AsyncGenerator + +from ocrdbrowser import Channel, OcrdBrowser + + +class ChannelDummy: + async def send_bytes(self, data: bytes) -> None: + pass + + async def receive_bytes(self) -> bytes: + return bytes() + + +class BrowserSpy: + def __init__( + self, + owner: str = "", + workspace_path: str = "", + address: str = "", + running: bool = False, + channel: Channel | None = None, + ) -> None: + self.running = running + self._address = address + self.owner_name = owner + self.workspace_path = workspace_path + self.channel = channel or ChannelDummy() + + def address(self) -> str: + return self._address + + def workspace(self) -> str: + return self.workspace_path + + def owner(self) -> str: + return self.owner_name + + def start(self) -> None: + self.running = True + + def stop(self) -> None: + self.running = False + + @asynccontextmanager + async def open_channel(self) -> AsyncGenerator[Channel, None]: + yield self.channel + + def __repr__(self) -> str: + return dedent( + f""" + BrowserSpy: + workspace: {self.workspace()} + owner: {self.owner()} + running: {self.running} + """ + ) + + +class BrowserSpyFactory: + def __init__(self, *processes: BrowserSpy) -> None: + self.proc_iter = iter(processes) + + def __call__(self, owner: str, workspace_path: str) -> OcrdBrowser: + browser = next(self.proc_iter, BrowserSpy()) + browser.owner_name = owner + browser.workspace_path = workspace_path + return browser diff --git a/ocrd_monitor/tests/ocrdbrowser/test_launch.py b/ocrd_monitor/tests/ocrdbrowser/test_launch.py index 263895d..1f6b8d3 100644 --- a/ocrd_monitor/tests/ocrdbrowser/test_launch.py +++ b/ocrd_monitor/tests/ocrdbrowser/test_launch.py @@ -1,58 +1,13 @@ -from textwrap import dedent from typing import cast import ocrdbrowser - - -class BrowserSpy: - def __init__( - self, owner: str = "", workspace_path: str = "", running: bool = False - ) -> None: - self.running = running - self.owner_name = owner - self.workspace_path = workspace_path - - def address(self) -> str: - return "" - - def workspace(self) -> str: - return self.workspace_path - - def owner(self) -> str: - return self.owner_name - - def start(self) -> None: - self.running = True - - def stop(self) -> None: - self.running = False - - def __repr__(self) -> str: - return dedent( - f""" - BrowserSpy: - workspace: {self.workspace()} - owner: {self.owner()} - running: {self.running} - """ - ) - - -class browser_spy_factory: - def __init__(self, *processes: BrowserSpy) -> None: - self.proc_iter = iter(processes) - - def __call__(self, owner: str, workspace_path: str) -> ocrdbrowser.OcrdBrowser: - browser = next(self.proc_iter, BrowserSpy()) - browser.owner_name = owner - browser.workspace_path = workspace_path - return browser +from tests.ocrdbrowser.browserdoubles import BrowserSpy, BrowserSpyFactory def test__workspace__launch__spawns_new_ocrd_browser() -> None: owner = "the-owner" workspace = "path/to/workspace" - process = ocrdbrowser.launch(workspace, owner, browser_spy_factory()) + process = ocrdbrowser.launch(workspace, owner, BrowserSpyFactory()) process = cast(BrowserSpy, process) assert process.running is True @@ -61,7 +16,7 @@ def test__workspace__launch__spawns_new_ocrd_browser() -> None: def test__workspace__launch_for_different_owners__both_processes_running() -> None: - factory = browser_spy_factory() + factory = BrowserSpyFactory() first_process = ocrdbrowser.launch("first-path", "first-owner", factory) second_process = ocrdbrowser.launch( @@ -77,7 +32,7 @@ def test__workspace__launch_for_different_owners__both_processes_running() -> No def test__workspace__launch_for_same_owner_and_workspace__does_not_start_new_process() -> None: owner = "the-owner" workspace = "the-workspace" - factory = browser_spy_factory() + factory = BrowserSpyFactory() first_process = ocrdbrowser.launch(workspace, owner, factory) second_process = ocrdbrowser.launch(workspace, owner, factory, {first_process}) diff --git a/ocrd_monitor/tests/ocrdbrowser/test_websocketchannel.py b/ocrd_monitor/tests/ocrdbrowser/test_websocketchannel.py new file mode 100644 index 0000000..985b77d --- /dev/null +++ b/ocrd_monitor/tests/ocrdbrowser/test_websocketchannel.py @@ -0,0 +1,44 @@ +import asyncio +from typing import Any, Coroutine, Protocol + +import pytest +from ocrdbrowser import Channel, ChannelClosed +from ocrdbrowser._websocketchannel import WebSocketChannel + +from tests.fakes import BackgroundProcess, broadway_fake + + +async def send(channel: Channel) -> None: + return await channel.send_bytes(bytes()) + + +async def receive(channel: Channel) -> bytes: + return await channel.receive_bytes() + + +class CommunicationFunction(Protocol): + def __call__(self, channel: Channel) -> Coroutine[Any, Any, Any]: + ... + + +@pytest.mark.asyncio +@pytest.mark.parametrize("comm_function", [send, receive]) +async def test__channel__losing_connection_while_communicating__raises_channel_closed( + comm_function: CommunicationFunction, +) -> None: + server = broadway_fake("") + await asyncio.to_thread(server.launch) + + with pytest.raises(ChannelClosed): + async with WebSocketChannel("http://localhost:7000/socket") as channel: + await shutdown_server(server) + await comm_function(channel) + + +async def shutdown_server(server: BackgroundProcess) -> None: + await asyncio.to_thread(server.shutdown) + + # TODO: + # it seems we need a short delay for the socket connection to close + # can we find a better solution for that? + await asyncio.sleep(1) diff --git a/ocrd_monitor/tests/ocrdmonitor/server/test_workspace_endpoint.py b/ocrd_monitor/tests/ocrdmonitor/server/test_workspace_endpoint.py index dc8a743..edc5fe2 100644 --- a/ocrd_monitor/tests/ocrdmonitor/server/test_workspace_endpoint.py +++ b/ocrd_monitor/tests/ocrdmonitor/server/test_workspace_endpoint.py @@ -1,14 +1,11 @@ from __future__ import annotations -import time -from typing import Iterator - import pytest from fastapi.testclient import TestClient -from ocrdbrowser import OcrdBrowser, OcrdBrowserFactory +from ocrdbrowser import ChannelClosed, OcrdBrowserFactory from ocrdmonitor.server.settings import OcrdBrowserSettings -from tests.fakes import OcrdBrowserFake -from tests.ocrdbrowser.test_launch import BrowserSpy, browser_spy_factory + +from tests.ocrdbrowser.browserdoubles import BrowserSpy, BrowserSpyFactory, ChannelDummy from tests.ocrdmonitor.server import scraping from tests.ocrdmonitor.server.fixtures import WORKSPACE_DIR @@ -18,29 +15,12 @@ def browser_spy(monkeypatch: pytest.MonkeyPatch) -> BrowserSpy: browser_spy = BrowserSpy() def factory(_: OcrdBrowserSettings) -> OcrdBrowserFactory: - return browser_spy_factory(browser_spy) + return BrowserSpyFactory(browser_spy) monkeypatch.setattr(OcrdBrowserSettings, "factory", factory) return browser_spy -@pytest.fixture -def browser_fake(monkeypatch: pytest.MonkeyPatch) -> Iterator[OcrdBrowserFake]: - fake = OcrdBrowserFake() - - def factory(_: OcrdBrowserSettings) -> OcrdBrowserFactory: - def _factory(owner: str, workspace_path: str) -> OcrdBrowser: - fake.set_owner_and_workspace(owner, workspace_path) - return fake - - return _factory - - monkeypatch.setattr(OcrdBrowserSettings, "factory", factory) - yield fake - - fake.broadway_server.shutdown() - - def test__workspaces__shows_the_workspace_names_starting_from_workspace_root( app: TestClient, ) -> None: @@ -60,17 +40,21 @@ def test__open_workspace__passes_full_workspace_path_to_ocrdbrowser( assert browser_spy.workspace() == str(WORKSPACE_DIR / "a_workspace") -@pytest.mark.skip( - "We get an unexpected exception that doesn't happen in production and is likely caused by the FastAPI TestClient" -) def test__opened_workspace__when_socket_disconnects_on_broadway_side_while_viewing__shuts_down_browser( - browser_fake: OcrdBrowserFake, + browser_spy: BrowserSpy, app: TestClient, ) -> None: + class DisconnectingChannel: + async def send_bytes(self, data: bytes) -> None: + raise ChannelClosed() + + async def receive_bytes(self) -> bytes: + raise ChannelClosed() + + browser_spy.channel = DisconnectingChannel() _ = app.get("/workspaces/open/a_workspace") with app.websocket_connect("/workspaces/view/a_workspace/socket"): - time.sleep(1) - browser_fake.broadway_server.shutdown() + pass - assert browser_fake.is_running is False + assert browser_spy.running is False diff --git a/ocrd_monitor/tests/ocrdmonitor/test_redirect.py b/ocrd_monitor/tests/ocrdmonitor/test_redirect.py index 18abd15..e5b47e1 100644 --- a/ocrd_monitor/tests/ocrdmonitor/test_redirect.py +++ b/ocrd_monitor/tests/ocrdmonitor/test_redirect.py @@ -1,15 +1,13 @@ from pathlib import Path import pytest -from ocrdmonitor.server.redirect import WorkspaceRedirect +from ocrdmonitor.server.redirect import BrowserRedirect +from tests.ocrdbrowser.browserdoubles import BrowserSpy -class ServerStub: - def __init__(self, address: str) -> None: - self._address = address - def address(self) -> str: - return self._address +def server_stub(address: str) -> BrowserSpy: + return BrowserSpy(address=address) SERVER_ADDRESS = "http://example.com:8080" @@ -17,8 +15,8 @@ def address(self) -> str: def test__redirect_for_empty_url_returns_server_address() -> None: workspace = Path("path/to/workspace") - browser = ServerStub("http://example.com:8080") - sut = WorkspaceRedirect(workspace, browser) + browser = server_stub("http://example.com:8080") + sut = BrowserRedirect(workspace, browser) assert sut.redirect_url("") == browser.address() @@ -30,40 +28,40 @@ def test__redirect_to_file_in_workspace__returns_server_slash_file( filename: str, ) -> None: workspace = Path("path/to/workspace") - browser = ServerStub(address) - sut = WorkspaceRedirect(workspace, browser) + browser = server_stub(address) + sut = BrowserRedirect(workspace, browser) assert sut.redirect_url(filename) == url(address, filename) def test__redirect_from_workspace__returns_server_address() -> None: workspace = Path("path/to/workspace") - browser = ServerStub("http://example.com:8080") - sut = WorkspaceRedirect(workspace, browser) + browser = server_stub("http://example.com:8080") + sut = BrowserRedirect(workspace, browser) assert sut.redirect_url(str(workspace)) == browser.address() def test__redirect_with_workspace__is_a_match() -> None: workspace = Path("path/to/workspace") - browser = ServerStub("") - sut = WorkspaceRedirect(workspace, browser) + browser = server_stub("") + sut = BrowserRedirect(workspace, browser) assert sut.matches(str(workspace)) is True def test__an_empty_path__does_not_match() -> None: workspace = Path("path/to/workspace") - browser = ServerStub("") - sut = WorkspaceRedirect(workspace, browser) + browser = server_stub("") + sut = BrowserRedirect(workspace, browser) assert sut.matches("") is False def test__a_path_starting_with_workspace__is_a_match() -> None: workspace = Path("path/to/workspace") - browser = ServerStub("") - sut = WorkspaceRedirect(workspace, browser) + browser = server_stub("") + sut = BrowserRedirect(workspace, browser) sub_path = workspace / "sub" / "path" / "file.txt" assert sut.matches(str(sub_path)) is True