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=[
"pytest",
"pytest-asyncio",
"pytest-aiohttp",
"pytest-cov",
"pytest-helpers-namespace",
"pytest-mock",

View File

@ -35,8 +35,8 @@ class Pacman:
:param config: configuration instance
"""
root = config.get("alpm", "root")
pacman_root = config.get("alpm", "database")
self.handle = Handle(root, pacman_root)
pacman_root = config.getpath("alpm", "database")
self.handle = Handle(root, str(pacman_root))
for repository in config.getlist("alpm", "repositories"):
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
"""
value = Path(self.get("settings", "include"))
return self.absolute_path_for(value)
return self.getpath("settings", "include")
@classmethod
def from_path(cls: Type[Configuration], path: Path, logfile: bool) -> Configuration:
@ -71,16 +70,6 @@ class Configuration(configparser.RawConfigParser):
config.load_logging(logfile)
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]]:
"""
dump configuration to dictionary
@ -112,6 +101,18 @@ class Configuration(configparser.RawConfigParser):
return []
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:
"""
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:
try:
value = Path(self.get("settings", "logging"))
config_path = self.absolute_path_for(value)
config_path = self.getpath("settings", "logging")
fileConfig(config_path)
except (FileNotFoundError, PermissionError):
console_logger()

View File

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

View File

@ -19,8 +19,6 @@
#
import logging
from pathlib import Path
from ahriman.core.alpm.pacman import Pacman
from ahriman.core.alpm.repo import Repo
from ahriman.core.configuration import Configuration
@ -52,7 +50,7 @@ class Properties:
self.aur_url = config.get("alpm", "aur_url")
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.pacman = Pacman(config)

View File

@ -82,8 +82,9 @@ def setup_service(architecture: str, config: Configuration) -> web.Application:
application.logger.info("setup routes")
setup_routes(application)
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["config"] = config

View File

@ -83,10 +83,10 @@ def package_description_python2_schedule() -> PackageDescription:
@pytest.fixture
def repository_paths() -> RepositoryPaths:
def repository_paths(configuration: Configuration) -> RepositoryPaths:
return RepositoryPaths(
architecture="x86_64",
root=Path("/var/lib/ahriman"))
root=configuration.getpath("repository", "root"))
@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
"""
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:
@ -34,7 +35,8 @@ def test_absolute_path_for_relative(configuration: Configuration) -> None:
must prepend root path to relative path
"""
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.parent == configuration.path.parent
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
@pytest.mark.asyncio
async def test_on_startup(application: web.Application, watcher: Watcher, mocker: MockerFixture) -> None:
"""
must call load method
@ -20,7 +19,6 @@ async def test_on_startup(application: web.Application, watcher: Watcher, mocker
load_mock.assert_called_once()
@pytest.mark.asyncio
async def test_on_startup_exception(application: web.Application, watcher: Watcher, mocker: MockerFixture) -> None:
"""
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 =
homepage =
link_path =
template_path = /usr/share/ahriman/repo-index.jinja2
template_path = ../web/templates/repo-index.jinja2
[upload]
target =
@ -41,4 +41,4 @@ remote =
bucket =
[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>