"""Shared pytest fixtures for the feedBack test suite.""" import importlib import logging import sys import pytest import structlog _LOGGING_NAMES = ("feedBack", "uvicorn", "uvicorn.error", "uvicorn.access") @pytest.fixture(autouse=True) def _reset_enrichment_state(): """Reset the enrichment worker's process-global state between tests. The `server` fixtures pop-and-reimport `server`, but `lib/enrichment.py` (which now owns the worker) stays imported for the whole session, so its module globals — the cancel Event, the status dict, the caches — would otherwise leak across tests. A test that set `_enrich_cancel` (or a stale `running` status) could silently short-circuit a later direct `_background_enrich()` call. Clear it up front so each test starts clean. """ try: import enrichment except ImportError: yield return enrichment._enrich_cancel.clear() enrichment._enrich_pending_pass = False enrichment._enrich_status.update( {"running": False, "processed": 0, "last_pass_at": None, "total": 0, "matched": 0, "current": None}) enrichment._enrich_last_fetch = 0.0 enrichment._artist_alias_cache.clear() # _caa_index_locks is deliberately left alone: it's guarded by # _caa_index_locks_guard, so clearing it here (unlocked) would race a # still-alive worker thread, and its entries are stateless per-release # mutexes that don't leak test state anyway. yield @pytest.fixture() def isolate_logging(): """Restore feedBack / uvicorn logger state after each test. Saves handlers, level, and propagate flag before the test runs and restores all three on teardown. Import into any test module that calls configure_logging() so mutations don't bleed across tests. """ saved = {} for name in _LOGGING_NAMES: lg = logging.getLogger(name) saved[name] = ( list(lg.handlers), # snapshot the handler list lg.level, lg.propagate, ) yield for name in _LOGGING_NAMES: lg = logging.getLogger(name) original_handlers, original_level, original_propagate = saved[name] # Close and remove any handlers that were added during the test. for h in list(lg.handlers): if h not in original_handlers: lg.removeHandler(h) h.close() # Remove any original handlers that may have been removed during the test # so we can add them back cleanly. for h in list(lg.handlers): lg.removeHandler(h) # Reattach the original handlers. for h in original_handlers: lg.addHandler(h) lg.setLevel(original_level) lg.propagate = original_propagate structlog.reset_defaults() # ── Plugin-loader isolation ───────────────────────────────────────────────────── # # Lifted verbatim out of tests/test_plugins.py so more than one test module can drive # the real plugins.load_plugins(). It has to be ONE fixture, not a copy per file: # load_plugins() mutates sys.path, sys.modules, PENDING_PLUGINS and LOADED_PLUGINS, and a # partial restore makes the suite order- and environment-dependent (Codex [P2] on # test_plugin_context_contract.py — it was right). # Bare module names that this test module pre-populates into # sys.modules to simulate the bare-import path. Saved/restored by # the reset_plugin_state fixture so they don't leak to other test # files. Codex / Copilot review on PR for feedBack#33. _BARE_NAMES_USED = ("util", "extractor") @pytest.fixture() def reset_plugin_state(monkeypatch): """Clear loader module-level state and restore on teardown. Saves and restores: * `plugins.LOADED_PLUGINS` * any `plugin_*` keys we add to `sys.modules` * the bare names this module simulates (`util`, `extractor`) * `sys.path` — `plugins.load_plugins()` mutates it Also unsets `FEEDBACK_PLUGINS_DIR` for the test's duration (via monkeypatch) so a CI env that pre-sets it can't leak real user plugins into a tmp_path-driven test. Per-module locks are owned by the standard import system (`importlib._bootstrap._module_locks`) and are not our responsibility to reset. """ monkeypatch.delenv("FEEDBACK_PLUGINS_DIR", raising=False) plugins = importlib.import_module("plugins") saved_loaded = list(plugins.LOADED_PLUGINS) saved_pending = dict(plugins.PENDING_PLUGINS) saved_modules = {k: v for k, v in sys.modules.items() if k.startswith("plugin_")} saved_bare = {k: sys.modules[k] for k in _BARE_NAMES_USED if k in sys.modules} saved_path = list(sys.path) plugins.LOADED_PLUGINS.clear() plugins.PENDING_PLUGINS.clear() for k in list(sys.modules): if k.startswith("plugin_") or k in _BARE_NAMES_USED: del sys.modules[k] try: yield plugins finally: plugins.LOADED_PLUGINS.clear() plugins.LOADED_PLUGINS.extend(saved_loaded) plugins.PENDING_PLUGINS.clear() plugins.PENDING_PLUGINS.update(saved_pending) for k in list(sys.modules): if k.startswith("plugin_") or k in _BARE_NAMES_USED: del sys.modules[k] sys.modules.update(saved_modules) sys.modules.update(saved_bare) sys.path[:] = saved_path # ── Scanner isolation ─────────────────────────────────────────────────────────── # # lib/scan.py holds MODULE-LEVEL state (_scan_status, and the kick/runner bookkeeping), # and `scan` is NOT re-imported by the fixtures that re-import `server` — so unlike the # old server-globals arrangement, that state now outlives a test. # # It matters because of a deliberate asymmetry in the scanner: background_scan() never # sets `running` back to False. Ownership of that flag lives in _scan_runner, so that a # kick_scan() racing the terminal write cannot see a stale False and start a second runner. # Correct in production — but a test that calls background_scan() DIRECTLY skips the runner # entirely and therefore leaves the scanner marked "running" forever. Every later scan or # rescan then returns "already in progress" and quietly does nothing. # # The suite passed anyway, on ordering luck. Codex [P2] caught it. So: snapshot and restore. @pytest.fixture() def reset_scan_state(): """Restore lib/scan.py's module-level state around a test that drives it directly.""" import scan saved_status = scan._scan_status saved_thread = scan._scan_thread saved_pending = scan._scan_rescan_pending scan._scan_status = dict(scan._SCAN_STATUS_INIT) try: yield scan finally: scan._scan_status = saved_status scan._scan_thread = saved_thread scan._scan_rescan_pending = saved_pending