diff --git a/src/ahriman/application/ahriman.py b/src/ahriman/application/ahriman.py index 75480161..8801f0e6 100644 --- a/src/ahriman/application/ahriman.py +++ b/src/ahriman/application/ahriman.py @@ -27,11 +27,10 @@ from ahriman import version from ahriman.application import handlers from ahriman.models.build_status import BuildStatusEnum from ahriman.models.sign_settings import SignSettings +from ahriman.models.user_access import UserAccess # pylint thinks it is bad idea, but get the fuck off -from ahriman.models.user_access import UserAccess - SubParserAction = argparse._SubParsersAction # pylint: disable=protected-access @@ -77,7 +76,7 @@ def _parser() -> argparse.ArgumentParser: _set_status_update_parser(subparsers) _set_sync_parser(subparsers) _set_update_parser(subparsers) - _set_web_parser(subparsers) + _set_web_parser(subparsers, parser) return parser @@ -359,15 +358,16 @@ def _set_update_parser(root: SubParserAction) -> argparse.ArgumentParser: return parser -def _set_web_parser(root: SubParserAction) -> argparse.ArgumentParser: +def _set_web_parser(root: SubParserAction, parent: argparse.ArgumentParser) -> argparse.ArgumentParser: """ add parser for web subcommand :param root: subparsers for the commands + :param parent: command line parser for the application :return: created argument parser """ parser = root.add_parser("web", help="start web server", description="start web server", formatter_class=argparse.ArgumentDefaultsHelpFormatter) - parser.set_defaults(handler=handlers.Web, lock=None, no_report=True) + parser.set_defaults(handler=handlers.Web, lock=None, no_report=True, parser=parent) return parser diff --git a/src/ahriman/application/handlers/web.py b/src/ahriman/application/handlers/web.py index 837dce4e..086d231d 100644 --- a/src/ahriman/application/handlers/web.py +++ b/src/ahriman/application/handlers/web.py @@ -23,6 +23,7 @@ from typing import Type from ahriman.application.handlers.handler import Handler from ahriman.core.configuration import Configuration +from ahriman.core.spawn import Spawn class Web(Handler): @@ -38,6 +39,11 @@ class Web(Handler): :param architecture: repository architecture :param configuration: configuration instance """ + # we are using local import for optional dependencies from ahriman.web.web import run_server, setup_service - application = setup_service(architecture, configuration) + + spawner = Spawn(args.parser, architecture, configuration) + spawner.start() + + application = setup_service(architecture, configuration, spawner) run_server(application) diff --git a/src/ahriman/core/spawn.py b/src/ahriman/core/spawn.py new file mode 100644 index 00000000..df46cf9d --- /dev/null +++ b/src/ahriman/core/spawn.py @@ -0,0 +1,151 @@ +# +# Copyright (c) 2021 ahriman team. +# +# This file is part of ahriman +# (see https://github.com/arcan1s/ahriman). +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +from __future__ import annotations + +import argparse +import logging +import uuid + +from multiprocessing import Process, Queue +from threading import Lock, Thread +from typing import Callable, Dict, Iterable, Optional, Tuple + +from ahriman.core.configuration import Configuration + + +class Spawn(Thread): + """ + helper to spawn external ahriman process + MUST NOT be used directly, the only one usage allowed is to spawn process from web services + :ivar active: map of active child processes required to avoid zombies + :ivar architecture: repository architecture + :ivar configuration: configuration instance + :ivar logger: spawner logger + :ivar queue: multiprocessing queue to read updates from processes + """ + + def __init__(self, args_parser: argparse.ArgumentParser, architecture: str, configuration: Configuration) -> None: + """ + default constructor + :param args_parser: command line parser for the application + :param architecture: repository architecture + :param configuration: configuration instance + """ + Thread.__init__(self, name="spawn") + self.architecture = architecture + self.args_parser = args_parser + self.configuration = configuration + self.logger = logging.getLogger("http") + + self.lock = Lock() + self.active: Dict[str, Process] = {} + # stupid pylint does not know that it is possible + self.queue: Queue[Tuple[str, Optional[Exception]]] = Queue() # pylint: disable=unsubscriptable-object + + @staticmethod + def process(callback: Callable[[argparse.Namespace, str, Configuration], None], + args: argparse.Namespace, architecture: str, configuration: Configuration, + process_id: str, queue: Queue[Tuple[str, Optional[Exception]]]) -> None: # pylint: disable=unsubscriptable-object + """ + helper to run external process + :param callback: application run function (i.e. Handler.run method) + :param args: command line arguments + :param architecture: repository architecture + :param configuration: configuration instance + :param process_id: process unique identifier + :param queue: output queue + """ + try: + callback(args, architecture, configuration) + error = None + except Exception as e: + error = e + queue.put((process_id, error)) + + def packages_add(self, packages: Iterable[str], now: bool) -> None: + """ + add packages + :param packages: packages list to add + :param now: build packages now + """ + kwargs = {"now": ""} if now else {} + self.spawn_process("add", *packages, **kwargs) + + def packages_remove(self, packages: Iterable[str]) -> None: + """ + remove packages + :param packages: packages list to remove + """ + self.spawn_process("remove", *packages) + + def packages_update(self, packages: Iterable[str]) -> None: + """ + update packages + :param packages: packages list to update + """ + self.spawn_process("update", *packages) + + def spawn_process(self, command: str, *args: str, **kwargs: str) -> None: + """ + spawn external ahriman process with supplied arguments + :param command: subcommand to run + :param args: positional command arguments + :param kwargs: named command arguments + """ + # default arguments + arguments = ["--architecture", self.architecture] + if self.configuration.path is not None: + arguments.extend(["--configuration", str(self.configuration.path)]) + # positional command arguments + arguments.append(command) + arguments.extend(args) + # named command arguments + for argument, value in kwargs.items(): + arguments.append(f"--{argument}") + if value: + arguments.append(value) + parsed = self.args_parser.parse_args(arguments) + + callback = parsed.handler.run + process_id = str(uuid.uuid4()) + process = Process(target=self.process, + args=(callback, parsed, self.architecture, self.configuration, process_id, self.queue), + daemon=True) + process.start() + + with self.lock: + self.active[process_id] = process + + def run(self) -> None: + """ + thread run method + """ + for process_id, error in iter(self.queue.get, None): + if error is None: + self.logger.info("process %s has been terminated successfully", process_id) + else: + self.logger.exception("process %s has been terminated with exception %s", process_id, error) + + with self.lock: + process = self.active.pop(process_id, None) + + if process is not None: + process.terminate() # make sure lol + process.join() diff --git a/src/ahriman/web/views/base.py b/src/ahriman/web/views/base.py index 9b10ba68..963c8019 100644 --- a/src/ahriman/web/views/base.py +++ b/src/ahriman/web/views/base.py @@ -21,6 +21,7 @@ from aiohttp.web import View from typing import Any, Dict from ahriman.core.auth.auth import Auth +from ahriman.core.spawn import Spawn from ahriman.core.status.watcher import Watcher @@ -37,6 +38,14 @@ class BaseView(View): watcher: Watcher = self.request.app["watcher"] return watcher + @property + def spawner(self) -> Spawn: + """ + :return: external process spawner instance + """ + spawner: Spawn = self.request.app["spawn"] + return spawner + @property def validator(self) -> Auth: """ @@ -54,4 +63,20 @@ class BaseView(View): json: Dict[str, Any] = await self.request.json() return json except ValueError: - return dict(await self.request.post()) + return await self.data_as_json() + + async def data_as_json(self) -> Dict[str, Any]: + """ + extract form data and convert it to json object + :return: form data converted to json. In case if a key is found multiple times it will be returned as list + """ + raw = await self.request.post() + json: Dict[str, Any] = {} + for key, value in raw.items(): + if key in json and isinstance(json[key], list): + json[key].append(value) + elif key in json: + json[key] = [json[key], value] + else: + json[key] = value + return json diff --git a/src/ahriman/web/web.py b/src/ahriman/web/web.py index 1cf98d3e..36a2b989 100644 --- a/src/ahriman/web/web.py +++ b/src/ahriman/web/web.py @@ -26,6 +26,7 @@ from aiohttp import web from ahriman.core.auth.auth import Auth from ahriman.core.configuration import Configuration from ahriman.core.exceptions import InitializeException +from ahriman.core.spawn import Spawn from ahriman.core.status.watcher import Watcher from ahriman.web.middlewares.exception_handler import exception_handler from ahriman.web.routes import setup_routes @@ -67,11 +68,12 @@ def run_server(application: web.Application) -> None: access_log=logging.getLogger("http")) -def setup_service(architecture: str, configuration: Configuration) -> web.Application: +def setup_service(architecture: str, configuration: Configuration, spawner: Spawn) -> web.Application: """ create web application :param architecture: repository architecture :param configuration: configuration instance + :param spawner: spawner thread :return: web application instance """ application = web.Application(logger=logging.getLogger("http")) @@ -93,6 +95,9 @@ def setup_service(architecture: str, configuration: Configuration) -> web.Applic application.logger.info("setup watcher") application["watcher"] = Watcher(architecture, configuration) + application.logger.info("setup process spawner") + application["spawn"] = spawner + application.logger.info("setup authorization") validator = application["validator"] = Auth.load(configuration) if validator.enabled: diff --git a/tests/ahriman/application/handlers/test_handler_web.py b/tests/ahriman/application/handlers/test_handler_web.py index 62f45f97..ef70b8db 100644 --- a/tests/ahriman/application/handlers/test_handler_web.py +++ b/tests/ahriman/application/handlers/test_handler_web.py @@ -6,11 +6,23 @@ from ahriman.application.handlers import Web from ahriman.core.configuration import Configuration +def _default_args(args: argparse.Namespace) -> argparse.Namespace: + """ + default arguments for these test cases + :param args: command line arguments fixture + :return: generated arguments for these test cases + """ + args.parser = True + return args + + def test_run(args: argparse.Namespace, configuration: Configuration, mocker: MockerFixture) -> None: """ must run command """ + args = _default_args(args) mocker.patch("pathlib.Path.mkdir") + mocker.patch("ahriman.core.spawn.Spawn.start") setup_mock = mocker.patch("ahriman.web.web.setup_service") run_mock = mocker.patch("ahriman.web.web.run_server") diff --git a/tests/ahriman/application/test_ahriman.py b/tests/ahriman/application/test_ahriman.py index 91838703..74320cb2 100644 --- a/tests/ahriman/application/test_ahriman.py +++ b/tests/ahriman/application/test_ahriman.py @@ -260,11 +260,12 @@ def test_subparsers_update(parser: argparse.ArgumentParser) -> None: def test_subparsers_web(parser: argparse.ArgumentParser) -> None: """ - web command must imply lock and no_report + web command must imply lock, no_report and parser """ args = parser.parse_args(["-a", "x86_64", "web"]) assert args.lock is None assert args.no_report + assert args.parser == parser def test_run(args: argparse.Namespace, mocker: MockerFixture) -> None: diff --git a/tests/ahriman/conftest.py b/tests/ahriman/conftest.py index 99a600fe..88530f83 100644 --- a/tests/ahriman/conftest.py +++ b/tests/ahriman/conftest.py @@ -3,9 +3,11 @@ import pytest from pathlib import Path from pytest_mock import MockerFixture from typing import Any, Type, TypeVar +from unittest.mock import MagicMock from ahriman.core.auth.auth import Auth from ahriman.core.configuration import Configuration +from ahriman.core.spawn import Spawn from ahriman.core.status.watcher import Watcher from ahriman.models.package import Package from ahriman.models.package_description import PackageDescription @@ -13,6 +15,7 @@ from ahriman.models.repository_paths import RepositoryPaths from ahriman.models.user import User from ahriman.models.user_access import UserAccess + T = TypeVar("T") @@ -47,6 +50,7 @@ def anyvar(cls: Type[T], strict: bool = False) -> T: def auth(configuration: Configuration) -> Auth: """ auth provider fixture + :param configuration: configuration fixture :return: auth service instance """ return Auth(configuration) @@ -160,6 +164,7 @@ def package_description_python2_schedule() -> PackageDescription: def repository_paths(configuration: Configuration) -> RepositoryPaths: """ repository paths fixture + :param configuration: configuration fixture :return: repository paths test instance """ return RepositoryPaths( @@ -167,6 +172,16 @@ def repository_paths(configuration: Configuration) -> RepositoryPaths: root=configuration.getpath("repository", "root")) +@pytest.fixture +def spawner(configuration: Configuration) -> Spawn: + """ + spawner fixture + :param configuration: configuration fixture + :return: spawner fixture + """ + return Spawn(MagicMock(), "x86_64", configuration) + + @pytest.fixture def user() -> User: """ diff --git a/tests/ahriman/core/test_spawn.py b/tests/ahriman/core/test_spawn.py new file mode 100644 index 00000000..644f4221 --- /dev/null +++ b/tests/ahriman/core/test_spawn.py @@ -0,0 +1,121 @@ +from pytest_mock import MockerFixture +from unittest.mock import MagicMock + +from ahriman.core.spawn import Spawn + + +def test_process(spawner: Spawn) -> None: + """ + must process external process run correctly + """ + args = MagicMock() + callback = MagicMock() + + spawner.process(callback, args, spawner.architecture, spawner.configuration, "id", spawner.queue) + + callback.assert_called_with(args, spawner.architecture, spawner.configuration) + (uuid, error) = spawner.queue.get() + assert uuid == "id" + assert error is None + assert spawner.queue.empty() + + +def test_process_error(spawner: Spawn) -> None: + """ + must process external run with error correctly + """ + callback = MagicMock() + callback.side_effect = Exception() + + spawner.process(callback, MagicMock(), spawner.architecture, spawner.configuration, "id", spawner.queue) + + (uuid, error) = spawner.queue.get() + assert uuid == "id" + assert isinstance(error, Exception) + assert spawner.queue.empty() + + +def test_packages_add(spawner: Spawn, mocker: MockerFixture) -> None: + """ + must call package addition + """ + spawn_mock = mocker.patch("ahriman.core.spawn.Spawn.spawn_process") + spawner.packages_add(["ahriman", "linux"], now=False) + spawn_mock.assert_called_with("add", "ahriman", "linux") + + +def test_packages_add_with_build(spawner: Spawn, mocker: MockerFixture) -> None: + """ + must call package addition with update + """ + spawn_mock = mocker.patch("ahriman.core.spawn.Spawn.spawn_process") + spawner.packages_add(["ahriman", "linux"], now=True) + spawn_mock.assert_called_with("add", "ahriman", "linux", now="") + + +def test_packages_remove(spawner: Spawn, mocker: MockerFixture) -> None: + """ + must call package removal + """ + spawn_mock = mocker.patch("ahriman.core.spawn.Spawn.spawn_process") + spawner.packages_remove(["ahriman", "linux"]) + spawn_mock.assert_called_with("remove", "ahriman", "linux") + + +def test_packages_update(spawner: Spawn, mocker: MockerFixture) -> None: + """ + must call package updates + """ + spawn_mock = mocker.patch("ahriman.core.spawn.Spawn.spawn_process") + spawner.packages_update(["ahriman", "linux"]) + spawn_mock.assert_called_with("update", "ahriman", "linux") + + +def test_spawn_process(spawner: Spawn, mocker: MockerFixture) -> None: + """ + must correctly spawn child process + """ + start_mock = mocker.patch("multiprocessing.Process.start") + + spawner.spawn_process("add", "ahriman", now="", maybe="?") + start_mock.assert_called_once() + spawner.args_parser.parse_args.assert_called_with([ + "--architecture", spawner.architecture, "--configuration", str(spawner.configuration.path), + "add", "ahriman", "--now", "--maybe", "?" + ]) + + +def test_run(spawner: Spawn, mocker: MockerFixture) -> None: + """ + must implement run method + """ + logging_exception_mock = mocker.patch("logging.Logger.exception") + logging_info_mock = mocker.patch("logging.Logger.info") + + spawner.queue.put(("1", None)) + spawner.queue.put(("2", Exception())) + spawner.queue.put(None) # terminate + + spawner.run() + logging_exception_mock.assert_called_once() + logging_info_mock.assert_called_once() + + +def test_run_pop(spawner: Spawn) -> None: + """ + must pop and terminate child process + """ + first = spawner.active["1"] = MagicMock() + second = spawner.active["2"] = MagicMock() + + spawner.queue.put(("1", None)) + spawner.queue.put(("2", Exception())) + spawner.queue.put(None) # terminate + + spawner.run() + + first.terminate.assert_called_once() + first.join.assert_called_once() + second.terminate.assert_called_once() + second.join.assert_called_once() + assert not spawner.active diff --git a/tests/ahriman/web/conftest.py b/tests/ahriman/web/conftest.py index f6919ed5..fc47d8e8 100644 --- a/tests/ahriman/web/conftest.py +++ b/tests/ahriman/web/conftest.py @@ -1,41 +1,64 @@ import pytest from aiohttp import web +from collections import namedtuple from pytest_mock import MockerFixture +from typing import Any import ahriman.core.auth.helpers from ahriman.core.configuration import Configuration +from ahriman.core.spawn import Spawn from ahriman.models.user import User from ahriman.web.web import setup_service +_request = namedtuple("_request", ["app", "path", "method", "json", "post"]) + + +@pytest.helpers.register +def request(app: web.Application, path: str, method: str, json: Any = None, data: Any = None) -> _request: + """ + request generator helper + :param app: application fixture + :param path: path for the request + :param method: method for the request + :param json: json payload of the request + :param data: form data payload of the request + :return: dummy request object + """ + return _request(app, path, method, json, data) + + @pytest.fixture -def application(configuration: Configuration, mocker: MockerFixture) -> web.Application: +def application(configuration: Configuration, spawner: Spawn, mocker: MockerFixture) -> web.Application: """ application fixture :param configuration: configuration fixture + :param spawner: spawner fixture :param mocker: mocker object :return: application test instance """ mocker.patch.object(ahriman.core.auth.helpers, "_has_aiohttp_security", False) mocker.patch("pathlib.Path.mkdir") - return setup_service("x86_64", configuration) + return setup_service("x86_64", configuration, spawner) @pytest.fixture -def application_with_auth(configuration: Configuration, user: User, mocker: MockerFixture) -> web.Application: +def application_with_auth(configuration: Configuration, user: User, spawner: Spawn, + mocker: MockerFixture) -> web.Application: """ application fixture with auth enabled :param configuration: configuration fixture :param user: user descriptor fixture + :param spawner: spawner fixture :param mocker: mocker object :return: application test instance """ configuration.set_option("auth", "target", "configuration") mocker.patch.object(ahriman.core.auth.helpers, "_has_aiohttp_security", True) mocker.patch("pathlib.Path.mkdir") - application = setup_service("x86_64", configuration) + application = setup_service("x86_64", configuration, spawner) generated = User(user.username, user.hash_password(application["validator"].salt), user.access) application["validator"]._users[generated.username] = generated diff --git a/tests/ahriman/web/middlewares/conftest.py b/tests/ahriman/web/middlewares/conftest.py index 716c29cd..a6b1a2df 100644 --- a/tests/ahriman/web/middlewares/conftest.py +++ b/tests/ahriman/web/middlewares/conftest.py @@ -1,23 +1,10 @@ import pytest -from collections import namedtuple - from ahriman.core.auth.auth import Auth from ahriman.core.configuration import Configuration from ahriman.models.user import User from ahriman.web.middlewares.auth_handler import AuthorizationPolicy -_request = namedtuple("_request", ["path", "method"]) - - -@pytest.fixture -def aiohttp_request() -> _request: - """ - fixture for aiohttp like object - :return: aiohttp like request test instance - """ - return _request("path", "GET") - @pytest.fixture def authorization_policy(configuration: Configuration, user: User) -> AuthorizationPolicy: diff --git a/tests/ahriman/web/middlewares/test_auth_handler.py b/tests/ahriman/web/middlewares/test_auth_handler.py index 96b09db8..fd8b84ed 100644 --- a/tests/ahriman/web/middlewares/test_auth_handler.py +++ b/tests/ahriman/web/middlewares/test_auth_handler.py @@ -1,6 +1,7 @@ +import pytest + from aiohttp import web from pytest_mock import MockerFixture -from typing import Any from unittest.mock import AsyncMock, MagicMock from ahriman.core.auth.auth import Auth @@ -29,11 +30,11 @@ async def test_permits(authorization_policy: AuthorizationPolicy, user: User) -> authorization_policy.validator.verify_access.assert_called_with(user.username, user.access, "/endpoint") -async def test_auth_handler_api(aiohttp_request: Any, auth: Auth, mocker: MockerFixture) -> None: +async def test_auth_handler_api(auth: Auth, mocker: MockerFixture) -> None: """ must ask for status permission for api calls """ - aiohttp_request = aiohttp_request._replace(path="/status-api") + aiohttp_request = pytest.helpers.request("", "/status-api", "GET") request_handler = AsyncMock() mocker.patch("ahriman.core.auth.auth.Auth.is_safe_request", return_value=False) check_permission_mock = mocker.patch("aiohttp_security.check_permission") @@ -43,11 +44,11 @@ async def test_auth_handler_api(aiohttp_request: Any, auth: Auth, mocker: Mocker check_permission_mock.assert_called_with(aiohttp_request, UserAccess.Status, aiohttp_request.path) -async def test_auth_handler_api_post(aiohttp_request: Any, auth: Auth, mocker: MockerFixture) -> None: +async def test_auth_handler_api_post(auth: Auth, mocker: MockerFixture) -> None: """ must ask for status permission for api calls with POST """ - aiohttp_request = aiohttp_request._replace(path="/status-api", method="POST") + aiohttp_request = pytest.helpers.request("", "/status-api", "POST") request_handler = AsyncMock() mocker.patch("ahriman.core.auth.auth.Auth.is_safe_request", return_value=False) check_permission_mock = mocker.patch("aiohttp_security.check_permission") @@ -57,12 +58,12 @@ async def test_auth_handler_api_post(aiohttp_request: Any, auth: Auth, mocker: M check_permission_mock.assert_called_with(aiohttp_request, UserAccess.Status, aiohttp_request.path) -async def test_auth_handler_read(aiohttp_request: Any, auth: Auth, mocker: MockerFixture) -> None: +async def test_auth_handler_read(auth: Auth, mocker: MockerFixture) -> None: """ must ask for read permission for api calls with GET """ for method in ("GET", "HEAD", "OPTIONS"): - aiohttp_request = aiohttp_request._replace(method=method) + aiohttp_request = pytest.helpers.request("", "", method) request_handler = AsyncMock() mocker.patch("ahriman.core.auth.auth.Auth.is_safe_request", return_value=False) check_permission_mock = mocker.patch("aiohttp_security.check_permission") @@ -72,12 +73,12 @@ async def test_auth_handler_read(aiohttp_request: Any, auth: Auth, mocker: Mocke check_permission_mock.assert_called_with(aiohttp_request, UserAccess.Read, aiohttp_request.path) -async def test_auth_handler_write(aiohttp_request: Any, auth: Auth, mocker: MockerFixture) -> None: +async def test_auth_handler_write(auth: Auth, mocker: MockerFixture) -> None: """ must ask for read permission for api calls with POST """ for method in ("CONNECT", "DELETE", "PATCH", "POST", "PUT", "TRACE"): - aiohttp_request = aiohttp_request._replace(method=method) + aiohttp_request = pytest.helpers.request("", "", method) request_handler = AsyncMock() mocker.patch("ahriman.core.auth.auth.Auth.is_safe_request", return_value=False) check_permission_mock = mocker.patch("aiohttp_security.check_permission") diff --git a/tests/ahriman/web/middlewares/test_exception_handler.py b/tests/ahriman/web/middlewares/test_exception_handler.py index 20435273..ca16d28e 100644 --- a/tests/ahriman/web/middlewares/test_exception_handler.py +++ b/tests/ahriman/web/middlewares/test_exception_handler.py @@ -3,45 +3,47 @@ import pytest from aiohttp.web_exceptions import HTTPBadRequest from pytest_mock import MockerFixture -from typing import Any from unittest.mock import AsyncMock from ahriman.web.middlewares.exception_handler import exception_handler -async def test_exception_handler(aiohttp_request: Any, mocker: MockerFixture) -> None: +async def test_exception_handler(mocker: MockerFixture) -> None: """ must pass success response """ + request = pytest.helpers.request("", "", "") request_handler = AsyncMock() logging_mock = mocker.patch("logging.Logger.exception") handler = exception_handler(logging.getLogger()) - await handler(aiohttp_request, request_handler) + await handler(request, request_handler) logging_mock.assert_not_called() -async def test_exception_handler_client_error(aiohttp_request: Any, mocker: MockerFixture) -> None: +async def test_exception_handler_client_error(mocker: MockerFixture) -> None: """ must pass client exception """ + request = pytest.helpers.request("", "", "") request_handler = AsyncMock(side_effect=HTTPBadRequest()) logging_mock = mocker.patch("logging.Logger.exception") handler = exception_handler(logging.getLogger()) with pytest.raises(HTTPBadRequest): - await handler(aiohttp_request, request_handler) + await handler(request, request_handler) logging_mock.assert_not_called() -async def test_exception_handler_server_error(aiohttp_request: Any, mocker: MockerFixture) -> None: +async def test_exception_handler_server_error(mocker: MockerFixture) -> None: """ must log server exception and re-raise it """ + request = pytest.helpers.request("", "", "") request_handler = AsyncMock(side_effect=Exception()) logging_mock = mocker.patch("logging.Logger.exception") handler = exception_handler(logging.getLogger()) with pytest.raises(Exception): - await handler(aiohttp_request, request_handler) + await handler(request, request_handler) logging_mock.assert_called_once() diff --git a/tests/ahriman/web/views/conftest.py b/tests/ahriman/web/views/conftest.py index 8ae6bcc9..0ced73e5 100644 --- a/tests/ahriman/web/views/conftest.py +++ b/tests/ahriman/web/views/conftest.py @@ -6,6 +6,18 @@ from pytest_aiohttp import TestClient from pytest_mock import MockerFixture from typing import Any +from ahriman.web.views.base import BaseView + + +@pytest.fixture +def base(application: web.Application) -> BaseView: + """ + base view fixture + :param application: application fixture + :return: generated base view fixture + """ + return BaseView(pytest.helpers.request(application, "", "")) + @pytest.fixture def client(application: web.Application, loop: BaseEventLoop, diff --git a/tests/ahriman/web/views/test_view_base.py b/tests/ahriman/web/views/test_view_base.py new file mode 100644 index 00000000..74ae0dba --- /dev/null +++ b/tests/ahriman/web/views/test_view_base.py @@ -0,0 +1,78 @@ +import pytest + +from multidict import MultiDict + +from ahriman.web.views.base import BaseView + + +def test_service(base: BaseView) -> None: + """ + must return service + """ + assert base.service + + +def test_spawn(base: BaseView) -> None: + """ + must return spawn thread + """ + assert base.spawner + + +def test_validator(base: BaseView) -> None: + """ + must return service + """ + assert base.validator + + +async def test_extract_data_json(base: BaseView) -> None: + """ + must parse and return json + """ + json = {"key1": "value1", "key2": "value2"} + + async def get_json(): + return json + + base._request = pytest.helpers.request(base.request.app, "", "", json=get_json) + assert await base.extract_data() == json + + +async def test_extract_data_post(base: BaseView) -> None: + """ + must parse and return form data + """ + json = {"key1": "value1", "key2": "value2"} + + async def get_json(): + raise ValueError() + + async def get_data(): + return json + + base._request = pytest.helpers.request(base.request.app, "", "", json=get_json, data=get_data) + assert await base.extract_data() == json + + +async def test_data_as_json(base: BaseView) -> None: + """ + must parse multi value form payload + """ + json = {"key1": "value1", "key2": ["value2", "value3"], "key3": ["value4", "value5", "value6"]} + + async def get_json(): + raise ValueError() + + async def get_data(): + result = MultiDict() + for key, values in json.items(): + if isinstance(values, list): + for value in values: + result.add(key, value) + else: + result.add(key, values) + return result + + base._request = pytest.helpers.request(base.request.app, "", "", json=get_json, data=get_data) + assert await base.data_as_json() == json