mirror of
https://github.com/sabnzbd/sabnzbd.git
synced 2026-09-22 02:45:20 -04:00
691 lines
25 KiB
Python
691 lines
25 KiB
Python
#!/usr/bin/python3 -OO
|
|
# Copyright 2007-2026 by The SABnzbd-Team (sabnzbd.org)
|
|
#
|
|
# 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 2
|
|
# 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, write to the Free Software
|
|
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
|
|
|
"""
|
|
tests.testhelper - Basic helper functions
|
|
"""
|
|
|
|
import asyncio
|
|
import copy
|
|
import io
|
|
import os
|
|
import shutil
|
|
import re
|
|
import socket
|
|
import tempfile
|
|
import time
|
|
import uuid
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from typing import BinaryIO, Optional, Callable
|
|
|
|
import pytest
|
|
from random import choice, randint
|
|
import requests
|
|
from playwright.sync_api import Page, expect
|
|
from string import ascii_lowercase, digits
|
|
from unittest import mock
|
|
import xmltodict
|
|
from werkzeug import Request
|
|
from werkzeug.utils import send_from_directory
|
|
from pyfakefs.fake_filesystem_unittest import Patcher
|
|
from pyfakefs.fake_filesystem import OSType
|
|
|
|
import sabnzbd
|
|
import sabnzbd.cfg as cfg
|
|
from sabnzbd.config import Option
|
|
from sabnzbd.constants import (
|
|
DEF_INI_FILE,
|
|
Status,
|
|
PP_LOOKUP,
|
|
NORMAL_PRIORITY,
|
|
)
|
|
import sabnzbd.database as db
|
|
from sabnzbd.misc import pp_to_opts
|
|
import sabnzbd.filesystem as filesystem
|
|
|
|
import tests.sabnews
|
|
|
|
|
|
def run_async(coro):
|
|
"""Run a coroutine to completion, also when the thread already has a running loop.
|
|
|
|
Playwright's sync API keeps a loop running for as long as a browser fixture is alive,
|
|
which makes asyncio.run() refuse to start another one in the same thread.
|
|
"""
|
|
try:
|
|
asyncio.get_running_loop()
|
|
except RuntimeError:
|
|
return asyncio.run(coro)
|
|
|
|
with ThreadPoolExecutor(max_workers=1) as pool:
|
|
return pool.submit(asyncio.run, coro).result()
|
|
|
|
|
|
SAB_HOST = "127.0.0.1"
|
|
SAB_NEWSSERVER_HOST = "127.0.0.1"
|
|
|
|
# Each pytest-xdist worker runs in its own process and imports its own copy of
|
|
# these module-level constants. Many test modules capture them with
|
|
# `from tests.testhelper import SAB_PORT, SAB_CACHE_DIR, ...`, so the values must
|
|
# be settled here at import time and never mutated afterwards. To let workers run
|
|
# in parallel (-n auto) without colliding on the shared cache dir or on fixed TCP
|
|
# ports, derive a per-worker suffix and bind free ports once, right here.
|
|
#
|
|
# For a normal single-process run PYTEST_XDIST_WORKER is unset, so the suffix is
|
|
# empty and the cache dir keeps its historical "tests/cache" path.
|
|
_XDIST_WORKER = os.environ.get("PYTEST_XDIST_WORKER", "")
|
|
_WORKER_SUFFIX = ("_" + _XDIST_WORKER) if _XDIST_WORKER else ""
|
|
|
|
|
|
def _find_free_port(host: str = SAB_HOST) -> int:
|
|
"""Ask the OS for a currently-unused TCP port and return it."""
|
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
sock.bind((host, 0))
|
|
return sock.getsockname()[1]
|
|
|
|
|
|
SAB_PORT = _find_free_port()
|
|
SAB_APIKEY = "apikey"
|
|
SAB_BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
SAB_CACHE_DIR = os.path.join(SAB_BASE_DIR, "cache" + _WORKER_SUFFIX)
|
|
SAB_DATA_DIR = os.path.join(SAB_BASE_DIR, "data")
|
|
SAB_INCOMPLETE_DIR = os.path.join(SAB_CACHE_DIR, "Downloads", "incomplete")
|
|
SAB_COMPLETE_DIR = os.path.join(SAB_CACHE_DIR, "Downloads", "complete")
|
|
SAB_NEWSSERVER_PORT = _find_free_port(SAB_NEWSSERVER_HOST)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def config_env(monkeypatch, request):
|
|
"""Change config-values on the fly, per test"""
|
|
monkeypatch.setattr(sabnzbd.config, "CONFIG", sabnzbd.config.SABnzbdConfig())
|
|
|
|
# Add default categories
|
|
sabnzbd.config.ConfigCat("*", {"order": 0, "pp": "3", "script": "None", "priority": NORMAL_PRIORITY})
|
|
sabnzbd.config.ConfigCat("movies", {"order": 1})
|
|
sabnzbd.config.ConfigCat("tv", {"order": 2})
|
|
sabnzbd.config.ConfigCat("audio", {"order": 3})
|
|
sabnzbd.config.ConfigCat("software", {"order": 4})
|
|
|
|
for attr in dir(cfg):
|
|
if isinstance(getattr(cfg, attr), Option):
|
|
option = copy.copy(getattr(cfg, attr))
|
|
monkeypatch.setattr(cfg, attr, option)
|
|
sabnzbd.config.add_to_database(option.section, option.keyword, option)
|
|
|
|
marker = request.node.get_closest_marker("config")
|
|
if marker is None:
|
|
# No config changes for this test
|
|
yield
|
|
return
|
|
|
|
if marker.args:
|
|
config = marker.args[0]
|
|
|
|
if callable(config):
|
|
if not hasattr(request.node, "callspec"):
|
|
raise RuntimeError("Dynamic config requires parameterized test")
|
|
config = config(request.node.callspec.params)
|
|
else:
|
|
if not hasattr(request.node, "callspec"):
|
|
raise RuntimeError("@pytest.mark.config requires parameterized 'config'")
|
|
config = request.node.callspec.params.get("config")
|
|
if config is None:
|
|
raise RuntimeError("Missing 'config' param for @pytest.mark.config")
|
|
|
|
# Setting up as requested
|
|
for item, val in config.items():
|
|
getattr(cfg, item).set(val)
|
|
|
|
yield
|
|
|
|
|
|
@pytest.mark.parametrize("config", [{"web_host": "0.0.0.0"}, {"web_host": "::1"}])
|
|
@pytest.mark.config()
|
|
def test_config_parametrize(config):
|
|
for item, val in config.items():
|
|
assert getattr(cfg, item)() == val
|
|
|
|
|
|
@pytest.mark.parametrize("web_host", ["0.0.0.0", "::1"])
|
|
@pytest.mark.config(lambda params: {"web_host": params["web_host"]})
|
|
def test_config_parametrize_dynamic(web_host):
|
|
assert cfg.web_host() == web_host
|
|
|
|
|
|
@pytest.mark.config({"web_host": "0.0.0.0"})
|
|
def test_config_marker():
|
|
assert cfg.web_host() == "0.0.0.0"
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def platform_env(monkeypatch, request):
|
|
"""Change platform-values on the fly, per test"""
|
|
marker = request.node.get_closest_marker("platform")
|
|
if marker is None:
|
|
# No platform changes for this test
|
|
yield
|
|
return
|
|
|
|
if marker.args:
|
|
platform_name = marker.args[0]
|
|
|
|
if callable(platform_name):
|
|
if not hasattr(request.node, "callspec"):
|
|
raise RuntimeError("Dynamic platform requires parameterized test")
|
|
platform_name = platform_name(request.node.callspec.params)
|
|
else:
|
|
if not hasattr(request.node, "callspec"):
|
|
raise RuntimeError("@pytest.mark.platform requires parameterized 'platform'")
|
|
platform_name = request.node.callspec.params.get("platform")
|
|
if platform_name is None:
|
|
raise RuntimeError("Missing 'platform' param for @pytest.mark.platform")
|
|
|
|
if platform_name == "win32":
|
|
monkeypatch.setattr(sabnzbd, "WINDOWS", True)
|
|
monkeypatch.setattr(sabnzbd, "MACOS", False)
|
|
elif platform_name == "macos":
|
|
monkeypatch.setattr(sabnzbd, "WINDOWS", False)
|
|
monkeypatch.setattr(sabnzbd, "MACOS", True)
|
|
elif platform_name == "linux":
|
|
monkeypatch.setattr(sabnzbd, "WINDOWS", False)
|
|
monkeypatch.setattr(sabnzbd, "MACOS", False)
|
|
else:
|
|
raise ValueError(f"Unknown platform: {platform_name}")
|
|
|
|
yield platform_name
|
|
|
|
|
|
@pytest.mark.parametrize("platform", ["win32", "macos", "linux"])
|
|
@pytest.mark.platform()
|
|
def test_platform_parametrize(platform):
|
|
if platform == "win32":
|
|
assert sabnzbd.WINDOWS
|
|
assert not sabnzbd.MACOS
|
|
elif platform == "macos":
|
|
assert sabnzbd.MACOS
|
|
assert not sabnzbd.WINDOWS
|
|
elif platform == "linux":
|
|
assert not sabnzbd.WINDOWS
|
|
assert not sabnzbd.MACOS
|
|
|
|
|
|
@pytest.mark.platform("win32")
|
|
def test_platform_marker_win32():
|
|
assert sabnzbd.WINDOWS
|
|
assert not sabnzbd.MACOS
|
|
|
|
|
|
@pytest.mark.platform("macos")
|
|
def test_platform_marker_macos():
|
|
assert sabnzbd.MACOS
|
|
assert not sabnzbd.WINDOWS
|
|
|
|
|
|
@pytest.mark.platform("linux")
|
|
def test_platform_marker_linux():
|
|
assert not sabnzbd.WINDOWS
|
|
assert not sabnzbd.MACOS
|
|
|
|
|
|
@pytest.fixture()
|
|
def fake_fs(request):
|
|
"""Create fake filesystem"""
|
|
fake_fs_marker = request.node.get_closest_marker("fake_fs")
|
|
platform_marker = request.node.get_closest_marker("platform")
|
|
|
|
with Patcher() as patcher:
|
|
if platform_marker:
|
|
os_map = {"win32": OSType.WINDOWS, "macos": OSType.MACOS, "linux": OSType.LINUX}
|
|
patcher.fs.os = os_map.get(platform_marker.args[0], patcher.fs.os)
|
|
|
|
if fake_fs_marker:
|
|
options = fake_fs_marker.args[0] if fake_fs_marker.args else {}
|
|
|
|
for key, val in options.items():
|
|
if key == "create_dirs":
|
|
# Special case: create directories
|
|
for dir_path in val:
|
|
patcher.fs.makedirs(dir_path, mode=755, exist_ok=True)
|
|
# Verify the fake filesystem does its thing
|
|
assert os.path.exists(dir_path) is True
|
|
elif hasattr(patcher.fs, key):
|
|
setattr(patcher.fs, key, val)
|
|
else:
|
|
raise AttributeError(f"fake_fs has no attribute '{key}'")
|
|
|
|
yield patcher.fs
|
|
|
|
|
|
def get_url_result(url="", host=SAB_HOST, port=SAB_PORT):
|
|
"""Do basic request to web page"""
|
|
arguments = {"apikey": SAB_APIKEY}
|
|
return requests.get("http://%s:%s/%s/" % (host, port, url), params=arguments).text
|
|
|
|
|
|
def get_page_session(host=SAB_HOST, port=SAB_PORT) -> tuple[requests.Session, str]:
|
|
"""Load a page as a browser would, returning the session holding its cookie and the CSRF token it rendered"""
|
|
session = requests.Session()
|
|
page = session.get("http://%s:%s/config/general" % (host, port))
|
|
page.raise_for_status()
|
|
|
|
# Every skin renders it into a var for its ajax calls; the quoting differs between them
|
|
token = re.search(r"""var csrfToken = ['"]([a-f0-9]+)['"]""", page.text)
|
|
assert token, "no CSRF token in the page, so a page POST cannot be built"
|
|
return session, token.group(1)
|
|
|
|
|
|
def post_url_result(url="", data=None, host=SAB_HOST, port=SAB_PORT) -> str:
|
|
"""POST to a page route the way the interface does, with a session cookie and its token"""
|
|
session, csrf_token = get_page_session(host, port)
|
|
payload = {"csrf_token": csrf_token}
|
|
payload.update(data or {})
|
|
response = session.post("http://%s:%s/%s" % (host, port, url), data=payload)
|
|
response.raise_for_status()
|
|
return response.text
|
|
|
|
|
|
def get_api_result(mode, host=SAB_HOST, port=SAB_PORT, extra_arguments={}):
|
|
"""Build request to SABnzbd"""
|
|
arguments = {"apikey": SAB_APIKEY, "mode": mode}
|
|
arguments.update(extra_arguments)
|
|
|
|
r = requests.get("http://%s:%s/api" % (host, port), params=arguments)
|
|
if "xml" in r.headers["Content-Type"]:
|
|
return xmltodict.parse(r.text)
|
|
if "json" in r.headers["Content-Type"]:
|
|
return r.json()
|
|
return r.text
|
|
|
|
|
|
def create_nzb(nzb_dir: str, metadata: Optional[dict[str, str]] = None, output_file: Optional[str] = None) -> str:
|
|
"""Create NZB from directory using SABNews"""
|
|
nzb_dir_full = os.path.join(SAB_DATA_DIR, nzb_dir)
|
|
return tests.sabnews.create_nzb(nzb_dir=nzb_dir_full, metadata=metadata, output_file=output_file)
|
|
|
|
|
|
def create_and_read_nzb_fp(nzbdir: str, metadata: Optional[dict[str, str]] = None) -> BinaryIO:
|
|
"""Create NZB and return its data, leaving no file behind"""
|
|
# Write the NZB to its own temporary directory. The input directories are shared
|
|
# between tests, so writing it there has tests running in parallel reading and
|
|
# removing each other's file. Leaving it out of there also keeps it from ending up
|
|
# in the next NZB created from the same input.
|
|
with tempfile.TemporaryDirectory() as nzb_output_dir:
|
|
nzb_path = create_nzb(nzbdir, metadata, output_file=os.path.join(nzb_output_dir, "test.nzb"))
|
|
with open(nzb_path, "rb") as nzb_data_fp:
|
|
return io.BytesIO(nzb_data_fp.read())
|
|
|
|
|
|
def httpserver_handler_data_dir(request: Request):
|
|
"""Respond to a httpserver request with a file in SAB_DATA_DIR"""
|
|
return send_from_directory(directory=SAB_DATA_DIR, path=request.path.lstrip("/"), environ=request.environ)
|
|
|
|
|
|
def random_name(length: int = 16) -> str:
|
|
"""Shorthand to create a simple random string"""
|
|
return "".join(choice(ascii_lowercase + digits) for _ in range(length))
|
|
|
|
|
|
def wait_for(
|
|
condition: Callable,
|
|
timeout: float = 2,
|
|
interval: float = 0.05,
|
|
err_msg: str = "Condition not met",
|
|
suppress: tuple[type[Exception], ...] = (),
|
|
):
|
|
"""Polls condition every interval until timeout."""
|
|
deadline = time.time() + timeout
|
|
while True:
|
|
try:
|
|
if result := condition():
|
|
return result
|
|
except suppress:
|
|
pass
|
|
if time.time() > deadline:
|
|
pytest.fail(err_msg)
|
|
time.sleep(interval)
|
|
|
|
|
|
@pytest.fixture
|
|
def sleepless(monkeypatch):
|
|
monkeypatch.setattr(time, "sleep", lambda _: None)
|
|
yield
|
|
|
|
|
|
class FakeHistoryDB(db.HistoryDB):
|
|
"""
|
|
HistoryDB class with added control of the db_path via an argument and the
|
|
capability to generate history entries.
|
|
"""
|
|
|
|
category_options = ["catA", "catB", "1234", "يوزنت"]
|
|
distro_names = ["Ubuntu", "デビアン", "Gentoo_Hobby_Edition", "Красная Шляпа"]
|
|
status_options = [
|
|
Status.COMPLETED,
|
|
Status.EXTRACTING,
|
|
Status.FAILED,
|
|
Status.MOVING,
|
|
Status.QUICK_CHECK,
|
|
Status.REPAIRING,
|
|
Status.RUNNING,
|
|
Status.VERIFYING,
|
|
]
|
|
|
|
def __init__(self, db_path):
|
|
self._monkeypatch = pytest.MonkeyPatch()
|
|
self._monkeypatch.setattr(db.HistoryDB, "db_path", db_path)
|
|
self._monkeypatch.setattr(db.HistoryDB, "startup_done", False)
|
|
super().__init__()
|
|
|
|
def close(self):
|
|
"""Close the connection and restore the class attributes patched on creation"""
|
|
try:
|
|
super().close()
|
|
finally:
|
|
self._monkeypatch.undo()
|
|
|
|
def add_fake_history_job(
|
|
self,
|
|
name: str,
|
|
status: str = Status.COMPLETED,
|
|
category: str = "*",
|
|
password: str = "",
|
|
path: Optional[str] = None,
|
|
futuretype: bool = False,
|
|
archive: bool = False,
|
|
completed: Optional[float] = None,
|
|
) -> str:
|
|
"""Add a single history entry, with random values for anything not specified"""
|
|
nzo = mock.Mock()
|
|
|
|
nzo.password = password
|
|
nzo.correct_password = "secret"
|
|
nzo.final_name = name
|
|
nzo.filename = "%s%s.nzb" % (name, "{{" + password + "}}" if password else "")
|
|
nzo.cat = category
|
|
nzo.script = "placeholder_script"
|
|
nzo.url = "placeholder_url"
|
|
nzo.status = status
|
|
nzo.fail_msg = "Failure" if status == Status.FAILED else ""
|
|
nzo.nzo_id = str(uuid.uuid4())
|
|
nzo.bytes_downloaded = randint(1024, 1024**4)
|
|
nzo.md5sum = "".join(choice("abcdef" + digits) for i in range(32))
|
|
nzo.repair, nzo.unpack, nzo.delete = pp_to_opts(choice(list(PP_LOOKUP.keys()))) # for "pp"
|
|
nzo.nzo_info = {"download_time": randint(1, 10**4)}
|
|
nzo.unpack_info = {"unpack_info": "placeholder unpack_info line\r\n" * 3}
|
|
nzo.duplicate_key = "show/season/episode"
|
|
nzo.time_added = int(time.time())
|
|
nzo.futuretype = futuretype # for "report", only True when fetching an URL
|
|
if path is None:
|
|
path = os.path.join(os.path.dirname(db.HistoryDB.db_path), "placeholder_downpath")
|
|
nzo.download_path = path
|
|
|
|
# Mock time when calling add_history_db() to randomize completion times
|
|
almost_time = mock.Mock(return_value=completed if completed is not None else time.time() - randint(0, 10**8))
|
|
with mock.patch("time.time", almost_time):
|
|
self.add_history_db(
|
|
nzo,
|
|
storage=os.path.join(os.path.dirname(db.HistoryDB.db_path), "placeholder_workdir"),
|
|
postproc_time=randint(1, 10**3),
|
|
script_output="",
|
|
script_line="",
|
|
)
|
|
if archive:
|
|
self.archive(nzo.nzo_id)
|
|
|
|
return nzo.nzo_id
|
|
|
|
def add_fake_history_jobs(self, number_of_entries=1):
|
|
"""Generate a history db with any number of fake entries"""
|
|
for _ in range(0, number_of_entries):
|
|
self.add_fake_history_job(
|
|
name="%s.%s.Linux.ISO-Usenet" % (choice(self.distro_names), random_name()),
|
|
status=choice([Status.COMPLETED, choice(self.status_options)]),
|
|
category=choice(self.category_options),
|
|
password=choice(["secret", ""]),
|
|
)
|
|
|
|
|
|
# Min/max size for random files used in generated NZBs (bytes)
|
|
MIN_FILESIZE = 128
|
|
MAX_FILESIZE = 1024
|
|
|
|
|
|
class AddingNZBsTestBase:
|
|
"""Helpers shared by the functional tests that add NZBs to a running SABnzbd"""
|
|
|
|
def _api_set_config(self, keyword, value):
|
|
"""Shorthand for the API-call to change the config settings"""
|
|
json = get_api_result(
|
|
mode="set_config",
|
|
extra_arguments={
|
|
"section": "misc",
|
|
"keyword": keyword,
|
|
"value": value,
|
|
},
|
|
)
|
|
assert value == json["config"]["misc"][keyword]
|
|
|
|
def _create_random_nzb(self, metadata=None):
|
|
# Create some simple, unique nzb
|
|
job_dir = os.path.join(SAB_CACHE_DIR, "NZB" + os.urandom(8).hex())
|
|
try:
|
|
os.mkdir(job_dir)
|
|
job_file = "%s.bin" % random_name()
|
|
with open(os.path.join(job_dir, job_file), "wb") as f:
|
|
f.write(os.urandom(randint(MIN_FILESIZE, MAX_FILESIZE)))
|
|
except Exception:
|
|
pytest.fail("Failed to create random nzb")
|
|
|
|
return create_nzb(job_dir, metadata=metadata)
|
|
|
|
def _add_backup_directory(self):
|
|
# Set an nzb backup directory
|
|
backup_dir = os.path.join(SAB_CACHE_DIR, "nzb_backup_dir" + os.urandom(4).hex())
|
|
self._api_set_config("nzb_backup_dir", backup_dir)
|
|
return backup_dir
|
|
|
|
def _clear_and_reset_backup_directory(self, backup_dir):
|
|
# Reset duplicate handling (0), nzb_backup_dir ("")
|
|
get_api_result(mode="set_config_default", extra_arguments={"keyword": ["no_dupes", "nzb_backup_dir"]})
|
|
|
|
# Remove backup_dir
|
|
for timer in range(0, 5):
|
|
try:
|
|
shutil.rmtree(backup_dir)
|
|
break
|
|
except OSError:
|
|
time.sleep(1)
|
|
else:
|
|
pytest.fail("Failed to erase nzb_backup_dir %s" % backup_dir)
|
|
|
|
|
|
@pytest.mark.usefixtures("run_sabnzbd")
|
|
class SABnzbdBaseTest:
|
|
@pytest.fixture(autouse=True)
|
|
def _setup_page(self, run_sabnews, page: Page):
|
|
self.page = page
|
|
|
|
def no_page_crash(self):
|
|
# Do a base test if CherryPy did not report test
|
|
assert "500 Internal Server Error" not in self.page.title()
|
|
|
|
def open_page(self, url):
|
|
# Open a page and test for crash
|
|
self.page.goto(url)
|
|
self.no_page_crash()
|
|
|
|
def click_expecting_dialog(self, locator, accept: bool = False, timeout: int = 15):
|
|
"""Click something that raises a JS confirm()/alert and answer it.
|
|
|
|
The handler is registered before the click: a confirm() raised synchronously from
|
|
the click handler blocks the page, so click() only returns once it is answered.
|
|
"""
|
|
answered = []
|
|
|
|
def handle_dialog(dialog):
|
|
answered.append(dialog.message)
|
|
if accept:
|
|
dialog.accept()
|
|
else:
|
|
dialog.dismiss()
|
|
|
|
self.page.once("dialog", handle_dialog)
|
|
locator.click()
|
|
|
|
# Wait through Playwright, which is what dispatches the event
|
|
deadline = time.time() + timeout
|
|
while not answered and time.time() < deadline:
|
|
self.page.wait_for_timeout(100)
|
|
|
|
if not answered:
|
|
pytest.fail("No dialog was raised within %s seconds" % timeout)
|
|
|
|
|
|
class DownloadFlowBasics(SABnzbdBaseTest):
|
|
def is_server_configured(self):
|
|
"""Check if the wizard was already performed.
|
|
If not: run the wizard!
|
|
"""
|
|
with open(os.path.join(SAB_CACHE_DIR, DEF_INI_FILE), "r") as config_file:
|
|
if f"[[{SAB_NEWSSERVER_HOST}]]" not in config_file.read():
|
|
self.start_wizard()
|
|
|
|
def start_wizard(self):
|
|
# Language-selection
|
|
self.open_page("http://%s:%s/wizard/" % (SAB_HOST, SAB_PORT))
|
|
self.page.locator("#en").click()
|
|
self.page.locator("button.btn.btn-default").click()
|
|
|
|
# Fill server-info
|
|
self.no_page_crash()
|
|
self.page.locator("[name='host']").fill(SAB_NEWSSERVER_HOST)
|
|
|
|
# Disable SSL for testing
|
|
self.page.locator("[name='ssl']").click()
|
|
|
|
# This will fail if the translations failed to compile!
|
|
self.page.locator("a:has-text('Advanced Settings')").click()
|
|
|
|
# Change port
|
|
port_inp = self.page.locator("[name='port']")
|
|
port_inp.fill(str(SAB_NEWSSERVER_PORT))
|
|
|
|
# Test server-check
|
|
self.page.locator("#serverTest").click()
|
|
expect(self.page.locator("#serverResponse")).to_contain_text("Connection Successful", timeout=5000)
|
|
|
|
# Final page done
|
|
self.page.locator("#next-button").click()
|
|
self.no_page_crash()
|
|
# The first block lists the access URLs; the others are the download folders
|
|
expect(self.page.locator(".quoteBlock").first).to_contain_text("http://%s:%s" % (SAB_HOST, SAB_PORT))
|
|
|
|
# Go to SAB!
|
|
self.page.locator(".btn.btn-success").click()
|
|
self.no_page_crash()
|
|
|
|
def download_nzb(self, nzb_dir: str, file_output: list[str], dir_name_as_job_name: bool = False):
|
|
# Verify if the server was setup before we start
|
|
self.is_server_configured()
|
|
|
|
# Delete all jobs from queue and history
|
|
for mode in ("queue", "history"):
|
|
get_api_result(mode=mode, extra_arguments={"name": "delete", "value": "all", "del_files": 1})
|
|
|
|
# Create NZB
|
|
nzb_path = create_nzb(nzb_dir)
|
|
|
|
# Add NZB
|
|
if dir_name_as_job_name:
|
|
test_job_name = os.path.basename(nzb_dir)
|
|
else:
|
|
# replace "-" because guessit thinks AB01-9999 is episodes 1 to 9999 and takes a long time
|
|
test_job_name = "TestDownload_%s" % str(uuid.uuid4()).replace("-", "")
|
|
job = get_api_result("addlocalfile", extra_arguments={"name": nzb_path, "nzbname": test_job_name})
|
|
assert job["nzo_ids"]
|
|
assert job["status"]
|
|
job_nzo_id = job["nzo_ids"][0]
|
|
|
|
# Remove NZB-file
|
|
os.remove(nzb_path)
|
|
|
|
completed_dir = None
|
|
queue = {}
|
|
history = {}
|
|
|
|
# Wait for the job to be removed and appear in the history
|
|
for _ in range(200):
|
|
try:
|
|
queue = get_api_result(mode="queue", extra_arguments={"nzo_ids": job_nzo_id})["queue"]
|
|
assert not queue["slots"]
|
|
history = get_api_result(mode="history", extra_arguments={"nzo_ids": job_nzo_id})["history"]
|
|
assert history["slots"][0]["nzo_id"] == job_nzo_id
|
|
assert history["slots"][0]["status"] == "Completed"
|
|
completed_dir = history["slots"][0]["storage"]
|
|
assert completed_dir # has finished postproc
|
|
if os.path.isfile(completed_dir):
|
|
completed_dir = os.path.dirname(completed_dir)
|
|
break
|
|
except (IndexError, AssertionError):
|
|
time.sleep(0.1)
|
|
else:
|
|
if not completed_dir:
|
|
completed_dir = os.path.join(SAB_COMPLETE_DIR, test_job_name)
|
|
completed_files = filesystem.globber(completed_dir, "*")
|
|
if queue.get("slots"):
|
|
pytest.fail("Download did not complete: it is still in the queue, queue=%s" % queue.get("slots"))
|
|
if history.get("slots"):
|
|
pytest.fail(
|
|
"Download did not complete: it is in the history, history=%s, completed_files=%s"
|
|
% (
|
|
history.get("slots"),
|
|
completed_files,
|
|
)
|
|
)
|
|
# Not in the queue or history
|
|
pytest.fail(
|
|
"Download did not complete: not in the queue or history, completed_files=%s" % (completed_files,)
|
|
)
|
|
|
|
# Verify all files in the expected file_output are present among the completed files.
|
|
# Sometimes par2 can also be included, but we accept that. For example when small
|
|
# par2 files get assembled in after the download already finished (see #1509)
|
|
for i in range(100):
|
|
completed_files = filesystem.globber(completed_dir, "*")
|
|
try:
|
|
for filename in file_output:
|
|
assert filename in completed_files
|
|
# All filenames found
|
|
break
|
|
except AssertionError:
|
|
if i % 10 == 0:
|
|
print("Expected filename %s not found in completed_files %s" % (filename, completed_files))
|
|
# Wait before trying again with a fresh list of completed files
|
|
time.sleep(0.1)
|
|
else:
|
|
pytest.fail("Time ran out waiting for expected filenames to show up")
|
|
|
|
# Verify if the garbage collection works (see #1628)
|
|
# We need to give it a second to calm down and clear the variables
|
|
for _ in range(100):
|
|
gc_results = get_api_result("gc_stats")["value"]
|
|
if not gc_results:
|
|
break
|
|
time.sleep(0.1)
|
|
else:
|
|
pytest.fail(f"Objects were left in memory after the job finished! {gc_results}")
|