Skip to content
This repository was archived by the owner on May 2, 2026. It is now read-only.
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,5 @@ jobs:
- name: Run type checking
run: uv run pyright

# TODO: write tests

# - name: Run tests
# run: uv run pytest tests
- name: Run tests
run: uv run pytest tests
20 changes: 15 additions & 5 deletions tests/cli/commands/anilist/commands/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from click.testing import CliRunner

from viu_media.cli.commands.anilist.commands.auth import auth
from viu_media.core.constants import ANILIST_AUTH


@pytest.fixture
Expand Down Expand Up @@ -43,8 +44,8 @@ def mock_api_client():


@pytest.fixture
def mock_webbrowser():
with patch("viu_media.cli.commands.anilist.commands.auth.webbrowser") as mock:
def mock_webbrowser_open():
with patch("viu_media.cli.commands.anilist.commands.auth.webbrowser.open") as mock:
yield mock


Expand Down Expand Up @@ -140,10 +141,10 @@ def test_auth_interactive(
mock_feedback_service,
mock_selector,
mock_api_client,
mock_webbrowser,
mock_webbrowser_open,
):
"""Test 'viu anilist auth' interactive mode."""
mock_webbrowser.open.return_value = True
mock_webbrowser_open.return_value = True

selector_instance = mock_selector.return_value
selector_instance.ask.return_value = "interactive_token"
Expand All @@ -159,6 +160,7 @@ def test_auth_interactive(
result = runner.invoke(auth, [], obj=mock_config)

assert result.exit_code == 0
mock_webbrowser_open.assert_called_once_with(ANILIST_AUTH, new=2)
selector_instance.ask.assert_called_with("Enter your AniList Access Token")
api_client_instance.authenticate.assert_called_with("interactive_token")
auth_service_instance.save_user_profile.assert_called_with(
Expand Down Expand Up @@ -235,6 +237,7 @@ def test_auth_already_logged_in_relogin_yes(
mock_feedback_service,
mock_selector,
mock_api_client,
mock_webbrowser_open,
):
"""Test 'viu anilist auth' when already logged in and user chooses to relogin."""
auth_service_instance = mock_auth_service.return_value
Expand All @@ -254,6 +257,7 @@ def test_auth_already_logged_in_relogin_yes(
result = runner.invoke(auth, [], obj=mock_config)

assert result.exit_code == 0
mock_webbrowser_open.assert_called_once_with(ANILIST_AUTH, new=2)
selector_instance.confirm.assert_called_with(
"You are already logged in as testuser. Would you like to relogin"
)
Expand All @@ -265,7 +269,12 @@ def test_auth_already_logged_in_relogin_yes(


def test_auth_already_logged_in_relogin_no(
runner, mock_config, mock_auth_service, mock_feedback_service, mock_selector
runner,
mock_config,
mock_auth_service,
mock_feedback_service,
mock_selector,
mock_webbrowser_open,
):
"""Test 'viu anilist auth' when already logged in and user chooses not to relogin."""
auth_service_instance = mock_auth_service.return_value
Expand All @@ -279,6 +288,7 @@ def test_auth_already_logged_in_relogin_no(
result = runner.invoke(auth, [], obj=mock_config)

assert result.exit_code == 0
mock_webbrowser_open.assert_not_called()
auth_service_instance.save_user_profile.assert_not_called()
feedback_instance = mock_feedback_service.return_value
feedback_instance.info.assert_not_called()
101 changes: 101 additions & 0 deletions tests/cli/commands/anilist/commands/test_notifications.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
from contextlib import nullcontext
from datetime import datetime
from types import SimpleNamespace
from unittest.mock import patch

import pytest
from click.testing import CliRunner

from viu_media.cli.commands.anilist.commands.notifications import notifications


@pytest.fixture
def runner():
return CliRunner()


@pytest.fixture
def mock_config():
return SimpleNamespace(general=SimpleNamespace(media_api="anilist"))


def test_notifications_requires_authentication(runner, mock_config):
with (
patch("viu_media.cli.service.feedback.FeedbackService") as mock_feedback,
patch("viu_media.libs.media_api.api.create_api_client") as mock_create_api,
patch("viu_media.cli.service.auth.AuthService") as mock_auth,
):
feedback_instance = mock_feedback.return_value
feedback_instance.progress.return_value = nullcontext()

auth_instance = mock_auth.return_value
auth_instance.get_auth.return_value = None

api_client = mock_create_api.return_value
api_client.is_authenticated.return_value = False

result = runner.invoke(notifications, [], obj=mock_config)

assert result.exit_code == 0
feedback_instance.error.assert_called_with(
"Authentication Required", "Please log in with 'viu anilist auth'."
)


def test_notifications_shows_all_caught_up_message(runner, mock_config):
with (
patch("viu_media.cli.service.feedback.FeedbackService") as mock_feedback,
patch("viu_media.libs.media_api.api.create_api_client") as mock_create_api,
patch("viu_media.cli.service.auth.AuthService") as mock_auth,
):
feedback_instance = mock_feedback.return_value
feedback_instance.progress.return_value = nullcontext()

auth_instance = mock_auth.return_value
auth_instance.get_auth.return_value = SimpleNamespace(token="token")

api_client = mock_create_api.return_value
api_client.is_authenticated.return_value = True
api_client.get_notifications.return_value = []

result = runner.invoke(notifications, [], obj=mock_config)

assert result.exit_code == 0
api_client.authenticate.assert_called_once_with("token")
feedback_instance.success.assert_called_with(
"All caught up!", "You have no new notifications."
)


def test_notifications_prints_table_and_mark_read_info(runner, mock_config):
notification_item = SimpleNamespace(
created_at=datetime(2025, 1, 1, 10, 0, 0),
media=SimpleNamespace(title=SimpleNamespace(english="Blue Lock", romaji=None)),
episode=12,
)

with (
patch("viu_media.cli.service.feedback.FeedbackService") as mock_feedback,
patch("viu_media.libs.media_api.api.create_api_client") as mock_create_api,
patch("viu_media.cli.service.auth.AuthService") as mock_auth,
patch("viu_media.cli.commands.anilist.commands.notifications.Console") as mock_console,
):
feedback_instance = mock_feedback.return_value
feedback_instance.progress.return_value = nullcontext()

auth_instance = mock_auth.return_value
auth_instance.get_auth.return_value = SimpleNamespace(token="token")

api_client = mock_create_api.return_value
api_client.is_authenticated.return_value = True
api_client.get_notifications.return_value = [notification_item]

console_instance = mock_console.return_value

result = runner.invoke(notifications, [], obj=mock_config)

assert result.exit_code == 0
console_instance.print.assert_called_once()
feedback_instance.info.assert_called_once_with(
"Notifications have been marked as read on AniList.",
)
42 changes: 42 additions & 0 deletions tests/cli/service/auth/test_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
from viu_media.cli.service.auth.service import AuthService
from viu_media.libs.media_api.types import UserProfile


def test_load_auth_creates_file_when_missing(tmp_path, monkeypatch):
auth_file = tmp_path / "auth.json"
monkeypatch.setattr("viu_media.cli.service.auth.service.AUTH_FILE", auth_file)

service = AuthService(media_api="anilist")
profile = service.get_auth()
Comment thread
Type-Delta marked this conversation as resolved.
Outdated

assert profile is None
assert auth_file.exists()


def test_save_and_get_auth_roundtrip(tmp_path, monkeypatch):
auth_file = tmp_path / "auth.json"
monkeypatch.setattr("viu_media.cli.service.auth.service.AUTH_FILE", auth_file)

service = AuthService(media_api="anilist")
user = UserProfile(id=1, name="test-user", avatar_url="https://img/avatar.png")

service.save_user_profile(user, "token-abc")
auth = service.get_auth()

assert auth is not None
assert auth.token == "token-abc"
assert auth.user_profile.name == "test-user"


def test_clear_user_profile_deletes_auth_file(tmp_path, monkeypatch):
auth_file = tmp_path / "auth.json"
monkeypatch.setattr("viu_media.cli.service.auth.service.AUTH_FILE", auth_file)

service = AuthService(media_api="anilist")
user = UserProfile(id=2, name="clear-me")
service.save_user_profile(user, "token")
assert auth_file.exists()

service.clear_user_profile()

assert not auth_file.exists()
76 changes: 76 additions & 0 deletions tests/core/utils/test_file.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import os
import time
from pathlib import Path

import pytest

from viu_media.core.utils.file import AtomicWriter, FileLock, check_file_modified, sanitize_filename


def test_atomic_writer_writes_atomically(tmp_path: Path):
target = tmp_path / "data.txt"

with AtomicWriter(target, mode="w", encoding="utf-8") as handle:
handle.write("hello world")

assert target.read_text(encoding="utf-8") == "hello world"


def test_atomic_writer_cleans_temp_file_on_exception(tmp_path: Path):
target = tmp_path / "data.txt"
target.write_text("original", encoding="utf-8")

with pytest.raises(RuntimeError, match="boom"):
with AtomicWriter(target, mode="w", encoding="utf-8") as handle:
handle.write("new")
raise RuntimeError("boom")

assert target.read_text(encoding="utf-8") == "original"
assert list(tmp_path.glob("*.tmp")) == []


def test_file_lock_acquire_release_non_blocking(tmp_path: Path):
lock_path = tmp_path / "my.lock"
lock = FileLock(lock_path, timeout=0, stale_timeout=10)

lock.acquire()
assert lock_path.exists()

lock.release()
assert not lock_path.exists()


def test_file_lock_breaks_stale_lock(tmp_path: Path):
lock_path = tmp_path / "stale.lock"
lock_path.write_text("999999\n0", encoding="utf-8")
stale_timestamp = time.time() - 100
os.utime(lock_path, (stale_timestamp, stale_timestamp))

lock = FileLock(lock_path, timeout=0.5, stale_timeout=0.01)
lock.acquire()

assert lock_path.exists()
lock.release()
assert not lock_path.exists()


def test_check_file_modified_detects_changes(tmp_path: Path):
target = tmp_path / "track.txt"
target.write_text("a", encoding="utf-8")
previous_mtime = target.stat().st_mtime

time.sleep(0.01)
target.write_text("b", encoding="utf-8")

current_mtime, modified = check_file_modified(target, previous_mtime)

assert modified is True
assert current_mtime > previous_mtime


def test_sanitize_filename_removes_invalid_characters():
result = sanitize_filename('My:/Invalid*"Name?', restricted=False)

assert "?" not in result
assert "*" not in result
assert result
56 changes: 56 additions & 0 deletions tests/core/utils/test_networking.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
from unittest.mock import patch

import httpx

from viu_media.core.utils.networking import get_remote_filename, random_user_agent


def _make_response(url: str, headers: dict[str, str] | None = None) -> httpx.Response:
request = httpx.Request("GET", url)
return httpx.Response(200, headers=headers, request=request)


def test_random_user_agent_uses_selected_chrome_version():
with patch("viu_media.core.utils.networking.random.choice", return_value="97.0.4692.20"):
user_agent = random_user_agent()

assert "Chrome/97.0.4692.20" in user_agent
assert user_agent.startswith("Mozilla/5.0")


def test_get_remote_filename_prefers_filename_star_header():
response = _make_response(
"https://example.com/download",
{"Content-Disposition": "attachment; filename*=UTF-8''my%20anime%20file.mkv"},
)

filename = get_remote_filename(response)

assert filename == "my anime file.mkv"


def test_get_remote_filename_uses_regular_filename_header():
response = _make_response(
"https://example.com/download",
{"Content-Disposition": 'attachment; filename="episode-01.mp4"'},
)

filename = get_remote_filename(response)

assert filename == "episode-01.mp4"


def test_get_remote_filename_falls_back_to_url_path():
response = _make_response("https://example.com/files/ep%2001.mp4?token=abc")

filename = get_remote_filename(response)

assert filename == "ep 01.mp4"


def test_get_remote_filename_returns_none_when_no_candidate_found():
response = _make_response("https://example.com/")

filename = get_remote_filename(response)

assert filename is None
Loading
Loading