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 all 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
15 changes: 7 additions & 8 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,11 @@ jobs:
with:
python-version: ${{ matrix.python-version }}

- name: Install dbus-python build dependencies
run: |
sudo apt-get update
sudo apt-get -y install libdbus-1-dev libglib2.0-dev
- name: Install dbus-python build dependencies with caching
uses: awalsh128/cache-apt-pkgs-action@latest
with:
packages: libdbus-1-dev libglib2.0-dev
version: 1.0

- name: Install uv
uses: astral-sh/setup-uv@v3
Expand All @@ -41,7 +42,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.",
)
40 changes: 40 additions & 0 deletions tests/cli/service/auth/test_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
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):
auth_file = tmp_path / "auth.json"

service = AuthService(media_api="anilist", auth_file=auth_file)
profile = service.get_auth()

assert profile is None
assert auth_file.exists()
assert (tmp_path / "auth.lock").exists() is False


def test_save_and_get_auth_roundtrip(tmp_path):
auth_file = tmp_path / "auth.json"

service = AuthService(media_api="anilist", auth_file=auth_file)
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):
auth_file = tmp_path / "auth.json"

service = AuthService(media_api="anilist", auth_file=auth_file)
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