diff --git a/app/conftest.py b/app/conftest.py new file mode 100644 index 00000000..e75a390b --- /dev/null +++ b/app/conftest.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +import os +import tempfile + +from app.tests.helpers import cleanup_test_run_root, get_test_run_root, get_test_system_temp_root + + +def pytest_configure(config) -> None: + temp_root = get_test_system_temp_root() + for env_name in ("TMPDIR", "TEMP", "TMP"): + os.environ[env_name] = str(temp_root) + + tempfile.tempdir = None + + if getattr(config.option, "basetemp", None) is None: + config.option.basetemp = str(get_test_run_root() / "pytest") + + +def pytest_unconfigure(config) -> None: + del config + cleanup_test_run_root() diff --git a/app/features/conditions/tests/test_conditions.py b/app/features/conditions/tests/test_conditions.py index 050f8aa6..f72ec622 100644 --- a/app/features/conditions/tests/test_conditions.py +++ b/app/features/conditions/tests/test_conditions.py @@ -14,6 +14,7 @@ from app.library.config import Config from app.library.encoder import Encoder from app.features.conditions.repository import ConditionsRepository from app.library.sqlite_store import SqliteStore +from app.tests.helpers import make_in_memory_db_path @pytest_asyncio.fixture @@ -21,7 +22,7 @@ async def repo(): ConditionsRepository._reset_singleton() SqliteStore._reset_singleton() - store = SqliteStore(db_path=":memory:") + store = SqliteStore(db_path=make_in_memory_db_path("conditions-repository")) await store.get_connection() # Create repository @@ -30,10 +31,7 @@ async def repo(): yield repository # Cleanup - close connections properly - if store._conn: - await store._conn.close() - if store._engine: - await store._engine.dispose() + await store.close() # Reset singletons ConditionsRepository._reset_singleton() diff --git a/app/features/dl_fields/tests/test_dl_fields.py b/app/features/dl_fields/tests/test_dl_fields.py index 9f7c408e..381bee54 100644 --- a/app/features/dl_fields/tests/test_dl_fields.py +++ b/app/features/dl_fields/tests/test_dl_fields.py @@ -8,26 +8,24 @@ import pytest_asyncio from app.features.dl_fields.repository import DLFieldsRepository from app.features.dl_fields.service import DLFields from app.library.sqlite_store import SqliteStore +from app.tests.helpers import make_in_memory_db_path @pytest_asyncio.fixture -async def repo(tmp_path): +async def repo(): """Provide a fresh repository instance with initialized database for each test.""" DLFieldsRepository._reset_singleton() DLFields._reset_singleton() SqliteStore._reset_singleton() - store = SqliteStore(db_path=":memory:") + store = SqliteStore(db_path=make_in_memory_db_path("dl-fields-service")) await store.get_connection() repository = DLFieldsRepository.get_instance() yield repository - if store._conn: - await store._conn.close() - if store._engine: - await store._engine.dispose() + await store.close() DLFieldsRepository._reset_singleton() DLFields._reset_singleton() diff --git a/app/features/dl_fields/tests/test_dl_fields_repository.py b/app/features/dl_fields/tests/test_dl_fields_repository.py index 4a08ba23..7b2de6aa 100644 --- a/app/features/dl_fields/tests/test_dl_fields_repository.py +++ b/app/features/dl_fields/tests/test_dl_fields_repository.py @@ -7,25 +7,23 @@ import pytest_asyncio from app.features.dl_fields.repository import DLFieldsRepository from app.library.sqlite_store import SqliteStore +from app.tests.helpers import make_in_memory_db_path @pytest_asyncio.fixture -async def repo(tmp_path): +async def repo(): """Provide a fresh repository instance with initialized database for each test.""" DLFieldsRepository._reset_singleton() SqliteStore._reset_singleton() - store = SqliteStore(db_path=str(":memory:")) + store = SqliteStore(db_path=make_in_memory_db_path("dl-fields-repository")) await store.get_connection() repository = DLFieldsRepository.get_instance() yield repository - if store._conn: - await store._conn.close() - if store._engine: - await store._engine.dispose() + await store.close() DLFieldsRepository._reset_singleton() SqliteStore._reset_singleton() diff --git a/app/features/notifications/tests/test_notifications_repository.py b/app/features/notifications/tests/test_notifications_repository.py index 51ef1260..0940ed4e 100644 --- a/app/features/notifications/tests/test_notifications_repository.py +++ b/app/features/notifications/tests/test_notifications_repository.py @@ -7,25 +7,23 @@ import pytest_asyncio from app.features.notifications.repository import NotificationsRepository from app.library.sqlite_store import SqliteStore +from app.tests.helpers import make_in_memory_db_path @pytest_asyncio.fixture -async def repo(tmp_path): +async def repo(): """Provide a fresh repository instance with initialized database for each test.""" NotificationsRepository._reset_singleton() SqliteStore._reset_singleton() - store = SqliteStore(db_path=":memory:") + store = SqliteStore(db_path=make_in_memory_db_path("notifications-repository")) await store.get_connection() repository = NotificationsRepository.get_instance() yield repository - if store._conn: - await store._conn.close() - if store._engine: - await store._engine.dispose() + await store.close() NotificationsRepository._reset_singleton() SqliteStore._reset_singleton() diff --git a/app/features/presets/tests/test_presets_repository.py b/app/features/presets/tests/test_presets_repository.py index 656ac446..1dfe6746 100644 --- a/app/features/presets/tests/test_presets_repository.py +++ b/app/features/presets/tests/test_presets_repository.py @@ -12,6 +12,7 @@ from app.features.presets.repository import PresetsRepository from app.features.presets.router import presets_list from app.library.encoder import Encoder from app.library.sqlite_store import SqliteStore +from app.tests.helpers import make_in_memory_db_path @pytest_asyncio.fixture @@ -19,16 +20,13 @@ async def repo(): PresetsRepository._reset_singleton() SqliteStore._reset_singleton() - store = SqliteStore(db_path=":memory:") + store = SqliteStore(db_path=make_in_memory_db_path("presets-repository")) await store.get_connection() repository = PresetsRepository.get_instance() yield repository - if store._conn: - await store._conn.close() - if store._engine: - await store._engine.dispose() + await store.close() PresetsRepository._reset_singleton() SqliteStore._reset_singleton() diff --git a/app/features/streaming/library/segments.py b/app/features/streaming/library/segments.py index 5789ed01..11d71f66 100644 --- a/app/features/streaming/library/segments.py +++ b/app/features/streaming/library/segments.py @@ -1,11 +1,11 @@ import asyncio -import hashlib import logging import os import subprocess # type: ignore import sys import tempfile from pathlib import Path +from secrets import token_hex from typing import TYPE_CHECKING, ClassVar from aiohttp import web @@ -68,21 +68,21 @@ class Segments: self.attempted: set[str] = set() "The set of attempted codecs." - async def build_ffmpeg_args(self, file: Path, s_codec: str) -> list[str]: + def _make_stream_input(self, file: Path) -> Path: + while True: + stream_input = Path(tempfile.gettempdir()).joinpath(f"ytptube_stream.{token_hex(8)}") + try: + stream_input.symlink_to(file, target_is_directory=False) + return stream_input + except FileExistsError: + continue + + async def build_ffmpeg_args(self, file: Path, s_codec: str, *, stream_input: Path | None = None) -> list[str]: try: ff: FFProbeResult = await ffprobe(file) except UnicodeDecodeError: pass - - tmpFile: Path = Path(tempfile.gettempdir()).joinpath( - f"ytptube_stream.{hashlib.sha256(str(file).encode()).hexdigest()}" - ) - - if not tmpFile.exists(): - try: - tmpFile.symlink_to(file, target_is_directory=False) - except FileExistsError: - pass + input_path = stream_input or file startTime: str = f"{0:.6f}" if self.index == 0 else f"{self.duration * self.index:.6f}" @@ -123,7 +123,7 @@ class Segments: # hardware/global input options must come before -i *input_args, "-i", - f"file:{tmpFile}", + f"file:{input_path}", "-map_metadata", "-1", ] @@ -240,22 +240,27 @@ class Segments: else: codecs: list[str] = [codec, *list(encoder_fallback_chain(codec))] - for s_codec in codecs: - if s_codec in self.attempted: - continue + stream_input = self._make_stream_input(file) + try: + for s_codec in codecs: + if s_codec in self.attempted: + continue - ffmpeg_args: list[str] = await self.build_ffmpeg_args(file, s_codec) - _, rc, client_disconnected, stderr_text = await self._run(resp, file, ffmpeg_args) + ffmpeg_args: list[str] = await self.build_ffmpeg_args(file, s_codec, stream_input=stream_input) + _, rc, client_disconnected, stderr_text = await self._run(resp, file, ffmpeg_args) - if 0 == rc: - Segments._cached_vcodec = s_codec - Segments._cache_initialized = True - return + if 0 == rc: + Segments._cached_vcodec = s_codec + Segments._cache_initialized = True + return - if client_disconnected: - return + if client_disconnected: + return - if 0 != rc: - err: str = stderr_text[:500] if stderr_text else "no error output" - LOG.warning(f"transcoding has failed (cmd={ffmpeg_args}) (rc={rc}): {err}. Trying fallbacks.") - self.attempted.add(s_codec) + if 0 != rc: + err: str = stderr_text[:500] if stderr_text else "no error output" + LOG.warning(f"transcoding has failed (cmd={ffmpeg_args}) (rc={rc}): {err}. Trying fallbacks.") + self.attempted.add(s_codec) + finally: + if stream_input.is_symlink(): + stream_input.unlink() diff --git a/app/features/streaming/tests/test_ffprobe.py b/app/features/streaming/tests/test_ffprobe.py index 1ef7c549..621ac12b 100644 --- a/app/features/streaming/tests/test_ffprobe.py +++ b/app/features/streaming/tests/test_ffprobe.py @@ -1,16 +1,17 @@ -import tempfile from pathlib import Path from unittest.mock import AsyncMock, patch import pytest +from app.tests.helpers import make_test_temp_dir + class TestFFProbe: """Test the ffprobe module functionality.""" def setup_method(self): """Set up test files.""" - self.temp_dir = tempfile.mkdtemp() + self.temp_dir = str(make_test_temp_dir("ffprobe")) self.test_file = Path(self.temp_dir) / "test_video.mp4" self.test_file.touch() diff --git a/app/features/streaming/tests/test_segments.py b/app/features/streaming/tests/test_segments.py index c2bb160e..57c30e5a 100644 --- a/app/features/streaming/tests/test_segments.py +++ b/app/features/streaming/tests/test_segments.py @@ -1,13 +1,12 @@ import asyncio -import hashlib import logging -import tempfile from pathlib import Path from typing import Any import pytest from app.features.streaming.library.segments import Segments +from app.tests.helpers import get_test_system_temp_root class DummyFF: @@ -22,6 +21,10 @@ class DummyFF: return self._a +def _ffmpeg_input_path(args: list[str]) -> Path: + return Path(args[args.index("-i") + 1].removeprefix("file:")) + + @pytest.mark.asyncio async def test_build_ffmpeg_args_video_and_audio(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: # Create a dummy media file @@ -53,8 +56,7 @@ async def test_build_ffmpeg_args_video_and_audio(tmp_path: Path, monkeypatch: py assert captured_args, "ffmpeg was not invoked" args = captured_args[0] - # Compute expected symlink path used by Segments - tmpFile = Path(tempfile.gettempdir()).joinpath(f"ytptube_stream.{hashlib.sha256(str(media).encode()).hexdigest()}") + tmp_file = _ffmpeg_input_path(args) # Start time is duration * index with 6 decimals for non-zero index assert "-ss" in args @@ -64,7 +66,9 @@ async def test_build_ffmpeg_args_video_and_audio(tmp_path: Path, monkeypatch: py assert args[args.index("-t") + 1] == f"{5.5:.6f}" # Input uses file: assert "-i" in args - assert args[args.index("-i") + 1] == f"file:{tmpFile}" + assert tmp_file.parent == get_test_system_temp_root() + assert tmp_file.name.startswith("ytptube_stream.") + assert not tmp_file.exists() # Includes video and audio mapping and codecs assert "-map" in args assert "0:v:0" in args @@ -338,6 +342,9 @@ async def test_stream_gpu_fallback_switches_codec(monkeypatch: pytest.MonkeyPatc @pytest.mark.asyncio async def test_stream_normal_flow(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + media = tmp_path / "file.mp4" + media.write_bytes(b"data") + async def fake_ffprobe(_file: Path): return DummyFF(v=True, a=True) @@ -346,16 +353,21 @@ async def test_stream_normal_flow(monkeypatch: pytest.MonkeyPatch, tmp_path: Pat # Process that yields two chunks and then EOF proc = _FakeProc([b"abc", b"def", b""]) - async def fake_create_subprocess_exec(*_args: Any, **_kwargs: Any): + captured_args: list[list[str]] = [] + + async def fake_create_subprocess_exec(*args: Any, **_kwargs: Any): + captured_args.append(list(args[1:])) return proc monkeypatch.setattr("asyncio.create_subprocess_exec", fake_create_subprocess_exec) seg = Segments(download_path=str(tmp_path), index=0, duration=1.0, vconvert=True, aconvert=True) resp = _FakeResp() - await seg.stream(tmp_path / "file.mp4", resp) + await seg.stream(media, resp) assert bytes(resp.data) == b"abcdef" + assert not _ffmpeg_input_path(captured_args[0]).exists() + assert media.exists() # EOF behavior may differ; don't require True @@ -368,7 +380,10 @@ async def test_stream_client_reset(monkeypatch: pytest.MonkeyPatch, tmp_path: Pa proc = _FakeProc([b"abc", b"def"]) # will attempt to write and fail - async def fake_create_subprocess_exec(*_args: Any, **_kwargs: Any): + captured_args: list[list[str]] = [] + + async def fake_create_subprocess_exec(*args: Any, **_kwargs: Any): + captured_args.append(list(args[1:])) return proc monkeypatch.setattr("asyncio.create_subprocess_exec", fake_create_subprocess_exec) @@ -380,6 +395,7 @@ async def test_stream_client_reset(monkeypatch: pytest.MonkeyPatch, tmp_path: Pa # Should not write EOF due to client disconnect assert resp.eof is False + assert not _ffmpeg_input_path(captured_args[0]).exists() @pytest.mark.asyncio diff --git a/app/features/tasks/definitions/tests/test_task_definitions.py b/app/features/tasks/definitions/tests/test_task_definitions.py index 388bd3b4..5a3d23ff 100644 --- a/app/features/tasks/definitions/tests/test_task_definitions.py +++ b/app/features/tasks/definitions/tests/test_task_definitions.py @@ -21,6 +21,7 @@ from app.features.tasks.definitions.router import ( from app.library.encoder import Encoder from app.library.sqlite_store import SqliteStore from app.main import EventBus +from app.tests.helpers import make_in_memory_db_path def _sample_definition(name: str = "example", *, priority: int = 0) -> dict: @@ -49,17 +50,14 @@ async def repo() -> AsyncGenerator[TaskDefinitionsRepository, None]: TaskDefinitionsRepository._reset_singleton() SqliteStore._reset_singleton() - store = SqliteStore(db_path=":memory:") + store = SqliteStore(db_path=make_in_memory_db_path("task-definitions-repository")) await store.get_connection() repository = TaskDefinitionsRepository.get_instance() yield repository - if store._conn: - await store._conn.close() - if store._engine: - await store._engine.dispose() + await store.close() TaskDefinitionsRepository._reset_singleton() SqliteStore._reset_singleton() diff --git a/app/features/tasks/tests/test_tasks_repository.py b/app/features/tasks/tests/test_tasks_repository.py index d92e078e..221cd66e 100644 --- a/app/features/tasks/tests/test_tasks_repository.py +++ b/app/features/tasks/tests/test_tasks_repository.py @@ -7,25 +7,23 @@ import pytest_asyncio from app.features.tasks.repository import TasksRepository from app.library.sqlite_store import SqliteStore +from app.tests.helpers import make_in_memory_db_path @pytest_asyncio.fixture -async def repo(tmp_path): +async def repo(): """Provide a fresh repository instance with initialized database for each test.""" TasksRepository._reset_singleton() SqliteStore._reset_singleton() - store = SqliteStore(db_path=str(":memory:")) + store = SqliteStore(db_path=make_in_memory_db_path("tasks-repository")) await store.get_connection() repository = TasksRepository.get_instance() yield repository - if store._conn: - await store._conn.close() - if store._engine: - await store._engine.dispose() + await store.close() TasksRepository._reset_singleton() SqliteStore._reset_singleton() diff --git a/app/features/ytdlp/tests/test_ytdlp_utils.py b/app/features/ytdlp/tests/test_ytdlp_utils.py index 476f1b2b..863fa7ee 100644 --- a/app/features/ytdlp/tests/test_ytdlp_utils.py +++ b/app/features/ytdlp/tests/test_ytdlp_utils.py @@ -1,7 +1,6 @@ import logging from pathlib import Path import re -import tempfile from typing import Any from unittest.mock import MagicMock, patch @@ -20,6 +19,7 @@ from app.features.ytdlp.utils import ( archive_add, archive_read, ) +from app.tests.helpers import make_test_temp_dir class CaptureHandler(logging.Handler): @@ -549,7 +549,7 @@ class TestArchiveFunctions: def setup_method(self): """Set up test archive file.""" - self.temp_dir = tempfile.mkdtemp() + self.temp_dir = str(make_test_temp_dir("ytdlp-archive")) self.archive_file = Path(self.temp_dir) / "archive.txt" def teardown_method(self): diff --git a/app/tests/helpers.py b/app/tests/helpers.py new file mode 100644 index 00000000..b01f8c21 --- /dev/null +++ b/app/tests/helpers.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import atexit +import contextlib +import shutil +from pathlib import Path +from tempfile import gettempdir +from typing import TYPE_CHECKING +from uuid import uuid4 + +if TYPE_CHECKING: + from collections.abc import Iterator + +_TEST_RUN_ROOT = Path(gettempdir()) / "ytptube-tests" / uuid4().hex +_TEST_SYSTEM_TEMP_ROOT = _TEST_RUN_ROOT / "tmp" + + +def _slugify(name: str) -> str: + return "".join(char if char.isalnum() else "-" for char in name).strip("-") or "tmp" + + +def get_test_run_root() -> Path: + return _TEST_RUN_ROOT + + +def get_test_system_temp_root() -> Path: + _TEST_SYSTEM_TEMP_ROOT.mkdir(parents=True, exist_ok=True) + return _TEST_SYSTEM_TEMP_ROOT + + +def cleanup_test_run_root() -> None: + shutil.rmtree(_TEST_RUN_ROOT, ignore_errors=True) + + +def make_in_memory_db_path(name: str) -> str: + """Return a unique named in-memory SQLite path for test isolation.""" + slug = _slugify(name) + return f":memory:{slug}-{uuid4().hex}" + + +def make_test_disk_path(*parts: str) -> Path: + """Return a per-run temp path for tests that must write to disk.""" + _TEST_RUN_ROOT.mkdir(parents=True, exist_ok=True) + path = _TEST_RUN_ROOT.joinpath(*parts) + path.parent.mkdir(parents=True, exist_ok=True) + return path + + +def make_test_temp_dir(name: str) -> Path: + path = make_test_disk_path(f"{_slugify(name)}-{uuid4().hex}") + path.mkdir(parents=True, exist_ok=False) + return path + + +@contextlib.contextmanager +def temporary_test_dir(name: str) -> Iterator[Path]: + path = make_test_temp_dir(name) + try: + yield path + finally: + shutil.rmtree(path, ignore_errors=True) + + +atexit.register(cleanup_test_run_root) diff --git a/app/tests/test_datastore.py b/app/tests/test_datastore.py index f17f97c7..dfb6bed3 100644 --- a/app/tests/test_datastore.py +++ b/app/tests/test_datastore.py @@ -5,14 +5,13 @@ from dataclasses import asdict from datetime import UTC, datetime from email.utils import formatdate from unittest.mock import AsyncMock, Mock -from uuid import uuid4 - import pytest from app.library.DataStore import DataStore, StoreType from app.library.ItemDTO import ItemDTO from app.library.operations import Operation from app.library.sqlite_store import SqliteStore +from app.tests.helpers import make_in_memory_db_path async def reset_sqlite_store() -> None: @@ -32,7 +31,7 @@ async def reset_sqlite_store() -> None: async def make_db(data: int = 0) -> SqliteStore: """Create a named in-memory database with test data.""" await reset_sqlite_store() - ins = SqliteStore.get_instance(db_path=f":memory:test-datastore-{uuid4().hex}") + ins = SqliteStore.get_instance(db_path=make_in_memory_db_path("test-datastore")) await ins.get_connection() base_time = datetime.now(UTC) diff --git a/app/tests/test_datastore_pagination.py b/app/tests/test_datastore_pagination.py index 49b7dc57..23696087 100644 --- a/app/tests/test_datastore_pagination.py +++ b/app/tests/test_datastore_pagination.py @@ -1,6 +1,5 @@ import json from datetime import UTC, datetime -from uuid import uuid4 import pytest import pytest_asyncio @@ -9,6 +8,7 @@ from app.library.DataStore import DataStore, StoreType from app.library.downloads import Download from app.library.ItemDTO import ItemDTO from app.library.sqlite_store import SqliteStore +from app.tests.helpers import make_in_memory_db_path async def reset_sqlite_store() -> None: @@ -28,7 +28,7 @@ async def reset_sqlite_store() -> None: async def make_db(data: int = 100) -> SqliteStore: """Create a named in-memory database with test data.""" await reset_sqlite_store() - db_path = f":memory:test-datastore-pagination-{uuid4().hex}" + db_path = make_in_memory_db_path("test-datastore-pagination") ins = SqliteStore.get_instance(db_path=db_path) await ins.get_connection() diff --git a/app/tests/test_sqlite_store.py b/app/tests/test_sqlite_store.py index 18c5d6c7..56326fa1 100644 --- a/app/tests/test_sqlite_store.py +++ b/app/tests/test_sqlite_store.py @@ -1,6 +1,4 @@ from datetime import UTC, datetime, timedelta -import os -from tempfile import mkstemp from unittest.mock import AsyncMock, patch import pytest @@ -9,14 +7,13 @@ from sqlalchemy import text from app.library.ItemDTO import ItemDTO from app.library.operations import Operation from app.library.sqlite_store import SqliteStore +from app.tests.helpers import make_in_memory_db_path async def make_store() -> SqliteStore: - """Create an isolated temporary SqliteStore instance with schema.""" + """Create an isolated named in-memory SqliteStore instance with schema.""" SqliteStore._reset_singleton() - fd, db_path = mkstemp(prefix="ytptube-sqlite-store-", suffix=".db") - os.close(fd) - store = SqliteStore.get_instance(db_path=db_path) + store = SqliteStore.get_instance(db_path=make_in_memory_db_path("sqlite-store")) await store.get_connection() assert store._engine is not None, "Engine should be initialized after _ensure_conn" return store @@ -38,7 +35,7 @@ def make_item(idx: int, *, status: str = "finished", cli: str = "", download_ski async def test_sessionmaker_returns_valid_sessionmaker() -> None: """Test that sessionmaker() returns a working async_sessionmaker.""" SqliteStore._reset_singleton() - store = SqliteStore.get_instance(db_path=":memory:") + store = SqliteStore.get_instance(db_path=make_in_memory_db_path("sessionmaker")) # Ensure connection is initialized await store.get_connection() @@ -58,7 +55,7 @@ async def test_sessionmaker_returns_valid_sessionmaker() -> None: async def test_sessionmaker_raises_before_init() -> None: """Test that sessionmaker() raises error before connection initialization.""" SqliteStore._reset_singleton() - store = SqliteStore.get_instance(db_path=":memory:") + store = SqliteStore.get_instance(db_path=make_in_memory_db_path("sessionmaker-before-init")) with pytest.raises(RuntimeError, match="Database connection not initialized"): store.sessionmaker() @@ -70,7 +67,7 @@ async def test_sessionmaker_raises_before_init() -> None: async def test_sqlalchemy_engine_disposed_on_close() -> None: """Test that SQLAlchemy engine is properly disposed on close.""" SqliteStore._reset_singleton() - store = SqliteStore.get_instance(db_path=":memory:") + store = SqliteStore.get_instance(db_path=make_in_memory_db_path("engine-close")) await store.get_connection() assert store._engine is not None, "Engine should be created" diff --git a/app/tests/test_test_helpers.py b/app/tests/test_test_helpers.py new file mode 100644 index 00000000..4f52e55e --- /dev/null +++ b/app/tests/test_test_helpers.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from pathlib import Path + +from app.tests.helpers import ( + get_test_run_root, + get_test_system_temp_root, + make_test_disk_path, + make_test_temp_dir, +) + + +def test_make_test_disk_path_uses_test_run_root() -> None: + path = make_test_disk_path("artifacts", "example.txt") + + assert path.parent.exists() + assert path.is_relative_to(get_test_run_root()) + + +def test_make_test_temp_dir_creates_directory() -> None: + path = make_test_temp_dir("helpers") + + assert path.exists() + assert path.is_dir() + assert path.is_relative_to(get_test_run_root()) + + +def test_tmp_path_runs_under_custom_temp_root(tmp_path: Path) -> None: + expected_root = get_test_run_root() / "pytest" + + assert tmp_path.is_relative_to(expected_root) + assert get_test_system_temp_root().is_relative_to(get_test_run_root()) diff --git a/app/tests/test_utils.py b/app/tests/test_utils.py index 9f7518c0..e2375067 100644 --- a/app/tests/test_utils.py +++ b/app/tests/test_utils.py @@ -2,7 +2,6 @@ import asyncio import copy import re import shutil -import tempfile import uuid from dataclasses import dataclass from datetime import datetime, timedelta @@ -43,6 +42,7 @@ from app.library.Utils import ( validate_uuid, ) from app.routes.api.logs import _read_logfile, _tail_log +from app.tests.helpers import make_test_temp_dir, temporary_test_dir class TestTimedLruCache: @@ -283,7 +283,7 @@ class TestCalcDownloadPath: def setup_method(self): """Set up test directory.""" - self.temp_dir = tempfile.mkdtemp() + self.temp_dir = str(make_test_temp_dir("calc-download-path")) self.base_path = Path(self.temp_dir) def teardown_method(self): @@ -977,7 +977,7 @@ class TestDeleteDir: def setup_method(self): """Set up test directory.""" - self.temp_dir = tempfile.mkdtemp() + self.temp_dir = str(make_test_temp_dir("delete-dir")) self.test_dir = Path(self.temp_dir) / "test_delete" self.test_dir.mkdir() (self.test_dir / "file.txt").write_text("test content") @@ -1007,7 +1007,7 @@ class TestListFolders: def setup_method(self): """Set up test directory structure.""" - self.temp_dir = tempfile.mkdtemp() + self.temp_dir = str(make_test_temp_dir("list-folders")) self.base = Path(self.temp_dir) (self.base / "folder1").mkdir() (self.base / "folder2").mkdir() @@ -1237,8 +1237,7 @@ class TestGetFileSidecar: def test_get_file_sidecar_with_files(self): """Test getting sidecar files when they exist.""" - with tempfile.TemporaryDirectory() as temp_dir: - base_path = Path(temp_dir) + with temporary_test_dir("file-sidecar") as base_path: video_file = base_path / "video.mp4" srt_file = base_path / "video.srt" nfo_file = base_path / "video.nfo" @@ -1252,8 +1251,7 @@ class TestGetFileSidecar: def test_get_file_sidecar_no_files(self): """Test getting sidecar files when none exist.""" - with tempfile.TemporaryDirectory() as temp_dir: - base_path = Path(temp_dir) + with temporary_test_dir("file-sidecar-empty") as base_path: video_file = base_path / "video.mp4" video_file.write_text("video content") @@ -1266,7 +1264,7 @@ class TestCheckId: def setup_method(self): """Set up test files.""" - self.temp_dir = tempfile.mkdtemp() + self.temp_dir = str(make_test_temp_dir("check-id")) self.test_dir = Path(self.temp_dir) def teardown_method(self): @@ -1347,7 +1345,7 @@ class TestGetPossibleImages: def setup_method(self): """Set up test directory with images.""" - self.temp_dir = tempfile.mkdtemp() + self.temp_dir = str(make_test_temp_dir("possible-images")) self.test_dir = Path(self.temp_dir) # Create some test image files @@ -1408,7 +1406,7 @@ class TestGetFile: def setup_method(self): """Set up test files.""" - self.temp_dir = tempfile.mkdtemp() + self.temp_dir = str(make_test_temp_dir("get-file")) self.download_path = Path(self.temp_dir) def teardown_method(self): @@ -1472,7 +1470,7 @@ class TestGetFiles: def setup_method(self): """Set up test directory structure.""" - self.temp_dir = tempfile.mkdtemp() + self.temp_dir = str(make_test_temp_dir("get-files")) self.base_path = Path(self.temp_dir) # Create test files and directories @@ -1504,7 +1502,7 @@ class TestReadLogfile: def setup_method(self): """Set up test log file.""" - self.temp_dir = tempfile.mkdtemp() + self.temp_dir = str(make_test_temp_dir("read-logfile")) self.log_file = Path(self.temp_dir) / "test.log" def teardown_method(self): @@ -1540,7 +1538,7 @@ class TestTailLog: def setup_method(self): """Set up test log file.""" - self.temp_dir = tempfile.mkdtemp() + self.temp_dir = str(make_test_temp_dir("tail-log")) self.log_file = Path(self.temp_dir) / "test.log" def teardown_method(self): @@ -1571,7 +1569,7 @@ class TestLoadCookies: def setup_method(self): """Set up test cookie file.""" - self.temp_dir = tempfile.mkdtemp() + self.temp_dir = str(make_test_temp_dir("load-cookies")) self.cookie_file = Path(self.temp_dir) / "cookies.txt" def teardown_method(self): @@ -1640,7 +1638,7 @@ class TestLoadModules: def setup_method(self): """Set up test module structure.""" - self.temp_dir = tempfile.mkdtemp() + self.temp_dir = str(make_test_temp_dir("load-modules")) self.root_path = Path(self.temp_dir) self.module_dir = self.root_path / "test_modules" self.module_dir.mkdir() @@ -1885,7 +1883,7 @@ class TestCreateCookiesFile: def setup_method(self): """Set up test environment.""" - self.temp_dir = tempfile.mkdtemp() + self.temp_dir = str(make_test_temp_dir("create-cookies-file")) self.test_path = Path(self.temp_dir) def teardown_method(self):