mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-21 04:11:36 +00:00
Remove the vestigial encrypted-archive ingestion path that the loader can no longer service: - scanner no longer lists .psarc files; library is sloppaks + loose folders - upload endpoint accepts .sloppak only (drop .psarc + magic-byte branch) - delete endpoint handles sloppak/loose only - drop the psarc_platform (pc/mac) setting, its validation and tests - default CONFIG_DIR no longer references an external game data folder - drop the now-unused pycryptodome dependency (no module imports it) - correct stale comments that described scanning as archive decryption Updates test_settings_api / test_settings_export to match (scan fixtures now build .sloppak stubs).
685 lines
27 KiB
Python
685 lines
27 KiB
Python
"""Tests for server.py /api/settings — partial-update safety and the
|
|
master_difficulty key added in slopsmith#48 PR 2.
|
|
|
|
The endpoint must merge only keys present in the request body so that
|
|
single-key POSTs (like the difficulty slider's oninput fire-and-forget)
|
|
don't clobber unrelated settings on disk.
|
|
|
|
Also covers _get_dlc_dir() precedence: empty/unset DLC_DIR must not
|
|
shadow the config.json dlc_dir fallback.
|
|
"""
|
|
|
|
import importlib
|
|
import json
|
|
import sys
|
|
|
|
import pytest
|
|
|
|
|
|
class _DirectResponse:
|
|
def __init__(self, payload, status_code=200):
|
|
self._payload = payload
|
|
self.status_code = status_code
|
|
|
|
def json(self):
|
|
return self._payload
|
|
|
|
|
|
class _DirectSettingsClient:
|
|
def __init__(self, server):
|
|
self._server = server
|
|
|
|
def get(self, path):
|
|
if path != "/api/settings":
|
|
raise ValueError(f"unsupported path: {path}")
|
|
return _DirectResponse(self._server.get_settings())
|
|
|
|
def post(self, path, json):
|
|
if path != "/api/settings":
|
|
raise ValueError(f"unsupported path: {path}")
|
|
return _DirectResponse(self._server.save_settings(json))
|
|
|
|
def close(self):
|
|
pass
|
|
|
|
|
|
@pytest.fixture()
|
|
def client(tmp_path, monkeypatch):
|
|
# Point CONFIG_DIR at a per-test temp path BEFORE server's
|
|
# import-time side effects run. server.py reads CONFIG_DIR from the
|
|
# environment at module load (line 35) and immediately constructs
|
|
# `meta_db = MetadataDB()` at module level, which calls
|
|
# CONFIG_DIR.mkdir(...) and opens a sqlite file — a plain
|
|
# post-import monkeypatch on server.CONFIG_DIR wouldn't catch those
|
|
# side effects, and the real user config dir would get written to.
|
|
# Forcing a fresh import inside the patched env means each test
|
|
# gets an isolated meta_db + config dir.
|
|
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
|
|
monkeypatch.setenv("SLOPSMITH_SKIP_STARTUP_TASKS", "1")
|
|
sys.modules.pop("server", None)
|
|
server = importlib.import_module("server")
|
|
test_client = _DirectSettingsClient(server)
|
|
try:
|
|
yield test_client
|
|
finally:
|
|
# This fixture drives settings handlers directly (no FastAPI/HTTP
|
|
# layer), so there's nothing to close on the client side — the
|
|
# `close()` is just a stub kept for symmetry. What we *do* need
|
|
# to release is the sqlite connection meta_db opened at import:
|
|
# without this teardown each test leaks a file handle and
|
|
# pytest's per-test tmp_path cleanup can fail on Windows while
|
|
# that handle is still open.
|
|
test_client.close()
|
|
meta_db = getattr(server, "meta_db", None)
|
|
conn = getattr(meta_db, "conn", None)
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
def _read_cfg(tmp_path):
|
|
return json.loads((tmp_path / "config.json").read_text())
|
|
|
|
|
|
# ── master_difficulty round-trip ─────────────────────────────────────────────
|
|
|
|
def test_post_master_difficulty_persists(client, tmp_path):
|
|
r = client.post("/api/settings", json={"master_difficulty": 75})
|
|
assert r.status_code == 200
|
|
assert _read_cfg(tmp_path)["master_difficulty"] == 75
|
|
|
|
|
|
def test_get_returns_persisted_master_difficulty(client, tmp_path):
|
|
client.post("/api/settings", json={"master_difficulty": 60})
|
|
r = client.get("/api/settings")
|
|
assert r.status_code == 200
|
|
assert r.json()["master_difficulty"] == 60
|
|
|
|
|
|
def test_master_difficulty_clamped_to_range(client, tmp_path):
|
|
client.post("/api/settings", json={"master_difficulty": 150})
|
|
assert _read_cfg(tmp_path)["master_difficulty"] == 100
|
|
client.post("/api/settings", json={"master_difficulty": -5})
|
|
assert _read_cfg(tmp_path)["master_difficulty"] == 0
|
|
|
|
|
|
def test_master_difficulty_accepts_numeric_string(client, tmp_path):
|
|
# Some clients stringify numbers before POSTing. int(float(...))
|
|
# covers both "75" and "75.0" without introducing a hard type
|
|
# constraint on the wire.
|
|
client.post("/api/settings", json={"master_difficulty": "75"})
|
|
assert _read_cfg(tmp_path)["master_difficulty"] == 75
|
|
client.post("/api/settings", json={"master_difficulty": "42.9"})
|
|
assert _read_cfg(tmp_path)["master_difficulty"] == 42
|
|
|
|
|
|
@pytest.mark.parametrize("bad_value", [
|
|
None, "", "abc", [], {},
|
|
"inf", "-inf", "1e309", # float("inf") / overflow past int range
|
|
True, False, # bool is a subclass of int in Python
|
|
])
|
|
def test_master_difficulty_rejects_non_numeric(client, tmp_path, bad_value):
|
|
# Public endpoint — a bad value shouldn't 500. Returns an error
|
|
# object like the dlc_dir validation branch, and doesn't write
|
|
# anything to disk. Overflow cases (int(float("inf"))) raise
|
|
# OverflowError distinctly from ValueError, so the handler catches
|
|
# both.
|
|
(tmp_path / "config.json").write_text(json.dumps({"master_difficulty": 50}))
|
|
r = client.post("/api/settings", json={"master_difficulty": bad_value})
|
|
assert r.status_code == 200 # handler returns dict, not HTTPException
|
|
assert "error" in r.json()
|
|
# Previous value is preserved
|
|
assert _read_cfg(tmp_path)["master_difficulty"] == 50
|
|
|
|
|
|
# ── Partial-update safety: a single-key POST must not clobber siblings ──────
|
|
|
|
def test_slider_post_does_not_clobber_other_keys(client, tmp_path):
|
|
# Seed all three "soft" keys.
|
|
(tmp_path / "config.json").write_text(json.dumps({
|
|
"default_arrangement": "Lead",
|
|
"demucs_server_url": "http://demucs.example:9000",
|
|
"master_difficulty": 100,
|
|
}))
|
|
|
|
# Simulate the slider's fire-and-forget POST — just the one key.
|
|
client.post("/api/settings", json={"master_difficulty": 50})
|
|
|
|
cfg = _read_cfg(tmp_path)
|
|
assert cfg["master_difficulty"] == 50
|
|
assert cfg["default_arrangement"] == "Lead"
|
|
assert cfg["demucs_server_url"] == "http://demucs.example:9000"
|
|
|
|
|
|
def test_default_arrangement_post_does_not_clobber_master_difficulty(client, tmp_path):
|
|
# Symmetric: persisting default_arrangement from the arrangement picker
|
|
# must not wipe a previously-set master_difficulty.
|
|
(tmp_path / "config.json").write_text(json.dumps({
|
|
"master_difficulty": 80,
|
|
}))
|
|
|
|
client.post("/api/settings", json={"default_arrangement": "Bass"})
|
|
|
|
cfg = _read_cfg(tmp_path)
|
|
assert cfg["master_difficulty"] == 80
|
|
assert cfg["default_arrangement"] == "Bass"
|
|
|
|
|
|
def test_dlc_dir_null_is_noop_not_clear(client, tmp_path):
|
|
# Pre-refactor, absent dlc_dir was implicitly ignored. Some clients
|
|
# send `null` rather than omitting the key; those should also be a
|
|
# no-op so an unrelated POST can't silently wipe the DLC setting.
|
|
(tmp_path / "config.json").write_text(json.dumps({
|
|
"dlc_dir": "/existing/path",
|
|
}))
|
|
client.post("/api/settings", json={"dlc_dir": None, "master_difficulty": 50})
|
|
assert _read_cfg(tmp_path)["dlc_dir"] == "/existing/path"
|
|
|
|
|
|
@pytest.mark.parametrize("bad_content", ["[]", '"hello"', "42", "null", "not valid json {"])
|
|
def test_post_recovers_from_malformed_config_file(client, tmp_path, bad_content):
|
|
# If config.json is valid JSON but a non-dict (e.g. a migrated
|
|
# version or user tampering), assignments like cfg["dlc_dir"] = ...
|
|
# would crash with TypeError. Treat non-dict parsed values the same
|
|
# as missing — fall back to defaults, merge the request, write back
|
|
# a clean dict-shaped file.
|
|
(tmp_path / "config.json").write_text(bad_content)
|
|
r = client.post("/api/settings", json={"master_difficulty": 60})
|
|
assert r.status_code == 200
|
|
cfg = _read_cfg(tmp_path)
|
|
assert isinstance(cfg, dict)
|
|
assert cfg["master_difficulty"] == 60
|
|
|
|
|
|
def test_first_run_slider_post_preserves_default_dlc_dir(client, tmp_path):
|
|
# Regression: on first run there's no config.json yet. If the
|
|
# slider's single-key POST is the first write, the server must
|
|
# seed cfg with _default_settings() first — otherwise the written
|
|
# config.json would lack dlc_dir, and subsequent GETs would return
|
|
# blank instead of the fallback DLC_DIR path.
|
|
assert not (tmp_path / "config.json").exists()
|
|
client.post("/api/settings", json={"master_difficulty": 50})
|
|
cfg = _read_cfg(tmp_path)
|
|
assert cfg["master_difficulty"] == 50
|
|
# dlc_dir key must be present (value can be empty string if the
|
|
# default DLC_DIR doesn't exist on this host — the point is the
|
|
# key survives rather than getting dropped).
|
|
assert "dlc_dir" in cfg
|
|
|
|
|
|
def test_dlc_dir_empty_string_clears(client, tmp_path):
|
|
# Explicit empty string IS "clear" — keeps a route for a user who
|
|
# wants to unset the DLC dir via the settings panel.
|
|
(tmp_path / "config.json").write_text(json.dumps({
|
|
"dlc_dir": "/existing/path",
|
|
}))
|
|
client.post("/api/settings", json={"dlc_dir": ""})
|
|
assert _read_cfg(tmp_path)["dlc_dir"] == ""
|
|
|
|
|
|
@pytest.mark.parametrize("key", ["default_arrangement", "demucs_server_url"])
|
|
def test_string_key_null_is_noop(client, tmp_path, key):
|
|
# Match the dlc_dir contract: null preserves the on-disk value.
|
|
(tmp_path / "config.json").write_text(json.dumps({key: "existing"}))
|
|
client.post("/api/settings", json={key: None, "master_difficulty": 50})
|
|
assert _read_cfg(tmp_path)[key] == "existing"
|
|
|
|
|
|
@pytest.mark.parametrize("key", ["default_arrangement", "demucs_server_url"])
|
|
@pytest.mark.parametrize("bad_value", [42, [], {}, True])
|
|
def test_string_key_non_string_rejected(client, tmp_path, key, bad_value):
|
|
# Downstream consumers call string methods on these values
|
|
# (e.g. demucs_server_url.rstrip('/') in lib/sloppak_convert.py).
|
|
# Reject non-strings at the boundary so garbage can't persist.
|
|
(tmp_path / "config.json").write_text(json.dumps({key: "existing"}))
|
|
r = client.post("/api/settings", json={key: bad_value})
|
|
assert "error" in r.json()
|
|
assert _read_cfg(tmp_path)[key] == "existing"
|
|
|
|
|
|
@pytest.mark.parametrize("key", ["default_arrangement", "demucs_server_url"])
|
|
def test_string_key_empty_string_clears(client, tmp_path, key):
|
|
(tmp_path / "config.json").write_text(json.dumps({key: "existing"}))
|
|
client.post("/api/settings", json={key: ""})
|
|
assert _read_cfg(tmp_path)[key] == ""
|
|
|
|
|
|
def test_dlc_dir_non_string_rejected(client, tmp_path):
|
|
# Non-string JSON (number, list, object) shouldn't reach Path(...)
|
|
# and crash. Returns the structured error + preserves on-disk value.
|
|
(tmp_path / "config.json").write_text(json.dumps({
|
|
"dlc_dir": "/existing/path",
|
|
}))
|
|
r = client.post("/api/settings", json={"dlc_dir": 42})
|
|
assert "error" in r.json()
|
|
assert _read_cfg(tmp_path)["dlc_dir"] == "/existing/path"
|
|
|
|
|
|
def test_empty_post_preserves_all_existing_keys(client, tmp_path):
|
|
(tmp_path / "config.json").write_text(json.dumps({
|
|
"default_arrangement": "Lead",
|
|
"demucs_server_url": "http://demucs.example:9000",
|
|
"master_difficulty": 42,
|
|
}))
|
|
|
|
client.post("/api/settings", json={})
|
|
|
|
assert _read_cfg(tmp_path) == {
|
|
"default_arrangement": "Lead",
|
|
"demucs_server_url": "http://demucs.example:9000",
|
|
"master_difficulty": 42,
|
|
}
|
|
|
|
|
|
# ── Absent master_difficulty → GET falls through (frontend default) ─────────
|
|
|
|
def test_get_without_master_difficulty_omits_key(client, tmp_path):
|
|
# When no master_difficulty has been saved, the GET response should
|
|
# not include it — frontend defaults to 100 on its own side. This
|
|
# matches the other keys' behaviour (GET reflects what's on disk).
|
|
r = client.get("/api/settings")
|
|
assert r.status_code == 200
|
|
assert "master_difficulty" not in r.json()
|
|
|
|
|
|
# ── _get_dlc_dir() — env-var / config.json precedence ───────────────────────
|
|
|
|
@pytest.fixture()
|
|
def server_module(tmp_path, monkeypatch):
|
|
"""Import server with CONFIG_DIR isolated in tmp_path and DLC_DIR unset."""
|
|
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
|
|
monkeypatch.delenv("DLC_DIR", raising=False)
|
|
sys.modules.pop("server", None)
|
|
mod = importlib.import_module("server")
|
|
yield mod
|
|
meta_db = getattr(mod, "meta_db", None)
|
|
conn = getattr(meta_db, "conn", None)
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
def test_get_dlc_dir_uses_config_when_env_unset(tmp_path, server_module):
|
|
"""When DLC_DIR is unset, _get_dlc_dir() returns the path from config.json."""
|
|
dlc_dir = tmp_path / "my_dlc"
|
|
dlc_dir.mkdir()
|
|
(tmp_path / "config.json").write_text(json.dumps({"dlc_dir": str(dlc_dir)}))
|
|
|
|
result = server_module._get_dlc_dir()
|
|
assert result == dlc_dir
|
|
|
|
|
|
def test_get_dlc_dir_uses_config_when_env_empty(tmp_path, monkeypatch):
|
|
"""When DLC_DIR is set to an empty string, config.json still wins."""
|
|
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
|
|
monkeypatch.setenv("DLC_DIR", "")
|
|
sys.modules.pop("server", None)
|
|
mod = importlib.import_module("server")
|
|
try:
|
|
dlc_dir = tmp_path / "my_dlc"
|
|
dlc_dir.mkdir()
|
|
(tmp_path / "config.json").write_text(json.dumps({"dlc_dir": str(dlc_dir)}))
|
|
result = mod._get_dlc_dir()
|
|
assert result == dlc_dir
|
|
finally:
|
|
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
def test_get_dlc_dir_env_takes_precedence(tmp_path, monkeypatch):
|
|
"""When DLC_DIR env var points to a real directory, it wins over config.json."""
|
|
env_dir = tmp_path / "env_dlc"
|
|
env_dir.mkdir()
|
|
cfg_dir = tmp_path / "cfg_dlc"
|
|
cfg_dir.mkdir()
|
|
|
|
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
|
|
monkeypatch.setenv("DLC_DIR", str(env_dir))
|
|
sys.modules.pop("server", None)
|
|
mod = importlib.import_module("server")
|
|
try:
|
|
(tmp_path / "config.json").write_text(json.dumps({"dlc_dir": str(cfg_dir)}))
|
|
result = mod._get_dlc_dir()
|
|
assert result == env_dir
|
|
finally:
|
|
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
def test_get_dlc_dir_env_dot_is_valid(tmp_path, monkeypatch):
|
|
"""An explicit DLC_DIR=. treats the current directory as the DLC folder."""
|
|
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
|
|
monkeypatch.setenv("DLC_DIR", ".")
|
|
sys.modules.pop("server", None)
|
|
mod = importlib.import_module("server")
|
|
try:
|
|
result = mod._get_dlc_dir()
|
|
# "." resolves to cwd which exists as a directory
|
|
assert result is not None
|
|
assert result.is_dir()
|
|
finally:
|
|
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
def test_get_dlc_dir_returns_none_when_no_dir(tmp_path, server_module):
|
|
"""Returns None when both env and config.json lack a valid directory."""
|
|
# No config.json → falls through to None
|
|
result = server_module._get_dlc_dir()
|
|
assert result is None
|
|
|
|
|
|
def test_get_dlc_dir_ignores_nonexistent_config_dir(tmp_path, server_module):
|
|
"""If config.json names a path that doesn't exist, returns None."""
|
|
(tmp_path / "config.json").write_text(json.dumps({"dlc_dir": str(tmp_path / "no_such_dir")}))
|
|
result = server_module._get_dlc_dir()
|
|
assert result is None
|
|
|
|
|
|
# ── library scan fixtures ────────────────────────────────────────────────────
|
|
|
|
@pytest.fixture()
|
|
def scan_module(tmp_path, monkeypatch, isolate_logging):
|
|
"""Import server with CONFIG_DIR and DLC_DIR isolated in tmp_path.
|
|
|
|
The background scan uses a `spawn` ProcessPoolExecutor in production
|
|
(see server._make_scan_executor), whose workers run in fresh
|
|
interpreters that an in-process mock.patch() can't reach. Override it
|
|
with an in-process ThreadPoolExecutor so these tests can mock metadata
|
|
extraction (on scan_worker, where the worker resolves it) and observe
|
|
the resulting DB state.
|
|
"""
|
|
import concurrent.futures
|
|
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
|
|
monkeypatch.delenv("DLC_DIR", raising=False)
|
|
sys.modules.pop("server", None)
|
|
mod = importlib.import_module("server")
|
|
monkeypatch.setattr(
|
|
mod, "_make_scan_executor",
|
|
lambda: concurrent.futures.ThreadPoolExecutor(max_workers=4),
|
|
)
|
|
yield mod
|
|
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
def _make_sloppaks(dlc_dir, names):
|
|
"""Create empty .sloppak stub files and return Path objects.
|
|
|
|
is_sloppak() keys on the .sloppak suffix, so empty stubs are enough for
|
|
scan-listing tests where metadata extraction is mocked.
|
|
"""
|
|
paths = []
|
|
for name in names:
|
|
p = dlc_dir / name
|
|
p.write_bytes(b"")
|
|
paths.append(p)
|
|
return paths
|
|
|
|
|
|
# ── is_first_scan flag ───────────────────────────────────────────────────────
|
|
|
|
def test_is_first_scan_true_when_all_songs_unscanned(tmp_path, scan_module):
|
|
"""is_first_scan is True when every discovered song needs scanning."""
|
|
dlc = tmp_path / "dlc"
|
|
dlc.mkdir()
|
|
_make_sloppaks(dlc, ["song_a.sloppak", "song_b.sloppak"])
|
|
(tmp_path / "config.json").write_text(json.dumps({"dlc_dir": str(dlc)}))
|
|
|
|
captured_status = {}
|
|
|
|
import unittest.mock as mock
|
|
|
|
def mock_extract(f, dlc):
|
|
# Capture the scan status on the first call (during the scanning phase)
|
|
if not captured_status:
|
|
captured_status.update(scan_module._scan_status)
|
|
return {"title": f.name, "artist": "", "album": ""}
|
|
|
|
with mock.patch("scan_worker._extract_meta_for_file", new=mock_extract):
|
|
scan_module._background_scan()
|
|
|
|
assert captured_status.get("is_first_scan") is True
|
|
|
|
|
|
def test_is_first_scan_false_when_some_songs_cached(tmp_path, scan_module):
|
|
"""is_first_scan is False when only a subset of discovered songs need scanning."""
|
|
dlc = tmp_path / "dlc"
|
|
dlc.mkdir()
|
|
files = _make_sloppaks(dlc, ["song_a.sloppak", "song_b.sloppak"])
|
|
(tmp_path / "config.json").write_text(json.dumps({"dlc_dir": str(dlc)}))
|
|
|
|
# Pre-populate the DB with song_a so only song_b needs scanning.
|
|
stat_a = files[0].stat()
|
|
scan_module.meta_db.put(
|
|
"song_a.sloppak", stat_a.st_mtime, stat_a.st_size,
|
|
{"title": "Song A", "artist": "", "album": ""},
|
|
)
|
|
|
|
captured_status = {}
|
|
|
|
import unittest.mock as mock
|
|
|
|
def mock_extract(f, dlc):
|
|
if not captured_status:
|
|
captured_status.update(scan_module._scan_status)
|
|
return {"title": f.name, "artist": "", "album": ""}
|
|
|
|
with mock.patch("scan_worker._extract_meta_for_file", new=mock_extract):
|
|
scan_module._background_scan()
|
|
|
|
assert captured_status.get("is_first_scan") is False
|
|
|
|
|
|
# ── TestClient lifespan coverage ─────────────────────────────────────────────
|
|
#
|
|
# The rest of this module drives settings handlers directly to keep tests fast
|
|
# and side-effect-free, but that bypass means the FastAPI route registration,
|
|
# request parsing, and lifespan/startup wiring would never get exercised. The
|
|
# tests below restore that coverage by going through a real `TestClient`, with
|
|
# `SLOPSMITH_SKIP_STARTUP_TASKS=1` so plugin loading and the background scan
|
|
# don't reach for the user's filesystem.
|
|
|
|
def _snapshot_loaded_plugins():
|
|
"""Snapshot `plugins.LOADED_PLUGINS` so a test that triggers the
|
|
skip-startup branch (which clears the registry) can restore it on
|
|
teardown — otherwise the cleared state leaks to later tests that
|
|
expect it to look as the importer left it."""
|
|
import plugins as plugins_mod
|
|
with plugins_mod.PLUGINS_LOCK:
|
|
return list(plugins_mod.LOADED_PLUGINS)
|
|
|
|
|
|
def _restore_loaded_plugins(snapshot):
|
|
import plugins as plugins_mod
|
|
with plugins_mod.PLUGINS_LOCK:
|
|
plugins_mod.LOADED_PLUGINS.clear()
|
|
plugins_mod.LOADED_PLUGINS.extend(snapshot)
|
|
|
|
|
|
@pytest.fixture()
|
|
def api_client(tmp_path, monkeypatch, isolate_logging):
|
|
"""A real FastAPI TestClient against a fresh server import.
|
|
|
|
Using `TestClient` as a context manager runs the lifespan/startup hook,
|
|
which is the only place the skip-tasks branch is actually exercised.
|
|
|
|
Pulls in `isolate_logging` from tests/conftest.py — the startup hook
|
|
calls `configure_logging()`, which mutates global slopsmith/uvicorn
|
|
handlers and structlog defaults; without snapshot/restore those
|
|
changes leak into later tests and the suite becomes order-dependent."""
|
|
from fastapi.testclient import TestClient
|
|
|
|
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
|
|
monkeypatch.setenv("SLOPSMITH_SKIP_STARTUP_TASKS", "1")
|
|
sys.modules.pop("server", None)
|
|
plugins_snapshot = _snapshot_loaded_plugins()
|
|
server = importlib.import_module("server")
|
|
try:
|
|
with TestClient(server.app) as tc:
|
|
yield tc, server
|
|
finally:
|
|
conn = getattr(getattr(server, "meta_db", None), "conn", None)
|
|
if conn is not None:
|
|
conn.close()
|
|
_restore_loaded_plugins(plugins_snapshot)
|
|
|
|
|
|
def test_api_get_settings_via_testclient(api_client, tmp_path):
|
|
"""End-to-end smoke through the real /api/settings GET route."""
|
|
tc, _server = api_client
|
|
r = tc.get("/api/settings")
|
|
assert r.status_code == 200
|
|
assert isinstance(r.json(), dict)
|
|
|
|
|
|
def test_api_post_settings_via_testclient(api_client, tmp_path):
|
|
"""End-to-end smoke through the real /api/settings POST route."""
|
|
tc, _server = api_client
|
|
r = tc.post("/api/settings", json={"master_difficulty": 73})
|
|
assert r.status_code == 200
|
|
assert _read_cfg(tmp_path)["master_difficulty"] == 73
|
|
|
|
|
|
# Validation/rejection cases that the direct-call fixture covers
|
|
# extensively, but that we also want to pin at the route level — that's
|
|
# the only layer FastAPI request parsing / response serialization runs
|
|
# at, so an API-contract regression (e.g. a Pydantic model change that
|
|
# subtly alters how null or non-numeric values are handled) could pass
|
|
# the direct-call tests while breaking the real /api/settings clients.
|
|
|
|
@pytest.mark.parametrize(
|
|
"payload",
|
|
[
|
|
{"master_difficulty": "abc"},
|
|
{"master_difficulty": "1e309"}, # overflow past int range
|
|
{"dlc_dir": 42}, # non-string for path field
|
|
],
|
|
)
|
|
def test_api_post_settings_invalid_values_via_testclient(api_client, payload):
|
|
"""POSTing invalid values through the real route still returns 200
|
|
with an `error` field (handler returns a dict rather than raising
|
|
HTTPException), and doesn't 500 on the wire."""
|
|
tc, _server = api_client
|
|
r = tc.post("/api/settings", json=payload)
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert "error" in body, body
|
|
|
|
|
|
def test_api_post_settings_null_string_is_noop_via_testclient(api_client, tmp_path):
|
|
"""null for a string-shaped key is a no-op at the API layer — the
|
|
handler must merge the request without clearing the on-disk value."""
|
|
(tmp_path / "config.json").write_text(json.dumps({"dlc_dir": "/existing/path"}))
|
|
tc, _server = api_client
|
|
r = tc.post("/api/settings", json={"dlc_dir": None, "master_difficulty": 50})
|
|
assert r.status_code == 200
|
|
assert _read_cfg(tmp_path)["dlc_dir"] == "/existing/path"
|
|
assert _read_cfg(tmp_path)["master_difficulty"] == 50
|
|
|
|
|
|
def test_skip_startup_tasks_drives_startup_to_complete(api_client):
|
|
"""With SLOPSMITH_SKIP_STARTUP_TASKS set, the startup hook must:
|
|
* skip plugin loading and the background scan,
|
|
* leave the status in a terminal `complete` phase with running=False,
|
|
* reset current_plugin/loaded/total so stale data from a prior import
|
|
doesn't bleed into the skip branch.
|
|
"""
|
|
_tc, server = api_client
|
|
status = server._startup_status
|
|
assert status["running"] is False
|
|
assert status["phase"] == "complete"
|
|
assert status["error"] is None
|
|
assert status["current_plugin"] == ""
|
|
assert status["loaded"] == 0
|
|
assert status["total"] == 0
|
|
|
|
|
|
def test_skip_startup_tasks_does_not_call_load_plugins_or_scan(tmp_path, monkeypatch, isolate_logging):
|
|
"""Concrete contract check: with the skip flag set, the startup hook
|
|
must not invoke `load_plugins` *or* `startup_scan`. The status
|
|
assertions in `test_skip_startup_tasks_drives_startup_to_complete`
|
|
only show the end state — they'd still pass if either ran and the
|
|
status was reset, so they don't prove the skip path actually skipped.
|
|
The background scan in particular doesn't touch _startup_status, so
|
|
without a dedicated tripwire a regression here would be silent."""
|
|
from fastapi.testclient import TestClient
|
|
|
|
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
|
|
monkeypatch.setenv("SLOPSMITH_SKIP_STARTUP_TASKS", "1")
|
|
sys.modules.pop("server", None)
|
|
plugins_snapshot = _snapshot_loaded_plugins()
|
|
server = importlib.import_module("server")
|
|
|
|
load_calls: list[tuple] = []
|
|
scan_calls: list[tuple] = []
|
|
|
|
def _load_tripwire(*args, **kwargs):
|
|
load_calls.append((args, kwargs))
|
|
|
|
def _scan_tripwire(*args, **kwargs):
|
|
scan_calls.append((args, kwargs))
|
|
|
|
# Patch the names the startup hook resolves at call time. server.py
|
|
# does `from plugins import load_plugins, ...`, and `startup_scan` is
|
|
# defined in server.py itself — both end up bound on the server module
|
|
# and that's what `@app.on_event("startup")` looks up.
|
|
monkeypatch.setattr(server, "load_plugins", _load_tripwire)
|
|
monkeypatch.setattr(server, "startup_scan", _scan_tripwire)
|
|
|
|
try:
|
|
with TestClient(server.app):
|
|
pass
|
|
finally:
|
|
conn = getattr(getattr(server, "meta_db", None), "conn", None)
|
|
if conn is not None:
|
|
conn.close()
|
|
_restore_loaded_plugins(plugins_snapshot)
|
|
|
|
assert load_calls == [], f"load_plugins was invoked despite skip flag: {load_calls}"
|
|
assert scan_calls == [], f"startup_scan was invoked despite skip flag: {scan_calls}"
|
|
|
|
|
|
def test_skip_startup_tasks_clears_stale_plugin_registry(tmp_path, monkeypatch, isolate_logging):
|
|
"""The plugins module is not re-imported when tests reload `server`, so
|
|
LOADED_PLUGINS can carry stale entries from a previous test's startup.
|
|
The skip branch must clear it so /api/plugins doesn't expose stale
|
|
plugins despite reporting zero loaded plugins in the status."""
|
|
from fastapi.testclient import TestClient
|
|
|
|
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
|
|
monkeypatch.setenv("SLOPSMITH_SKIP_STARTUP_TASKS", "1")
|
|
|
|
# Snapshot the registry as the importer left it so we can restore it
|
|
# after this test mutates it — otherwise the cleared state would leak
|
|
# to any later test that imports `plugins`.
|
|
plugins_snapshot = _snapshot_loaded_plugins()
|
|
server = None
|
|
|
|
try:
|
|
# Pre-seed plugins.LOADED_PLUGINS with a fake entry from a
|
|
# "previous run". The try/finally wraps this mutation so that even
|
|
# if the subsequent server import raises, the sentinel doesn't
|
|
# leak into later tests.
|
|
import plugins as plugins_mod
|
|
sentinel = {"id": "stale.previous-run", "name": "stale"}
|
|
with plugins_mod.PLUGINS_LOCK:
|
|
plugins_mod.LOADED_PLUGINS.clear()
|
|
plugins_mod.LOADED_PLUGINS.append(sentinel)
|
|
|
|
sys.modules.pop("server", None)
|
|
server = importlib.import_module("server")
|
|
|
|
with TestClient(server.app):
|
|
pass
|
|
# After the skip branch runs, the stale entry must be gone.
|
|
assert sentinel not in plugins_mod.LOADED_PLUGINS
|
|
assert plugins_mod.LOADED_PLUGINS == []
|
|
finally:
|
|
conn = getattr(getattr(server, "meta_db", None), "conn", None) if server else None
|
|
if conn is not None:
|
|
conn.close()
|
|
_restore_loaded_plugins(plugins_snapshot)
|