complete web tests

This commit is contained in:
Evgenii Alekseev 2021-03-28 14:57:28 +03:00
parent 2c90efc339
commit 839b241ec1
21 changed files with 530 additions and 32 deletions

View File

@ -36,7 +36,7 @@ setup(
], ],
tests_require=[ tests_require=[
"pytest", "pytest",
"pytest-asyncio", "pytest-aiohttp",
"pytest-cov", "pytest-cov",
"pytest-helpers-namespace", "pytest-helpers-namespace",
"pytest-mock", "pytest-mock",

View File

@ -35,8 +35,8 @@ class Pacman:
:param config: configuration instance :param config: configuration instance
""" """
root = config.get("alpm", "root") root = config.get("alpm", "root")
pacman_root = config.get("alpm", "database") pacman_root = config.getpath("alpm", "database")
self.handle = Handle(root, pacman_root) self.handle = Handle(root, str(pacman_root))
for repository in config.getlist("alpm", "repositories"): for repository in config.getlist("alpm", "repositories"):
self.handle.register_syncdb(repository, 0) # 0 is pgp_level self.handle.register_syncdb(repository, 0) # 0 is pgp_level

View File

@ -55,8 +55,7 @@ class Configuration(configparser.RawConfigParser):
""" """
:return: path to directory with configuration includes :return: path to directory with configuration includes
""" """
value = Path(self.get("settings", "include")) return self.getpath("settings", "include")
return self.absolute_path_for(value)
@classmethod @classmethod
def from_path(cls: Type[Configuration], path: Path, logfile: bool) -> Configuration: def from_path(cls: Type[Configuration], path: Path, logfile: bool) -> Configuration:
@ -71,16 +70,6 @@ class Configuration(configparser.RawConfigParser):
config.load_logging(logfile) config.load_logging(logfile)
return config return config
def absolute_path_for(self, path_part: Path) -> Path:
"""
helper to generate absolute configuration path for relative settings value
:param path_part: path to generate
:return: absolute path according to current path configuration
"""
if self.path is None or path_part.is_absolute():
return path_part
return self.path.parent / path_part
def dump(self, architecture: str) -> Dict[str, Dict[str, str]]: def dump(self, architecture: str) -> Dict[str, Dict[str, str]]:
""" """
dump configuration to dictionary dump configuration to dictionary
@ -112,6 +101,18 @@ class Configuration(configparser.RawConfigParser):
return [] return []
return raw.split() return raw.split()
def getpath(self, section: str, key: str) -> Path:
"""
helper to generate absolute configuration path for relative settings value
:param section: section name
:param key: key name
:return: absolute path according to current path configuration
"""
value = Path(self.get(section, key))
if self.path is None or value.is_absolute():
return value
return self.path.parent / value
def get_section_name(self, prefix: str, suffix: str) -> str: def get_section_name(self, prefix: str, suffix: str) -> str:
""" """
check if there is `prefix`_`suffix` section and return it on success. Return `prefix` otherwise check if there is `prefix`_`suffix` section and return it on success. Return `prefix` otherwise
@ -148,8 +149,7 @@ class Configuration(configparser.RawConfigParser):
""" """
def file_logger() -> None: def file_logger() -> None:
try: try:
value = Path(self.get("settings", "logging")) config_path = self.getpath("settings", "logging")
config_path = self.absolute_path_for(value)
fileConfig(config_path) fileConfig(config_path)
except (FileNotFoundError, PermissionError): except (FileNotFoundError, PermissionError):
console_logger() console_logger()

View File

@ -19,7 +19,6 @@
# #
import jinja2 import jinja2
from pathlib import Path
from typing import Callable, Dict, Iterable from typing import Callable, Dict, Iterable
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
@ -60,9 +59,9 @@ class HTML(Report):
""" """
Report.__init__(self, architecture, config) Report.__init__(self, architecture, config)
section = config.get_section_name("html", architecture) section = config.get_section_name("html", architecture)
self.report_path = Path(config.get(section, "path")) self.report_path = config.getpath(section, "path")
self.link_path = config.get(section, "link_path") self.link_path = config.get(section, "link_path")
self.template_path = Path(config.get(section, "template_path")) self.template_path = config.getpath(section, "template_path")
# base template vars # base template vars
self.homepage = config.get(section, "homepage", fallback=None) self.homepage = config.get(section, "homepage", fallback=None)

View File

@ -19,8 +19,6 @@
# #
import logging import logging
from pathlib import Path
from ahriman.core.alpm.pacman import Pacman from ahriman.core.alpm.pacman import Pacman
from ahriman.core.alpm.repo import Repo from ahriman.core.alpm.repo import Repo
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
@ -52,7 +50,7 @@ class Properties:
self.aur_url = config.get("alpm", "aur_url") self.aur_url = config.get("alpm", "aur_url")
self.name = config.get("repository", "name") self.name = config.get("repository", "name")
self.paths = RepositoryPaths(Path(config.get("repository", "root")), architecture) self.paths = RepositoryPaths(config.getpath("repository", "root"), architecture)
self.paths.create_tree() self.paths.create_tree()
self.pacman = Pacman(config) self.pacman = Pacman(config)

View File

@ -82,8 +82,9 @@ def setup_service(architecture: str, config: Configuration) -> web.Application:
application.logger.info("setup routes") application.logger.info("setup routes")
setup_routes(application) setup_routes(application)
application.logger.info("setup templates") application.logger.info("setup templates")
aiohttp_jinja2.setup(application, loader=jinja2.FileSystemLoader(config.get("web", "templates"))) aiohttp_jinja2.setup(application, loader=jinja2.FileSystemLoader(config.getpath("web", "templates")))
application.logger.info("setup configuration") application.logger.info("setup configuration")
application["config"] = config application["config"] = config

View File

@ -83,10 +83,10 @@ def package_description_python2_schedule() -> PackageDescription:
@pytest.fixture @pytest.fixture
def repository_paths() -> RepositoryPaths: def repository_paths(configuration: Configuration) -> RepositoryPaths:
return RepositoryPaths( return RepositoryPaths(
architecture="x86_64", architecture="x86_64",
root=Path("/var/lib/ahriman")) root=configuration.getpath("repository", "root"))
@pytest.fixture @pytest.fixture

View File

@ -26,7 +26,8 @@ def test_absolute_path_for_absolute(configuration: Configuration) -> None:
must not change path for absolute path in settings must not change path for absolute path in settings
""" """
path = Path("/a/b/c") path = Path("/a/b/c")
assert configuration.absolute_path_for(path) == path configuration.set("build", "path", str(path))
assert configuration.getpath("build", "path") == path
def test_absolute_path_for_relative(configuration: Configuration) -> None: def test_absolute_path_for_relative(configuration: Configuration) -> None:
@ -34,7 +35,8 @@ def test_absolute_path_for_relative(configuration: Configuration) -> None:
must prepend root path to relative path must prepend root path to relative path
""" """
path = Path("a") path = Path("a")
result = configuration.absolute_path_for(path) configuration.set("build", "path", str(path))
result = configuration.getpath("build", "path")
assert result.is_absolute() assert result.is_absolute()
assert result.parent == configuration.path.parent assert result.parent == configuration.path.parent
assert result.name == path.name assert result.name == path.name

View File

@ -8,7 +8,6 @@ from ahriman.core.status.watcher import Watcher
from ahriman.web.web import on_startup, run_server from ahriman.web.web import on_startup, run_server
@pytest.mark.asyncio
async def test_on_startup(application: web.Application, watcher: Watcher, mocker: MockerFixture) -> None: async def test_on_startup(application: web.Application, watcher: Watcher, mocker: MockerFixture) -> None:
""" """
must call load method must call load method
@ -20,7 +19,6 @@ async def test_on_startup(application: web.Application, watcher: Watcher, mocker
load_mock.assert_called_once() load_mock.assert_called_once()
@pytest.mark.asyncio
async def test_on_startup_exception(application: web.Application, watcher: Watcher, mocker: MockerFixture) -> None: async def test_on_startup_exception(application: web.Application, watcher: Watcher, mocker: MockerFixture) -> None:
""" """
must throw exception on load error must throw exception on load error

View File

@ -0,0 +1,14 @@
import pytest
from aiohttp import web
from asyncio import BaseEventLoop
from pytest_aiohttp import TestClient
from pytest_mock import MockerFixture
from typing import Any
@pytest.fixture
def client(application: web.Application, loop: BaseEventLoop,
aiohttp_client: Any, mocker: MockerFixture) -> TestClient:
mocker.patch("pathlib.Path.iterdir", return_value=[])
return loop.run_until_complete(aiohttp_client(application))

View File

@ -0,0 +1,37 @@
from aiohttp.test_utils import TestClient
from ahriman.models.build_status import BuildStatus, BuildStatusEnum
async def test_get(client: TestClient) -> None:
"""
must return valid service status
"""
response = await client.get("/api/v1/ahriman")
status = BuildStatus.from_json(await response.json())
assert response.status == 200
assert status.status == BuildStatusEnum.Unknown
async def test_post(client: TestClient) -> None:
"""
must update service status correctly
"""
payload = {"status": BuildStatusEnum.Success.value}
post_response = await client.post("/api/v1/ahriman", json=payload)
assert post_response.status == 204
response = await client.get("/api/v1/ahriman")
status = BuildStatus.from_json(await response.json())
assert response.status == 200
assert status.status == BuildStatusEnum.Success
async def test_post_exception(client: TestClient) -> None:
"""
must raise exception on invalid payload
"""
post_response = await client.post("/api/v1/ahriman", json={})
assert post_response.status == 400

View File

@ -0,0 +1,19 @@
from pytest_aiohttp import TestClient
async def test_get(client: TestClient) -> None:
"""
must generate status page correctly (/)
"""
response = await client.get("/")
assert response.status == 200
assert await response.text()
async def test_get_index(client: TestClient) -> None:
"""
must generate status page correctly (/index.html)
"""
response = await client.get("/index.html")
assert response.status == 200
assert await response.text()

View File

@ -0,0 +1,117 @@
from pytest_aiohttp import TestClient
from ahriman.models.build_status import BuildStatus, BuildStatusEnum
from ahriman.models.package import Package
async def test_get(client: TestClient, package_ahriman: Package, package_python_schedule: Package) -> None:
"""
must return status for specific package
"""
await client.post(f"/api/v1/packages/{package_ahriman.base}",
json={"status": BuildStatusEnum.Success.value, "package": package_ahriman.view()})
await client.post(f"/api/v1/packages/{package_python_schedule.base}",
json={"status": BuildStatusEnum.Success.value, "package": package_python_schedule.view()})
response = await client.get(f"/api/v1/packages/{package_ahriman.base}")
assert response.status == 200
packages = [Package.from_json(item["package"]) for item in await response.json()]
assert packages
assert {package.base for package in packages} == {package_ahriman.base}
async def test_get_not_found(client: TestClient, package_ahriman: Package) -> None:
"""
must return Not Found for unknown package
"""
response = await client.get(f"/api/v1/packages/{package_ahriman.base}")
assert response.status == 404
async def test_delete(client: TestClient, package_ahriman: Package, package_python_schedule: Package) -> None:
"""
must delete single base
"""
await client.post(f"/api/v1/packages/{package_ahriman.base}",
json={"status": BuildStatusEnum.Success.value, "package": package_ahriman.view()})
await client.post(f"/api/v1/packages/{package_python_schedule.base}",
json={"status": BuildStatusEnum.Success.value, "package": package_python_schedule.view()})
response = await client.delete(f"/api/v1/packages/{package_ahriman.base}")
assert response.status == 204
response = await client.get(f"/api/v1/packages/{package_ahriman.base}")
assert response.status == 404
response = await client.get(f"/api/v1/packages/{package_python_schedule.base}")
assert response.status == 200
async def test_delete_unknown(client: TestClient, package_ahriman: Package, package_python_schedule: Package) -> None:
"""
must suppress errors on unknown package deletion
"""
await client.post(f"/api/v1/packages/{package_python_schedule.base}",
json={"status": BuildStatusEnum.Success.value, "package": package_python_schedule.view()})
response = await client.delete(f"/api/v1/packages/{package_ahriman.base}")
assert response.status == 204
response = await client.get(f"/api/v1/packages/{package_ahriman.base}")
assert response.status == 404
response = await client.get(f"/api/v1/packages/{package_python_schedule.base}")
assert response.status == 200
async def test_post(client: TestClient, package_ahriman: Package) -> None:
"""
must update package status
"""
post_response = await client.post(
f"/api/v1/packages/{package_ahriman.base}",
json={"status": BuildStatusEnum.Success.value, "package": package_ahriman.view()})
assert post_response.status == 204
response = await client.get(f"/api/v1/packages/{package_ahriman.base}")
assert response.status == 200
async def test_post_exception(client: TestClient, package_ahriman: Package) -> None:
"""
must raise exception on invalid payload
"""
post_response = await client.post(f"/api/v1/packages/{package_ahriman.base}", json={})
assert post_response.status == 400
async def test_post_light(client: TestClient, package_ahriman: Package) -> None:
"""
must update package status only
"""
post_response = await client.post(
f"/api/v1/packages/{package_ahriman.base}",
json={"status": BuildStatusEnum.Unknown.value, "package": package_ahriman.view()})
assert post_response.status == 204
post_response = await client.post(
f"/api/v1/packages/{package_ahriman.base}", json={"status": BuildStatusEnum.Success.value})
assert post_response.status == 204
response = await client.get(f"/api/v1/packages/{package_ahriman.base}")
assert response.status == 200
statuses = {
Package.from_json(item["package"]).base: BuildStatus.from_json(item["status"])
for item in await response.json()
}
assert statuses[package_ahriman.base].status == BuildStatusEnum.Success
async def test_post_not_found(client: TestClient, package_ahriman: Package) -> None:
"""
must raise exception on status update for unknown package
"""
post_response = await client.post(
f"/api/v1/packages/{package_ahriman.base}", json={"status": BuildStatusEnum.Success.value})
assert post_response.status == 400

View File

@ -0,0 +1,32 @@
from pytest_aiohttp import TestClient
from pytest_mock import MockerFixture
from ahriman.models.build_status import BuildStatusEnum
from ahriman.models.package import Package
async def test_get(client: TestClient, package_ahriman: Package, package_python_schedule: Package) -> None:
"""
must return status for all packages
"""
await client.post(f"/api/v1/packages/{package_ahriman.base}",
json={"status": BuildStatusEnum.Success.value, "package": package_ahriman.view()})
await client.post(f"/api/v1/packages/{package_python_schedule.base}",
json={"status": BuildStatusEnum.Success.value, "package": package_python_schedule.view()})
response = await client.get("/api/v1/packages")
assert response.status == 200
packages = [Package.from_json(item["package"]) for item in await response.json()]
assert packages
assert {package.base for package in packages} == {package_ahriman.base, package_python_schedule.base}
async def test_post(client: TestClient, mocker: MockerFixture) -> None:
"""
must be able to reload packages
"""
load_mock = mocker.patch("ahriman.core.status.watcher.Watcher.load")
response = await client.post("/api/v1/packages")
assert response.status == 204
load_mock.assert_called_once()

View File

@ -29,7 +29,7 @@ target =
path = path =
homepage = homepage =
link_path = link_path =
template_path = /usr/share/ahriman/repo-index.jinja2 template_path = ../web/templates/repo-index.jinja2
[upload] [upload]
target = target =
@ -41,4 +41,4 @@ remote =
bucket = bucket =
[web] [web]
templates = /usr/share/ahriman templates = ../web/templates

View File

@ -0,0 +1,54 @@
<!doctype html>
<html lang="en">
<head>
<title>{{ repository|e }}</title>
{% include "style.jinja2" %}
{% include "sorttable.jinja2" %}
{% include "search.jinja2" %}
</head>
<body>
<div class="root">
<h1>ahriman
<img src="https://img.shields.io/badge/version-{{ version|e }}-informational" alt="{{ version|e }}">
<img src="https://img.shields.io/badge/architecture-{{ architecture|e }}-informational" alt="{{ architecture|e }}">
<img src="https://img.shields.io/badge/service%20status-{{ service.status|e }}-{{ service.status_color|e }}" alt="{{ service.status|e }}" title="{{ service.timestamp|e }}">
</h1>
{% include "search-line.jinja2" %}
<section class="element">
<table class="sortable search-table">
<tr class="header">
<th>package base</th>
<th>packages</th>
<th>version</th>
<th>last update</th>
<th>status</th>
</tr>
{% for package in packages %}
<tr class="package">
<td class="include-search"><a href="{{ package.web_url|e }}" title="{{ package.base|e }}">{{ package.base|e }}</a></td>
<td class="include-search">{{ package.packages|join("<br>"|safe) }}</td>
<td>{{ package.version|e }}</td>
<td>{{ package.timestamp|e }}</td>
<td class="status package-{{ package.status|e }}">{{ package.status|e }}</td>
</tr>
{% endfor %}
</table>
</section>
<footer>
<ul class="navigation">
<li><a href="https://github.com/arcan1s/ahriman" title="sources">ahriman</a></li>
<li><a href="https://github.com/arcan1s/ahriman/releases" title="releases list">releases</a></li>
<li><a href="https://github.com/arcan1s/ahriman/issues" title="issues tracker">report a bug</a></li>
</ul>
</footer>
</div>
</body>
</html>

View File

@ -0,0 +1,62 @@
<!doctype html>
<html lang="en">
<head>
<title>{{ repository|e }}</title>
{% include "style.jinja2" %}
{% include "sorttable.jinja2" %}
{% include "search.jinja2" %}
</head>
<body>
<div class="root">
<h1>Archlinux user repository</h1>
<section class="element">
{% if pgp_key is not none %}
<p>This repository is signed with <a href="http://keys.gnupg.net/pks/lookup?search=0x{{ pgp_key|e }}&fingerprint=on&op=index" title="key search">{{ pgp_key|e }}</a> by default.</p>
{% endif %}
<code>
$ cat /etc/pacman.conf<br>
[{{ repository|e }}]<br>
Server = {{ link_path|e }}<br>
SigLevel = Database{% if has_repo_signed %}Required{% else %}Never{% endif %} Package{% if has_package_signed %}Required{% else %}Never{% endif %} TrustedOnly
</code>
</section>
{% include "search-line.jinja2" %}
<section class="element">
<table class="sortable search-table">
<tr class="header">
<th>package</th>
<th>version</th>
<th>archive size</th>
<th>installed size</th>
<th>build date</th>
</tr>
{% for package in packages %}
<tr class="package">
<td class="include-search"><a href="{{ link_path|e }}/{{ package.filename|e }}" title="{{ package.name|e }}">{{ package.name|e }}</a></td>
<td>{{ package.version|e }}</td>
<td>{{ package.archive_size|e }}</td>
<td>{{ package.installed_size|e }}</td>
<td>{{ package.build_date|e }}</td>
</tr>
{% endfor %}
</table>
</section>
<footer>
<ul class="navigation">
{% if homepage is not none %}
<li><a href="{{ homepage|e }}" title="homepage">Homepage</a></li>
{% endif %}
</ul>
</footer>
</div>
</body>
</html>

View File

@ -0,0 +1,3 @@
<section class="element">
<input type="search" id="search" onkeyup="searchInTable()" placeholder="search for package" title="search for package"/>
</section>

View File

@ -0,0 +1,25 @@
<script type="text/javascript">
function searchInTable() {
const input = document.getElementById("search");
const filter = input.value.toLowerCase();
const tables = document.getElementsByClassName("search-table");
for (let i = 0; i < tables.length; i++) {
const tr = tables[i].getElementsByTagName("tr");
// from 1 coz of header
for (let i = 1; i < tr.length; i++) {
let td = tr[i].getElementsByClassName("include-search");
let display = "none";
for (let j = 0; j < td.length; j++) {
if (td[j].tagName.toLowerCase() === "td") {
if (td[j].innerHTML.toLowerCase().indexOf(filter) > -1) {
display = "";
break;
}
}
}
tr[i].style.display = display;
}
}
}
</script>

View File

@ -0,0 +1 @@
<script src="https://www.kryogenix.org/code/browser/sorttable/sorttable.js"></script>

View File

@ -0,0 +1,136 @@
<style>
:root {
--color-building: 255, 255, 146;
--color-failed: 255, 94, 94;
--color-pending: 255, 255, 146;
--color-success: 94, 255, 94;
--color-unknown: 225, 225, 225;
--color-header: 200, 200, 255;
--color-hover: 255, 255, 225;
--color-line-blue: 235, 235, 255;
--color-line-white: 255, 255, 255;
}
@keyframes blink-building {
0% { background-color: rgba(var(--color-building), 1.0); }
10% { background-color: rgba(var(--color-building), 0.9); }
20% { background-color: rgba(var(--color-building), 0.8); }
30% { background-color: rgba(var(--color-building), 0.7); }
40% { background-color: rgba(var(--color-building), 0.6); }
50% { background-color: rgba(var(--color-building), 0.5); }
60% { background-color: rgba(var(--color-building), 0.4); }
70% { background-color: rgba(var(--color-building), 0.3); }
80% { background-color: rgba(var(--color-building), 0.2); }
90% { background-color: rgba(var(--color-building), 0.1); }
100% { background-color: rgba(var(--color-building), 0.0); }
}
div.root {
width: 70%;
padding: 15px 15% 0;
}
section.element, footer {
width: 100%;
padding: 10px 0;
}
code, input, table {
width: inherit;
}
th, td {
padding: 5px;
}
tr.package:nth-child(odd) {
background-color: rgba(var(--color-line-white), 1.0);
}
tr.package:nth-child(even) {
background-color: rgba(var(--color-line-blue), 1.0);
}
tr.package:hover {
background-color: rgba(var(--color-hover), 1.0);
}
tr.header{
background-color: rgba(var(--color-header), 1.0);
}
td.status {
text-align: center;
}
td.package-unknown {
background-color: rgba(var(--color-unknown), 1.0);
}
td.package-pending {
background-color: rgba(var(--color-pending), 1.0);
}
td.package-building {
background-color: rgba(var(--color-building), 1.0);
animation-name: blink-building;
animation-duration: 1s;
animation-timing-function: linear;
animation-iteration-count: infinite;
animation-direction: alternate;
}
td.package-failed {
background-color: rgba(var(--color-failed), 1.0);
}
td.package-success {
background-color: rgba(var(--color-success), 1.0);
}
li.service-unknown {
background-color: rgba(var(--color-unknown), 1.0);
}
li.service-building {
background-color: rgba(var(--color-building), 1.0);
animation-name: blink-building;
animation-duration: 1s;
animation-timing-function: linear;
animation-iteration-count: infinite;
animation-direction: alternate;
}
li.service-failed {
background-color: rgba(var(--color-failed), 1.0);
}
li.service-success {
background-color: rgba(var(--color-success), 1.0);
}
ul.navigation {
list-style-type: none;
margin: 0;
padding: 0;
overflow: hidden;
background-color: rgba(var(--color-header), 1.0);
}
ul.navigation li {
float: left;
}
ul.navigation li.status {
display: block;
text-align: center;
text-decoration: none;
padding: 14px 16px;
}
ul.navigation li a {
display: block;
color: black;
text-align: center;
text-decoration: none;
padding: 14px 16px;
}
ul.navigation li a:hover {
background-color: rgba(var(--color-hover), 1.0);
}
</style>