booru-viewer/tests/conftest.py
pax bf14466382 Add Phase A test suite for core/ primitives
First regression-test layer for booru-viewer. Pure Python — no Qt, no
mpv, no network, no real filesystem outside tmp_path. Locks in the
security and concurrency invariants from the 54ccc40 + eb58d76 hardening
commits ahead of the upcoming popout state machine refactor (Prompt 3),
which needs a stable baseline to refactor against.

16 tests across five files mirroring the source layout under
booru_viewer/core/:

- tests/core/test_db.py (4):
  - _validate_folder_name rejection rules (.., /foo, \\foo, .hidden,
    ~user, empty) and acceptance categories (unicode, spaces, parens)
  - add_bookmark INSERT OR IGNORE collision returns the existing row
    id (locks in the lastrowid=0 fix)
  - get_bookmarks LIKE escaping (literal cat_ear does not match catear)

- tests/core/test_cache.py (7):
  - _referer_for hostname suffix matching (gelbooru.com / donmai.us
    apex rewrite, both exact-match and subdomain)
  - _referer_for rejects substring attackers
    (imgblahgelbooru.attacker.com does NOT pick up the booru referer)
  - ugoira frame-count and uncompressed-size caps refuse zip bombs
    before any decompression
  - _do_download MAX_DOWNLOAD_BYTES enforced both at the
    Content-Length pre-check AND in the chunk-loop running total
  - _is_valid_media returns True on OSError (no delete + redownload
    loop on transient EBUSY)

- tests/core/test_config.py (2):
  - saved_folder_dir rejects literal .. and ../escape
  - find_library_files walks root + 1 level, filters by
    MEDIA_EXTENSIONS, exact post-id stem match

- tests/core/test_concurrency.py (2):
  - get_app_loop raises RuntimeError before set_app_loop is called
  - run_on_app_loop round-trips a coroutine result from a worker
    thread loop back to the test thread

- tests/core/api/test_base.py (1):
  - BooruClient._shared_client lazy singleton constructor-once under
    10-thread first-call race

Plus tests/conftest.py with fixtures: tmp_db, tmp_library,
reset_app_loop, reset_shared_clients. All fixtures use tmp_path or
reset module-level globals around the test so the suite is parallel-
safe.

pyproject.toml:
- New [project.optional-dependencies] test extra: pytest>=8.0,
  pytest-asyncio>=0.23
- New [tool.pytest.ini_options]: asyncio_mode = "auto",
  testpaths = ["tests"]

README.md:
- Linux install section gains "Run tests" with the
  pip install -e ".[test]" + pytest tests/ invocation

Phase B (post-sweep VideoPlayer regression tests for the seek slider
pin, _pending_mute lazy replay, and volume replay) is deferred to
Prompt 3's state machine work — VideoPlayer cannot be instantiated
without QApplication and a real mpv, which is out of scope for a
unit test suite. Once the state machine carves the pure-Python state
out of VideoPlayer, those tests become trivial against the helper
module.

Suite runs in 0.07s (16 tests). Independent of Qt/mpv/network/ffmpeg.

Test cases for Prompt 3:
- (already covered) — this IS the test suite Prompt 3 builds on top of
2026-04-08 18:50:00 -05:00

72 lines
2.3 KiB
Python

"""Shared fixtures for the booru-viewer test suite.
All fixtures here are pure-Python — no Qt, no mpv, no network. Filesystem
writes go through `tmp_path` (or fixtures that wrap it). Module-level globals
that the production code mutates (the concurrency loop, the httpx singletons)
get reset around each test that touches them.
"""
from __future__ import annotations
import pytest
@pytest.fixture
def tmp_db(tmp_path):
"""Fresh `Database` instance writing to a temp file. Auto-closes."""
from booru_viewer.core.db import Database
db = Database(tmp_path / "test.db")
yield db
db.close()
@pytest.fixture
def tmp_library(tmp_path):
"""Point `saved_dir()` at `tmp_path/saved` for the duration of the test.
Uses `core.config.set_library_dir` (the official override hook) so the
redirect goes through the same code path the GUI uses for the
user-configurable library location. Tear-down restores the previous
value so tests can run in any order without bleed.
"""
from booru_viewer.core import config
saved = tmp_path / "saved"
saved.mkdir()
original = config._library_dir_override
config.set_library_dir(saved)
yield saved
config.set_library_dir(original)
@pytest.fixture
def reset_app_loop():
"""Reset `concurrency._app_loop` between tests.
The module global is set once at app startup in production; tests need
to start from a clean slate to assert the unset-state behavior.
"""
from booru_viewer.core import concurrency
original = concurrency._app_loop
concurrency._app_loop = None
yield
concurrency._app_loop = original
@pytest.fixture
def reset_shared_clients():
"""Reset both shared httpx singletons (cache module + BooruClient class).
Both are class/module-level globals; tests that exercise the lazy-init
+ lock pattern need them cleared so the test sees a fresh first-call
race instead of a leftover instance from a previous test.
"""
from booru_viewer.core.api.base import BooruClient
from booru_viewer.core import cache
original_booru = BooruClient._shared_client
original_cache = cache._shared_client
BooruClient._shared_client = None
cache._shared_client = None
yield
BooruClient._shared_client = original_booru
cache._shared_client = original_cache