ytptube/app/tests/test_history_routes.py
2026-06-14 18:35:03 +03:00

353 lines
12 KiB
Python

import json
from types import SimpleNamespace
from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock, Mock
import pytest
from aiohttp import web
from app.library.DataStore import StoreType
from app.library.cache import Cache
from app.library.ItemDTO import ItemDTO
from app.library.encoder import Encoder
from app.routes.api import history
from app.routes.api.history import item_rename, item_thumbnail, items_delete, items_live
from app.tests.helpers import temporary_test_dir
class _FakeRequest:
def __init__(self, *, payload: dict[str, Any] | None = None) -> None:
self._payload = payload or {}
self.query: dict[str, str] = {}
self.match_info: dict[str, str] = {}
self.body_exists = bool(payload)
async def json(self) -> dict[str, Any]:
return self._payload
def _make_download(
*,
filename: str | None = None,
folder: str = "",
download_dir: str | None = None,
status: str = "finished",
) -> SimpleNamespace:
base_dir = download_dir or "/downloads"
original_post_init = ItemDTO.__post_init__
cls: Any = ItemDTO
cls.__post_init__ = lambda self: None
try:
item = ItemDTO(
id="test-id",
title="Test Video",
url="https://example.com/watch?v=test-id",
folder=folder,
status=status,
filename=filename,
download_dir=base_dir,
)
finally:
ItemDTO.__post_init__ = original_post_init
return SimpleNamespace(info=item)
@pytest.mark.asyncio
async def test_items_delete_status() -> None:
request = _FakeRequest(payload={"type": StoreType.HISTORY.value, "status": "finished,skip", "remove_file": False})
queue = Mock()
queue.clear_by_status = AsyncMock(return_value={"deleted": 12})
encoder = Encoder()
response = await items_delete(request, queue, encoder)
assert response.status == 200
queue.clear_by_status.assert_awaited_once_with("finished,skip", remove_file=False)
body = json.loads(response.body.decode("utf-8"))
assert body == {"items": {}, "deleted": 12}
@pytest.mark.asyncio
async def test_items_delete_ids() -> None:
request = _FakeRequest(payload={"type": StoreType.HISTORY.value, "ids": ["a", "b"], "remove_file": False})
queue = Mock()
queue.clear_bulk = AsyncMock(return_value={"deleted": 2})
encoder = Encoder()
response = await items_delete(request, queue, encoder)
assert response.status == 200
queue.clear_bulk.assert_awaited_once_with(["a", "b"], remove_file=False)
body = json.loads(response.body.decode("utf-8"))
assert body == {"items": {}, "deleted": 2}
@pytest.mark.asyncio
async def test_items_live_metadata() -> None:
request = _FakeRequest()
request.query = {}
queue = Mock()
queue.live_queue = Mock(
return_value={"queue": {"a": {"title": "A"}}, "queue_count": 3, "queue_loaded": 1, "queue_limit": 1}
)
queue.done.get_total_count = AsyncMock(return_value=7)
encoder = Encoder()
config = SimpleNamespace(queue_display_limit=1)
response = await items_live(request, queue, encoder, config)
assert response.status == 200
queue.live_queue.assert_called_once_with(1)
body = json.loads(response.body.decode("utf-8"))
assert body["queue_count"] == 3
assert body["queue_loaded"] == 1
assert body["queue_limit"] == 1
assert body["history_count"] == 7
@pytest.mark.asyncio
async def test_items_live_limit_query() -> None:
request = _FakeRequest()
request.query = {"limit": "25"}
queue = Mock()
queue.live_queue = Mock(return_value={"queue": {}, "queue_count": 0, "queue_loaded": 0, "queue_limit": 25})
queue.done.get_total_count = AsyncMock(return_value=0)
encoder = Encoder()
config = SimpleNamespace(queue_display_limit=1)
response = await items_live(request, queue, encoder, config)
assert response.status == 200
queue.live_queue.assert_called_once_with(25)
@pytest.mark.asyncio
async def test_items_live_bad_limit() -> None:
request = _FakeRequest()
request.query = {"limit": "many"}
queue = Mock()
queue.live_queue = Mock()
encoder = Encoder()
config = SimpleNamespace(queue_display_limit=1)
response = await items_live(request, queue, encoder, config)
assert response.status == 400
queue.live_queue.assert_not_called()
@pytest.mark.asyncio
async def test_item_rename_needs_name() -> None:
request = _FakeRequest(payload={})
request.match_info["id"] = "item-1"
queue = SimpleNamespace(
done=SimpleNamespace(get_by_id=AsyncMock(return_value=_make_download(filename="video.mp4")))
)
encoder = Encoder()
notify = Mock()
config = SimpleNamespace(download_path="/downloads")
response = await item_rename(request, queue, encoder, notify, config)
assert response.status == 400
body = json.loads(response.body.decode("utf-8"))
assert body == {"error": "no data provided."}
@pytest.mark.asyncio
async def test_item_rename_missing() -> None:
request = _FakeRequest(payload={"new_name": "renamed.mp4"})
request.match_info["id"] = "missing"
queue = SimpleNamespace(done=SimpleNamespace(get_by_id=AsyncMock(return_value=None)))
encoder = Encoder()
notify = Mock()
config = SimpleNamespace(download_path="/downloads")
response = await item_rename(request, queue, encoder, notify, config)
assert response.status == 404
body = json.loads(response.body.decode("utf-8"))
assert body == {"error": "item 'missing' not found."}
@pytest.mark.asyncio
async def test_item_rename_needs_file() -> None:
request = _FakeRequest(payload={"new_name": "renamed.mp4"})
request.match_info["id"] = "item-1"
item = _make_download(filename="video.mp4")
queue = SimpleNamespace(done=SimpleNamespace(get_by_id=AsyncMock(return_value=item)))
encoder = Encoder()
notify = Mock()
config = SimpleNamespace(download_path="/downloads")
item.info.get_file = lambda download_path=None: None
response = await item_rename(request, queue, encoder, notify, config)
assert response.status == 400
body = json.loads(response.body.decode("utf-8"))
assert body == {"error": "item has no downloaded file."}
@pytest.mark.asyncio
async def test_item_rename_sidecars() -> None:
with temporary_test_dir("history-rename") as temp_dir:
media = temp_dir / "video.mp4"
subtitle = temp_dir / "video.en.srt"
media.write_text("video")
subtitle.write_text("subtitle")
request = _FakeRequest(payload={"new_name": "renamed.mp4"})
request.match_info["id"] = "item-1"
item = _make_download(filename="video.mp4", download_dir=str(temp_dir))
item.info._id = "item-1"
queue = SimpleNamespace(done=SimpleNamespace(get_by_id=AsyncMock(return_value=item), put=AsyncMock()))
encoder = Encoder()
notify = Mock()
config = SimpleNamespace(download_path=str(temp_dir))
response = await item_rename(request, queue, encoder, notify, config)
assert response.status == 200
body = json.loads(response.body.decode("utf-8"))
assert body["filename"] == "renamed.mp4"
assert item.info.filename == "renamed.mp4"
assert (temp_dir / "renamed.mp4").exists()
assert (temp_dir / "renamed.en.srt").exists()
assert not media.exists()
assert not subtitle.exists()
queue.done.put.assert_awaited_once_with(item, no_notify=True)
notify.emit.assert_called_once()
@pytest.mark.asyncio
async def test_item_rename_conflict() -> None:
with temporary_test_dir("history-rename-conflict") as temp_dir:
media = temp_dir / "video.mp4"
conflict = temp_dir / "renamed.mp4"
media.write_text("video")
conflict.write_text("existing")
request = _FakeRequest(payload={"new_name": "renamed.mp4"})
request.match_info["id"] = "item-1"
item = _make_download(filename="video.mp4", download_dir=str(temp_dir))
queue = SimpleNamespace(done=SimpleNamespace(get_by_id=AsyncMock(return_value=item), put=AsyncMock()))
encoder = Encoder()
notify = Mock()
config = SimpleNamespace(download_path=str(temp_dir))
response = await item_rename(request, queue, encoder, notify, config)
assert response.status == 409
body = json.loads(response.body.decode("utf-8"))
assert body == {"error": "Destination 'renamed.mp4' already exists"}
queue.done.put.assert_not_awaited()
notify.emit.assert_not_called()
@pytest.mark.asyncio
async def test_item_thumbnail_sidecar() -> None:
with temporary_test_dir("history-thumb-sidecar") as temp_dir:
media = temp_dir / "video.mp4"
image = temp_dir / "video.jpg"
media.write_text("video")
image.write_text("image")
request = _FakeRequest()
request.match_info["id"] = "item-1"
item = _make_download(filename="video.mp4", download_dir=str(temp_dir))
queue = SimpleNamespace(done=SimpleNamespace(get_by_id=AsyncMock(return_value=item)))
config = SimpleNamespace(download_path=str(temp_dir), temp_path=str(temp_dir / "tmp"))
response = await item_thumbnail(request, queue, config)
assert isinstance(response, web.FileResponse)
assert response.status == 200
assert response._path == image
@pytest.mark.asyncio
async def test_item_thumbnail_generated(monkeypatch: pytest.MonkeyPatch) -> None:
with temporary_test_dir("history-thumb-gen") as temp_dir:
media = temp_dir / "video.mp4"
media.write_text("video")
cache_dir = temp_dir / "tmp"
generated = cache_dir / "thumbnails" / "item-1.jpg"
request = _FakeRequest()
item = _make_download(filename="video.mp4", download_dir=str(temp_dir))
request.match_info["id"] = item.info._id
queue = SimpleNamespace(done=SimpleNamespace(get_by_id=AsyncMock(return_value=item)))
config = SimpleNamespace(download_path=str(temp_dir), temp_path=str(cache_dir))
monkeypatch.setattr(history, "pick_local_thumb", lambda _file: None)
called = {"count": 0}
async def fake_ensure_thumb(_file: Path, _cache_root: Path, item_id: str | None = None) -> Path:
called["count"] += 1
assert item_id == request.match_info["id"]
generated.parent.mkdir(parents=True, exist_ok=True)
generated.write_text("generated")
return generated
monkeypatch.setattr(history, "ensure_thumb", fake_ensure_thumb)
response = await item_thumbnail(request, queue, config)
assert isinstance(response, web.FileResponse)
assert response.status == 200
assert response._path == generated
assert called["count"] == 1
@pytest.mark.asyncio
async def test_item_thumbnail_no_thumb(monkeypatch: pytest.MonkeyPatch) -> None:
with temporary_test_dir("history-thumb-miss") as temp_dir:
media = temp_dir / "video.mp4"
media.write_text("video")
request = _FakeRequest()
request.match_info["id"] = "item-1"
item = _make_download(filename="video.mp4", download_dir=str(temp_dir))
queue = SimpleNamespace(done=SimpleNamespace(get_by_id=AsyncMock(return_value=item)))
config = SimpleNamespace(download_path=str(temp_dir), temp_path=str(temp_dir / "tmp"))
monkeypatch.setattr(history, "pick_local_thumb", lambda _file: None)
monkeypatch.setattr(history, "ensure_thumb", AsyncMock(return_value=None))
response = await item_thumbnail(request, queue, config)
assert response.status == 404
body = json.loads(response.body.decode("utf-8"))
assert body == {"error": "thumbnail not found."}
@pytest.mark.asyncio
async def test_item_thumbnail_missing_cache() -> None:
Cache.get_instance().clear()
request = _FakeRequest()
request.match_info["id"] = "item-1"
item = _make_download(filename="video.mp4", download_dir="/downloads")
seen = {"count": 0}
def fake_get_file(download_path=None):
del download_path
seen["count"] += 1
return None
item.info.get_file = fake_get_file
queue = SimpleNamespace(done=SimpleNamespace(get_by_id=AsyncMock(return_value=item)))
config = SimpleNamespace(download_path="/downloads", temp_path="/tmp")
first = await item_thumbnail(request, queue, config)
second = await item_thumbnail(request, queue, config)
assert first.status == 404
assert second.status == 404
assert seen["count"] == 1