diff --git a/docs/faq.rst b/docs/faq.rst index 578808be..094e8f66 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -1303,6 +1303,18 @@ It is possible to customize html templates. In order to do so, create files some In addition, default html templates supports style customization out-of-box. In order to customize style, just put file named ``user-style.jinja2`` to the templates directory. +Web API extension +^^^^^^^^^^^^^^^^^ + +The application loads web views dynamically, so it is possible relatively easy extend its API. In order to do so: + +#. Create view class which is derived from ``ahriman.web.views.base.BaseView`` class. +#. Create implementation for this class. +#. Put file into ``ahriman.web.views`` package. +#. Restart application. + +For more details about implementation and possibilities, kindly refer to module documentation and source code and `aiohttp documentation `_. + I did not find my question ^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/src/ahriman/core/database/migrations/__init__.py b/src/ahriman/core/database/migrations/__init__.py index 72df8146..3179eef8 100644 --- a/src/ahriman/core/database/migrations/__init__.py +++ b/src/ahriman/core/database/migrations/__init__.py @@ -92,6 +92,7 @@ class Migrations(LazyLogging): list[Migration]: list of found migrations """ migrations: list[Migration] = [] + package_dir = Path(__file__).resolve().parent modules = [module_name for (_, module_name, _) in iter_modules([str(package_dir)])] diff --git a/src/ahriman/web/routes.py b/src/ahriman/web/routes.py index ed04d7e4..1c080676 100644 --- a/src/ahriman/web/routes.py +++ b/src/ahriman/web/routes.py @@ -17,18 +17,90 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . # -from aiohttp.web import Application +from aiohttp.web import Application, View +from collections.abc import Generator +from importlib.machinery import SourceFileLoader from pathlib import Path +from pkgutil import ModuleInfo, iter_modules +from types import ModuleType +from typing import Any, Type, TypeGuard -from ahriman.web.views.api.docs import DocsView -from ahriman.web.views.api.swagger import SwaggerView -from ahriman.web.views.index import IndexView -from ahriman.web.views import v1, v2 +from ahriman.web.views.base import BaseView __all__ = ["setup_routes"] +def _dynamic_routes(module_root: Path) -> dict[str, Type[View]]: + """ + extract dynamic routes based on views + + Args: + module_root(Path): root module path with views + + Returns: + dict[str, Type[View]]: map of the route to its view + """ + def is_base_view(clz: Any) -> TypeGuard[Type[BaseView]]: + return isinstance(clz, type) and issubclass(clz, BaseView) + + routes: dict[str, Type[View]] = {} + for module_info in _modules(module_root): + module = _module(module_info) + + for attribute_name in dir(module): + view = getattr(module, attribute_name) + if not is_base_view(view): + continue + routes.update([(route, view) for route in view.ROUTES]) + + return routes + + +def _module(module_info: ModuleInfo) -> ModuleType: + """ + load module from its info + + Args: + module_info(ModuleInfo): module info descriptor + + Returns: + ModuleType: loaded module + + Raises: + ValueError: if loader is not an instance of ``SourceFileLoader`` + """ + module_spec = module_info.module_finder.find_spec(module_info.name, None) + if module_spec is None: + raise ValueError(f"Module specification of {module_info.name} is empty") + + loader = module_spec.loader + if not isinstance(loader, SourceFileLoader): + raise ValueError(f"Module {module_info.name} loader is not an instance of SourceFileLoader") + + module = ModuleType(loader.name) + loader.exec_module(module) + + return module + + +def _modules(module_root: Path) -> Generator[ModuleInfo, None, None]: + """ + extract available modules from package + + Args: + module_root(Path): module root path + + Yields: + ModuleInfo: module information each available module + """ + for module_info in iter_modules([str(module_root)]): + if module_info.ispkg: + yield from _modules(module_root / module_info.name) + else: + yield module_info + + def setup_routes(application: Application, static_path: Path) -> None: """ setup all defined routes @@ -37,30 +109,8 @@ def setup_routes(application: Application, static_path: Path) -> None: application(Application): web application instance static_path(Path): path to static files directory """ - application.router.add_view("/", IndexView) - application.router.add_view("/index.html", IndexView) - - application.router.add_view("/api-docs", DocsView) - application.router.add_view("/api-docs/swagger.json", SwaggerView) - application.router.add_static("/static", static_path, follow_symlinks=True) - application.router.add_view("/api/v1/service/add", v1.AddView) - application.router.add_view("/api/v1/service/pgp", v1.PGPView) - application.router.add_view("/api/v1/service/rebuild", v1.RebuildView) - application.router.add_view("/api/v1/service/process/{process_id}", v1.ProcessView) - application.router.add_view("/api/v1/service/remove", v1.RemoveView) - application.router.add_view("/api/v1/service/request", v1.RequestView) - application.router.add_view("/api/v1/service/search", v1.SearchView) - application.router.add_view("/api/v1/service/update", v1.UpdateView) - application.router.add_view("/api/v1/service/upload", v1.UploadView) - - application.router.add_view("/api/v1/packages", v1.PackagesView) - application.router.add_view("/api/v1/packages/{package}", v1.PackageView) - application.router.add_view("/api/v1/packages/{package}/logs", v1.LogsView) - application.router.add_view("/api/v2/packages/{package}/logs", v2.LogsView) - - application.router.add_view("/api/v1/status", v1.StatusView) - - application.router.add_view("/api/v1/login", v1.LoginView) - application.router.add_view("/api/v1/logout", v1.LogoutView) + views = Path(__file__).parent / "views" + for route, view in _dynamic_routes(views).items(): + application.router.add_view(route, view) diff --git a/src/ahriman/web/views/api/docs.py b/src/ahriman/web/views/api/docs.py index 24a6bfd5..bde988e1 100644 --- a/src/ahriman/web/views/api/docs.py +++ b/src/ahriman/web/views/api/docs.py @@ -34,6 +34,7 @@ class DocsView(BaseView): """ GET_PERMISSION = UserAccess.Unauthorized + ROUTES = ["/api-docs"] @aiohttp_jinja2.template("api.jinja2") async def get(self) -> dict[str, Any]: diff --git a/src/ahriman/web/views/api/swagger.py b/src/ahriman/web/views/api/swagger.py index e3517a07..c663f209 100644 --- a/src/ahriman/web/views/api/swagger.py +++ b/src/ahriman/web/views/api/swagger.py @@ -34,6 +34,7 @@ class SwaggerView(BaseView): """ GET_PERMISSION = UserAccess.Unauthorized + ROUTES = ["/api-docs/swagger.json"] async def get(self) -> Response: """ diff --git a/src/ahriman/web/views/base.py b/src/ahriman/web/views/base.py index de4b6cab..dca09e5a 100644 --- a/src/ahriman/web/views/base.py +++ b/src/ahriman/web/views/base.py @@ -38,9 +38,11 @@ class BaseView(View, CorsViewMixin): Attributes: OPTIONS_PERMISSION(UserAccess): (class attribute) options permissions of self + ROUTES(list[str]): (class attribute) list of supported routes """ OPTIONS_PERMISSION = UserAccess.Unauthorized + ROUTES: list[str] = [] @property def configuration(self) -> Configuration: diff --git a/src/ahriman/web/views/index.py b/src/ahriman/web/views/index.py index b2df4a48..63d5adb4 100644 --- a/src/ahriman/web/views/index.py +++ b/src/ahriman/web/views/index.py @@ -44,6 +44,7 @@ class IndexView(BaseView): """ GET_PERMISSION = UserAccess.Unauthorized + ROUTES = ["/", "/index.html"] @aiohttp_jinja2.template("build-status.jinja2") async def get(self) -> dict[str, Any]: diff --git a/src/ahriman/web/views/v1/__init__.py b/src/ahriman/web/views/v1/__init__.py index a7057660..8fc622e9 100644 --- a/src/ahriman/web/views/v1/__init__.py +++ b/src/ahriman/web/views/v1/__init__.py @@ -17,20 +17,3 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . # -from ahriman.web.views.v1.service.add import AddView -from ahriman.web.views.v1.service.pgp import PGPView -from ahriman.web.views.v1.service.process import ProcessView -from ahriman.web.views.v1.service.rebuild import RebuildView -from ahriman.web.views.v1.service.remove import RemoveView -from ahriman.web.views.v1.service.request import RequestView -from ahriman.web.views.v1.service.search import SearchView -from ahriman.web.views.v1.service.update import UpdateView -from ahriman.web.views.v1.service.upload import UploadView - -from ahriman.web.views.v1.status.logs import LogsView -from ahriman.web.views.v1.status.package import PackageView -from ahriman.web.views.v1.status.packages import PackagesView -from ahriman.web.views.v1.status.status import StatusView - -from ahriman.web.views.v1.user.login import LoginView -from ahriman.web.views.v1.user.logout import LogoutView diff --git a/src/ahriman/web/views/v1/service/add.py b/src/ahriman/web/views/v1/service/add.py index 68dbe0c6..6a13f362 100644 --- a/src/ahriman/web/views/v1/service/add.py +++ b/src/ahriman/web/views/v1/service/add.py @@ -35,6 +35,7 @@ class AddView(BaseView): """ POST_PERMISSION = UserAccess.Full + ROUTES = ["/api/v1/service/add"] @aiohttp_apispec.docs( tags=["Actions"], diff --git a/src/ahriman/web/views/v1/service/pgp.py b/src/ahriman/web/views/v1/service/pgp.py index cfb56d88..2ccf4465 100644 --- a/src/ahriman/web/views/v1/service/pgp.py +++ b/src/ahriman/web/views/v1/service/pgp.py @@ -35,8 +35,9 @@ class PGPView(BaseView): POST_PERMISSION(UserAccess): (class attribute) post permissions of self """ - POST_PERMISSION = UserAccess.Full GET_PERMISSION = UserAccess.Reporter + POST_PERMISSION = UserAccess.Full + ROUTES = ["/api/v1/service/pgp"] @aiohttp_apispec.docs( tags=["Actions"], diff --git a/src/ahriman/web/views/v1/service/process.py b/src/ahriman/web/views/v1/service/process.py index c27057f8..849088fa 100644 --- a/src/ahriman/web/views/v1/service/process.py +++ b/src/ahriman/web/views/v1/service/process.py @@ -35,6 +35,7 @@ class ProcessView(BaseView): """ GET_PERMISSION = UserAccess.Reporter + ROUTES = ["/api/v1/service/process/{process_id}"] @aiohttp_apispec.docs( tags=["Actions"], diff --git a/src/ahriman/web/views/v1/service/rebuild.py b/src/ahriman/web/views/v1/service/rebuild.py index bf32beeb..728c4484 100644 --- a/src/ahriman/web/views/v1/service/rebuild.py +++ b/src/ahriman/web/views/v1/service/rebuild.py @@ -35,6 +35,7 @@ class RebuildView(BaseView): """ POST_PERMISSION = UserAccess.Full + ROUTES = ["/api/v1/service/rebuild"] @aiohttp_apispec.docs( tags=["Actions"], diff --git a/src/ahriman/web/views/v1/service/remove.py b/src/ahriman/web/views/v1/service/remove.py index b241cd0d..2f0fc73e 100644 --- a/src/ahriman/web/views/v1/service/remove.py +++ b/src/ahriman/web/views/v1/service/remove.py @@ -35,6 +35,7 @@ class RemoveView(BaseView): """ POST_PERMISSION = UserAccess.Full + ROUTES = ["/api/v1/service/remove"] @aiohttp_apispec.docs( tags=["Actions"], diff --git a/src/ahriman/web/views/v1/service/request.py b/src/ahriman/web/views/v1/service/request.py index f6dbfede..e1220a8a 100644 --- a/src/ahriman/web/views/v1/service/request.py +++ b/src/ahriman/web/views/v1/service/request.py @@ -35,6 +35,7 @@ class RequestView(BaseView): """ POST_PERMISSION = UserAccess.Reporter + ROUTES = ["/api/v1/service/request"] @aiohttp_apispec.docs( tags=["Actions"], diff --git a/src/ahriman/web/views/v1/service/search.py b/src/ahriman/web/views/v1/service/search.py index 4c75c3fd..0718cdcf 100644 --- a/src/ahriman/web/views/v1/service/search.py +++ b/src/ahriman/web/views/v1/service/search.py @@ -38,6 +38,7 @@ class SearchView(BaseView): """ GET_PERMISSION = UserAccess.Reporter + ROUTES = ["/api/v1/service/search"] @aiohttp_apispec.docs( tags=["Actions"], diff --git a/src/ahriman/web/views/v1/service/update.py b/src/ahriman/web/views/v1/service/update.py index 944c04e2..67108374 100644 --- a/src/ahriman/web/views/v1/service/update.py +++ b/src/ahriman/web/views/v1/service/update.py @@ -35,6 +35,7 @@ class UpdateView(BaseView): """ POST_PERMISSION = UserAccess.Full + ROUTES = ["/api/v1/service/update"] @aiohttp_apispec.docs( tags=["Actions"], diff --git a/src/ahriman/web/views/v1/service/upload.py b/src/ahriman/web/views/v1/service/upload.py index 686e2c85..96a9dbd5 100644 --- a/src/ahriman/web/views/v1/service/upload.py +++ b/src/ahriman/web/views/v1/service/upload.py @@ -39,6 +39,7 @@ class UploadView(BaseView): """ POST_PERMISSION = UserAccess.Full + ROUTES = ["/api/v1/service/upload"] @staticmethod async def save_file(part: BodyPartReader, target: Path, *, max_body_size: int | None = None) -> tuple[str, Path]: diff --git a/src/ahriman/web/views/v1/status/logs.py b/src/ahriman/web/views/v1/status/logs.py index 2ab07fcb..4d9a0b99 100644 --- a/src/ahriman/web/views/v1/status/logs.py +++ b/src/ahriman/web/views/v1/status/logs.py @@ -41,6 +41,7 @@ class LogsView(BaseView): DELETE_PERMISSION = POST_PERMISSION = UserAccess.Full GET_PERMISSION = UserAccess.Reporter + ROUTES = ["/api/v1/packages/{package}/logs"] @aiohttp_apispec.docs( tags=["Packages"], diff --git a/src/ahriman/web/views/v1/status/package.py b/src/ahriman/web/views/v1/status/package.py index 68c1da49..cf9556fa 100644 --- a/src/ahriman/web/views/v1/status/package.py +++ b/src/ahriman/web/views/v1/status/package.py @@ -41,6 +41,7 @@ class PackageView(BaseView): DELETE_PERMISSION = POST_PERMISSION = UserAccess.Full GET_PERMISSION = UserAccess.Read + ROUTES = ["/api/v1/packages/{package}"] @aiohttp_apispec.docs( tags=["Packages"], diff --git a/src/ahriman/web/views/v1/status/packages.py b/src/ahriman/web/views/v1/status/packages.py index 604352d0..cb01c688 100644 --- a/src/ahriman/web/views/v1/status/packages.py +++ b/src/ahriman/web/views/v1/status/packages.py @@ -41,6 +41,7 @@ class PackagesView(BaseView): GET_PERMISSION = UserAccess.Read POST_PERMISSION = UserAccess.Full + ROUTES = ["/api/v1/packages"] @aiohttp_apispec.docs( tags=["Packages"], diff --git a/src/ahriman/web/views/v1/status/status.py b/src/ahriman/web/views/v1/status/status.py index 8bd4feef..bb625a36 100644 --- a/src/ahriman/web/views/v1/status/status.py +++ b/src/ahriman/web/views/v1/status/status.py @@ -41,6 +41,7 @@ class StatusView(BaseView): GET_PERMISSION = UserAccess.Read POST_PERMISSION = UserAccess.Full + ROUTES = ["/api/v1/status"] @aiohttp_apispec.docs( tags=["Status"], diff --git a/src/ahriman/web/views/v1/user/login.py b/src/ahriman/web/views/v1/user/login.py index bf78399f..28a05c56 100644 --- a/src/ahriman/web/views/v1/user/login.py +++ b/src/ahriman/web/views/v1/user/login.py @@ -37,6 +37,7 @@ class LoginView(BaseView): """ GET_PERMISSION = POST_PERMISSION = UserAccess.Unauthorized + ROUTES = ["/api/v1/login"] @aiohttp_apispec.docs( tags=["Login"], diff --git a/src/ahriman/web/views/v1/user/logout.py b/src/ahriman/web/views/v1/user/logout.py index 3db4aaf2..be9fe0bc 100644 --- a/src/ahriman/web/views/v1/user/logout.py +++ b/src/ahriman/web/views/v1/user/logout.py @@ -36,6 +36,7 @@ class LogoutView(BaseView): """ POST_PERMISSION = UserAccess.Unauthorized + ROUTES = ["/api/v1/logout"] @aiohttp_apispec.docs( tags=["Login"], diff --git a/src/ahriman/web/views/v2/__init__.py b/src/ahriman/web/views/v2/__init__.py index c67945b4..8fc622e9 100644 --- a/src/ahriman/web/views/v2/__init__.py +++ b/src/ahriman/web/views/v2/__init__.py @@ -17,4 +17,3 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . # -from ahriman.web.views.v2.status.logs import LogsView diff --git a/src/ahriman/web/views/v2/status/logs.py b/src/ahriman/web/views/v2/status/logs.py index 45ed66f5..a9e2fbbb 100644 --- a/src/ahriman/web/views/v2/status/logs.py +++ b/src/ahriman/web/views/v2/status/logs.py @@ -37,6 +37,7 @@ class LogsView(BaseView): """ GET_PERMISSION = UserAccess.Reporter + ROUTES = ["/api/v2/packages/{package}/logs"] @aiohttp_apispec.docs( tags=["Packages"], diff --git a/tests/ahriman/web/conftest.py b/tests/ahriman/web/conftest.py index 05b522ee..7110b846 100644 --- a/tests/ahriman/web/conftest.py +++ b/tests/ahriman/web/conftest.py @@ -7,7 +7,7 @@ from collections.abc import Awaitable, Callable from marshmallow import Schema from pytest_mock import MockerFixture from typing import Any -from unittest.mock import MagicMock +from unittest.mock import MagicMock, Mock import ahriman.core.auth.helpers @@ -20,6 +20,26 @@ from ahriman.models.user import User from ahriman.web.web import setup_service +@pytest.helpers.register +def patch_view(application: Application, attribute: str, mock: Mock) -> Mock: + """ + patch given attribute in views. This method is required because of dynamic load + + Args: + application(Application): application fixture + attribute(str): attribute name to patch + mock(Mock): mock object + + Returns: + Mock: mock set to object + """ + for route in application.router.routes(): + if hasattr(route.handler, attribute): + setattr(route.handler, attribute, mock) + + return mock + + @pytest.helpers.register def request(application: Application, path: str, method: str, params: Any = None, json: Any = None, data: Any = None, extra: dict[str, Any] | None = None, resource: Resource | None = None) -> MagicMock: diff --git a/tests/ahriman/web/test_routes.py b/tests/ahriman/web/test_routes.py index 48c139b5..12f54a88 100644 --- a/tests/ahriman/web/test_routes.py +++ b/tests/ahriman/web/test_routes.py @@ -1,7 +1,73 @@ +import pytest + from aiohttp.web import Application +from importlib.machinery import ModuleSpec +from pathlib import Path +from pytest_mock import MockerFixture +from types import ModuleType from ahriman.core.configuration import Configuration -from ahriman.web.routes import setup_routes +from ahriman.core.util import walk +from ahriman.web.routes import _dynamic_routes, _module, _modules, setup_routes + + +def test_dynamic_routes(resource_path_root: Path) -> None: + """ + must return all available routes + """ + views_root = resource_path_root / ".." / ".." / "src" / "ahriman" / "web" / "views" + expected_views = [ + file + for file in walk(views_root) + if file.suffix == ".py" and file.name not in ("__init__.py", "base.py") + ] + + routes = _dynamic_routes(views_root) + assert all(isinstance(view, type) for view in routes.values()) + assert len(set(routes.values())) == len(expected_views) + + +def test_module(mocker: MockerFixture) -> None: + """ + must load module + """ + exec_mock = mocker.patch("importlib.machinery.SourceFileLoader.exec_module") + module_info = next(_modules(Path(__file__).parent)) + + module = _module(module_info) + assert isinstance(module, ModuleType) + exec_mock.assert_called_once_with(pytest.helpers.anyvar(int)) + + +def test_module_no_spec(mocker: MockerFixture) -> None: + """ + must raise ValueError if spec is not available + """ + mocker.patch("importlib.machinery.FileFinder.find_spec", return_value=None) + module_info = next(_modules(Path(__file__).parent)) + + with pytest.raises(ValueError): + _module(module_info) + + +def test_module_no_loader(mocker: MockerFixture) -> None: + """ + must raise ValueError if loader is not available + """ + mocker.patch("importlib.machinery.FileFinder.find_spec", return_value=ModuleSpec("name", None)) + module_info = next(_modules(Path(__file__).parent)) + + with pytest.raises(ValueError): + _module(module_info) + + +def test_modules() -> None: + """ + must load modules + """ + modules = list(_modules(Path(__file__).parent.parent)) + assert modules + assert all(not module.ispkg for module in modules) def test_setup_routes(application: Application, configuration: Configuration) -> None: diff --git a/tests/ahriman/web/views/test_view_base.py b/tests/ahriman/web/views/test_view_base.py index 4433dc18..3b040e19 100644 --- a/tests/ahriman/web/views/test_view_base.py +++ b/tests/ahriman/web/views/test_view_base.py @@ -10,6 +10,13 @@ from ahriman.models.user_access import UserAccess from ahriman.web.views.base import BaseView +def test_routes() -> None: + """ + must return correct routes + """ + assert BaseView.ROUTES == [] + + def test_configuration(base: BaseView) -> None: """ must return configuration diff --git a/tests/ahriman/web/views/test_view_index.py b/tests/ahriman/web/views/test_view_index.py index d73ccbde..cf75dd27 100644 --- a/tests/ahriman/web/views/test_view_index.py +++ b/tests/ahriman/web/views/test_view_index.py @@ -15,6 +15,13 @@ async def test_get_permission() -> None: assert await IndexView.get_permission(request) == UserAccess.Unauthorized +def test_routes() -> None: + """ + must return correct routes + """ + assert IndexView.ROUTES == ["/", "/index.html"] + + async def test_get(client_with_auth: TestClient) -> None: """ must generate status page correctly (/) diff --git a/tests/ahriman/web/views/v1/service/test_view_v1_service_add.py b/tests/ahriman/web/views/v1/service/test_view_v1_service_add.py index f6948906..87a1c64b 100644 --- a/tests/ahriman/web/views/v1/service/test_view_v1_service_add.py +++ b/tests/ahriman/web/views/v1/service/test_view_v1_service_add.py @@ -5,7 +5,7 @@ from pytest_mock import MockerFixture from unittest.mock import AsyncMock from ahriman.models.user_access import UserAccess -from ahriman.web.views.v1 import AddView +from ahriman.web.views.v1.service.add import AddView async def test_get_permission() -> None: @@ -17,6 +17,13 @@ async def test_get_permission() -> None: assert await AddView.get_permission(request) == UserAccess.Full +def test_routes() -> None: + """ + must return correct routes + """ + assert AddView.ROUTES == ["/api/v1/service/add"] + + async def test_post(client: TestClient, mocker: MockerFixture) -> None: """ must call post request correctly diff --git a/tests/ahriman/web/views/v1/service/test_view_v1_service_pgp.py b/tests/ahriman/web/views/v1/service/test_view_v1_service_pgp.py index 9cb95dbb..fd577a83 100644 --- a/tests/ahriman/web/views/v1/service/test_view_v1_service_pgp.py +++ b/tests/ahriman/web/views/v1/service/test_view_v1_service_pgp.py @@ -4,7 +4,7 @@ from aiohttp.test_utils import TestClient from pytest_mock import MockerFixture from ahriman.models.user_access import UserAccess -from ahriman.web.views.v1 import PGPView +from ahriman.web.views.v1.service.pgp import PGPView async def test_get_permission() -> None: @@ -19,6 +19,13 @@ async def test_get_permission() -> None: assert await PGPView.get_permission(request) == UserAccess.Full +def test_routes() -> None: + """ + must return correct routes + """ + assert PGPView.ROUTES == ["/api/v1/service/pgp"] + + async def test_get(client: TestClient, mocker: MockerFixture) -> None: """ must retrieve key from the keyserver diff --git a/tests/ahriman/web/views/v1/service/test_view_v1_service_process.py b/tests/ahriman/web/views/v1/service/test_view_v1_service_process.py index 1fa16cae..339972e2 100644 --- a/tests/ahriman/web/views/v1/service/test_view_v1_service_process.py +++ b/tests/ahriman/web/views/v1/service/test_view_v1_service_process.py @@ -4,7 +4,7 @@ from aiohttp.test_utils import TestClient from pytest_mock import MockerFixture from ahriman.models.user_access import UserAccess -from ahriman.web.views.v1 import ProcessView +from ahriman.web.views.v1.service.process import ProcessView async def test_get_permission() -> None: @@ -16,6 +16,13 @@ async def test_get_permission() -> None: assert await ProcessView.get_permission(request) == UserAccess.Reporter +def test_routes() -> None: + """ + must return correct routes + """ + assert ProcessView.ROUTES == ["/api/v1/service/process/{process_id}"] + + async def test_get(client: TestClient, mocker: MockerFixture) -> None: """ must call post request correctly diff --git a/tests/ahriman/web/views/v1/service/test_view_v1_service_rebuild.py b/tests/ahriman/web/views/v1/service/test_view_v1_service_rebuild.py index 66bf475d..a092e0c7 100644 --- a/tests/ahriman/web/views/v1/service/test_view_v1_service_rebuild.py +++ b/tests/ahriman/web/views/v1/service/test_view_v1_service_rebuild.py @@ -5,7 +5,7 @@ from pytest_mock import MockerFixture from unittest.mock import AsyncMock from ahriman.models.user_access import UserAccess -from ahriman.web.views.v1 import RebuildView +from ahriman.web.views.v1.service.rebuild import RebuildView async def test_get_permission() -> None: @@ -17,6 +17,13 @@ async def test_get_permission() -> None: assert await RebuildView.get_permission(request) == UserAccess.Full +def test_routes() -> None: + """ + must return correct routes + """ + assert RebuildView.ROUTES == ["/api/v1/service/rebuild"] + + async def test_post(client: TestClient, mocker: MockerFixture) -> None: """ must call post request correctly diff --git a/tests/ahriman/web/views/v1/service/test_view_v1_service_remove.py b/tests/ahriman/web/views/v1/service/test_view_v1_service_remove.py index dc1feaf0..1489a6c8 100644 --- a/tests/ahriman/web/views/v1/service/test_view_v1_service_remove.py +++ b/tests/ahriman/web/views/v1/service/test_view_v1_service_remove.py @@ -4,7 +4,7 @@ from aiohttp.test_utils import TestClient from pytest_mock import MockerFixture from ahriman.models.user_access import UserAccess -from ahriman.web.views.v1 import RemoveView +from ahriman.web.views.v1.service.remove import RemoveView async def test_get_permission() -> None: @@ -16,6 +16,13 @@ async def test_get_permission() -> None: assert await RemoveView.get_permission(request) == UserAccess.Full +def test_routes() -> None: + """ + must return correct routes + """ + assert RemoveView.ROUTES == ["/api/v1/service/remove"] + + async def test_post(client: TestClient, mocker: MockerFixture) -> None: """ must call post request correctly diff --git a/tests/ahriman/web/views/v1/service/test_view_v1_service_request.py b/tests/ahriman/web/views/v1/service/test_view_v1_service_request.py index 4966eecf..904f444f 100644 --- a/tests/ahriman/web/views/v1/service/test_view_v1_service_request.py +++ b/tests/ahriman/web/views/v1/service/test_view_v1_service_request.py @@ -5,7 +5,7 @@ from pytest_mock import MockerFixture from unittest.mock import AsyncMock from ahriman.models.user_access import UserAccess -from ahriman.web.views.v1 import RequestView +from ahriman.web.views.v1.service.request import RequestView async def test_get_permission() -> None: @@ -17,6 +17,13 @@ async def test_get_permission() -> None: assert await RequestView.get_permission(request) == UserAccess.Reporter +def test_routes() -> None: + """ + must return correct routes + """ + assert RequestView.ROUTES == ["/api/v1/service/request"] + + async def test_post(client: TestClient, mocker: MockerFixture) -> None: """ must call post request correctly diff --git a/tests/ahriman/web/views/v1/service/test_view_v1_service_search.py b/tests/ahriman/web/views/v1/service/test_view_v1_service_search.py index 533f8fc0..8e288a0d 100644 --- a/tests/ahriman/web/views/v1/service/test_view_v1_service_search.py +++ b/tests/ahriman/web/views/v1/service/test_view_v1_service_search.py @@ -5,7 +5,7 @@ from pytest_mock import MockerFixture from ahriman.models.aur_package import AURPackage from ahriman.models.user_access import UserAccess -from ahriman.web.views.v1 import SearchView +from ahriman.web.views.v1.service.search import SearchView async def test_get_permission() -> None: @@ -17,6 +17,13 @@ async def test_get_permission() -> None: assert await SearchView.get_permission(request) == UserAccess.Reporter +def test_routes() -> None: + """ + must return correct routes + """ + assert SearchView.ROUTES == ["/api/v1/service/search"] + + async def test_get(client: TestClient, aur_package_ahriman: AURPackage, mocker: MockerFixture) -> None: """ must call get request correctly diff --git a/tests/ahriman/web/views/v1/service/test_view_v1_service_update.py b/tests/ahriman/web/views/v1/service/test_view_v1_service_update.py index d29f4b9d..8c185a27 100644 --- a/tests/ahriman/web/views/v1/service/test_view_v1_service_update.py +++ b/tests/ahriman/web/views/v1/service/test_view_v1_service_update.py @@ -5,7 +5,7 @@ from pytest_mock import MockerFixture from unittest.mock import AsyncMock from ahriman.models.user_access import UserAccess -from ahriman.web.views.v1 import UpdateView +from ahriman.web.views.v1.service.update import UpdateView async def test_get_permission() -> None: @@ -17,6 +17,13 @@ async def test_get_permission() -> None: assert await UpdateView.get_permission(request) == UserAccess.Full +def test_routes() -> None: + """ + must return correct routes + """ + assert UpdateView.ROUTES == ["/api/v1/service/update"] + + async def test_post(client: TestClient, mocker: MockerFixture) -> None: """ must call post request correctly for alias diff --git a/tests/ahriman/web/views/v1/service/test_view_v1_service_upload.py b/tests/ahriman/web/views/v1/service/test_view_v1_service_upload.py index a472651a..8e4ed26b 100644 --- a/tests/ahriman/web/views/v1/service/test_view_v1_service_upload.py +++ b/tests/ahriman/web/views/v1/service/test_view_v1_service_upload.py @@ -10,7 +10,7 @@ from unittest.mock import AsyncMock, MagicMock, call as MockCall from ahriman.models.repository_paths import RepositoryPaths from ahriman.models.user_access import UserAccess -from ahriman.web.views.v1 import UploadView +from ahriman.web.views.v1.service.upload import UploadView async def test_get_permission() -> None: @@ -22,6 +22,13 @@ async def test_get_permission() -> None: assert await UploadView.get_permission(request) == UserAccess.Full +def test_routes() -> None: + """ + must return correct routes + """ + assert UploadView.ROUTES == ["/api/v1/service/upload"] + + async def test_save_file(mocker: MockerFixture) -> None: """ must correctly save file @@ -84,8 +91,8 @@ async def test_post(client: TestClient, repository_paths: RepositoryPaths, mocke must process file upload via http """ local = Path("local") - save_mock = mocker.patch("ahriman.web.views.v1.UploadView.save_file", - side_effect=AsyncMock(return_value=("filename", local / ".filename"))) + save_mock = pytest.helpers.patch_view(client.app, "save_file", + AsyncMock(return_value=("filename", local / ".filename"))) rename_mock = mocker.patch("pathlib.Path.rename") # no content validation here because it has invalid schema @@ -103,11 +110,11 @@ async def test_post_with_sig(client: TestClient, repository_paths: RepositoryPat must process file upload with signature via http """ local = Path("local") - save_mock = mocker.patch("ahriman.web.views.v1.UploadView.save_file", - side_effect=AsyncMock(side_effect=[ - ("filename", local / ".filename"), - ("filename.sig", local / ".filename.sig"), - ])) + save_mock = pytest.helpers.patch_view(client.app, "save_file", + AsyncMock(side_effect=[ + ("filename", local / ".filename"), + ("filename.sig", local / ".filename.sig"), + ])) rename_mock = mocker.patch("pathlib.Path.rename") # no content validation here because it has invalid schema diff --git a/tests/ahriman/web/views/v1/status/test_view_v1_status_logs.py b/tests/ahriman/web/views/v1/status/test_view_v1_status_logs.py index 1a73a010..8759b53f 100644 --- a/tests/ahriman/web/views/v1/status/test_view_v1_status_logs.py +++ b/tests/ahriman/web/views/v1/status/test_view_v1_status_logs.py @@ -5,7 +5,7 @@ from aiohttp.test_utils import TestClient from ahriman.models.build_status import BuildStatusEnum from ahriman.models.package import Package from ahriman.models.user_access import UserAccess -from ahriman.web.views.v1 import LogsView +from ahriman.web.views.v1.status.logs import LogsView async def test_get_permission() -> None: @@ -20,6 +20,13 @@ async def test_get_permission() -> None: assert await LogsView.get_permission(request) == UserAccess.Full +def test_routes() -> None: + """ + must return correct routes + """ + assert LogsView.ROUTES == ["/api/v1/packages/{package}/logs"] + + async def test_delete(client: TestClient, package_ahriman: Package, package_python_schedule: Package) -> None: """ must delete logs for package diff --git a/tests/ahriman/web/views/v1/status/test_view_v1_status_package.py b/tests/ahriman/web/views/v1/status/test_view_v1_status_package.py index 8559f7fd..d8c2efad 100644 --- a/tests/ahriman/web/views/v1/status/test_view_v1_status_package.py +++ b/tests/ahriman/web/views/v1/status/test_view_v1_status_package.py @@ -5,7 +5,7 @@ from aiohttp.test_utils import TestClient from ahriman.models.build_status import BuildStatus, BuildStatusEnum from ahriman.models.package import Package from ahriman.models.user_access import UserAccess -from ahriman.web.views.v1 import PackageView +from ahriman.web.views.v1.status.package import PackageView async def test_get_permission() -> None: @@ -20,6 +20,13 @@ async def test_get_permission() -> None: assert await PackageView.get_permission(request) == UserAccess.Full +def test_routes() -> None: + """ + must return correct routes + """ + assert PackageView.ROUTES == ["/api/v1/packages/{package}"] + + async def test_delete(client: TestClient, package_ahriman: Package, package_python_schedule: Package) -> None: """ must delete single base diff --git a/tests/ahriman/web/views/v1/status/test_view_v1_status_packages.py b/tests/ahriman/web/views/v1/status/test_view_v1_status_packages.py index fabde948..1718cddb 100644 --- a/tests/ahriman/web/views/v1/status/test_view_v1_status_packages.py +++ b/tests/ahriman/web/views/v1/status/test_view_v1_status_packages.py @@ -6,7 +6,7 @@ from pytest_mock import MockerFixture from ahriman.models.build_status import BuildStatusEnum from ahriman.models.package import Package from ahriman.models.user_access import UserAccess -from ahriman.web.views.v1 import PackagesView +from ahriman.web.views.v1.status.packages import (PackagesView) async def test_get_permission() -> None: @@ -21,6 +21,13 @@ async def test_get_permission() -> None: assert await PackagesView.get_permission(request) == UserAccess.Full +def test_routes() -> None: + """ + must return correct routes + """ + assert PackagesView.ROUTES == ["/api/v1/packages"] + + async def test_get(client: TestClient, package_ahriman: Package, package_python_schedule: Package) -> None: """ must return status for all packages diff --git a/tests/ahriman/web/views/v1/status/test_view_v1_status_status.py b/tests/ahriman/web/views/v1/status/test_view_v1_status_status.py index fbb1035d..76bea32d 100644 --- a/tests/ahriman/web/views/v1/status/test_view_v1_status_status.py +++ b/tests/ahriman/web/views/v1/status/test_view_v1_status_status.py @@ -8,7 +8,7 @@ from ahriman.models.build_status import BuildStatusEnum from ahriman.models.internal_status import InternalStatus from ahriman.models.package import Package from ahriman.models.user_access import UserAccess -from ahriman.web.views.v1 import StatusView +from ahriman.web.views.v1.status.status import StatusView async def test_get_permission() -> None: @@ -23,6 +23,13 @@ async def test_get_permission() -> None: assert await StatusView.get_permission(request) == UserAccess.Full +def test_routes() -> None: + """ + must return correct routes + """ + assert StatusView.ROUTES == ["/api/v1/status"] + + async def test_get(client: TestClient, package_ahriman: Package) -> None: """ must generate web service status correctly diff --git a/tests/ahriman/web/views/v1/user/test_view_v1_user_login.py b/tests/ahriman/web/views/v1/user/test_view_v1_user_login.py index c8f35f67..9a47e250 100644 --- a/tests/ahriman/web/views/v1/user/test_view_v1_user_login.py +++ b/tests/ahriman/web/views/v1/user/test_view_v1_user_login.py @@ -5,7 +5,7 @@ from pytest_mock import MockerFixture from ahriman.models.user import User from ahriman.models.user_access import UserAccess -from ahriman.web.views.v1 import LoginView +from ahriman.web.views.v1.user.login import LoginView async def test_get_permission() -> None: @@ -17,6 +17,13 @@ async def test_get_permission() -> None: assert await LoginView.get_permission(request) == UserAccess.Unauthorized +def test_routes() -> None: + """ + must return correct routes + """ + assert LoginView.ROUTES == ["/api/v1/login"] + + async def test_get_default_validator(client_with_auth: TestClient) -> None: """ must return 405 in case if no OAuth enabled diff --git a/tests/ahriman/web/views/v1/user/test_view_v1_user_logout.py b/tests/ahriman/web/views/v1/user/test_view_v1_user_logout.py index 651a38a6..8859be3e 100644 --- a/tests/ahriman/web/views/v1/user/test_view_v1_user_logout.py +++ b/tests/ahriman/web/views/v1/user/test_view_v1_user_logout.py @@ -5,7 +5,7 @@ from aiohttp.web import HTTPUnauthorized from pytest_mock import MockerFixture from ahriman.models.user_access import UserAccess -from ahriman.web.views.v1 import LogoutView +from ahriman.web.views.v1.user.logout import LogoutView async def test_get_permission() -> None: @@ -17,6 +17,13 @@ async def test_get_permission() -> None: assert await LogoutView.get_permission(request) == UserAccess.Unauthorized +def test_routes() -> None: + """ + must return correct routes + """ + assert LogoutView.ROUTES == ["/api/v1/logout"] + + async def test_post(client_with_auth: TestClient, mocker: MockerFixture) -> None: """ must log out user correctly diff --git a/tests/ahriman/web/views/v2/status/test_view_v2_status_logs.py b/tests/ahriman/web/views/v2/status/test_view_v2_status_logs.py index c9a31ef7..73466318 100644 --- a/tests/ahriman/web/views/v2/status/test_view_v2_status_logs.py +++ b/tests/ahriman/web/views/v2/status/test_view_v2_status_logs.py @@ -5,7 +5,7 @@ from aiohttp.test_utils import TestClient from ahriman.models.build_status import BuildStatusEnum from ahriman.models.package import Package from ahriman.models.user_access import UserAccess -from ahriman.web.views.v2 import LogsView +from ahriman.web.views.v2.status.logs import LogsView async def test_get_permission() -> None: @@ -17,6 +17,13 @@ async def test_get_permission() -> None: assert await LogsView.get_permission(request) == UserAccess.Reporter +def test_routes() -> None: + """ + must return correct routes + """ + assert LogsView.ROUTES == ["/api/v2/packages/{package}/logs"] + + async def test_get(client: TestClient, package_ahriman: Package) -> None: """ must get logs for package