refactor: use one tmp directory for tests

This commit is contained in:
arabcoders 2026-04-27 00:31:14 +03:00
parent f33dc84c57
commit fc6bc88e61
18 changed files with 230 additions and 110 deletions

22
app/conftest.py Normal file
View file

@ -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()

View file

@ -14,6 +14,7 @@ from app.library.config import Config
from app.library.encoder import Encoder from app.library.encoder import Encoder
from app.features.conditions.repository import ConditionsRepository from app.features.conditions.repository import ConditionsRepository
from app.library.sqlite_store import SqliteStore from app.library.sqlite_store import SqliteStore
from app.tests.helpers import make_in_memory_db_path
@pytest_asyncio.fixture @pytest_asyncio.fixture
@ -21,7 +22,7 @@ async def repo():
ConditionsRepository._reset_singleton() ConditionsRepository._reset_singleton()
SqliteStore._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() await store.get_connection()
# Create repository # Create repository
@ -30,10 +31,7 @@ async def repo():
yield repository yield repository
# Cleanup - close connections properly # Cleanup - close connections properly
if store._conn: await store.close()
await store._conn.close()
if store._engine:
await store._engine.dispose()
# Reset singletons # Reset singletons
ConditionsRepository._reset_singleton() ConditionsRepository._reset_singleton()

View file

@ -8,26 +8,24 @@ import pytest_asyncio
from app.features.dl_fields.repository import DLFieldsRepository from app.features.dl_fields.repository import DLFieldsRepository
from app.features.dl_fields.service import DLFields from app.features.dl_fields.service import DLFields
from app.library.sqlite_store import SqliteStore from app.library.sqlite_store import SqliteStore
from app.tests.helpers import make_in_memory_db_path
@pytest_asyncio.fixture @pytest_asyncio.fixture
async def repo(tmp_path): async def repo():
"""Provide a fresh repository instance with initialized database for each test.""" """Provide a fresh repository instance with initialized database for each test."""
DLFieldsRepository._reset_singleton() DLFieldsRepository._reset_singleton()
DLFields._reset_singleton() DLFields._reset_singleton()
SqliteStore._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() await store.get_connection()
repository = DLFieldsRepository.get_instance() repository = DLFieldsRepository.get_instance()
yield repository yield repository
if store._conn: await store.close()
await store._conn.close()
if store._engine:
await store._engine.dispose()
DLFieldsRepository._reset_singleton() DLFieldsRepository._reset_singleton()
DLFields._reset_singleton() DLFields._reset_singleton()

View file

@ -7,25 +7,23 @@ import pytest_asyncio
from app.features.dl_fields.repository import DLFieldsRepository from app.features.dl_fields.repository import DLFieldsRepository
from app.library.sqlite_store import SqliteStore from app.library.sqlite_store import SqliteStore
from app.tests.helpers import make_in_memory_db_path
@pytest_asyncio.fixture @pytest_asyncio.fixture
async def repo(tmp_path): async def repo():
"""Provide a fresh repository instance with initialized database for each test.""" """Provide a fresh repository instance with initialized database for each test."""
DLFieldsRepository._reset_singleton() DLFieldsRepository._reset_singleton()
SqliteStore._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() await store.get_connection()
repository = DLFieldsRepository.get_instance() repository = DLFieldsRepository.get_instance()
yield repository yield repository
if store._conn: await store.close()
await store._conn.close()
if store._engine:
await store._engine.dispose()
DLFieldsRepository._reset_singleton() DLFieldsRepository._reset_singleton()
SqliteStore._reset_singleton() SqliteStore._reset_singleton()

View file

@ -7,25 +7,23 @@ import pytest_asyncio
from app.features.notifications.repository import NotificationsRepository from app.features.notifications.repository import NotificationsRepository
from app.library.sqlite_store import SqliteStore from app.library.sqlite_store import SqliteStore
from app.tests.helpers import make_in_memory_db_path
@pytest_asyncio.fixture @pytest_asyncio.fixture
async def repo(tmp_path): async def repo():
"""Provide a fresh repository instance with initialized database for each test.""" """Provide a fresh repository instance with initialized database for each test."""
NotificationsRepository._reset_singleton() NotificationsRepository._reset_singleton()
SqliteStore._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() await store.get_connection()
repository = NotificationsRepository.get_instance() repository = NotificationsRepository.get_instance()
yield repository yield repository
if store._conn: await store.close()
await store._conn.close()
if store._engine:
await store._engine.dispose()
NotificationsRepository._reset_singleton() NotificationsRepository._reset_singleton()
SqliteStore._reset_singleton() SqliteStore._reset_singleton()

View file

@ -12,6 +12,7 @@ from app.features.presets.repository import PresetsRepository
from app.features.presets.router import presets_list from app.features.presets.router import presets_list
from app.library.encoder import Encoder from app.library.encoder import Encoder
from app.library.sqlite_store import SqliteStore from app.library.sqlite_store import SqliteStore
from app.tests.helpers import make_in_memory_db_path
@pytest_asyncio.fixture @pytest_asyncio.fixture
@ -19,16 +20,13 @@ async def repo():
PresetsRepository._reset_singleton() PresetsRepository._reset_singleton()
SqliteStore._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() await store.get_connection()
repository = PresetsRepository.get_instance() repository = PresetsRepository.get_instance()
yield repository yield repository
if store._conn: await store.close()
await store._conn.close()
if store._engine:
await store._engine.dispose()
PresetsRepository._reset_singleton() PresetsRepository._reset_singleton()
SqliteStore._reset_singleton() SqliteStore._reset_singleton()

View file

@ -1,11 +1,11 @@
import asyncio import asyncio
import hashlib
import logging import logging
import os import os
import subprocess # type: ignore import subprocess # type: ignore
import sys import sys
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from secrets import token_hex
from typing import TYPE_CHECKING, ClassVar from typing import TYPE_CHECKING, ClassVar
from aiohttp import web from aiohttp import web
@ -68,21 +68,21 @@ class Segments:
self.attempted: set[str] = set() self.attempted: set[str] = set()
"The set of attempted codecs." "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: try:
ff: FFProbeResult = await ffprobe(file) ff: FFProbeResult = await ffprobe(file)
except UnicodeDecodeError: except UnicodeDecodeError:
pass pass
input_path = stream_input or file
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
startTime: str = f"{0:.6f}" if self.index == 0 else f"{self.duration * self.index:.6f}" 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 # hardware/global input options must come before -i
*input_args, *input_args,
"-i", "-i",
f"file:{tmpFile}", f"file:{input_path}",
"-map_metadata", "-map_metadata",
"-1", "-1",
] ]
@ -240,22 +240,27 @@ class Segments:
else: else:
codecs: list[str] = [codec, *list(encoder_fallback_chain(codec))] codecs: list[str] = [codec, *list(encoder_fallback_chain(codec))]
for s_codec in codecs: stream_input = self._make_stream_input(file)
if s_codec in self.attempted: try:
continue for s_codec in codecs:
if s_codec in self.attempted:
continue
ffmpeg_args: list[str] = await self.build_ffmpeg_args(file, s_codec) 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) _, rc, client_disconnected, stderr_text = await self._run(resp, file, ffmpeg_args)
if 0 == rc: if 0 == rc:
Segments._cached_vcodec = s_codec Segments._cached_vcodec = s_codec
Segments._cache_initialized = True Segments._cache_initialized = True
return return
if client_disconnected: if client_disconnected:
return return
if 0 != rc: if 0 != rc:
err: str = stderr_text[:500] if stderr_text else "no error output" 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.") LOG.warning(f"transcoding has failed (cmd={ffmpeg_args}) (rc={rc}): {err}. Trying fallbacks.")
self.attempted.add(s_codec) self.attempted.add(s_codec)
finally:
if stream_input.is_symlink():
stream_input.unlink()

View file

@ -1,16 +1,17 @@
import tempfile
from pathlib import Path from pathlib import Path
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
import pytest import pytest
from app.tests.helpers import make_test_temp_dir
class TestFFProbe: class TestFFProbe:
"""Test the ffprobe module functionality.""" """Test the ffprobe module functionality."""
def setup_method(self): def setup_method(self):
"""Set up test files.""" """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 = Path(self.temp_dir) / "test_video.mp4"
self.test_file.touch() self.test_file.touch()

View file

@ -1,13 +1,12 @@
import asyncio import asyncio
import hashlib
import logging import logging
import tempfile
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
import pytest import pytest
from app.features.streaming.library.segments import Segments from app.features.streaming.library.segments import Segments
from app.tests.helpers import get_test_system_temp_root
class DummyFF: class DummyFF:
@ -22,6 +21,10 @@ class DummyFF:
return self._a return self._a
def _ffmpeg_input_path(args: list[str]) -> Path:
return Path(args[args.index("-i") + 1].removeprefix("file:"))
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_build_ffmpeg_args_video_and_audio(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: async def test_build_ffmpeg_args_video_and_audio(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
# Create a dummy media file # 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" assert captured_args, "ffmpeg was not invoked"
args = captured_args[0] args = captured_args[0]
# Compute expected symlink path used by Segments tmp_file = _ffmpeg_input_path(args)
tmpFile = Path(tempfile.gettempdir()).joinpath(f"ytptube_stream.{hashlib.sha256(str(media).encode()).hexdigest()}")
# Start time is duration * index with 6 decimals for non-zero index # Start time is duration * index with 6 decimals for non-zero index
assert "-ss" in args 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}" assert args[args.index("-t") + 1] == f"{5.5:.6f}"
# Input uses file:<symlink> # Input uses file:<symlink>
assert "-i" in args 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 # Includes video and audio mapping and codecs
assert "-map" in args assert "-map" in args
assert "0:v:0" 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 @pytest.mark.asyncio
async def test_stream_normal_flow(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: 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): async def fake_ffprobe(_file: Path):
return DummyFF(v=True, a=True) 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 # Process that yields two chunks and then EOF
proc = _FakeProc([b"abc", b"def", b""]) 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 return proc
monkeypatch.setattr("asyncio.create_subprocess_exec", fake_create_subprocess_exec) 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) seg = Segments(download_path=str(tmp_path), index=0, duration=1.0, vconvert=True, aconvert=True)
resp = _FakeResp() resp = _FakeResp()
await seg.stream(tmp_path / "file.mp4", resp) await seg.stream(media, resp)
assert bytes(resp.data) == b"abcdef" 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 # 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 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 return proc
monkeypatch.setattr("asyncio.create_subprocess_exec", fake_create_subprocess_exec) 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 # Should not write EOF due to client disconnect
assert resp.eof is False assert resp.eof is False
assert not _ffmpeg_input_path(captured_args[0]).exists()
@pytest.mark.asyncio @pytest.mark.asyncio

View file

@ -21,6 +21,7 @@ from app.features.tasks.definitions.router import (
from app.library.encoder import Encoder from app.library.encoder import Encoder
from app.library.sqlite_store import SqliteStore from app.library.sqlite_store import SqliteStore
from app.main import EventBus 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: def _sample_definition(name: str = "example", *, priority: int = 0) -> dict:
@ -49,17 +50,14 @@ async def repo() -> AsyncGenerator[TaskDefinitionsRepository, None]:
TaskDefinitionsRepository._reset_singleton() TaskDefinitionsRepository._reset_singleton()
SqliteStore._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() await store.get_connection()
repository = TaskDefinitionsRepository.get_instance() repository = TaskDefinitionsRepository.get_instance()
yield repository yield repository
if store._conn: await store.close()
await store._conn.close()
if store._engine:
await store._engine.dispose()
TaskDefinitionsRepository._reset_singleton() TaskDefinitionsRepository._reset_singleton()
SqliteStore._reset_singleton() SqliteStore._reset_singleton()

View file

@ -7,25 +7,23 @@ import pytest_asyncio
from app.features.tasks.repository import TasksRepository from app.features.tasks.repository import TasksRepository
from app.library.sqlite_store import SqliteStore from app.library.sqlite_store import SqliteStore
from app.tests.helpers import make_in_memory_db_path
@pytest_asyncio.fixture @pytest_asyncio.fixture
async def repo(tmp_path): async def repo():
"""Provide a fresh repository instance with initialized database for each test.""" """Provide a fresh repository instance with initialized database for each test."""
TasksRepository._reset_singleton() TasksRepository._reset_singleton()
SqliteStore._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() await store.get_connection()
repository = TasksRepository.get_instance() repository = TasksRepository.get_instance()
yield repository yield repository
if store._conn: await store.close()
await store._conn.close()
if store._engine:
await store._engine.dispose()
TasksRepository._reset_singleton() TasksRepository._reset_singleton()
SqliteStore._reset_singleton() SqliteStore._reset_singleton()

View file

@ -1,7 +1,6 @@
import logging import logging
from pathlib import Path from pathlib import Path
import re import re
import tempfile
from typing import Any from typing import Any
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
@ -20,6 +19,7 @@ from app.features.ytdlp.utils import (
archive_add, archive_add,
archive_read, archive_read,
) )
from app.tests.helpers import make_test_temp_dir
class CaptureHandler(logging.Handler): class CaptureHandler(logging.Handler):
@ -549,7 +549,7 @@ class TestArchiveFunctions:
def setup_method(self): def setup_method(self):
"""Set up test archive file.""" """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" self.archive_file = Path(self.temp_dir) / "archive.txt"
def teardown_method(self): def teardown_method(self):

64
app/tests/helpers.py Normal file
View file

@ -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)

View file

@ -5,14 +5,13 @@ from dataclasses import asdict
from datetime import UTC, datetime from datetime import UTC, datetime
from email.utils import formatdate from email.utils import formatdate
from unittest.mock import AsyncMock, Mock from unittest.mock import AsyncMock, Mock
from uuid import uuid4
import pytest import pytest
from app.library.DataStore import DataStore, StoreType from app.library.DataStore import DataStore, StoreType
from app.library.ItemDTO import ItemDTO from app.library.ItemDTO import ItemDTO
from app.library.operations import Operation from app.library.operations import Operation
from app.library.sqlite_store import SqliteStore from app.library.sqlite_store import SqliteStore
from app.tests.helpers import make_in_memory_db_path
async def reset_sqlite_store() -> None: async def reset_sqlite_store() -> None:
@ -32,7 +31,7 @@ async def reset_sqlite_store() -> None:
async def make_db(data: int = 0) -> SqliteStore: async def make_db(data: int = 0) -> SqliteStore:
"""Create a named in-memory database with test data.""" """Create a named in-memory database with test data."""
await reset_sqlite_store() 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() await ins.get_connection()
base_time = datetime.now(UTC) base_time = datetime.now(UTC)

View file

@ -1,6 +1,5 @@
import json import json
from datetime import UTC, datetime from datetime import UTC, datetime
from uuid import uuid4
import pytest import pytest
import pytest_asyncio import pytest_asyncio
@ -9,6 +8,7 @@ from app.library.DataStore import DataStore, StoreType
from app.library.downloads import Download from app.library.downloads import Download
from app.library.ItemDTO import ItemDTO from app.library.ItemDTO import ItemDTO
from app.library.sqlite_store import SqliteStore from app.library.sqlite_store import SqliteStore
from app.tests.helpers import make_in_memory_db_path
async def reset_sqlite_store() -> None: async def reset_sqlite_store() -> None:
@ -28,7 +28,7 @@ async def reset_sqlite_store() -> None:
async def make_db(data: int = 100) -> SqliteStore: async def make_db(data: int = 100) -> SqliteStore:
"""Create a named in-memory database with test data.""" """Create a named in-memory database with test data."""
await reset_sqlite_store() 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) ins = SqliteStore.get_instance(db_path=db_path)
await ins.get_connection() await ins.get_connection()

View file

@ -1,6 +1,4 @@
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
import os
from tempfile import mkstemp
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
import pytest import pytest
@ -9,14 +7,13 @@ from sqlalchemy import text
from app.library.ItemDTO import ItemDTO from app.library.ItemDTO import ItemDTO
from app.library.operations import Operation from app.library.operations import Operation
from app.library.sqlite_store import SqliteStore from app.library.sqlite_store import SqliteStore
from app.tests.helpers import make_in_memory_db_path
async def make_store() -> SqliteStore: 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() SqliteStore._reset_singleton()
fd, db_path = mkstemp(prefix="ytptube-sqlite-store-", suffix=".db") store = SqliteStore.get_instance(db_path=make_in_memory_db_path("sqlite-store"))
os.close(fd)
store = SqliteStore.get_instance(db_path=db_path)
await store.get_connection() await store.get_connection()
assert store._engine is not None, "Engine should be initialized after _ensure_conn" assert store._engine is not None, "Engine should be initialized after _ensure_conn"
return store 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: async def test_sessionmaker_returns_valid_sessionmaker() -> None:
"""Test that sessionmaker() returns a working async_sessionmaker.""" """Test that sessionmaker() returns a working async_sessionmaker."""
SqliteStore._reset_singleton() 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 # Ensure connection is initialized
await store.get_connection() await store.get_connection()
@ -58,7 +55,7 @@ async def test_sessionmaker_returns_valid_sessionmaker() -> None:
async def test_sessionmaker_raises_before_init() -> None: async def test_sessionmaker_raises_before_init() -> None:
"""Test that sessionmaker() raises error before connection initialization.""" """Test that sessionmaker() raises error before connection initialization."""
SqliteStore._reset_singleton() 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"): with pytest.raises(RuntimeError, match="Database connection not initialized"):
store.sessionmaker() store.sessionmaker()
@ -70,7 +67,7 @@ async def test_sessionmaker_raises_before_init() -> None:
async def test_sqlalchemy_engine_disposed_on_close() -> None: async def test_sqlalchemy_engine_disposed_on_close() -> None:
"""Test that SQLAlchemy engine is properly disposed on close.""" """Test that SQLAlchemy engine is properly disposed on close."""
SqliteStore._reset_singleton() 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() await store.get_connection()
assert store._engine is not None, "Engine should be created" assert store._engine is not None, "Engine should be created"

View file

@ -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())

View file

@ -2,7 +2,6 @@ import asyncio
import copy import copy
import re import re
import shutil import shutil
import tempfile
import uuid import uuid
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timedelta from datetime import datetime, timedelta
@ -43,6 +42,7 @@ from app.library.Utils import (
validate_uuid, validate_uuid,
) )
from app.routes.api.logs import _read_logfile, _tail_log from app.routes.api.logs import _read_logfile, _tail_log
from app.tests.helpers import make_test_temp_dir, temporary_test_dir
class TestTimedLruCache: class TestTimedLruCache:
@ -283,7 +283,7 @@ class TestCalcDownloadPath:
def setup_method(self): def setup_method(self):
"""Set up test directory.""" """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) self.base_path = Path(self.temp_dir)
def teardown_method(self): def teardown_method(self):
@ -977,7 +977,7 @@ class TestDeleteDir:
def setup_method(self): def setup_method(self):
"""Set up test directory.""" """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 = Path(self.temp_dir) / "test_delete"
self.test_dir.mkdir() self.test_dir.mkdir()
(self.test_dir / "file.txt").write_text("test content") (self.test_dir / "file.txt").write_text("test content")
@ -1007,7 +1007,7 @@ class TestListFolders:
def setup_method(self): def setup_method(self):
"""Set up test directory structure.""" """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 = Path(self.temp_dir)
(self.base / "folder1").mkdir() (self.base / "folder1").mkdir()
(self.base / "folder2").mkdir() (self.base / "folder2").mkdir()
@ -1237,8 +1237,7 @@ class TestGetFileSidecar:
def test_get_file_sidecar_with_files(self): def test_get_file_sidecar_with_files(self):
"""Test getting sidecar files when they exist.""" """Test getting sidecar files when they exist."""
with tempfile.TemporaryDirectory() as temp_dir: with temporary_test_dir("file-sidecar") as base_path:
base_path = Path(temp_dir)
video_file = base_path / "video.mp4" video_file = base_path / "video.mp4"
srt_file = base_path / "video.srt" srt_file = base_path / "video.srt"
nfo_file = base_path / "video.nfo" nfo_file = base_path / "video.nfo"
@ -1252,8 +1251,7 @@ class TestGetFileSidecar:
def test_get_file_sidecar_no_files(self): def test_get_file_sidecar_no_files(self):
"""Test getting sidecar files when none exist.""" """Test getting sidecar files when none exist."""
with tempfile.TemporaryDirectory() as temp_dir: with temporary_test_dir("file-sidecar-empty") as base_path:
base_path = Path(temp_dir)
video_file = base_path / "video.mp4" video_file = base_path / "video.mp4"
video_file.write_text("video content") video_file.write_text("video content")
@ -1266,7 +1264,7 @@ class TestCheckId:
def setup_method(self): def setup_method(self):
"""Set up test files.""" """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) self.test_dir = Path(self.temp_dir)
def teardown_method(self): def teardown_method(self):
@ -1347,7 +1345,7 @@ class TestGetPossibleImages:
def setup_method(self): def setup_method(self):
"""Set up test directory with images.""" """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) self.test_dir = Path(self.temp_dir)
# Create some test image files # Create some test image files
@ -1408,7 +1406,7 @@ class TestGetFile:
def setup_method(self): def setup_method(self):
"""Set up test files.""" """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) self.download_path = Path(self.temp_dir)
def teardown_method(self): def teardown_method(self):
@ -1472,7 +1470,7 @@ class TestGetFiles:
def setup_method(self): def setup_method(self):
"""Set up test directory structure.""" """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) self.base_path = Path(self.temp_dir)
# Create test files and directories # Create test files and directories
@ -1504,7 +1502,7 @@ class TestReadLogfile:
def setup_method(self): def setup_method(self):
"""Set up test log file.""" """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" self.log_file = Path(self.temp_dir) / "test.log"
def teardown_method(self): def teardown_method(self):
@ -1540,7 +1538,7 @@ class TestTailLog:
def setup_method(self): def setup_method(self):
"""Set up test log file.""" """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" self.log_file = Path(self.temp_dir) / "test.log"
def teardown_method(self): def teardown_method(self):
@ -1571,7 +1569,7 @@ class TestLoadCookies:
def setup_method(self): def setup_method(self):
"""Set up test cookie file.""" """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" self.cookie_file = Path(self.temp_dir) / "cookies.txt"
def teardown_method(self): def teardown_method(self):
@ -1640,7 +1638,7 @@ class TestLoadModules:
def setup_method(self): def setup_method(self):
"""Set up test module structure.""" """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.root_path = Path(self.temp_dir)
self.module_dir = self.root_path / "test_modules" self.module_dir = self.root_path / "test_modules"
self.module_dir.mkdir() self.module_dir.mkdir()
@ -1885,7 +1883,7 @@ class TestCreateCookiesFile:
def setup_method(self): def setup_method(self):
"""Set up test environment.""" """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) self.test_path = Path(self.temp_dir)
def teardown_method(self): def teardown_method(self):